diff --git a/.claude/skills/use-cmeta-cli/SKILL.md b/.claude/skills/use-cmeta-cli/SKILL.md index 79d905c..18cf192 100644 --- a/.claude/skills/use-cmeta-cli/SKILL.md +++ b/.claude/skills/use-cmeta-cli/SKILL.md @@ -88,6 +88,7 @@ Parsed by `cmeta/utils/cli.py::parse_cmd`. Rules: |--------|--------| | `--key` | boolean `True` | | `--key-` | boolean `False` | +| `--key=yes` / `--key=no` | boolean when the command annotates `key: bool` (also true/false, on/off, 1/0); any other word is an error; text for a parameter without the annotation | | `--no-key` | boolean `False` | | `--key=value` | string / int (repeats overwrite — last wins); `--key value` is the flag `--key` (True) + a positional `value` | | `--key,=v1,v2,v3` | list `[v1, v2, v3]` (trailing comma on the key requests split) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf76cf..3efb509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to cMeta are documented here, newest first. +## 0.37.0 +- **The words of the command line are read by what a command expects.** For the parameters a command annotates + `bool` (`sort: bool = False`, as `--help` shows), `--sort=no` now hands it `False`: yes/no, true/false, on/off + and 1/0 in any case are booleans, and any other word is an error naming the parameter and the command. + Before, the command got the string `'no'`, which is true - a latent bug in every such parameter. A parameter + without the annotation gets the text as it is; the Python API is never read (it passes Python values), and + neither is a nested call. A category can also describe the sensitive parameters of a command in its `_cmeta` + (`cli_params`, per command: a type among bool, int, float and str, allowed `values`, a `min` and a `max`), and + the engine converts and checks exactly those - the gradual way: describe what matters, when it matters. Two + switches keep the words text as before: `CMETA_CLI_TYPES=off` for a process and `cli_types: false` in a + category's `_cmeta`. The helper is `utils.cli.typed_params_from_cli`; the aops task category calls it for a + task's `run()`, so `--x=no` and `--use..x=no` reach a task as booleans too (cmeta-aops 0.46). The cost is + below a microsecond per call. Docs: using-cmeta.md §3.5, common-commands.md, the use-cmeta-cli skill. + + ## 0.36.0 - **The command line: free text with `=` stays text, a negative number is a value, a control flag takes a word, and `--` is refused in words.** Four small corrections of the parser, none of which changes a command that diff --git a/CITATION.cff b/CITATION.cff index ddb76ae..7d78cc5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ authors: website: "https://cTuning.ai/@gfursin" affiliation: "cTuning Labs" orcid: "https://orcid.org/0000-0001-7719-1624" -version: "0.36.0" +version: "0.37.0" date-released: "2026-10-13" license: Apache-2.0 repository-code: "https://github.com/cTuningLabs/cmeta" diff --git a/README.md b/README.md index 5b34047..c37e39f 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,8 @@ command): ### Flag syntax at a glance -- Booleans: `--flag` (True), `--flag-` (False), `--no-flag` (False). +- Booleans: `--flag` (True), `--flag-` (False), `--no-flag` (False); `--flag=yes` / `--flag=no` (also + true/false, on/off, 1/0) when the command annotates the parameter `bool` - any other word is an error. - Strings: `--key=value` (repeats overwrite — last wins); `--key value` is the flag `--key` and a positional `value`. - Lists: `--key,=v1,v2,v3` (trailing comma on the key). diff --git a/cmeta/core.py b/cmeta/core.py index 2f31a2d..be503fc 100644 --- a/cmeta/core.py +++ b/cmeta/core.py @@ -893,6 +893,25 @@ def access( f'is passed to the command unparsed, and this command does not read it).\n\n' f'Rerun with {extra_flags_help}--help to view API usage and options') + # The words of the command line, read by what the command expects (cMeta 0.37): for the request + # the command line made (a nested call passes Python values), a keyword-style function gets + # booleans for the parameters it annotates bool, and the parameters its category describes + # (`cli_params` in the category's _cmeta, per command) their described type, values and range + if keyword_style and inside_cli and nested_call == 0 and utils.cli.cli_types_enabled() \ + and category_meta.get(utils.cli.CLI_TYPES_META_KEY, True): + described = category_meta.get(utils.cli.CLI_PARAMS_META_KEY) or {} + + def where(): + r = utils.names.restore_cmeta_obj(cmeta_ref_parts, key='artifact', fail_on_error=False) + return f' of "{r["obj"]} {command}"' if 'obj' in r else f' of "{command}"' + + r = utils.cli.typed_params_from_cli(func, command_params, described.get(command), where=where) + + if r['return'] > 0: + extra_flags_help = category_meta.get('extra_flags_help', '') + + return self.error(f"{r['error']}\n\nRerun with {extra_flags_help}--help to view API usage and options") + try: command_params['ctx'] = ctx if keyword_style: diff --git a/cmeta/internal-repo/_cmr.yaml b/cmeta/internal-repo/_cmr.yaml index d002f96..cbb1e2d 100644 --- a/cmeta/internal-repo/_cmr.yaml +++ b/cmeta/internal-repo/_cmr.yaml @@ -3,5 +3,5 @@ authors: "[Grigori Fursin](https://cTuning.ai/@gfursin)" category: repo,f4f792ab40c7498f copyright: "2025-2026 Grigori Fursin and cTuning Labs. See the cMeta COPYRIGHT and LICENSE files for details." permanent: True -version: '0.36.0' +version: '0.37.0' version_date: '20261013' diff --git a/cmeta/utils/cli.py b/cmeta/utils/cli.py index 922206a..48f1248 100644 --- a/cmeta/utils/cli.py +++ b/cmeta/utils/cli.py @@ -6,6 +6,9 @@ See the cMeta COPYRIGHT and LICENSE files in the project root for details. """ +import functools +import inspect +import os import re from typing import Dict, Any, List, Optional @@ -374,6 +377,151 @@ def check_params( } +################################################################################################### +# The words of the command line, read by what a command expects (cMeta 0.37). For the parameters a +# keyword-style function annotates `bool`, the words yes/no, true/false, on/off and 1/0 become booleans +# (before, `--x=no` handed the command the string 'no', which is true). A command or a task can also DESCRIBE +# its sensitive parameters in its metadata (`cli_params`: a type, allowed values, a range), checked only for +# the parameters it names. Only strings are touched: the Python API passes Python values and is left alone. + +CLI_TYPES_ENV = 'CMETA_CLI_TYPES' # off / 0 / no / false: the words stay text everywhere (as before 0.37) +CLI_TYPES_META_KEY = 'cli_types' # false in a category's _cmeta or a task's _desc: the words stay text for it +CLI_PARAMS_META_KEY = 'cli_params' # the described parameters: per command in a category, flat in a task + +BOOL_WORDS = {'1': True, 'on': True, 'true': True, 'yes': True, '0': False, 'off': False, 'false': False, 'no': False} + +CLI_TYPES = {'bool': bool, 'int': int, 'float': float, 'str': str} + +# Python 3.14 evaluates annotations lazily: ask for the forward-reference form, which never raises +try: + import annotationlib + _SIGNATURE_ARGS = {'annotation_format': annotationlib.Format.FORWARDREF} +except ImportError: + _SIGNATURE_ARGS = {} + + +def cli_types_enabled(): + """ + Whether the words of the command line are read by annotations and descriptions (the default) or stay + text as before cMeta 0.37: CMETA_CLI_TYPES=off (0, no, false) turns the reading off for a process. + """ + return os.environ.get(CLI_TYPES_ENV, '').strip().lower() not in ('0', 'off', 'no', 'false') + + +def _is_bool_annotation( + annotation, # The annotation of a parameter: a class, a typing form, or a string. +): + """ + True for `bool`, also as the string 'bool' (`from __future__ import annotations`). A default of None is + fine (`sort: bool = None`); a union form such as `bool | None` is not read - annotate `bool`. + """ + return annotation is bool or (isinstance(annotation, str) and annotation.strip() == 'bool') + + +@functools.lru_cache(maxsize=512) +def bool_params_of( + func, # The function (or bound method) that will receive the parameters. +): + """ + The names of the parameters `func` annotates `bool`, read once per function (empty without a signature). + """ + try: + return frozenset(name for name, p in inspect.signature(func, **_SIGNATURE_ARGS).parameters.items() + if _is_bool_annotation(p.annotation)) + except (TypeError, ValueError): + return frozenset() + + +def _word_as( + value, # The string from the command line. + kind, # bool, int or float; str or None keep the string. +): + """ + (the string as `kind`, None), or (None, the name of the type the string is not). + """ + text = value.strip() + + if kind is bool: + word = text.lower() + return (BOOL_WORDS[word], None) if word in BOOL_WORDS else (None, 'bool') + + if kind in (int, float): + try: + return kind(text), None + except ValueError: + return None, kind.__name__ + + return value, None + + +def typed_params_from_cli( + func, # The function (or bound method) that will receive the parameters. + params, # The parameters as the command line made them (changed in place). + described = None, # The described parameters of the command or task: name -> type name or dict, if any. + where = '', # Text naming the command or task for an error (' of "cache show"'), or a callable giving it. + fail_on_error = False, # If True, raise exceptions instead of returning error dictionaries. +): + """ + Read the words of the command line by what the function expects: the strings of `params` for the + parameters `func` annotates `bool` become booleans; the strings for the parameters `described` names + (name -> 'bool' | 'int' | 'float' | 'str', or name -> {'type', 'values', 'min', 'max'}) become the + described type and are checked against the allowed values and the range. Any other parameter, and + any value that is not a string, is left as it is. A string that is not of the type is an error. + + Returns: + dict: {'return': 0, 'converted': {name: value}} or {'return': 1, 'error': ...}. + """ + def error(text, name, hint = ''): + place = where() if callable(where) else where + return _error(f"{text} for '{name}'{place}{hint}", 1, None, fail_on_error) + + bool_hint = ': use yes/no, true/false, on/off, 1/0 or the flags --{0} / --{0}-' + converted = {} + + for name in params.keys() & bool_params_of(func): + if isinstance(params[name], str): + value, wrong = _word_as(params[name], bool) + + if wrong: + return error(f"Invalid bool value '{params[name]}'", name, bool_hint.format(name)) + + params[name] = converted[name] = value + + for name, desc in (described or {}).items(): + desc = {'type': desc} if isinstance(desc, str) else desc + + if name not in params or not isinstance(desc, dict): + continue + + kind = CLI_TYPES.get(str(desc.get('type', '')).strip().lower()) + value = params[name] + + if kind in (bool, int, float) and isinstance(value, str): + value, wrong = _word_as(value, kind) + + if wrong: + return error(f"Invalid {wrong} value '{params[name]}'", name, bool_hint.format(name) if wrong == 'bool' else '') + + params[name] = converted[name] = value + + values = desc.get('values') + + if values is not None and value not in values: + return error(f"Invalid value '{value}'", name, f": must be one of {', '.join(map(str, values))}") + + try: + if desc.get('min') is not None and value < desc['min']: + return error(f"Invalid value {value}", name, f": must be at least {desc['min']}") + + if desc.get('max') is not None and value > desc['max']: + return error(f"Invalid value {value}", name, f": must be at most {desc['max']}") + + except TypeError: # a value that cannot be compared with the bound: not this check's business + pass + + return {'return': 0, 'converted': converted} + + def print_params_help( params_description: list, # List of parameter dictionaries containing 'key', 'type', ): diff --git a/cmeta/version.py b/cmeta/version.py index 288da7f..c8ae469 100644 --- a/cmeta/version.py +++ b/cmeta/version.py @@ -6,5 +6,5 @@ See the cMeta COPYRIGHT and LICENSE files in the project root for details. """ -__version__ = "0.36.0" +__version__ = "0.37.0" __release_date__ = "2026-10-13" diff --git a/cmeta/versions.json b/cmeta/versions.json index 1ef4e07..47ea76e 100644 --- a/cmeta/versions.json +++ b/cmeta/versions.json @@ -117,6 +117,10 @@ { "version": "0.36.0", "version_date": "20261013" + }, + { + "version": "0.37.0", + "version_date": "20261013" } ] } diff --git a/docs/common-commands.md b/docs/common-commands.md index e2b6e57..aa88ff0 100644 --- a/docs/common-commands.md +++ b/docs/common-commands.md @@ -244,7 +244,10 @@ not a failure. Details and shell examples in ## Flag syntax -- Booleans: `--flag` (True), `--flag-` or `--no-flag` (False). +- Booleans: `--flag` (True), `--flag-` or `--no-flag` (False); `--flag=yes` / `--flag=no` (also true/false, + on/off, 1/0) when the command annotates the parameter `bool` - any other word is an error. A parameter without + the annotation gets the text as it is. A category can describe sensitive parameters of a command in its + `_cmeta` (`cli_params`: a type, allowed values, a range) and the engine checks them (using-cmeta.md §3.5). - Strings: `--key=value` (last wins). `--key value` is the flag `--key` and a positional `value`. - Bare `key=value` works when the part before `=` looks like a key; other text diff --git a/docs/en/api/index.rst b/docs/en/api/index.rst index c8bcd81..919f233 100644 --- a/docs/en/api/index.rst +++ b/docs/en/api/index.rst @@ -1,7 +1,7 @@ -API Reference (v0.36.0) +API Reference (v0.37.0) ======================= -This section contains the complete API reference for cMeta v0.36.0. +This section contains the complete API reference for cMeta v0.37.0. .. toctree:: :maxdepth: 2 diff --git a/docs/en/index.rst b/docs/en/index.rst index 0cf5735..61ccb53 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -1,11 +1,11 @@ cMeta Documentation =================== -Welcome to cMeta v0.36.0 documentation! +Welcome to cMeta v0.37.0 documentation! .. toctree:: :maxdepth: 2 - :caption: cMeta v0.36.0 Contents: + :caption: cMeta v0.37.0 Contents: home diff --git a/docs/history.md b/docs/history.md index 95ae3ff..ec2acfe 100644 --- a/docs/history.md +++ b/docs/history.md @@ -82,7 +82,7 @@ produces APA and BibTeX automatically. The BibTeX entry: author = {Fursin, Grigori}, title = {{cMeta (Common Meta Framework)}}, year = {2026}, - version = {0.36.0}, + version = {0.37.0}, license = {Apache-2.0}, url = {https://github.com/cTuningLabs/cmeta}, note = {cTuning Labs} diff --git a/docs/installation.md b/docs/installation.md index cef5ddb..bf3919b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -168,8 +168,8 @@ repositories) into these commands. **A specific version**, to pin or to go back: ```bash -uv tool install --force "cmeta[server]==0.36.0" -pip install "cmeta==0.36.0" +uv tool install --force "cmeta[server]==0.37.0" +pip install "cmeta==0.37.0" ``` **Moving an install from git to PyPI** (or back) is a reinstall from the other diff --git a/docs/using-cmeta.md b/docs/using-cmeta.md index d59add5..f0dcd03 100644 --- a/docs/using-cmeta.md +++ b/docs/using-cmeta.md @@ -119,10 +119,31 @@ Parsed by `cmeta/utils/cli.py::parse_cmd`. Full rules: - `--flag-` → **False** (trailing dash — handy when you want to explicitly override a default `True`). - `--no-flag` → **False** (alternative form). -- `--flag=false` / `--flag=true` → literal string values `"false"` / `"true"`. - (Don't rely on this for boolean semantics — use the trailing-dash / `--no-` - forms above.) The engine's own control flags (`--quiet`, `--verbose`, - `--json`, ...) read `yes`/`no`, `true`/`false`, `on`/`off`, `1`/`0` as booleans. +- `--flag=no` / `--flag=yes` → a **boolean** when the command annotates the + parameter `bool` (`sort: bool = False` in its signature, which `--help` shows): + `yes`/`no`, `true`/`false`, `on`/`off`, `1`/`0`, any case. Any other word is + an error naming the parameter and the command. The engine's own control flags + (`--quiet`, `--verbose`, `--json`, ...) read the same words. A parameter + without the annotation gets the text as it is (`"false"` is a true string). + `CMETA_CLI_TYPES=off` keeps every word text, as before cMeta 0.37, and so does + `cli_types: false` in a category's `_cmeta`; only the command line is read, + never the Python API. +- A category can **describe** the sensitive parameters of a command in its + `_cmeta` and the engine converts and checks them: a type (`bool`, `int`, + `float`, `str`), allowed `values`, a `min` and a `max`. Nothing else is + checked; describe what matters, when it matters. + + ```yaml + cli_params: + show: # the command + limit: int # --limit=10 arrives as 10, --limit=ten is an error + mode: + values: [fast, full] # --mode=auto is an error + ratio: + type: float + min: 0 + max: 1 + ``` **Strings and ints** - `--key=value`, with the `=`: `--key value` is the flag `--key` (True) diff --git a/tests/core_tests/test_cli_typed_params.py b/tests/core_tests/test_cli_typed_params.py new file mode 100644 index 0000000..dd8f559 --- /dev/null +++ b/tests/core_tests/test_cli_typed_params.py @@ -0,0 +1,283 @@ +""" +The words of the command line, read by what a command expects (cMeta 0.37): for the parameters a keyword-style +command annotates `bool`, the words yes/no, true/false, on/off and 1/0 become booleans (before, `--x=no` handed +the command the string 'no', which is true); a category can describe sensitive parameters of a command in its +`_cmeta` (`cli_params`: a type, allowed values, a range) and they are converted and checked; everything else is as +it was. The Python API is not read: a caller passes Python values. `CMETA_CLI_TYPES=off` and `cli_types: false` +in a category's `_cmeta` keep the words text. + +Licensed under the Apache License, Version 2.0. +See the cMeta COPYRIGHT and LICENSE files in the project root for details. +""" + +import json +import os +import subprocess +import sys + +import pytest + +from cmeta import CMeta +from cmeta.utils import cli +from cmeta.utils.cli import bool_params_of, typed_params_from_cli + + +################################################################################################### +# The helper + +def sample(ctx, flag: bool = False, other: bool = None, count: int = 0, name: str = None, items: list = None, free = None): + pass + + +@pytest.mark.parametrize('word,expected', [ + ('yes', True), ('YES', True), (' true ', True), ('on', True), ('1', True), + ('no', False), ('False', False), ('off', False), ('OFF', False), ('0', False), +]) +def test_every_word_of_a_bool(word, expected): + params = {'flag': word} + + r = typed_params_from_cli(sample, params) + + assert r['return'] == 0 and params['flag'] is expected and r['converted'] == {'flag': expected} + + +def test_only_the_strings_of_bool_parameters_change(): + params = {'flag': 'no', 'other': True, 'count': '5', 'name': 'yes', 'items': ['yes'], 'free': 'no', + 'unknown': 'no', 'nested': {'flag': 'no'}} + before = dict(params) + + r = typed_params_from_cli(sample, params) + + assert r['return'] == 0 and r['converted'] == {'flag': False} + assert params['flag'] is False + + for name in before: + if name != 'flag': + assert params[name] == before[name], name + + +@pytest.mark.parametrize('word', ['auto', '', ' ', 'yess', 'n', 'TRUE!']) +def test_a_word_that_is_not_a_bool_is_an_error(word): + r = typed_params_from_cli(sample, {'flag': word}, where=' of "x y"') + + assert r['return'] == 1 + assert r['error'] == f"Invalid bool value '{word}' for 'flag' of \"x y\": use yes/no, true/false, on/off, 1/0 or the flags --flag / --flag-" + + +def test_the_place_of_an_error_can_be_computed_only_when_needed(): + calls = [] + + def where(): + calls.append(1) + return ' of "late"' + + assert typed_params_from_cli(sample, {'flag': 'no'}, where=where)['return'] == 0 and calls == [] + assert 'of "late"' in typed_params_from_cli(sample, {'flag': 'x'}, where=where)['error'] and calls == [1] + + +def test_the_annotations_of_future_annotations_are_strings_and_are_read(): + namespace = {} + exec('from __future__ import annotations\n' + 'def f(ctx, a: bool = False, b: int = 0, c: str = None): pass\n', namespace) + + assert bool_params_of(namespace['f']) == frozenset({'a'}) + + +def test_a_bound_method_and_a_function_without_a_signature(): + class Api: + def run(self, ctx, flag: bool = False, count: int = 0): + pass + + assert bool_params_of(Api().run) == frozenset({'flag'}) + assert bool_params_of(print) == frozenset() + + params = {'a': 'no'} + assert typed_params_from_cli(print, params) == {'return': 0, 'converted': {}} and params == {'a': 'no'} + + +def test_described_parameters_are_converted_and_checked(): + described = {'count': 'int', 'ratio': {'type': 'float', 'min': 0, 'max': 1}, 'mode': {'values': ['fast', 'full']}, + 'name': 'str', 'size': {'type': 'INT', 'min': 1}, 'odd': 'no-such-type', 'absent': 'int'} + + params = {'count': '7', 'ratio': '0.5', 'mode': 'fast', 'name': 'x', 'size': '3', 'odd': '1'} + r = typed_params_from_cli(sample, params, described, where=' of "t"') + assert r['return'] == 0, r + assert params == {'count': 7, 'ratio': 0.5, 'mode': 'fast', 'name': 'x', 'size': 3, 'odd': '1'} + assert r['converted'] == {'count': 7, 'ratio': 0.5, 'size': 3} + + for params, text in (({'count': 'ten'}, "Invalid int value 'ten' for 'count' of \"t\""), + ({'ratio': 'x'}, "Invalid float value 'x' for 'ratio' of \"t\""), + ({'ratio': '1.5'}, "Invalid value 1.5 for 'ratio' of \"t\": must be at most 1"), + ({'size': '0'}, "Invalid value 0 for 'size' of \"t\": must be at least 1"), + ({'mode': 'auto'}, "Invalid value 'auto' for 'mode' of \"t\": must be one of fast, full"), + ({'mode': True}, "Invalid value 'True' for 'mode' of \"t\": must be one of fast, full")): + r = typed_params_from_cli(sample, params, described, where=' of "t"') + assert r['return'] == 1 and r['error'] == text, r + + # A described bool says the same as an annotated one; a value that cannot be compared with a bound is left alone + r = typed_params_from_cli(sample, {'free': 'maybe'}, {'free': 'bool'}) + assert r['return'] == 1 and r['error'].startswith("Invalid bool value 'maybe' for 'free': use yes/no") + + params = {'count': 'x'} + assert typed_params_from_cli(sample, params, {'count': {'min': 1}})['return'] == 0 and params == {'count': 'x'} + + +@pytest.mark.parametrize('value,enabled', [(None, True), ('', True), ('on', True), ('1', True), ('anything', True), + ('off', False), ('OFF ', False), ('0', False), ('no', False), ('false', False)]) +def test_the_environment_switch(monkeypatch, value, enabled): + if value is None: + monkeypatch.delenv(cli.CLI_TYPES_ENV, raising=False) + else: + monkeypatch.setenv(cli.CLI_TYPES_ENV, value) + + assert cli.cli_types_enabled() is enabled + + +################################################################################################### +# The engine: a category with commands of each style, through the Python API and the command line + +CATEGORY_UID = 'c5c5c5c5c5c5c5c5' +OFF_CATEGORY_UID = 'c5c5c5c5c5c5c5c6' + +API_V1 = '''from cmeta.category import InitCategory + +class Category(InitCategory): + def __init__(self, *args, **kwargs): + super().__init__(*args, module_file_path = __file__, **kwargs) + + def typed_(self, ctx, flag: bool = False, other: bool = None, count: int = 0, name: str = None, free = None): + return {'return': 0, 'got': {'flag': flag, 'other': other, 'count': count, 'name': name, 'free': free}} + + def nested_(self, ctx, flag: bool = False): + # a nested call passes Python values and is not read, whatever the request that started it + r = self.cm.access({'category': 'c5cat', 'command': 'typed', 'flag': 'no', 'con': False}) + if r['return'] > 0: return r + return {'return': 0, 'flag': flag, 'inner': r['got']['flag']} + + def asdict__(self, params): + return {'return': 0, 'got': params.get('flag')} +''' + +META = 'artifact: {alias},{uid}\ncategory: category,dd9ea50e7f76467f\nlast_api_version: 1\nbase_category_default_api_versions:\n "1": 1\n' + +DESCRIBED = 'cli_params:\n typed:\n count: int\n name:\n values: [a, b]\n' + + +@pytest.fixture() +def home(tmp_path, monkeypatch): + """A home of the test with a repository of two categories: one with described parameters, one switched off.""" + for var in ('CMETA_HOME', 'CMETA_HOME2', 'VIRTUAL_ENV', 'CONDA_PREFIX', 'CMETA_DEBUG', 'CMETA_VERBOSE', cli.CLI_TYPES_ENV): + monkeypatch.delenv(var, raising=False) + + home = tmp_path / 'home' + monkeypatch.setenv('CMETA_HOME', str(home)) + + repo = tmp_path / 'repo-c5' + (repo / '_cmr.yaml').parent.mkdir(parents=True) + (repo / '_cmr.yaml').write_text('artifact: repo-c5,2222222222222222\ncategory: repo,f4f792ab40c7498f\n', encoding='utf-8') + + for alias, uid, extra in (('c5cat', CATEGORY_UID, DESCRIBED), ('c5off', OFF_CATEGORY_UID, 'cli_types: false\n')): + category = repo / 'category' / alias + (category / 'api').mkdir(parents=True) + (category / '_cmeta.yaml').write_text(META.format(alias=alias, uid=uid) + extra, encoding='utf-8') + (category / 'api' / 'v1.py').write_text(API_V1, encoding='utf-8') + + cm = CMeta(home=str(home)) + r = cm.access({'category': 'repo', 'command': 'plug', 'arg1': str(repo), 'con': False}) + assert r['return'] == 0, r.get('error') + + return {'cm': cm, 'home': home} + + +def typed(cm, category='c5cat', from_cli=True, **params): + request = {'category': category, 'command': 'typed', 'con': False} + request.update(params) + + if from_cli: + request['_cli'] = {'cmd': [category, 'typed']} # what cmeta/cli.py adds to the request it makes + + return cm.access(request) + + +def test_the_python_api_is_not_read(home): + r = typed(home['cm'], from_cli=False, flag='no', other='yes', count='7', name='a', free='no') + + assert r['return'] == 0 and r['got'] == {'flag': 'no', 'other': 'yes', 'count': '7', 'name': 'a', 'free': 'no'} + + +def test_the_request_of_the_command_line_is_read(home): + r = typed(home['cm'], flag='no', other='yes', count='7', name='a', free='no') + + assert r['return'] == 0, r.get('error') + assert r['got'] == {'flag': False, 'other': True, 'count': 7, 'name': 'a', 'free': 'no'} + + # The flags are what they were + r = typed(home['cm'], flag=True, other=False) + assert r['got']['flag'] is True and r['got']['other'] is False + + +def test_a_nested_call_is_not_read(home): + r = home['cm'].access({'category': 'c5cat', 'command': 'nested', 'flag': 'yes', 'con': False, '_cli': {'cmd': []}}) + + assert r['return'] == 0, r.get('error') + assert r['flag'] is True and r['inner'] == 'no' + + +def test_a_dict_style_command_is_not_read(home): + r = home['cm'].access({'category': 'c5cat', 'command': 'asdict', 'flag': 'no', 'con': False, '_cli': {'cmd': []}}) + + assert r['return'] == 0 and r['got'] == 'no' + + +def test_the_switches(home, monkeypatch): + assert typed(home['cm'], category='c5off', flag='no')['got']['flag'] == 'no' + + monkeypatch.setenv(cli.CLI_TYPES_ENV, 'off') + assert typed(home['cm'], flag='no', count='7')['got'] == {'flag': 'no', 'other': None, 'count': '7', 'name': None, 'free': None} + + +def test_errors_name_the_command(home): + for params, text in ((dict(flag='auto'), "Invalid bool value 'auto' for 'flag' of \"c5cat typed\": use yes/no, true/false, on/off, 1/0 or the flags --flag / --flag-"), + (dict(count='x'), "Invalid int value 'x' for 'count' of \"c5cat typed\""), + (dict(name='c'), "Invalid value 'c' for 'name' of \"c5cat typed\": must be one of a, b")): + r = typed(home['cm'], **params) + + assert r['return'] > 0 and r['error'].startswith(text), r + assert 'Rerun with --help' in r['error'] + + +def cx(args, env_home, **env_extra): + env = {k: v for k, v in os.environ.items() if not k.startswith('CMETA_')} + env.update({'CMETA_HOME': str(env_home), 'PYTHONIOENCODING': 'utf-8'}) + env.update(env_extra) + p = subprocess.run([sys.executable, '-m', 'cmeta'] + list(args), env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=600) + return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace') + + +def got(out): + return json.loads(out[out.index('{'):])['got'] + + +def test_the_command_line_end_to_end(home): + rc, out, err = cx(['c5cat', 'typed', '--flag=no', '--other=1', '--count=7', '--name=b', '--free=no', '--json'], home['home']) + assert rc == 0, out + err + assert got(out) == {'flag': False, 'other': True, 'count': 7, 'name': 'b', 'free': 'no'} + + # The engine's own commands: the test command of the cache category annotates flag1 bool + rc, out, err = cx(['cache', 'test', '--flag1=no'], home['home']) + assert rc == 0 and 'flag1=False' in out, out + err + + # A word that is not of the type stops the command with the message, before the command runs + rc, out, err = cx(['c5cat', 'typed', '--flag=auto'], home['home']) + assert rc != 0 and "Invalid bool value 'auto' for 'flag' of \"c5cat typed\"" in err, out + err + + rc, out, err = cx(['c5cat', 'typed', '--name=c'], home['home']) + assert rc != 0 and "must be one of a, b" in err, out + err + + # The switches: the category's and the environment's + rc, out, err = cx(['c5off', 'typed', '--flag=no', '--json'], home['home']) + assert rc == 0 and got(out)['flag'] == 'no', out + err + + rc, out, err = cx(['c5cat', 'typed', '--flag=no', '--count=7', '--json'], home['home'], CMETA_CLI_TYPES='off') + assert rc == 0 and got(out) == {'flag': 'no', 'other': None, 'count': '7', 'name': None, 'free': None}, out + err