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
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,8 @@ celerybeat.pid
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New class `logs.LoggingSetupHelper`.
- New functions `logs.get_logger()` and `convert_log_level()`.
- New ***naive*** functions `strings.camel_to_snake()` and `snake_to_camel()`.
- New class `types.LiteralHelper[T]`.
- New wrapper functions `types.verify_literal()` and `verify_enum()`.
- New module `rics.env`:
* Added `read`-functions for primitive types; `read_bool()`, `read_int`, `read_enum()`.
* Added `types.LiteralHelper[T].read_env()`.
- New function `strings.str_as_bool()`.

### Changed
- Update `basic_config.basic_config()`: Allow and handle `level=None` to avoid logging from root.
- The `misc.get_by_full_name()` function now supports reading member attributes. Uses
[entrypoint](https://packaging.python.org/en/latest/specifications/entry-points/) syntax, e.g.
`pandas:DataFrame.sum`.
- Moved some functions to new `rics.env` module:
* Moves implementation `rics.envinterp` -> `rics.env.interpolation`.
* Moved implementation of `misc.interpolate_environment_variables()` -> `env.interpolation.replace_in_string()`.

Aliases above will be deprecated in `0.6.0` and removed in `0.7.0`.

### Fixed
- Calling `MultiCaseTimer.run(number=<int>)` no longer crashes.
Expand Down
441 changes: 228 additions & 213 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 0 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,6 @@ ignore = [
"D205", # Clashes with sphinx_gallery (generated examples)
"B007",
]
"**/time_split/*" = [
"PLR0913", # Too many arguments in function definition
]
"**/time_split/integration/sklearn/*" = [
"N803", # Argument name `X` should be lowercase
"ARG002", # Unused method argument: `groups`
]

[tool.ruff.lint.pydocstyle]
convention = "google"
Expand Down
1 change: 1 addition & 0 deletions src/rics/env/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Utilities for working with environment variables."""
65 changes: 65 additions & 0 deletions src/rics/env/interpolation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Environment variable interpolation in files and strings.

This module provides utilities to read text and replace environment variable references with the actual environment
variable value using a familiar syntax. Optional default values are also possible, as well as recursively nested
variables (must be explicitly enabled).

Syntax:
Similar to Bash variable interpolation; ``${<var>}``, where ``<var>`` is the name of the environment variable you
wish to extract. This particular form will raise an exception if ``<var>`` is not set.

Default values:
Default values are specified by stating the default value after a colon; ``${<var>:default-value}``. The default
value may be blank, in which case an empty string is used as the default.

There are three main ways of using this module:

* The :meth:`.Variable.parse_string`-function, combined with :meth:`.Variable.get_value`. This gives you the most
amount of control.
* The :func:`replace_in_string`-function, for basic interpolation without recursion in an entire string.
* The :func:`rics.misc.interpolate_environment_variables`-function, which adds some additional logic on top of what
the :class:`Variable`-methods provide.

Examples:
We'll set ``ENV_VAR0=VALUE0`` and ``ENV_VAR1=VALUE1``. Vars 2 and 4 are not set.

>>> from os import environ
>>> environ["ENV_VAR0"] = "VALUE0"
>>> environ["ENV_VAR1"] = "VALUE1"

Basic interpolation.

>>> Variable.parse_first("${ENV_VAR0}").get_value()
'VALUE0'

Setting a default works as expected. The default may also be empty.

>>> Variable.parse_first("${ENV_VAR2:default}").get_value()
'default'
>>> Variable.parse_first("${ENV_VAR2:}").get_value()
''

The variable must exist if no default is given.

>>> Variable.parse_first("${DOESNT_EXIST}").get_value() # doctest: +SKIP
UnsetVariableError: Required Environment Variable 'DOESNT_EXIST': Not set.

Furthermore, variables may be nested, but this requires setting the flag to enable recursive parsing.

>>> Variable.parse_first("${ ENV_VAR2 :${ ENV_VAR0 }}").get_value(
... resolve_nested_defaults=True
... )
'VALUE0'

Whitespaces around the names is fine. Finally, nesting may be arbitrarily deep.

>>> Variable.parse_first("${ENV_VAR3:${ENV_VAR4:${ENV_VAR0}}}").get_value(True)
'VALUE0'

TODO infinite recursion ovan?
"""

from ._file_utils import replace_in_string
from ._variable import UnsetVariableError, Variable

__all__ = ["UnsetVariableError", "Variable", "replace_in_string"]
40 changes: 40 additions & 0 deletions src/rics/env/interpolation/_file_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from ._variable import UnsetVariableError, Variable


def replace_in_string(
s: str,
*,
allow_nested: bool = True,
allow_blank: bool = False,
) -> str:
"""Interpolate environment variables in a string `s`.

This function replaces references to environment variables with the actual value of the variable, or a default if
specified. The syntax is similar to Bash string interpolation; use ``${<var>}`` for mandatory variables, and
``${<var>:default}`` for optional variables.

Args:
s: A string in which to interpolate.
allow_blank: If ``True``, allow variables to be set but empty.
allow_nested: If ``True`` allow using another environment variable as the default value. This option will not
verify whether the actual values are interpolation-strings.

Returns:
A copy of `s`, after environment variable interpolation.

Raises:
ValueError: If nested variables are discovered (only when ``allow_nested=False``).
UnsetVariableError: If any required environment variables are unset or blank (only when ``allow_blank=False``).
"""
for var in Variable.parse_string(s):
if not allow_nested and (var.default and Variable.parse_string(var.default)):
raise ValueError(f"Nested variables forbidden since {allow_nested=}.")

value = var.get_value(resolve_nested_defaults=allow_nested).strip()

if not (allow_blank or value):
msg = f"Empty values forbidden since {allow_blank=}."
raise UnsetVariableError(var.name, msg)

s = s.replace(var.full_match, value)
return s
19 changes: 19 additions & 0 deletions src/rics/env/read/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Read environment variables as specific types.

To read ``typing.Literal`` values, use :meth:`rics.types.LiteralHelper.read_env` instead.
"""

from ._base import read_env
from ._bool import read_bool
from ._enum import read_enum
from ._numeric import read_float, read_int
from ._str import read_str

__all__ = [
"read_bool",
"read_enum",
"read_env",
"read_float",
"read_int",
"read_str",
]
123 changes: 123 additions & 0 deletions src/rics/env/read/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import os
from collections.abc import Callable
from typing import NamedTuple, overload

from rics.types import T


@overload
def read_env(
var: str,
converter: Callable[[str], T],
default: T,
*,
strict: bool = True,
type_name: str | None = None,
split: None = None,
catch: tuple[type[Exception], ...] | None = None,
) -> T: ...


@overload
def read_env(
var: str,
converter: Callable[[str], T],
default: T,
*,
strict: bool = True,
type_name: str | None = None,
split: str,
catch: tuple[type[Exception], ...] | None = None,
) -> list[T]: ...


def read_env(
var: str,
converter: Callable[[str], T],
default: T,
*,
strict: bool = True,
type_name: str | None = None,
split: str | None = None,
catch: tuple[type[Exception], ...] | None = None,
) -> T | list[T]:
"""Read and convert an environment variable.

Args:
var: Variable to read.
converter: A callable ``(str) -> T``, where the argument is the environment variable value.
default: Default value to use when `var` is not set (or blank).
strict: If ``False``, always fall back to `default` instead of raising.
type_name: Used in error messages. Derive if ``None``.
split: Character to split on. Returns ``list[T]`` when set.
catch: Types to suppress when calling ``converter``. Default is ``(ValueError, TypeError)``.

Returns:
A ``T`` value, or a list thereof (if `split` is set).

Raises:
ValueError: If conversion fails.

Notes:
If the `variable` key is unset or the value is empty, the `default` value is always returned.
"""
value = os.environ.get(var)
if value is None:
return [default] if split else default

value = str(value).strip() # Just in case; should already be a string.
if value == "":
return [default] if split else default

if catch is None:
catch = ValueError, TypeError

cause: BaseException
reason: str
if split is None:
try:
return converter(value)
except catch as exc:
cause = exc
reason = f": {exc}"
else:
result = _split(value.split(split), converter, catch)
if isinstance(result, _ExceptionDetails):
reason = f".\nNOTE: Failed at {var}[{result.idx}]={result.value!r}: {result.exception}"
cause = result.exception
else:
return result

if strict:
if type_name is None:
type_name = type(default).__name__
if split:
type_name = f"list[{type_name}]"
msg = f"Bad value {var}={value!r}; not a valid `{type_name}` value" + reason
raise ValueError(msg) from cause

return [default] if split else default


class _ExceptionDetails(NamedTuple):
exception: BaseException
idx: int
value: str


def _split(
values: list[str],
converter: Callable[[str], T],
catch: tuple[type[BaseException], ...],
) -> list[T] | _ExceptionDetails:
items = []
for i, value in enumerate(values):
stripped = value.strip()
if stripped:
try:
result = converter(stripped)
items.append(result)
except catch as exc:
return _ExceptionDetails(exc, i, stripped)

return items
54 changes: 54 additions & 0 deletions src/rics/env/read/_bool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from typing import overload

from rics.strings import str_as_bool

from ._base import read_env


@overload
def read_bool(var: str, default: bool = False, *, strict: bool = True, split: str) -> list[bool]: ...
@overload
def read_bool(var: str, default: bool = False, *, strict: bool = True, split: None = None) -> bool: ...
def read_bool(var: str, default: bool = False, *, strict: bool = True, split: str | None = None) -> bool | list[bool]:
"""Read ``bool`` variable.

Args:
var: Variable to read.
default: Default value to use when `var` is not set (or blank).
strict: If ``False``, always fall back to `default` instead of raising.
split: Character to split on. Returns ``list[bool]`` when set.

Returns:
A ``bool`` value, or a list thereof (if `split` is set).

Notes:
See :func:`~rics.strings.str_as_bool` for value mapping.

Examples:
Basic usage.

>>> import os
>>> os.environ["MY_BOOL"] = "true"
>>> read_bool("MY_BOOL")
True

>>> os.environ["MY_BOOL"] = "0"
>>> read_bool("MY_BOOL")
False

When using ``strict=False``, unmapped values are converted to the `default` instead of raising.

>>> os.environ["MY_BOOL"] = "not-a-bool"
>>> read_bool("MY_BOOL", default=True, strict=False)
True

When using `split`, elements are cleaned individually and blank items are skipped.

>>> os.environ["MY_BOOL_LIST"] = "true, 0, no, yes, enable,, false"
>>> read_bool("MY_BOOL_LIST", split=",")
[True, False, False, True, True, False]

Conversion (see :attr:`~rics.strings.str_as_bool`) must succeed for all elements, or the `default` will be
returned.
"""
return read_env(var, str_as_bool, default, strict=strict, split=split)
Loading