Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
kalekundert committed May 3, 2022
2 parents 59d7ebb + a94e421 commit b5e80ca
Show file tree
Hide file tree
Showing 51 changed files with 2,549 additions and 230 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/test_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:

strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: [3.6, 3.7, 3.8, 3.9, '3.10']

steps:
- uses: actions/checkout@v2
Expand All @@ -32,7 +32,7 @@ jobs:
release:
name: Release to PyPI
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/release'
needs: [test]

steps:
Expand Down
42 changes: 25 additions & 17 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,38 @@
.. image:: https://img.shields.io/coveralls/kalekundert/byoc.svg
:target: https://coveralls.io/github/kalekundert/byoc?branch=master

BYOC is a python library for loading configuration values from any number/kind
of sources, e.g. files, command-line arguments, environment variables, remote
JSON APIs, etc. The primary goal of BYOC is to give you complete control over
your configuration. This means:
BYOC is a python library for integrating configuration values from any
number/kind of sources, e.g. files, command-line arguments, environment
variables, remote JSON APIs, etc. The primary goal of BYOC is to give you
complete control over your configuration. This means:

- Complete control over how things are named and organized.
- Complete control over how files, options, etc. are named and organized.

- Complete control over how values from different config sources are merged.
- Complete control over how values from different config sources are parsed and
merged.

- Support for any kind of file format, argument parsing library, etc.

- No opinions about anything enforced by BYOC.

The basic idea is to create a class with special attributes that know where to
look for configuration values. When these attributes are accessed, the correct
values are looked up, possibly merged, possibly cached, and returned. Here's a
brief example to show what this looks like::
To use BYOC, you would create a class with special attributes (called
parameters) that know where to look for configuration values. When these
parameters are accessed, the desired values are looked up, possibly merged,
possibly cached, and returned. Here's a brief example:

