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
1 change: 0 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ jobs:
- {name: "baseline", os: ubuntu-latest, python-version: "3.14", matrix-backend: numpy, nprocs: 1}
- {name: "windows", os: windows-latest, python-version: "3.14", matrix-backend: numpy, nprocs: 1}
- {name: "macos", os: macos-latest, python-version: "3.14", matrix-backend: numpy, nprocs: 1}
- {name: "python 3.9", os: ubuntu-latest, python-version: "3.9", matrix-backend: numpy, nprocs: 1}
- {name: "python 3.10", os: ubuntu-latest, python-version: "3.10", matrix-backend: numpy, nprocs: 1}
- {name: "python 3.11", os: ubuntu-latest, python-version: "3.11", matrix-backend: numpy, nprocs: 1}
- {name: "python 3.12", os: ubuntu-latest, python-version: "3.12", matrix-backend: numpy, nprocs: 1}
Expand Down
10 changes: 10 additions & 0 deletions nutils/SI.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,16 @@
return NotImplemented
return f(*args, **kwargs)

def __into_ags__(self) -> str:
try:
return self._parsed_from
except AttributeError:
raise NotImplementedError

Check warning on line 580 in nutils/SI.py

View workflow job for this annotation

GitHub Actions / Test coverage

Lines not covered

Lines 579–580 of `nutils/SI.py` are not covered by tests.

@classmethod
def __from_ags__(cls, s: str):
return cls(s)


class Units(dict):

Expand Down
134 changes: 66 additions & 68 deletions nutils/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from . import numeric, warnings
from .types import arraydata
from ags import yaml, ucsl, load
import stringly
import sys
import os
Expand Down Expand Up @@ -367,9 +368,12 @@
This decorator searches the environment for variables matching the pattern
``NUTILS_MYPARAM``, where ``myparam`` is a parameter of the decorated
function. Only parameters with type annotation and a default value are
considered, and the string value is deserialized using `Stringly
<https://pypi.org/project/stringly/>`_. In case deserialization fails, a
warning is emitted and the original default is maintained.'''
considered, and the string value is deserialized `UCSL`_. In case
deserialization fails, a warning is emitted and the original default is
maintained.

.. _`UCSL`: https://github.com/evalf/ags?tab=readme-ov-file#ucsl
'''

sig = inspect.signature(f)
params = []
Expand All @@ -378,7 +382,7 @@
envname = f'NUTILS_{param.name.upper()}'
if envname in os.environ and param.annotation != param.empty and param.default != param.empty:
try:
v = stringly.loads(param.annotation, os.environ[envname])
v = ucsl.loads(os.environ[envname], param.annotation)
except Exception as e:
warnings.warn(f'ignoring environment variable {envname}: {e}')
else:
Expand Down Expand Up @@ -458,7 +462,10 @@

@functools.wraps(f)
def in_context(*args, **kwargs):
with context(**{param.name: kwargs.pop(param.name) for param in params if param.name in kwargs}):
context_args = {param.name: kwargs.pop(param.name) for param in params if param.name in kwargs}
for arg in context_args:
warnings.deprecation(f"setting {param.name!r} via the command line is deprecated and will be removed in Nutils 11; please set the environment variable NUTILS_{param.name.upper()} instead")

Check warning on line 467 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Line not covered

Line 467 of `nutils/_util.py` is not covered by tests.
with context(**context_args):
return f(*args, **kwargs)

sig = inspect.signature(f)
Expand Down Expand Up @@ -491,15 +498,13 @@

@functools.wraps(f)
def log_arguments(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
with treelog.context('arguments'):
for k, v in bound.arguments.items():
try:
s = stringly.dumps(_infer_type(sig.parameters[k]), v)
except:
s = str(v)
treelog.info(f'{k}={s}')
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
treelog.info(yaml.dumps(bound, sig).rstrip())
except Exception as e:
treelog.error("failed to serialize arguments:", e)
return f(*args, **kwargs)

return log_arguments
Expand Down Expand Up @@ -691,67 +696,60 @@
def cli(f, *, argv=None):
'''Call a function using command line arguments.'''

import textwrap

progname, *args = argv or sys.argv
doc = stringly.util.DocString(f)
serializers = {}
booleans = set()
mandatory = set()

for param in inspect.signature(f).parameters.values():
T = _infer_type(param)
try:
s = stringly.serializer.get(T)
except Exception as e:
raise Exception(f'stringly cannot deserialize argument {param.name!r} of type {T}') from e
if param.kind not in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY):
raise Exception(f'argument {param.name!r} is positional-only')
if param.default is param.empty and param.name not in doc.defaults:
mandatory.add(param.name)
if T == bool:
booleans.add(param.name)
serializers[param.name] = s

usage = [f'USAGE: {progname}']
if doc.presets:
usage.append(f'[{"|".join(doc.presets)}]')
usage.extend(('{}' if arg in mandatory else '[{}]').format(f'{arg}={arg[0].upper()}') for arg in serializers)
usage = '\n'.join(textwrap.wrap(' '.join(usage), subsequent_indent=' '))
sig = inspect.signature(f)

if '-h' in args or '--help' in args:
help = [usage]
if doc.text:
help.append('')
help.append(inspect.cleandoc(doc.text))
if doc.argdocs:
import textwrap
usage = f'USAGE: {progname} [path]'
for arg in sig.parameters:
usage += f' {arg}={arg[0].upper()}'
help = textwrap.wrap(usage, subsequent_indent=' ')
doc = inspect.getdoc(f)
if doc:
help.append('')
for k, d in doc.argdocs.items():
if k in serializers:
help.append(f'{k} (default: {doc.defaults[k]})' if k in doc.defaults else k)
help.extend(textwrap.wrap(doc.argdocs[k], initial_indent=' ', subsequent_indent=' '))
sys.exit('\n'.join(help))

kwargs = doc.defaults
if args and args[0] in doc.presets:
kwargs.update(doc.presets[args.pop(0)])
help.append(doc)
print('\n'.join(help))
sys.exit(0)

stringly_doc = stringly.util.DocString(f)
stringly_presets = stringly_doc.presets
stringly_defaults = stringly_doc.defaults

if args and '=' not in args[0]:
path, *args = args
if path in stringly_presets:
warnings.deprecation(
"Embedded presets are deprecated and will be removed in Nutils"

Check warning on line 723 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Lines not covered

Lines 720–723 of `nutils/_util.py` are not covered by tests.
"11. Consider copying the 'arguments' output to a .yml file and"
"using that as a first argument instead.")
kwargs = stringly_presets[path]

Check warning on line 726 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Line not covered

Line 726 of `nutils/_util.py` is not covered by tests.
else:
kwargs = load(path, sig).arguments

Check warning on line 728 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Line not covered

Line 728 of `nutils/_util.py` is not covered by tests.
else:
if stringly_defaults:
warnings.deprecation(
"Embedded function defaults are deprecated and will be removed"

Check warning on line 732 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Lines not covered

Lines 731–732 of `nutils/_util.py` are not covered by tests.
"in Nutils 11. Consider changing them into actual default values"
"of the Python function.")
kwargs = stringly_defaults

Check warning on line 735 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Line not covered

Line 735 of `nutils/_util.py` is not covered by tests.
else:
kwargs = {}

for arg in args:
name, sep, value = arg.partition('=')
kwargs[name] = value if sep else 'yes' if name in booleans else None

for name, s in kwargs.items():
if name not in serializers:
sys.exit(f'{usage}\n\nError: invalid argument {name!r}')
if s is None:
sys.exit(f'{usage}\n\nError: argument {name!r} requires a value')
try:
value = serializers[name].loads(s)
except Exception as e:
sys.exit(f'{usage}\n\nError: invalid value {s!r} for {name}: {e}')
kwargs[name] = value

for name in mandatory.difference(kwargs):
sys.exit(f'{usage}\n\nError: missing argument {name}')
if name not in sig.parameters:
sys.exit(f"Error: invalid argument {name!r}")
T = _infer_type(sig.parameters[name])
if sep is None:
if T != bool:
sys.exit(f"Error: argument {name!r} requires a value")
kwargs[name] = True

Check warning on line 747 in nutils/_util.py

View workflow job for this annotation

GitHub Actions / Test coverage

Lines not covered

Lines 745–747 of `nutils/_util.py` are not covered by tests.
else:
try:
kwargs[name] = ucsl.loads(value, T)
except Exception as e:
sys.exit(f"Error: invalid value {value!r} for {name}: {e}")

