Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/dj_toml_settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from dj_toml_settings.config import configure_toml_settings, get_toml_settings
from dj_toml_settings.toml_parser import parse_file
from dj_toml_settings.toml_parser import Parser

__all__ = [
"Parser",
"configure_toml_settings",
"get_toml_settings",
"parse_file",
]
5 changes: 3 additions & 2 deletions src/dj_toml_settings/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typeguard import typechecked

from dj_toml_settings.toml_parser import parse_file
from dj_toml_settings.toml_parser import Parser

TOML_SETTINGS_FILES = ["pyproject.toml", "django.toml"]

Expand All @@ -21,8 +21,9 @@ def get_toml_settings(base_dir: Path, data: dict | None = None, toml_settings_fi

for settings_file_name in toml_settings_files:
settings_path = base_dir / settings_file_name

if settings_path.exists():
file_data = parse_file(settings_path, data=data.copy())
file_data = Parser(settings_path, data=data.copy()).parse_file()
data.update(file_data)

return data
Expand Down
286 changes: 109 additions & 177 deletions src/dj_toml_settings/toml_parser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
Expand All @@ -9,205 +8,138 @@
from dateutil import parser as dateparser
from typeguard import typechecked

from dj_toml_settings.exceptions import InvalidActionError
from dj_toml_settings.value_parsers.dict_parsers import (
EnvParser,
InsertParser,
NoneParser,
PathParser,
TypeParser,
ValueParser,
)
from dj_toml_settings.value_parsers.str_parsers import VariableParser

logger = logging.getLogger(__name__)


@typechecked
def parse_file(path: Path, data: dict | None = None):
"""Parse data from the specified TOML file to use for Django settings.
class Parser:
path: Path
data: dict

The sections get parsed in the following order with the later sections overriding the earlier:
1. `[tool.django]`
2. `[tool.django.apps.*]`
3. `[tool.django.envs.{ENVIRONMENT}]` where {ENVIRONMENT} is defined in the `ENVIRONMENT` env variable
"""
def __init__(self, path: Path, data: dict | None = None):
self.path = path
self.data = data or {}

toml_data = get_data(path)
data = data or {}
@typechecked
def parse_file(self):
"""Parse data from the specified TOML file to use for Django settings.

# Get potential settings from `tool.django.apps` and `tool.django.envs`
apps_data = toml_data.pop("apps", {})
envs_data = toml_data.pop("envs", {})
The sections get parsed in the following order with the later sections overriding the earlier:
1. `[tool.django]`
2. `[tool.django.apps.*]`
3. `[tool.django.envs.{ENVIRONMENT}]` where {ENVIRONMENT} is defined in the `ENVIRONMENT` env variable
"""

# Add default settings from `tool.django`
for key, value in toml_data.items():
logger.debug(f"tool.django: Update '{key}' with '{value}'")
toml_data = self.get_data()

data.update(parse_key_value(data, key, value, path))
# Get potential settings from `tool.django.apps` and `tool.django.envs`
apps_data = toml_data.pop("apps", {})
envs_data = toml_data.pop("envs", {})

# Add settings from `tool.django.apps.*`
for apps_name, apps_value in apps_data.items():
for app_key, app_value in apps_value.items():
logger.debug(f"tool.django.apps.{apps_name}: Update '{app_key}' with '{app_value}'")
# Add default settings from `tool.django`
for key, value in toml_data.items():
logger.debug(f"tool.django: Update '{key}' with '{value}'")

data.update(parse_key_value(data, app_key, app_value, path))
self.data.update({key: self.parse_value(key, value)})

# Add settings from `tool.django.envs.*` if it matches the `ENVIRONMENT` env variable
if environment_env_variable := os.getenv("ENVIRONMENT"):
for envs_name, envs_value in envs_data.items():
if environment_env_variable == envs_name:
for env_key, env_value in envs_value.items():
logger.debug(f"tool.django.envs.{envs_name}: Update '{env_key}' with '{env_value}'")

data.update(parse_key_value(data, env_key, env_value, path))

return data


@typechecked
def get_data(path: Path) -> dict:
"""Gets the data from the passed-in TOML file."""

data = {}

try:
data = toml.load(path)
except FileNotFoundError:
logger.warning(f"Cannot find file at: {path}")
except toml.TomlDecodeError:
logger.error(f"Cannot parse TOML at: {path}")

return data.get("tool", {}).get("django", {}) or {}


@typechecked
def parse_key_value(data: dict, key: str, value: Any, path: Path) -> dict:
"""Handle special cases for `value`.

Special cases:
- `dict` keys
- `$env`: retrieves an environment variable; optional `default` argument
- `$path`: converts string to a `Path`; handles relative path
- `$insert`: inserts the value to an array; optional `index` argument
- `$none`: inserts the `None` value
- variables in `str`
- `datetime`
"""

if isinstance(value, dict):
# Defaults to "$env" and "$default"
env_special_key = _get_special_key(data, "env")
default_special_key = _get_special_key(data, "default")

# Defaults to "$path"
path_special_key = _get_special_key(data, "path")

# Defaults to "$insert" and "$variable"
insert_special_key = _get_special_key(data, "insert")
index_special_key = _get_special_key(data, "index")

# Defaults to "$none"
none_special_key = _get_special_key(data, "none")

if env_special_key in value:
default_value = value.get(default_special_key)

value = os.getenv(value[env_special_key], default_value)
elif path_special_key in value:
file_name = value[path_special_key]

value = _parse_path(path, file_name)
elif insert_special_key in value:
insert_data = data.get(key, [])

# Check the existing value is an array
if not isinstance(insert_data, list):
raise InvalidActionError(f"`insert` cannot be used for value of type: {type(data[key])}")

# Insert the data
index = value.get(index_special_key, len(insert_data))
insert_data.insert(index, value[insert_special_key])

# Set the value to the new data
value = insert_data
elif none_special_key in value and value.get(none_special_key):
value = None
elif isinstance(value, str):
# Handle variable substitution
for match in re.finditer(r"\$\{\w+\}", value):
data_key = value[match.start() : match.end()][2:-1]

if variable := data.get(data_key):
if isinstance(variable, Path):
path_str = _combine_bookends(value, match, variable)

value = Path(path_str)
elif callable(variable):
value = variable
elif isinstance(variable, int):
value = _combine_bookends(value, match, variable)

try:
value = int(value)
except Exception: # noqa: S110
pass
elif isinstance(variable, float):
value = _combine_bookends(value, match, variable)

try:
value = float(value)
except Exception: # noqa: S110
pass
elif isinstance(variable, list):
value = variable
elif isinstance(variable, dict):
value = variable
elif isinstance(variable, datetime):
value = dateparser.isoparse(str(variable))
else:
value = value.replace(match.string, str(variable))
else:
logger.warning(f"Missing variable substitution {value}")
elif isinstance(value, datetime):
value = dateparser.isoparse(str(value))

return {key: value}
# Add settings from `tool.django.apps.*`
for apps_name, apps_value in apps_data.items():
for app_key, app_value in apps_value.items():
logger.debug(f"tool.django.apps.{apps_name}: Update '{app_key}' with '{app_value}'")

self.data.update({app_key: self.parse_value(app_key, app_value)})

@typechecked
def _parse_path(path: Path, file_name: str) -> Path:
"""Parse a path string relative to a base path.
# Add settings from `tool.django.envs.*` if it matches the `ENVIRONMENT` env variable
if environment_env_variable := os.getenv("ENVIRONMENT"):
for envs_name, envs_value in envs_data.items():
if environment_env_variable == envs_name:
for env_key, env_value in envs_value.items():
logger.debug(f"tool.django.envs.{envs_name}: Update '{env_key}' with '{env_value}'")

Args:
file_name: Relative or absolute file name.
path: Base path to resolve file_name against.
"""
self.data.update({env_key: self.parse_value(env_key, env_value)})

_path = Path(path).parent if path.is_file() else path
return self.data

return (_path / file_name).resolve()
@typechecked
def get_data(self) -> dict:
"""Gets the data from the passed-in TOML file."""

data = {}

@typechecked
def _combine_bookends(original: str, match: re.Match, middle: Any) -> str:
"""Get the beginning of the original string before the match, and the
end of the string after the match and smush the replaced value in between
them to generate a new string.
"""
try:
data = toml.load(self.path)
except FileNotFoundError:
logger.warning(f"Cannot find file at: {self.path}")
except toml.TomlDecodeError:
logger.error(f"Cannot parse TOML at: {self.path}")

start_idx = match.start()
start = original[:start_idx]
return data.get("tool", {}).get("django", {}) or {}

end_idx = match.end()
ending = original[end_idx:]
@typechecked
def parse_value(self, key: Any, value: Any) -> Any:
"""Handle special cases for `value`.

return start + str(middle) + ending
Special cases:
- `dict` keys
- `$env`: retrieves an environment variable; optional `default` argument
- `$path`: converts string to a `Path`; handles relative path
- `$insert`: inserts the value to an array; optional `index` argument
- `$none`: inserts the `None` value
- `$value`: literal value
- `$type`: casts the value to a particular type
- variables in `str`
- `datetime`
"""

if isinstance(value, list):
# Process each item in the list
processed_list = []

@typechecked
def _get_special_key(data: dict, key: str) -> str:
"""Gets the key for the special operator. Defaults to "$" as the prefix, and "" as the suffix.
for item in value:
processed_item = self.parse_value(key, item)
processed_list.append(processed_item)

To change in the included TOML settings, set:
```
TOML_SETTINGS_SPECIAL_PREFIX = ""
TOML_SETTINGS_SPECIAL_SUFFIX = ""
```
"""
value = processed_list
elif isinstance(value, dict):
# Process nested dictionaries
processed_dict = {}

prefix = data.get("TOML_SETTINGS_SPECIAL_PREFIX", "$")
suffix = data.get("TOML_SETTINGS_SPECIAL_SUFFIX", "")

return f"{prefix}{key}{suffix}"
for k, v in value.items():
if isinstance(v, dict):
processed_dict.update({k: self.parse_value(key, v)})
else:
processed_dict[k] = v

value = processed_dict

type_parser = TypeParser(data=self.data, value=value)
env_parser = EnvParser(data=self.data, value=value)
path_parser = PathParser(data=self.data, value=value, path=self.path)
value_parser = ValueParser(data=self.data, value=value)
none_parser = NoneParser(data=self.data, value=value)
insert_parser = InsertParser(data=self.data, value=value, data_key=key)

# Check for a match for all operators (except $type)
for parser in [env_parser, path_parser, value_parser, insert_parser, none_parser]:
if parser.match():
value = parser.parse()
break

# Parse $type last because it can operate on the resolved value from the other parsers
if type_parser.match():
value = type_parser.parse(value)
elif isinstance(value, str):
value = VariableParser(data=self.data, value=value).parse()
elif isinstance(value, datetime):
value = dateparser.isoparse(str(value))

return value
Empty file.
Loading
Loading