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

Issue 195 #196

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

We follow Semantic Version.

## 1.1.0

Improvements:

- Packaged resources may now be included
- Alternate file extensions are allowed

## 1.0.1

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ We use `mypy` to run type checks on our code.
To use it:

```bash
mypy django_split_settings
mypy split_settings
```

This step is mandatory during the CI.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ previous files.

We also made a in-depth [tutorial](https://sobolevn.me/2017/04/managing-djangos-settings).

## Package Resources

You may also include package resources and use alternate extensions:
```python
from mypackge import settings
include(resource(settings, 'base.conf'))
include(optional(resource(settings, 'local.conf')))
```
Resources may be also be included by passing the module as a string:

```python
include(resource('mypackage.settings', 'base.conf'))
```
Note that resources included from archived packages (i.e. zip files), will have a temporary
file created, which will be deleted after the settings file has been compiled.

## Tips and tricks

Expand Down
32 changes: 28 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ classifiers = [

[tool.poetry.dependencies]
python = "^3.6"
importlib-resources = "^2.0.0"

[tool.poetry.dev-dependencies]
django = "^2.2"
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ per-file-ignores =
# Our module is complex, there's nothing we can do:
split_settings/tools.py: WPS232
# Tests contain examples with logic in init files:
tests/*/__init__.py: WPS412
tests/*/__init__.py: WPS412, WPS226
# 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


[isort]
Expand Down
118 changes: 112 additions & 6 deletions split_settings/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,38 @@
settings files.
"""

import contextlib
import glob
import inspect
import os
import sys
from importlib.util import module_from_spec, spec_from_file_location
import types
from importlib.machinery import SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader
from typing import List, Union

try:
from importlib.resources import ( # type: ignore # noqa: WPS433
files,
as_file,
)
except ImportError:
# Use backport to PY<3.9 `importlib_resources`.
# importlib_resources is included in python stdlib starting at 3.7 but
# the files function is not available until python 3.9
from importlib_resources import files, as_file # noqa: WPS433, WPS440

__all__ = ('optional', 'include') # noqa: WPS410
__all__ = ('optional', 'include', 'resource') # noqa: WPS410

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

# If resources are located in archives, importlib will create temporary
# files to access them contained within contexts, we track the contexts
# here as opposed to the _Resource.__del__ method because invocation of
# that method is non-deterministic
__resource_file_contexts__: List[contextlib.ExitStack] = []


def optional(filename: str) -> str:
"""
Expand All @@ -44,6 +65,68 @@ class _Optional(str): # noqa: WPS600
"""


def resource(package: Union[str, types.ModuleType], filename: str) -> str:
"""
Include a packaged resource as a settings file.

Args:
package: the package as either an imported module, or a string
filename: the filename of the resource to include.

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

"""
return _Resource(package, filename)


class _Resource(str): # noqa: WPS600
"""
Wrap an included package resource as a str.

Resource includes may also be wrapped as Optional and record if the
package was found or not.
"""

module_not_found = False
package: str
filename: str

def __new__(
cls,
package: Union[str, types.ModuleType],
filename: str,
) -> '_Resource':

# the type ignores workaround a known mypy issue
# https://github.com/python/mypy/issues/1021
try:
ref = files(package) / filename
except ModuleNotFoundError:
rsrc = super().__new__(cls, '') # type: ignore
rsrc.module_not_found = True
return rsrc

file_manager = contextlib.ExitStack()
__resource_file_contexts__.append(file_manager)
return super().__new__( # type: ignore
cls,
file_manager.enter_context(as_file(ref)),
)

def __init__(
self,
package: Union[str, types.ModuleType],
filename: str,
) -> None:
super().__init__()
if isinstance(package, types.ModuleType):
self.package = package.__name__
else:
self.package = package
self.filename = filename


def include(*args: str, **kwargs) -> None: # noqa: WPS210, WPS231, C901
"""
Used for including Django project settings from multiple files.
Expand All @@ -52,11 +135,13 @@ def include(*args: str, **kwargs) -> None: # noqa: WPS210, WPS231, C901

.. code:: python

from split_settings.tools import optional, include
from split_settings.tools import optional, include, resource
import components

include(
'components/base.py',
'components/database.py',
resource(components, settings.conf), # package resource
optional('local_settings.py'),

scope=globals(), # optional scope
Expand All @@ -68,6 +153,7 @@ def include(*args: str, **kwargs) -> None: # noqa: WPS210, WPS231, C901

Raises:
IOError: if a required settings file is not found.
ModuleNotFoundError: if a required resource package is not found.

"""
# we are getting globals() from previous frame
Expand All @@ -85,7 +171,18 @@ def include(*args: str, **kwargs) -> None: # noqa: WPS210, WPS231, C901

for conf_file in args:
saved_included_file = scope.get(_INCLUDED_FILE)
pattern = os.path.join(conf_path, conf_file)
pattern = conf_file
# if a resource was not found the path will resolve to empty str here
if pattern:
pattern = os.path.join(conf_path, conf_file)

# check if this include is a resource with an unfound module
# and issue a more specific exception
if isinstance(conf_file, _Resource):
if conf_file.module_not_found:
raise ModuleNotFoundError(
'No module named {0}'.format(conf_file.package),
)

# find files per pattern, raise an error if not found
# (unless file is optional)
Expand Down Expand Up @@ -114,12 +211,21 @@ def include(*args: str, **kwargs) -> None: # noqa: WPS210, WPS231, C901
rel_path[:rel_path.rfind('.')].replace('/', '.'),
)

spec = spec_from_file_location(
module_name, included_file,
spec = spec_from_loader(
module_name,
SourceFileLoader(
os.path.basename(included_file).split('.')[0],
included_file,
),
)
module = module_from_spec(spec)
sys.modules[module_name] = module
if saved_included_file:
scope[_INCLUDED_FILE] = saved_included_file
elif _INCLUDED_FILE in scope:
scope.pop(_INCLUDED_FILE)

# close the contexts of any temporary files created to access
# resource contents thereby deleting them
for ctx in __resource_file_contexts__:
ctx.close()
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest


class Scope(dict): # noqa: WPS600
class Scope(dict): # noqa: WPS600, WPS202
"""This class emulates `globals()`, but does not share state in tests."""

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -43,6 +43,20 @@ def merged():
return _merged


@pytest.fixture()
def alt_ext():
"""This fixture returns alt_ext settings example."""
from tests.settings import alt_ext as _alt_ext # noqa: WPS433
return _alt_ext


@pytest.fixture()
def resources():
"""This fixture returns resource settings example."""
from tests.settings import resources as _resources # noqa: WPS433
return _resources


@pytest.fixture()
def stacked():
"""This fixture returns stacked settings example."""
Expand Down
10 changes: 10 additions & 0 deletions tests/settings/alt_ext/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-

from split_settings.tools import include, optional

# Includes files with non-standard extensions:
include(
'include',
'*.conf',
optional('optional.ext'),
)
3 changes: 3 additions & 0 deletions tests/settings/alt_ext/include
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

NO_EXT_INCLUDED = True
3 changes: 3 additions & 0 deletions tests/settings/alt_ext/include.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

DOT_CONF_INCLUDED = True
3 changes: 3 additions & 0 deletions tests/settings/alt_ext/include.double.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

DOUBLE_EXT_INCLUDED = True
3 changes: 3 additions & 0 deletions tests/settings/alt_ext/optional.ext
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

OPTIONAL_INCLUDED = True
27 changes: 27 additions & 0 deletions tests/settings/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-

from split_settings.tools import include, optional, resource
from tests.settings import resources

include(
# Components:
resource('tests.settings.resources', 'base.conf'),
resource('tests.settings.resources', 'locale.conf'),
resource('tests.settings.resources', 'apps_middleware'),
resource(resources, 'static.settings'),
resource(resources, 'templates.py'),
resource(resources, 'multiple/subdirs/file.conf'),
optional(resource(resources, 'database.conf')),
'logging.py',

# Missing file:
optional(resource(resources, 'missing_file.py')),
optional(resource('tests.settings.resources', 'missing_file.conf')),
resource('tests.settings.resources', 'error.conf'),

# Missing module
optional(resource('module.does.not.exist', 'settings.conf')),

# Scope:
scope=globals(), # noqa: WPS421
)
Loading