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: 1 addition & 0 deletions .claude/skills/use-cmeta-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>.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
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 19 additions & 0 deletions cmeta/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cmeta/internal-repo/_cmr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
148 changes: 148 additions & 0 deletions cmeta/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
):
Expand Down
2 changes: 1 addition & 1 deletion cmeta/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions cmeta/versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@
{
"version": "0.36.0",
"version_date": "20261013"
},
{
"version": "0.37.0",
"version_date": "20261013"
}
]
}
5 changes: 4 additions & 1 deletion docs/common-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/en/api/index.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions docs/using-cmeta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading