minimal-magic 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs/api.md +52 -0
- docs/comparison.md +28 -0
- docs/merging.md +82 -0
- docs/types.md +15 -0
- minimal_magic/__init__.py +7 -0
- minimal_magic/__main__.py +68 -0
- minimal_magic/_convert.py +534 -0
- minimal_magic/_errors.py +175 -0
- minimal_magic/_parsing.py +122 -0
- minimal_magic/_version.py +24 -0
- minimal_magic/api.py +505 -0
- minimal_magic/py.typed +0 -0
- minimal_magic-0.6.0.dist-info/METADATA +161 -0
- minimal_magic-0.6.0.dist-info/RECORD +33 -0
- minimal_magic-0.6.0.dist-info/WHEEL +5 -0
- minimal_magic-0.6.0.dist-info/entry_points.txt +2 -0
- minimal_magic-0.6.0.dist-info/licenses/LICENSE +21 -0
- minimal_magic-0.6.0.dist-info/top_level.txt +3 -0
- tests/__init__.py +0 -0
- tests/_types.py +89 -0
- tests/conftest.py +67 -0
- tests/test_benchmark_loader.py +89 -0
- tests/test_benchmark_nested_loader.py +258 -0
- tests/test_cli.py +119 -0
- tests/test_loader_candidate_provenance.py +267 -0
- tests/test_loader_compat.py +210 -0
- tests/test_loader_dataclass.py +645 -0
- tests/test_loader_fails.py +186 -0
- tests/test_loader_hypothesis.py +84 -0
- tests/test_loader_stress.py +61 -0
- tests/test_loader_tagged_unions.py +168 -0
- tests/tricky.toml +8 -0
- tests/tricky.yaml +5 -0
docs/api.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# API details
|
|
2
|
+
|
|
3
|
+
## `alias()`
|
|
4
|
+
|
|
5
|
+
Maps a config file key name to a Python attribute name. Useful for kebab-case TOML keys:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import dataclasses
|
|
9
|
+
from minimal_magic import load, alias
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class RetryConfig:
|
|
13
|
+
retry_count: int = alias("retry-count", default=3)
|
|
14
|
+
connect_timeout: float = alias("connect-timeout", default=5.0)
|
|
15
|
+
|
|
16
|
+
config = load("config.toml", type=RetryConfig)
|
|
17
|
+
# reads "retry-count" and "connect-timeout" from the file,
|
|
18
|
+
# exposes them as config.retry_count and config.connect_timeout
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`alias()` is a thin wrapper around `dataclasses.field(metadata={"alias": ...})` and accepts all the same keyword arguments.
|
|
22
|
+
|
|
23
|
+
## Error handling
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from minimal_magic import load, ParseError
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
config = load("config.yaml", type=ServerConfig)
|
|
30
|
+
except ParseError as exc:
|
|
31
|
+
print(exc) # "config.yaml:3:7: Expected `int`, got `str`"
|
|
32
|
+
print(exc.filename) # "config.yaml" — always a str, never a Path
|
|
33
|
+
print(exc.line) # 3
|
|
34
|
+
print(exc.column) # 7
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`ParseError` is re-exported from the `parse-errors` package (pinned to `>= 0.6.0, < 1.0`) so
|
|
38
|
+
catching it doesn't need a second import. `ParseContext` and the source-map types
|
|
39
|
+
(`Location`, `Entry`, `TSourceMap`) are `parse-errors`' own manual location-extraction
|
|
40
|
+
building blocks — nothing here calls them, so import them from `parse_errors` directly
|
|
41
|
+
if you need them.
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
Validate a config file against a type without writing a wrapper script — handy in pre-commit or CI:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m minimal_magic validate config.toml --type mypkg.config:AppConfig
|
|
49
|
+
# or, after install: minimal-magic validate config.toml --type mypkg.config:AppConfig
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--type` is `MODULE:ATTR` (dotted `ATTR` paths like `Outer.Inner` work too); `--format` and `--forbid-unknown-fields` are also accepted. Exits 0 and prints `<file>: OK` on success; exits 1 with the located error on stderr on failure.
|
docs/comparison.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Shapes and alternate libraries
|
|
2
|
+
|
|
3
|
+
msgspec decodes millions of records a second because its converter is a C extension driven by its own `Struct` types. pydantic validates through pydantic-core, a Rust extension, and does much more than type conversion — coercion policy, computed fields, JSON schema generation. minimal-magic does one thing, in Python, against the `dataclass` and `TypedDict` types you already declared, and spends the time it saves on ergonomics.
|
|
4
|
+
|
|
5
|
+
| | minimal-magic | msgspec | pydantic |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| Target types | plain `dataclass`, `TypedDict` | `msgspec.Struct` | `BaseModel` |
|
|
8
|
+
| 20k-item convert, converts/sec | ~150 | ~5,000 | ~2,700 |
|
|
9
|
+
| Library size | ~1k lines pure Python | 2,400 lines Python + C extension | Python + Rust extension (pydantic-core) |
|
|
10
|
+
| Syntax errors | filename, line, column | n/a | n/a |
|
|
11
|
+
| Type errors | filename, line, column | field path only | field path only |
|
|
12
|
+
| Field aliases | `alias("kebab-case")` | `rename=` on the Struct | `Field(alias=...)` |
|
|
13
|
+
| Tagged unions | discriminator auto-detected | declared per Struct | declared via `discriminator=` |
|
|
14
|
+
| Config cascade | `load_candidate()` with per-field merge | n/a | n/a |
|
|
15
|
+
|
|
16
|
+
msgspec's "field path only" errors are still wrappable by `parse-errors`' `ParseContext` today, because msgspec happens to embed a JSONPath string in its message (`"... - at \`$.port\`"`) that `parse-errors` pattern-matches. pydantic's `ValidationError` carries the same kind of structured path — `exc.errors()` gives a `loc` tuple — but not embedded in the message text, so `ParseContext` can't extract it yet; that would need its own extraction branch in `parse-errors`, the same way JSON's and YAML's `lineno`/`colno` got one.
|
|
17
|
+
|
|
18
|
+
All three convert the same already-parsed Python dict — `{"items": [0, 1, ..., 19999]}` — into a typed structure holding a `list[int]`: `minimal_magic.convert()`, `msgspec.convert()`, and `BaseModel.model_validate()`. No file I/O or text parsing in any of the three; that step is common to all of them and isn't what this table measures. Median of 11 runs after 2 warmup calls. Re-measure before quoting these; they move with the interpreter.
|
|
19
|
+
|
|
20
|
+
The ratio moves with the data's shape, not just the interpreter: a flat homogeneous array is minimal-magic's worst case — it's the same compiled converter called 20,000 times with no branching, and msgspec's/pydantic's native code has nothing else to do either. A deeply nested, heterogeneous config (many small `dataclass`/`Struct`/`BaseModel` types, `Optional` fields, `dict[str, str]` maps) narrows the gap to roughly 8x vs. msgspec and 1.5x vs. pydantic, because msgspec's and pydantic's per-object construction cost stops being negligible too.
|
|
21
|
+
|
|
22
|
+
None of the above hits a source map: it's the cost of a value that converts cleanly. A value that doesn't pays for one. `test_benchmark_large_list_load_validation_failure` / `test_benchmark_nested_envoy_config_load_validation_failure` measure exactly that against their clean-load counterparts. minimal-magic asks parse-errors' `locate_pointer()` for the one pointer that failed instead of mapping the whole document. `_FixedSource` stays lazy: nothing built on the common path, and only the failed pointer is located when something actually fails.
|
|
23
|
+
|
|
24
|
+
That failure path is doing extra work on purpose: it turns "some nested value had the wrong type" into a filename, line, and column a person can fix. On current local runs with parse-errors 0.6.0, that richer error costs on the order of tens of milliseconds, not a whole-document source-map walk. Run the benchmark tests before quoting exact timings; they depend on the parser, Python, CPU, and where the bad value sits.
|
|
25
|
+
|
|
26
|
+
See `tests/test_benchmark_loader.py` (flat) and `tests/test_benchmark_nested_loader.py` (deeply nested, Envoy-config-shaped) for the actual benchmarks this repo tracks, success and failure paths both.
|
|
27
|
+
|
|
28
|
+
Use msgspec or pydantic when throughput or heavier validation is the point. Use minimal-magic when the data is a config file, the types are already dataclasses, and the person who has to fix a bad value is a human reading the error message.
|
docs/merging.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Merge rules
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
For each key that appears in both the accumulated base and the incoming candidate:
|
|
5
|
+
|
|
6
|
+
1. **Field merge callable** — if the dataclass field declares `metadata={"merge": fn}`,
|
|
7
|
+
`fn(base_value, override_value)` is called and its return value is used. This takes
|
|
8
|
+
priority over all other rules, including dict recursion.
|
|
9
|
+
2. **Both values are dicts** — the dicts are merged recursively by the same rules,
|
|
10
|
+
propagating the field's type so nested merge callables are respected.
|
|
11
|
+
3. **Everything else** — the override value replaces the base value entirely.
|
|
12
|
+
This includes lists, sets, scalars, `None`, and type changes (e.g. dict → scalar).
|
|
13
|
+
|
|
14
|
+
Keys present only in the base are kept; keys present only in the override are added.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import dataclasses
|
|
18
|
+
from minimal_magic import load_candidate
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class AppConfig:
|
|
22
|
+
host: str
|
|
23
|
+
port: int
|
|
24
|
+
# concatenate tags from all config files instead of replacing
|
|
25
|
+
tags: list[str] = dataclasses.field(
|
|
26
|
+
default_factory=list,
|
|
27
|
+
metadata={"merge": lambda base, override: base + override},
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Example: `extend-ignore`
|
|
32
|
+
|
|
33
|
+
A common config pattern is `ignore` (replace) paired with `extend-ignore` (append). The merge callable accumulates `extend-ignore` across candidates; `__post_init__` folds it into `ignore` at the end:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import dataclasses
|
|
37
|
+
from minimal_magic import load_candidate
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass
|
|
40
|
+
class LinterConfig:
|
|
41
|
+
ignore: list[str] = dataclasses.field(default_factory=list)
|
|
42
|
+
extend_ignore: list[str] = dataclasses.field(
|
|
43
|
+
default_factory=list,
|
|
44
|
+
metadata={"alias": "extend-ignore", "merge": lambda base, override: base + override},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self):
|
|
48
|
+
self.ignore = self.ignore + self.extend_ignore
|
|
49
|
+
|
|
50
|
+
config = load_candidate([
|
|
51
|
+
"defaults.toml", # extend-ignore = ["E001"]
|
|
52
|
+
"pyproject.toml", # extend-ignore = ["E501"]
|
|
53
|
+
"local.toml", # extend-ignore = ["W503"]
|
|
54
|
+
], type=LinterConfig)
|
|
55
|
+
# config.ignore == base_ignore + ["E001", "E501", "W503"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Without the merge callable, `extend-ignore` would have last-wins semantics and only `["W503"]` would survive the merge phase.
|
|
59
|
+
|
|
60
|
+
### Notes on merge callables
|
|
61
|
+
|
|
62
|
+
**Merge is only called when a key appears in two or more candidates.** If a key
|
|
63
|
+
appears in only one candidate (or comes from a `default_factory`), the merge
|
|
64
|
+
callable is never invoked — the value is used as-is.
|
|
65
|
+
|
|
66
|
+
**Avoid `base or override` in merge callables.** If `base` is truthy and
|
|
67
|
+
`override` is falsy, `base or override` short-circuits and returns `base`,
|
|
68
|
+
silently ignoring the override. For example, `['x'] or []` returns `['x']`
|
|
69
|
+
even though the override explicitly set an empty list. Prefer explicit
|
|
70
|
+
operations: `base + override` for concatenation, `base | override` for sets and
|
|
71
|
+
dicts, or `base if base is not None else override` for null-coalescing.
|
|
72
|
+
|
|
73
|
+
**Merge callables receive and return raw values** (dicts, lists, scalars) as they
|
|
74
|
+
appear in the config files, before type conversion. A `list[LinterConfig]` field's
|
|
75
|
+
merge callable receives a list of dicts, not `LinterConfig` objects. If your
|
|
76
|
+
callable constructs a typed instance internally — even temporarily — be aware that
|
|
77
|
+
its `__post_init__` fires at that point, and then `_convert` constructs another
|
|
78
|
+
instance from the returned raw value, firing `__post_init__` a second time on a
|
|
79
|
+
different object.
|
|
80
|
+
|
|
81
|
+
**Exceptions raised inside a merge callable** are caught and re-raised as
|
|
82
|
+
`ParseError` pointing to the override value's location in the candidate file.
|
docs/types.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Supported types
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
| Category | Types |
|
|
5
|
+
|---|---|
|
|
6
|
+
| Structured | `dataclass`, `TypedDict` |
|
|
7
|
+
| Collections | `list`, `tuple`, `dict`, `set`, `frozenset`, `Sequence`, `Mapping`, `AbstractSet` |
|
|
8
|
+
| Unions | `Optional[T]`, `Union[A, B]`, `A \| B`, tagged unions (discriminator auto-detected) |
|
|
9
|
+
| Scalars | `str`, `int`, `float`, `bool`, `Literal[...]`, `Enum` |
|
|
10
|
+
| Rich scalars | `Path`, `Decimal`, `date`, `datetime`, `time` |
|
|
11
|
+
| Unchecked | `Any` (passed through), `Annotated[T, ...]`, `Required[T]`, `NotRequired[T]` (unwrapped, then `T` is checked) |
|
|
12
|
+
|
|
13
|
+
`bool` is rejected for `int` fields. Bare `int` is coerced to `float` when a `float` field is expected.
|
|
14
|
+
|
|
15
|
+
A collection type used bare, with no type argument (`dict` rather than `dict[str, int]`), is checked no more than `Any` is: only that the value is a `dict`. Parameterize it if you want its contents checked too.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""minimal_magic — msgspec-style typed conversion for plain dataclasses and TypedDicts."""
|
|
2
|
+
|
|
3
|
+
from parse_errors import ParseError
|
|
4
|
+
|
|
5
|
+
from .api import alias, Candidate, convert, load, load_candidate
|
|
6
|
+
|
|
7
|
+
__all__ = ["ParseError", "convert", "load", "load_candidate", "alias", "Candidate"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""``python -m minimal_magic`` — validate a config file against a type from the command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import load, ParseError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_type(spec: str) -> type:
|
|
11
|
+
if ":" not in spec:
|
|
12
|
+
raise ValueError(f"--type must look like MODULE:ATTR, got {spec!r}")
|
|
13
|
+
module_name, _, attr_path = spec.partition(":")
|
|
14
|
+
module = importlib.import_module(module_name)
|
|
15
|
+
obj = module
|
|
16
|
+
for part in attr_path.split("."):
|
|
17
|
+
obj = getattr(obj, part)
|
|
18
|
+
return obj
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
22
|
+
try:
|
|
23
|
+
target_type = _resolve_type(args.type)
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
print(f"Cannot resolve --type {args.type!r}: {exc}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
load(
|
|
30
|
+
args.file,
|
|
31
|
+
type=target_type,
|
|
32
|
+
format=args.format,
|
|
33
|
+
forbid_unknown_fields=args.forbid_unknown_fields,
|
|
34
|
+
)
|
|
35
|
+
except ParseError as exc:
|
|
36
|
+
print(str(exc), file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
print(f"{args.file}: OK")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
parser = argparse.ArgumentParser(prog="minimal-magic")
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
validate = subparsers.add_parser("validate", help="validate a config file against a type")
|
|
48
|
+
validate.add_argument("file", help="path to the config file")
|
|
49
|
+
validate.add_argument(
|
|
50
|
+
"--type",
|
|
51
|
+
required=True,
|
|
52
|
+
help="MODULE:ATTR of the type to validate against, e.g. mypkg.config:AppConfig",
|
|
53
|
+
)
|
|
54
|
+
validate.add_argument("--format", choices=["json", "yaml", "toml"], default=None)
|
|
55
|
+
validate.add_argument("--forbid-unknown-fields", action="store_true")
|
|
56
|
+
validate.set_defaults(func=_validate)
|
|
57
|
+
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> None:
|
|
62
|
+
parser = build_parser()
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
sys.exit(args.func(args))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|