Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
from . 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