return f(**kwargs)

Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ readme = "README.md"
authors = [
{ name = "Evalf", email = "info@evalf.com" },
]
requires-python = '>=3.9'
requires-python = '>=3.10'
dependencies = [
"appdirs >=1,<2",
"numpy >=2.0.1,<3",
"nutils-poly >=1,<2",
"ags[yaml] >=0.3,<0.4",
"stringly",
"treelog >=1,<2",
"treelog >=2,<3",
]
dynamic = ["description", "version"]
classifiers = [
Expand Down
8 changes: 8 additions & 0 deletions tests/test_SI.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pickle
import typing
import unittest
import ags.ucsl


class Dimension(unittest.TestCase):
Expand Down Expand Up @@ -284,6 +285,13 @@ def test_interp(self):
self.assertEqual(f[0], SI.Force('11N'))
self.assertEqual(f[1], SI.Force('12N'))

def test_ags(self):
q = SI.Mass('2kg')
s = ags.ucsl.dumps(q, SI.Mass)
self.assertEqual(s, '2kg')
q_ = ags.ucsl.loads(s, SI.Mass)
self.assertEqual(q_, q)


class Locate(unittest.TestCase):

Expand Down
99 changes: 62 additions & 37 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,90 @@
import tempfile
import os
from nutils import cli, testing


def main(
iarg: int = 1,
farg: float = 1.,
sarg: str = 'foo'):
'''Dummy function to test argument parsing.'''

assert isinstance(iarg, int), 'n should be int, got {}'.format(type(iarg))
assert isinstance(farg, float), 'f should be float, got {}'.format(type(farg))
assert isinstance(sarg, str), 'f should be str, got {}'.format(type(sarg))
return f'received iarg={iarg} <{type(iarg).__name__}>, farg={farg} <{type(farg).__name__}>, sarg={sarg} <{type(sarg).__name__}>'
from contextlib import redirect_stdout
from io import StringIO


class method(testing.TestCase):

def setUp(self):
super().setUp()
self.outrootdir = self.enter_context(tempfile.TemporaryDirectory())
self.method = getattr(cli, self.__class__.__name__)

def assertEndsWith(self, s, suffix):
self.assertEqual(s[-len(suffix):], suffix)

def _cli(self, *args, funcname='main'):
argv = ['test.py', *args, 'pdb=no', 'outrootdir='+self.outrootdir]
if self.method is cli.choose:
argv.insert(1, funcname)
try:
return self.method(main, argv=argv)
except SystemExit as e:
return e.code
self._setenv('NUTILS_OUTROOTDIR', self.enter_context(tempfile.TemporaryDirectory()))
self._setenv('NUTILS_PDB', 'no')

def _setenv(self, key, value):
old_value = os.getenv(key)
if old_value is None:
self.addCleanup(os.unsetenv, key)
else:
self.addCleanup(os.putenv, key, old_value)
os.putenv(key, value)

def main(
self,
iarg: int = 1,
farg: float = 1.,
sarg: str = 'foo'):
'''Dummy function to test argument parsing.'''

self.assertIsInstance(iarg, int)
self.assertIsInstance(farg, float)
self.assertIsInstance(sarg, str)

def test_good(self):
retval = self._cli('iarg=1', 'farg=1', 'sarg=1')
self.assertEqual(retval, 'received iarg=1 <int>, farg=1.0 <float>, sarg=1 <str>')
self._cli('iarg=1', 'farg=1', 'sarg=1')

def test_badarg(self):
retval = self._cli('bla')
self.assertEndsWith(retval, "Error: invalid argument 'bla'")
with self.assertRaisesRegex(SystemExit, "Error: invalid argument 'bla'"):
self._cli('iarg=1', 'bla')

def test_badvalue(self):
retval = self._cli('iarg=1', 'farg=x', 'sarg=1')
self.assertEndsWith(retval, "Error: invalid value 'x' for farg: could not convert string to float: 'x'")
with self.assertRaisesRegex(SystemExit, "Error: invalid value 'x' for farg: could not convert string to float: 'x'"):
self._cli('iarg=1', 'farg=x', 'sarg=1')

def test_help(self):
for arg in '-h', '--help':
retval = self._cli(arg)
self.assertEndsWith(retval, 'Dummy function to test argument parsing.')
with self.assertRaises(SystemExit) as cm, redirect_stdout(StringIO()) as f:
self._cli(arg)
self.assertEqual(f.getvalue(), self.usage)
self.assertEqual(cm.exception.code, 0)


class run(method):
pass

def _cli(self, *args):
argv = ['test.py', *args]
return cli.run(self.main, argv=argv)

usage = '''\
USAGE: test.py [path] iarg=I farg=F sarg=S pdb=P gracefulexit=G
outrootdir=O outrooturi=O scriptname=S outdir=O outuri=O
richoutput=R verbose=V matrix=M nprocs=N cache=C cachedir=C

Dummy function to test argument parsing.
'''


class choose(method):

def other(*_):
self.fail("wrong function")

def _cli(self, *args, funcname='main'):
argv = ['test.py', funcname, *args]
return cli.choose(self.main, self.other, argv=argv)

def test_badchoice(self):
retval = self._cli(funcname='bla')
self.assertEqual(retval, 'USAGE: test.py main [...]')
with self.assertRaisesRegex(SystemExit, r'USAGE: test.py main|other \[...\]'):
self._cli(funcname='bla')

usage = '''\
USAGE: test.py main [path] iarg=I farg=F sarg=S pdb=P gracefulexit=G
outrootdir=O outrooturi=O scriptname=S outdir=O outuri=O
richoutput=R verbose=V matrix=M nprocs=N cache=C cachedir=C

Dummy function to test argument parsing.
'''


del method # hide base class from unittest discovery
Loading
Loading