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 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()