Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reading .pyc files #565

Closed
wants to merge 12 commits into from
13 changes: 11 additions & 2 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
API
===

.. automodule:: split_settings.tools
.. automodule:: split_settings.wrappers
:members:

.. autoclass:: _Optional
.. autoclass:: Entry
:members:

.. autoclass:: Compiled
:members:

.. autoclass:: OneOf
:members:

.. autoclass:: Optional
:members:
6 changes: 3 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ exclude =
ignore = D100, D104, D401, W504, RST210, RST213, RST299, RST303, RST304, DAR103, DAR203

per-file-ignores =
# Our module is complex, there's nothing we can do:
split_settings/tools.py: WPS232
# We have 4 wrappers and 4 functions
split_settings/wrappers.py: WPS202
# Tests contain examples with logic in init files:
tests/*/__init__.py: WPS412
# There are multiple fixtures, `assert`s, and subprocesses in tests:
tests/*.py: S101, S105, S404, S603, S607
tests/*.py: S101, S105, S404, S603, S607, WPS202, WPS226


[isort]
Expand Down
54 changes: 54 additions & 0 deletions split_settings/loaders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import marshal
import types

_PYC_HEADER_SIZE = 16


def load_py(included_file: str) -> types.CodeType:
"""
Compile the given file into a Python AST.

This AST can then be passed to `exec`.

Args:
included_file: the file to be compiled.

Returns:
The compiled code.
"""
with open(included_file, 'rb') as to_compile:
return compile( # noqa: WPS421
to_compile.read(), included_file, 'exec',
)


def load_pyc(included_file: str) -> types.CodeType:
"""
Load a Python compiled file that can be unmarshalled to an AST.

This AST can then be passed to `exec`.

Args:
included_file: the file to be loaded and unmarshalled.

Returns:
The compiled code.
"""
# Python compiled files have a header before the marshalled code.
# This header can be different sizes in different Python versions,
# but it is 16 bytes in all versions supported by this package.

with open(included_file, 'rb') as to_compile:
to_compile.seek(_PYC_HEADER_SIZE) # Skip .pyc header.
try:
compiled_code = marshal.load(to_compile) # noqa: S302
except (EOFError, ValueError, TypeError) as exc:
raise ValueError(
'Could not load Python compiled file: {0}'.format(
included_file,
),
) from exc

# This is only needed for mypy:
assert isinstance(compiled_code, types.CodeType) # noqa: S101
return compiled_code
66 changes: 25 additions & 41 deletions split_settings/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,30 @@

from __future__ import annotations

import glob
import os
import sys
import typing
from importlib.util import module_from_spec, spec_from_file_location

__all__ = ('optional', 'include') # noqa: WPS410
from split_settings.loaders import load_py, load_pyc
from split_settings.wrappers import (
Entry,
OneOf,
Optional,
compiled,
entry,
one_of,
optional,
)

__all__ = ('compiled', 'entry', 'include', 'optional', 'one_of') # noqa: WPS410

#: Special magic attribute that is sometimes set by `uwsgi` / `gunicorn`.
_INCLUDED_FILE = '__included_file__'


def optional(filename: typing.Optional[str]) -> str:
"""
This function is used for compatibility reasons.

It masks the old `optional` class with the name error.
Now `invalid-name` is removed from `pylint`.

Args:
filename: the filename to be optional.

Returns:
New instance of :class:`_Optional`.

"""
return _Optional(filename or '')


class _Optional(str): # noqa: WPS600
"""
Wrap a file path with this class to mark it as optional.

Optional paths don't raise an :class:`OSError` if file is not found.
"""


def include( # noqa: WPS210, WPS231, C901
*args: str,
*args: typing.Union[str, Entry, OneOf, Optional],
scope: dict[str, typing.Any] | None = None,
) -> None:
"""
Expand All @@ -57,6 +42,7 @@ def include( # noqa: WPS210, WPS231, C901

Raises:
OSError: if a required settings file is not found.
ValueError: if a Python compiled file could not be loaded.

Usage example:

Expand Down Expand Up @@ -87,17 +73,15 @@ def include( # noqa: WPS210, WPS231, C901
conf_path = os.path.dirname(including_file)

for conf_file in args:
if isinstance(conf_file, _Optional) and not conf_file:
# If the argument is a simple `str`, we wrap it with `Entry`.
if isinstance(conf_file, str):
conf_file = entry(conf_file)

if isinstance(conf_file, Optional) and not conf_file.inner.inner:
continue # skip empty optional values

saved_included_file = scope.get(_INCLUDED_FILE)
pattern = os.path.join(conf_path, conf_file)

# find files per pattern, raise an error if not found
# (unless file is optional)
files_to_include = glob.glob(pattern)
if not files_to_include and not isinstance(conf_file, _Optional):
raise OSError('No such file: {0}'.format(pattern))
files_to_include = conf_file.get_files_to_include(conf_path)

for included_file in files_to_include:
included_file = os.path.abspath(included_file) # noqa: WPS440
Expand All @@ -107,11 +91,11 @@ def include( # noqa: WPS210, WPS231, C901
included_files.append(included_file)

scope[_INCLUDED_FILE] = included_file
with open(included_file, 'rb') as to_compile:
compiled_code = compile( # noqa: WPS421
to_compile.read(), included_file, 'exec',
)
exec(compiled_code, scope) # noqa: S102, WPS421
if included_file.endswith('.pyc'):
compiled_code = load_pyc(included_file)
else:
compiled_code = load_py(included_file)
exec(compiled_code, scope) # noqa: S102, WPS421

# Adds dummy modules to sys.modules to make runserver autoreload
# work with settings components:
Expand Down
Loading
Loading