diff --git a/byoc/__init__.py b/byoc/__init__.py index efe7f2e..65e6e78 100644 --- a/byoc/__init__.py +++ b/byoc/__init__.py @@ -25,7 +25,7 @@ from .configs.on_load import on_load from .getters import Key, Method, Func, Value from .pick import first -from .cast import Context, arithmetic_eval, float_eval, int_eval +from .cast import Context, relpath, arithmetic_eval, float_eval, int_eval from .key import jmes from .meta import meta_view from .errors import NoValueFound diff --git a/byoc/cast.py b/byoc/cast.py index 3ad392e..d6033a0 100644 --- a/byoc/cast.py +++ b/byoc/cast.py @@ -5,9 +5,29 @@ from .errors import Error from more_itertools import first -from typing import Union +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 @@ -29,6 +49,36 @@ def call_with_context(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. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index b513b5b..13a0559 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -72,9 +72,11 @@ Key/Pick/Cast functions byoc.first byoc.jmes + byoc.relpath byoc.int_eval byoc.float_eval byoc.arithmetic_eval + byoc.Context Metadata ======== diff --git a/tests/param_helpers.py b/tests/param_helpers.py index a8c4df0..4e909e1 100644 --- a/tests/param_helpers.py +++ b/tests/param_helpers.py @@ -8,6 +8,7 @@ from voluptuous import Schema, And, Or, Optional, Invalid, Coerce from unittest.mock import Mock from re_assert import Matches +from pathlib import Path if sys.version_info[:2] >= (3, 10): from functools import partial @@ -218,3 +219,28 @@ def get(ns): get_config = get_obj_or_cls('config') get_meta = get_obj_or_cls('meta') no_templates = '^[^{}]*$' + +@pytest.fixture +def files(request, tmp_path): + + # We want to return a `pathlib.Path` instance with an extra `manifest` + # attribute that records the specific file names/contents the were + # provided. Unfortunately, `pathlib.Path` uses slots, so the only way to + # do this is to make our own subclass. This is complicated further by the + # fact that `pathlib.Path` is just a factory, and instantiates different + # classes of objects depending on the operating system. + + class TestPath(type(tmp_path)): + __slots__ = ('manifest',) + + tmp_path = TestPath._from_parts([tmp_path]) + tmp_path.manifest = request.param + + for name, contents in request.param.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(contents) + + return tmp_path + +files.schema = empty_ok({str: str}) diff --git a/tests/test_cast.nt b/tests/test_cast.nt index a45900f..d08ffab 100644 --- a/tests/test_cast.nt +++ b/tests/test_cast.nt @@ -59,6 +59,41 @@ test_call_with_context: type: ValueError message: value: 1 +test_relpath: + - + id: relative + files: + conf.nt: + > x: a + obj: + > class DummyObj: + > __config__ = [NtConfig.setup('conf.nt')] + > x = byoc.param(cast=byoc.relpath) + expected: + x: a + - + id: relative + files: + a/conf.nt: + > x: b + obj: + > class DummyObj: + > __config__ = [NtConfig.setup('a/conf.nt')] + > x = byoc.param(cast=byoc.relpath) + expected: + x: a/b + - + id: absolute + files: + a/conf.nt: + > x: /b + obj: + > class DummyObj: + > __config__ = [NtConfig.setup('a/conf.nt')] + > x = byoc.param(cast=byoc.relpath) + expected: + x: /b + test_arithmetic_eval: # Literals - diff --git a/tests/test_cast.py b/tests/test_cast.py index 05cbf47..7999a6e 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -23,6 +23,22 @@ def test_call_with_context(func, value, meta, obj, expected, error): with error: assert call_with_context(func, context) == expected + +@parametrize_from_file( + schema=Schema({ + 'obj': with_byoc.exec(get=get_obj, defer=True), + 'expected': {str: str}, + 'files': files.schema + }), + indirect=['files'], +) +def test_relpath(obj, expected, files, monkeypatch): + monkeypatch.chdir(files) + + obj = obj.exec() + for param, relpath in expected.items(): + assert getattr(obj, param) == files / relpath + @parametrize_from_file( schema=Schema({ 'expr': str, diff --git a/tests/test_config.py b/tests/test_config.py index a270cec..19354e4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -105,9 +105,10 @@ class DummyObj: 'slug': with_py.eval, 'author': with_py.eval, 'version': with_py.eval, - 'files': {str: str}, + 'files': files.schema, 'layers': eval_config_layers, - }) + }), + indirect=['files'], ) def test_appdirs_config(tmp_chdir, monkeypatch, obj, slug, author, version, files, layers): import appdirs @@ -124,15 +125,13 @@ def __init__(self, slug, author, version): monkeypatch.setattr(appdirs, 'AppDirs', AppDirs) - for name, content in files.items(): - path = tmp_chdir / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - assert obj.dirs.slug == slug assert obj.dirs.author == author assert obj.dirs.version == version - assert list(obj.config_paths) == unordered([Path(x) for x in files.keys()]) + assert list(obj.config_paths) == unordered([ + Path(x) + for x in files.manifest.keys() + ]) byoc.init(obj) assert collect_layers(obj)[0] == layers @@ -160,14 +159,10 @@ def test_appdirs_config_get_name_and_config_cls(config, name, config_cls, error) 'files': empty_ok({str: str}), 'layers': eval_config_layers, Optional('load_status', default=[]): [str], - }) + }), + indirect=['files'], ) def test_file_config(tmp_chdir, obj, files, layers, load_status): - for name, content in files.items(): - path = tmp_chdir / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - byoc.init(obj) assert collect_layers(obj)[0] == layers