.. code-block:: python
import byoc
from byoc import Key, DocoptConfig, AppDirsConfig
class Greet(byoc.App):
"""
Say a greeting.
Say a greeting.
Usage:
greet <name> [-g <greeting>]
"""
Usage:
greet <name> [-g <greeting>]
"""
# Define which config sources are available to this class.
__config__ = [
DocoptConfig,
Expand All @@ -68,22 +72,26 @@ brief example to show what this looks like::
if __name__ == '__main__':
Greet.entry_point()
We can configure this script from the command line::
We can configure this script from the command line:

.. code-block:: bash
$ ./greet 'Sir Bedevere'
Hello, Sir Bedevere!
$ ./greet 'Sir Lancelot' -g Goodbye
Goodbye, Sir Lancelot!
We can also configure this script via its configuration files::
...or from its config files:

.. code-block:: bash
$ mkdir -p ~/.config/greet
$ echo "greeting: Run away" > ~/.config/greet/conf.yml
$ greet 'Sir Robin'
Run away, Sir Robin!
This example only scratches the surface, but hopefully you can already get a
sense for how powerful and flexible this property-based approach is. For more
sense for how powerful and flexible these parameters are. For more
information, refer to the following examples (in lieu of complete
documentation).

Expand Down
15 changes: 14 additions & 1 deletion byoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
__version__ = '0.29.0'

# Define the public API
_pre_import_keys = set()
_pre_import_keys |= set(globals())

from .app import App, BareMeta
from .model import (
init, load, reload, insert_config, insert_configs, append_config,
Expand All @@ -21,7 +24,17 @@
from .configs.attrs import config_attr
from .configs.on_load import on_load
from .getters import Key, Method, Func, Value
from .pickers import first
from .pick import first, list, merge_dicts
from .cast import Context, relpath, arithmetic_eval, float_eval, int_eval
from .key import jmes
from .meta import meta_view
from .errors import NoValueFound
from .utils import lookup

# Make everything imported above appear to come from this module:
_post_import_keys = set(globals())
for _key in _post_import_keys - _pre_import_keys:
globals()[_key].__module__ = 'byoc'
del _pre_import_keys, _post_import_keys, _key

toggle.__name__ = 'toggle'
176 changes: 176 additions & 0 deletions byoc/cast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env python3

import sys
import inspect

from .errors import Error
from more_itertools import first
from pathlib import Path
from typing import Union, Callable, Any

class Context:
"""
Extra information that can be made available to *cast* functions.
The *cast* argument to `param` is must be a function that takes one
argument and returns one value. Normally, this argument is simply the
value to cast. However, BYOC will instead provide a `Context` object if
the type annotation of that argument is `Context`::
>>> import byoc
>>> def f(context: byoc.Context):
... return context.value
Context objects have the following attributes:
- :attr:`value`: The value to convert. This is the same value that would
normally be passed directly to the *cast* function.
- :attr:`meta`: The metadata object associated with the parameter.
- :attr:`obj`: The object that owns the parameter, i.e. *self*.
"""

def __init__(self, value, meta, obj):
self.value = value
self.meta = meta
self.obj = obj

def call_with_context(f, context):
try:
sig = inspect.signature(f)
param = first(sig.parameters.values())

except ValueError:
pass

else:
if param.annotation is Context:
return f(context)

return f(context.value)


def relpath(
context: Context,
root_from_meta: Callable[[Any], Path]=\
lambda meta: Path(meta.location).parent,
) -> Path:
"""
Resolve paths loaded from a file. Relative paths are interpreted as being
relative to the parent directory of the file they were loaded from.
Arguments:
context: The context object provided by BYOC to cast functions.
root_from_meta: A callable that returns the parent directory for
relative paths, given a metadata object describing how the value in
question was loaded. The default implementation assumes that the
metadata object has a :attr:`location` attribute that specifies the
path to the relevant file. This will work if (i) the value was
actually loaded from a file and (ii) the default pick function was
used (i.e. `first`). For other pick functions, you may need to
modify this argument accordingly.
Returns:
An absolute path.
"""
path = Path(context.value)
if path.is_absolute():
return path

root = root_from_meta(context.meta)
return root.resolve() / path

def arithmetic_eval(expr: str) -> Union[int, float]:
"""\
Evaluate the given arithmetic expression.
Arguments:
expr:
The expression to evaluate. The syntax is identical to python, but
only `int` literals, `float` literals, binary operators (except
left/right shift, bitwise and/or/xor, and matrix multiplication),
and unary operators are allowed.
Returns:
The value of the given expression.
Raises:
SyntaxError: If *expr* cannot be parsed for any reason.
TypeError: If the *expr* argument is not a string.
ZeroDivisionError: If *expr* divides by zero.
It is safe to call this function on untrusted input, as there is no way to
construct an expression that will execute arbitrary code.
"""
import ast, operator

operators = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,

ast.USub: operator.neg,
ast.UAdd: operator.pos,
}

def eval_node(node):

if sys.version_info[:2] < (3, 8):
if isinstance(node, ast.Num):
return node.n

else:
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
else:
err = ArithmeticError(expr, non_number=node.value)
err.blame += "{non_number!r} is not a number"
raise err

if isinstance(node, ast.BinOp):
try:
op = operators[type(node.op)]
except KeyError:
err = ArithmeticError(expr, op=node.op)
err.blame += "the {op.__class__.__name__} operator is not supported"
raise err

left = eval_node(node.left)
right = eval_node(node.right)
return op(left, right)

if isinstance(node, ast.UnaryOp):
assert type(node.op) in operators
op = operators[type(node.op)]
value = eval_node(node.operand)
return op(value)

raise ArithmeticError(expr)

root = ast.parse(expr.lstrip(" \t"), mode='eval')
return eval_node(root.body)

def int_eval(expr: str) -> int:
"""\
Same as `arithmetic_eval()`, but convert the result to `int`.
"""
return int(arithmetic_eval(expr))

def float_eval(expr: str) -> float:
"""\
Same as `arithmetic_eval()`, but convert the result to `float`.
"""
return float(arithmetic_eval(expr))

class ArithmeticError(Error, SyntaxError):

def __init__(self, expr, **kwargs):
super().__init__(expr=expr, **kwargs)
self.brief = "unable to evaluate arithmetic expression"
self.info += "expression: {expr}"


12 changes: 6 additions & 6 deletions byoc/configs/attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ def __get__(self, obj, cls=None):
getter = self.getter or attrgetter(self.name)

log = Log()
log.info("getting '{attr}' config_attr for {obj!r}", obj=obj, attr=self.name)
log += f"getting '{self.name}' config_attr for {obj!r}"

for config in configs:
if not _is_selected_by_cls(config, self.config_cls):
log.info("skipped {config}: not derived from {config_cls.__name__}", config=config, config_cls=self.config_cls)
log += f"skipped {config}: not derived from {self.config_cls.__name__}"
continue

try:
return getter(config)
except AttributeError as err:
log.info(
"skipped {config}: {getter} raised {err.__class__.__name__}: {err}"
if self.getter else "skipped {config}: {err}",
config=config, getter=getter, err=err,
log += (
f"skipped {config}: {getter} raised {err.__class__.__name__}: {err}"
if self.getter else
f"skipped {config}: {err}"
)
continue

Expand Down
17 changes: 13 additions & 4 deletions byoc/configs/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ class CliConfig(Config):

@autoprop
class ArgparseConfig(CliConfig):
autoload = False
parser_getter = lambda obj: obj.get_argparse()
schema = None

Expand Down Expand Up @@ -82,7 +81,6 @@ def get_brief(self):

@autoprop
class DocoptConfig(CliConfig):
autoload = False
usage_getter = lambda obj: obj.__doc__
version_getter = lambda obj: getattr(obj, '__version__')
usage_io_getter = lambda obj: sys.stdout
Expand Down Expand Up @@ -252,24 +250,35 @@ def get_config_paths(self):

@autoprop
class FileConfig(Config):
path = None
path_getter = lambda obj: obj.path
schema = None
root_key = None

def __init__(self, obj, path=None, *, path_getter=None, schema=None, root_key=None, **kwargs):
super().__init__(obj, **kwargs)
self._path = path
self._path = path or self.path
self._path_getter = path_getter or unbind_method(self.path_getter)
self.schema = schema or self.schema
self.root_key = root_key or self.root_key

def get_paths(self):
try:
p = self._path or self._path_getter(self.obj)

except AttributeError as err:
self.load_status = lambda log, err=err: log.info("failed to get path(s):\nraised {err.__class__.__name__}: {err}", err=err)

def load_status(log, err=err, config=self):
log += f"failed to get path(s):\nraised {err.__class__.__name__}: {err}"
if config.paths:
br = '\n'
log += f"the following path(s) were specified post-load:{br}{br.join(str(p) for p in config.paths)}"
log += "to use these path(s), call `byoc.reload()`"

self.load_status = load_status
return []


if isinstance(p, Iterable) and not isinstance(p, str):
return [Path(pi) for pi in p]
else:
Expand Down
Loading

0 comments on commit b5e80ca

Please sign in to comment.