Skip to content

Commit

Permalink
feat: add the relpath() cast function
Browse files Browse the repository at this point in the history
  • Loading branch information
kalekundert committed Mar 14, 2022
1 parent 17df65b commit e5c5981
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 16 deletions.
2 changes: 1 addition & 1 deletion byoc/__init__.py
Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion byoc/cast.py
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/api_reference.rst
Expand Up @@ -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
========
Expand Down
26 changes: 26 additions & 0 deletions tests/param_helpers.py
Expand Up @@ -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
Expand Down Expand Up @@ -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})
35 changes: 35 additions & 0 deletions tests/test_cast.nt
Expand Up @@ -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
-
Expand Down
16 changes: 16 additions & 0 deletions tests/test_cast.py
Expand Up @@ -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,
Expand Down
23 changes: 9 additions & 14 deletions tests/test_config.py
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down

0 comments on commit e5c5981

Please sign in to comment.