velarium 0.1.0__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Virtual environments
8
+ .venv/
9
+ venv/
10
+ ENV/
11
+ env/
12
+
13
+ # Packaging / build
14
+ build/
15
+ dist/
16
+ packages/*/dist/
17
+ *.egg-info/
18
+ *.egg
19
+ .eggs/
20
+ pip-wheel-metadata/
21
+ share/python-wheels/
22
+
23
+ # Hatch / setuptools
24
+ .hatch/
25
+
26
+ # Testing / coverage
27
+ .pytest_cache/
28
+ .coverage
29
+ .coverage.*
30
+ htmlcov/
31
+ .tox/
32
+ .nox/
33
+ coverage.xml
34
+ *.cover
35
+ .hypothesis/
36
+
37
+ # Type checkers / linters
38
+ .mypy_cache/
39
+ .dmypy.json
40
+ dmypy.json
41
+ .ruff_cache/
42
+ .pytype/
43
+
44
+ # IDEs / editors
45
+ .idea/
46
+ .vscode/
47
+ *.swp
48
+ *.swo
49
+ *~
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Local env / secrets
56
+ .env
57
+ .env.*
58
+ !.env.example
59
+
60
+ # Cursor / tooling (optional local state)
61
+ *.log
velarium-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) stubber contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: velarium
3
+ Version: 0.1.0
4
+ Summary: Velarium IR (ModelSpec): normalization, JSON codec, Python→IR extraction
5
+ Project-URL: Homepage, https://github.com/eddiethedean/velarium
6
+ Project-URL: Repository, https://github.com/eddiethedean/velarium
7
+ Project-URL: Documentation, https://github.com/eddiethedean/velarium/blob/main/docs/README.md
8
+ Project-URL: Changelog, https://github.com/eddiethedean/velarium/blob/main/CHANGELOG.md
9
+ Author: Velarium contributors
10
+ License: MIT
11
+ Keywords: ir,modelspec,static-analysis,typing
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: typing-extensions>=4.2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.0; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
23
+ Requires-Dist: pytest>=7.0; extra == 'dev'
24
+ Requires-Dist: ty>=0.0.29; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # velarium
28
+
29
+ **Role in Velarium:** core **ModelSpec IR** — normalized types (`TypeSpec`, `TypeKind`, …), **`ModelSpec`**, JSON codec, union normalization, and builders that turn dataclasses and `TypedDict` into IR.
30
+
31
+ | | |
32
+ |---|---|
33
+ | **PyPI** | `velarium` |
34
+ | **Import** | `import velarium` / `from velarium.ir import ModelSpec, TypeSpec` |
35
+ | **Dependencies** | `typing_extensions` only |
36
+
37
+ Downstream packages (e.g. [**stubber**](../stubber/README.md)) consume this IR to emit `.pyi` stubs and other artifacts. The IR contract is specified in [docs/modelspec-ir.md](../../docs/modelspec-ir.md); ecosystem context is in [docs/valarium.md](../../docs/valarium.md) and [docs/design.md](../../docs/design.md).
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install velarium
43
+ ```
44
+
45
+ From the monorepo root (with [uv](https://docs.astral.sh/uv/)):
46
+
47
+ ```bash
48
+ uv sync --group dev
49
+ ```
50
+
51
+ ## Version
52
+
53
+ `__version__` lives in `velarium/__init__.py` (Hatch reads it from that package’s `pyproject.toml`).
54
+
55
+ ## See also
56
+
57
+ - [Repository README](../../README.md) — full package table and workspace setup
58
+ - [Documentation index](../../docs/README.md)
@@ -0,0 +1,32 @@
1
+ # velarium
2
+
3
+ **Role in Velarium:** core **ModelSpec IR** — normalized types (`TypeSpec`, `TypeKind`, …), **`ModelSpec`**, JSON codec, union normalization, and builders that turn dataclasses and `TypedDict` into IR.
4
+
5
+ | | |
6
+ |---|---|
7
+ | **PyPI** | `velarium` |
8
+ | **Import** | `import velarium` / `from velarium.ir import ModelSpec, TypeSpec` |
9
+ | **Dependencies** | `typing_extensions` only |
10
+
11
+ Downstream packages (e.g. [**stubber**](../stubber/README.md)) consume this IR to emit `.pyi` stubs and other artifacts. The IR contract is specified in [docs/modelspec-ir.md](../../docs/modelspec-ir.md); ecosystem context is in [docs/valarium.md](../../docs/valarium.md) and [docs/design.md](../../docs/design.md).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install velarium
17
+ ```
18
+
19
+ From the monorepo root (with [uv](https://docs.astral.sh/uv/)):
20
+
21
+ ```bash
22
+ uv sync --group dev
23
+ ```
24
+
25
+ ## Version
26
+
27
+ `__version__` lives in `velarium/__init__.py` (Hatch reads it from that package’s `pyproject.toml`).
28
+
29
+ ## See also
30
+
31
+ - [Repository README](../../README.md) — full package table and workspace setup
32
+ - [Documentation index](../../docs/README.md)
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "velarium"
7
+ dynamic = ["version"]
8
+ description = "Velarium IR (ModelSpec): normalization, JSON codec, Python→IR extraction"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Velarium contributors" }]
13
+ keywords = ["typing", "ir", "static-analysis", "modelspec"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "typing_extensions>=4.2.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7.0", "ty>=0.0.29", "build>=1.0", "pytest-cov>=4.0"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/eddiethedean/velarium"
31
+ Repository = "https://github.com/eddiethedean/velarium"
32
+ Documentation = "https://github.com/eddiethedean/velarium/blob/main/docs/README.md"
33
+ Changelog = "https://github.com/eddiethedean/velarium/blob/main/CHANGELOG.md"
34
+
35
+ [tool.hatch.version]
36
+ path = "velarium/__init__.py"
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["velarium", "README.md"]
40
+
41
+ [tool.hatch.build.targets.sdist.force-include]
42
+ "../../LICENSE" = "LICENSE"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["velarium"]
@@ -0,0 +1,46 @@
1
+ """Velarium: ModelSpec IR, normalization, JSON codec, and Python→IR extraction."""
2
+
3
+ from velarium.annotations import annotation_to_typespec, type_to_typespec
4
+ from velarium.ir import (
5
+ FieldSpec,
6
+ ModelConfig,
7
+ ModelMetadata,
8
+ ModelSpec,
9
+ TypeKind,
10
+ TypeSpec,
11
+ )
12
+ from velarium.json_codec import (
13
+ dumps_model_spec,
14
+ loads_model_spec,
15
+ model_spec_from_dict,
16
+ model_spec_to_dict,
17
+ )
18
+ from velarium.modelspec_build import (
19
+ modelspec_from_dataclass,
20
+ modelspec_from_typed_dict,
21
+ typespec_from_object,
22
+ )
23
+ from velarium.normalize import normalize_typespec, normalize_union, optional_to_union
24
+
25
+ __all__ = [
26
+ "annotation_to_typespec",
27
+ "type_to_typespec",
28
+ "FieldSpec",
29
+ "ModelConfig",
30
+ "ModelMetadata",
31
+ "ModelSpec",
32
+ "TypeKind",
33
+ "TypeSpec",
34
+ "dumps_model_spec",
35
+ "loads_model_spec",
36
+ "model_spec_from_dict",
37
+ "model_spec_to_dict",
38
+ "modelspec_from_dataclass",
39
+ "modelspec_from_typed_dict",
40
+ "typespec_from_object",
41
+ "normalize_typespec",
42
+ "normalize_union",
43
+ "optional_to_union",
44
+ ]
45
+
46
+ __version__ = "0.1.0"
@@ -0,0 +1,200 @@
1
+ """Map Python typing objects to TypeSpec (MVP subset)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import typing
7
+ from collections.abc import Callable as CallableABC
8
+ from enum import Enum
9
+ from types import UnionType
10
+ from typing import Any, ForwardRef, TypeVar, Union, get_args, get_origin
11
+
12
+ from velarium.ir import TypeKind, TypeSpec
13
+ from velarium.normalize import normalize_typespec, optional_to_union
14
+
15
+
16
+ def _none() -> TypeSpec:
17
+ return TypeSpec(kind=TypeKind.NONE)
18
+
19
+
20
+ def _merge_optional(ts: TypeSpec, *, optional: bool) -> TypeSpec:
21
+ out = TypeSpec(
22
+ kind=ts.kind,
23
+ args=ts.args,
24
+ optional=optional or ts.optional,
25
+ nullable=ts.nullable,
26
+ default=ts.default,
27
+ )
28
+ return optional_to_union(out) if out.optional else out
29
+
30
+
31
+ def type_to_typespec(
32
+ t: Any,
33
+ *,
34
+ optional: bool = False,
35
+ ) -> TypeSpec:
36
+ """Convert a runtime typing object or builtin type to TypeSpec."""
37
+ if t is type(None) or t is None:
38
+ return _merge_optional(_none(), optional=optional)
39
+
40
+ if t is Any or t is typing.Any:
41
+ return _merge_optional(TypeSpec(kind=TypeKind.ANY), optional=optional)
42
+
43
+ if isinstance(t, ForwardRef):
44
+ return _merge_optional(TypeSpec(kind=TypeKind.ANY), optional=optional)
45
+
46
+ if isinstance(t, str):
47
+ return _merge_optional(TypeSpec(kind=TypeKind.ANY), optional=optional)
48
+
49
+ origin = get_origin(t)
50
+ args = get_args(t)
51
+
52
+ if origin is typing.Literal:
53
+ parts: list[TypeSpec] = []
54
+ for a in args:
55
+ parts.append(TypeSpec(kind=TypeKind.LITERAL, default=a))
56
+ if len(parts) == 1:
57
+ u = parts[0]
58
+ else:
59
+ u = TypeSpec(kind=TypeKind.UNION, args=parts)
60
+ return _merge_optional(normalize_typespec(u), optional=optional)
61
+
62
+ if origin is UnionType or origin is Union:
63
+ parts = [type_to_typespec(a, optional=False) for a in args]
64
+ has_none = any(p.kind == TypeKind.NONE for p in parts)
65
+ u = TypeSpec(kind=TypeKind.UNION, args=parts)
66
+ u = normalize_typespec(u)
67
+ opt = optional or has_none
68
+ if has_none:
69
+ u.optional = True
70
+ return _merge_optional(u, optional=opt)
71
+
72
+ if origin is list or origin is typing.List:
73
+ inner = (
74
+ type_to_typespec(args[0], optional=False)
75
+ if args
76
+ else TypeSpec(kind=TypeKind.ANY)
77
+ )
78
+ return _merge_optional(
79
+ TypeSpec(kind=TypeKind.LIST, args=[inner]), optional=optional
80
+ )
81
+
82
+ if origin is dict or origin is typing.Dict:
83
+ k = (
84
+ type_to_typespec(args[0], optional=False)
85
+ if len(args) > 0
86
+ else TypeSpec(kind=TypeKind.ANY)
87
+ )
88
+ v = (
89
+ type_to_typespec(args[1], optional=False)
90
+ if len(args) > 1
91
+ else TypeSpec(kind=TypeKind.ANY)
92
+ )
93
+ return _merge_optional(
94
+ TypeSpec(kind=TypeKind.DICT, args=[k, v]), optional=optional
95
+ )
96
+
97
+ if origin is set or origin is typing.Set:
98
+ inner = (
99
+ type_to_typespec(args[0], optional=False)
100
+ if args
101
+ else TypeSpec(kind=TypeKind.ANY)
102
+ )
103
+ return _merge_optional(
104
+ TypeSpec(kind=TypeKind.SET, args=[inner]), optional=optional
105
+ )
106
+
107
+ if origin is tuple or origin is typing.Tuple:
108
+ if not args:
109
+ inner = TypeSpec(kind=TypeKind.ANY)
110
+ return _merge_optional(
111
+ TypeSpec(kind=TypeKind.TUPLE, args=[inner]), optional=optional
112
+ )
113
+ if len(args) == 2 and args[1] is Ellipsis:
114
+ inner = type_to_typespec(args[0], optional=False)
115
+ return _merge_optional(
116
+ TypeSpec(kind=TypeKind.TUPLE, args=[inner]), optional=optional
117
+ )
118
+ inners = [type_to_typespec(a, optional=False) for a in args]
119
+ return _merge_optional(
120
+ TypeSpec(kind=TypeKind.TUPLE, args=inners), optional=optional
121
+ )
122
+
123
+ if origin is typing.Callable or origin is CallableABC:
124
+ if args:
125
+ param_args, ret = args[0], args[1]
126
+ if (
127
+ get_origin(param_args) is list
128
+ ): # pragma: no cover — rare Callable[..., T] shape; else path covers real callables
129
+ plist = get_args(param_args)
130
+ plist_ts = TypeSpec(
131
+ kind=TypeKind.LIST,
132
+ args=[type_to_typespec(p, optional=False) for p in plist],
133
+ )
134
+ else:
135
+ plist_ts = type_to_typespec(param_args, optional=False)
136
+ ret_ts = type_to_typespec(ret, optional=False)
137
+ return _merge_optional(
138
+ TypeSpec(kind=TypeKind.CALLABLE, args=[plist_ts, ret_ts]),
139
+ optional=optional,
140
+ )
141
+ return _merge_optional(
142
+ TypeSpec(kind=TypeKind.CALLABLE, args=None), optional=optional
143
+ )
144
+
145
+ if isinstance(t, TypeVar):
146
+ return _merge_optional(TypeSpec(kind=TypeKind.TYPE_VAR), optional=optional)
147
+
148
+ if origin is not None:
149
+ if args:
150
+ inner_args = [type_to_typespec(a, optional=False) for a in args]
151
+ return _merge_optional(
152
+ TypeSpec(kind=TypeKind.GENERIC, args=inner_args), optional=optional
153
+ )
154
+ return _merge_optional(
155
+ TypeSpec(kind=TypeKind.GENERIC, args=[]), optional=optional
156
+ )
157
+
158
+ if isinstance(t, type):
159
+ if t is int:
160
+ return _merge_optional(TypeSpec(kind=TypeKind.INT), optional=optional)
161
+ if t is float:
162
+ return _merge_optional(TypeSpec(kind=TypeKind.FLOAT), optional=optional)
163
+ if t is bool:
164
+ return _merge_optional(TypeSpec(kind=TypeKind.BOOL), optional=optional)
165
+ if t is str:
166
+ return _merge_optional(TypeSpec(kind=TypeKind.STR), optional=optional)
167
+ if t is bytes:
168
+ return _merge_optional(TypeSpec(kind=TypeKind.BYTES), optional=optional)
169
+ if t is datetime.datetime:
170
+ return _merge_optional(TypeSpec(kind=TypeKind.DATETIME), optional=optional)
171
+ if t is datetime.date:
172
+ return _merge_optional(TypeSpec(kind=TypeKind.DATE), optional=optional)
173
+ if t is datetime.time:
174
+ return _merge_optional(TypeSpec(kind=TypeKind.TIME), optional=optional)
175
+ if issubclass(t, Enum):
176
+ members = [TypeSpec(kind=TypeKind.LITERAL, default=m.value) for m in t]
177
+ u = (
178
+ TypeSpec(kind=TypeKind.ENUM, args=members)
179
+ if members
180
+ else TypeSpec(kind=TypeKind.ANY)
181
+ )
182
+ return _merge_optional(u, optional=optional)
183
+ return _merge_optional(TypeSpec(kind=TypeKind.ANY), optional=optional)
184
+
185
+ return _merge_optional(TypeSpec(kind=TypeKind.ANY), optional=optional)
186
+
187
+
188
+ def annotation_to_typespec(
189
+ annotation: Any, *, globalns: dict[str, Any] | None = None
190
+ ) -> TypeSpec:
191
+ """Resolve string annotations and return TypeSpec."""
192
+ if isinstance(annotation, str):
193
+ if globalns is None:
194
+ return TypeSpec(kind=TypeKind.ANY)
195
+ try:
196
+ anno = eval(annotation, globalns, globalns) # noqa: S307
197
+ return normalize_typespec(type_to_typespec(anno, optional=False))
198
+ except Exception:
199
+ return TypeSpec(kind=TypeKind.ANY)
200
+ return normalize_typespec(type_to_typespec(annotation, optional=False))
@@ -0,0 +1,86 @@
1
+ """ModelSpec intermediate representation (see docs/modelspec-ir.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any, Literal
8
+
9
+
10
+ class TypeKind(str, Enum):
11
+ # Primitives
12
+ INT = "int"
13
+ FLOAT = "float"
14
+ BOOL = "bool"
15
+ STR = "str"
16
+ BYTES = "bytes"
17
+
18
+ # Complex
19
+ LIST = "list"
20
+ TUPLE = "tuple"
21
+ DICT = "dict"
22
+ SET = "set"
23
+
24
+ # Special
25
+ UNION = "union"
26
+ LITERAL = "literal"
27
+ ENUM = "enum"
28
+ ANY = "any"
29
+ NONE = "none"
30
+
31
+ # Temporal
32
+ DATETIME = "datetime"
33
+ DATE = "date"
34
+ TIME = "time"
35
+ TIMESTAMP = "timestamp"
36
+
37
+ # Advanced
38
+ GENERIC = "generic"
39
+ CALLABLE = "callable"
40
+ TYPE_VAR = "typevar"
41
+
42
+
43
+ @dataclass
44
+ class TypeSpec:
45
+ kind: TypeKind
46
+ args: list[TypeSpec] | None = None
47
+ optional: bool = False
48
+ nullable: bool = False
49
+ default: Any | None = None
50
+
51
+
52
+ @dataclass
53
+ class ModelConfig:
54
+ frozen: bool = False
55
+ extra: Literal["allow", "forbid", "ignore"] = "forbid"
56
+ validate_assignment: bool = True
57
+ from_attributes: bool = True
58
+ strict: bool = False
59
+
60
+
61
+ @dataclass
62
+ class FieldSpec:
63
+ name: str
64
+ type: TypeSpec
65
+ required: bool
66
+ default: Any | None = None
67
+ alias: str | None = None
68
+ description: str | None = None
69
+ deprecated: bool = False
70
+
71
+
72
+ @dataclass
73
+ class ModelMetadata:
74
+ source_module: str | None = None
75
+ source_file: str | None = None
76
+ line_number: int | None = None
77
+ generated_by: str = "velarium"
78
+ version: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class ModelSpec:
83
+ name: str
84
+ fields: dict[str, TypeSpec] = field(default_factory=dict)
85
+ config: ModelConfig | None = None
86
+ metadata: ModelMetadata | None = None
@@ -0,0 +1,156 @@
1
+ """JSON serialization for ModelSpec IR (deterministic, round-trippable)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from velarium.ir import (
10
+ FieldSpec,
11
+ ModelConfig,
12
+ ModelMetadata,
13
+ ModelSpec,
14
+ TypeKind,
15
+ TypeSpec,
16
+ )
17
+
18
+
19
+ def _typespec_from_dict(d: dict[str, Any]) -> TypeSpec:
20
+ args = d.get("args")
21
+ if args is not None:
22
+ args = [_typespec_from_dict(x) if isinstance(x, dict) else x for x in args]
23
+ return TypeSpec(
24
+ kind=TypeKind(d["kind"]),
25
+ args=args,
26
+ optional=d.get("optional", False),
27
+ nullable=d.get("nullable", False),
28
+ default=d.get("default", None),
29
+ )
30
+
31
+
32
+ def _json_default_value(value: Any) -> Any:
33
+ if isinstance(value, Enum):
34
+ return value.value
35
+ try:
36
+ json.dumps(value)
37
+ except TypeError:
38
+ return {"_velarium_repr": repr(value)}
39
+ return value
40
+
41
+
42
+ def typespec_to_dict(ts: TypeSpec) -> dict[str, Any]:
43
+ out: dict[str, Any] = {"kind": ts.kind.value}
44
+ if ts.args is not None:
45
+ out["args"] = [typespec_to_dict(a) for a in ts.args]
46
+ if ts.optional:
47
+ out["optional"] = True
48
+ if ts.nullable:
49
+ out["nullable"] = True
50
+ if ts.default is not None:
51
+ out["default"] = _json_default_value(ts.default)
52
+ return out
53
+
54
+
55
+ def typespec_from_dict(d: dict[str, Any]) -> TypeSpec:
56
+ return _typespec_from_dict(d)
57
+
58
+
59
+ def model_spec_to_dict(m: ModelSpec) -> dict[str, Any]:
60
+ out: dict[str, Any] = {
61
+ "name": m.name,
62
+ "fields": {k: typespec_to_dict(v) for k, v in m.fields.items()},
63
+ }
64
+ if m.config is not None:
65
+ c = m.config
66
+ out["config"] = {
67
+ "frozen": c.frozen,
68
+ "extra": c.extra,
69
+ "validate_assignment": c.validate_assignment,
70
+ "from_attributes": c.from_attributes,
71
+ "strict": c.strict,
72
+ }
73
+ if m.metadata is not None:
74
+ md = m.metadata
75
+ out["metadata"] = {
76
+ "source_module": md.source_module,
77
+ "source_file": md.source_file,
78
+ "line_number": md.line_number,
79
+ "generated_by": md.generated_by,
80
+ "version": md.version,
81
+ }
82
+ return out
83
+
84
+
85
+ def model_spec_from_dict(d: dict[str, Any]) -> ModelSpec:
86
+ fields_raw = d.get("fields") or {}
87
+ fields = {k: typespec_from_dict(v) for k, v in fields_raw.items()}
88
+ config = None
89
+ if "config" in d and d["config"] is not None:
90
+ c = d["config"]
91
+ config = ModelConfig(
92
+ frozen=c.get("frozen", False),
93
+ extra=c.get("extra", "forbid"),
94
+ validate_assignment=c.get("validate_assignment", True),
95
+ from_attributes=c.get("from_attributes", True),
96
+ strict=c.get("strict", False),
97
+ )
98
+ metadata = None
99
+ if "metadata" in d and d["metadata"] is not None:
100
+ md = d["metadata"]
101
+ metadata = ModelMetadata(
102
+ source_module=md.get("source_module"),
103
+ source_file=md.get("source_file"),
104
+ line_number=md.get("line_number"),
105
+ generated_by=md.get("generated_by", "velarium"),
106
+ version=md.get("version"),
107
+ )
108
+ return ModelSpec(
109
+ name=d["name"],
110
+ fields=fields,
111
+ config=config,
112
+ metadata=metadata,
113
+ )
114
+
115
+
116
+ def field_spec_to_dict(f: FieldSpec) -> dict[str, Any]:
117
+ out: dict[str, Any] = {
118
+ "name": f.name,
119
+ "type": typespec_to_dict(f.type),
120
+ "required": f.required,
121
+ "default": f.default,
122
+ "alias": f.alias,
123
+ "description": f.description,
124
+ "deprecated": f.deprecated,
125
+ }
126
+ return {
127
+ k: v
128
+ for k, v in out.items()
129
+ if v is not None or k in ("name", "type", "required")
130
+ }
131
+
132
+
133
+ def field_spec_from_dict(d: dict[str, Any]) -> FieldSpec:
134
+ return FieldSpec(
135
+ name=d["name"],
136
+ type=typespec_from_dict(d["type"]),
137
+ required=d["required"],
138
+ default=d.get("default"),
139
+ alias=d.get("alias"),
140
+ description=d.get("description"),
141
+ deprecated=d.get("deprecated", False),
142
+ )
143
+
144
+
145
+ def dumps_model_spec(m: ModelSpec, *, indent: int | None = 2) -> str:
146
+ """Serialize ModelSpec to JSON with stable key ordering."""
147
+ return json.dumps(
148
+ model_spec_to_dict(m),
149
+ indent=indent,
150
+ sort_keys=True,
151
+ default=_json_default_value,
152
+ )
153
+
154
+
155
+ def loads_model_spec(s: str) -> ModelSpec:
156
+ return model_spec_from_dict(json.loads(s))
@@ -0,0 +1,101 @@
1
+ """Build ModelSpec from Python classes (dataclasses MVP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import inspect
7
+ import sys
8
+ from typing import Any, get_type_hints
9
+
10
+ from typing_extensions import is_typeddict
11
+
12
+ from velarium.annotations import annotation_to_typespec, type_to_typespec
13
+ from velarium.ir import ModelConfig, ModelMetadata, ModelSpec, TypeSpec
14
+ from velarium.normalize import normalize_typespec
15
+
16
+
17
+ def _module_globals(cls: type) -> dict[str, Any]:
18
+ mod = sys.modules.get(cls.__module__)
19
+ if mod is None:
20
+ return dict(vars(cls))
21
+ g = dict(vars(mod))
22
+ g.setdefault(cls.__name__, cls)
23
+ return g
24
+
25
+
26
+ def modelspec_from_dataclass(cls: type, *, include_extras: bool = False) -> ModelSpec:
27
+ """Extract ModelSpec from a dataclass type."""
28
+ if not dataclasses.is_dataclass(cls):
29
+ raise TypeError(f"{cls!r} is not a dataclass")
30
+
31
+ globalns = _module_globals(cls)
32
+ try:
33
+ hints = get_type_hints(
34
+ cls, globalns=globalns, localns=None, include_extras=include_extras
35
+ )
36
+ except Exception:
37
+ hints = {}
38
+
39
+ fields: dict[str, TypeSpec] = {}
40
+ for f in dataclasses.fields(cls):
41
+ if not f.init:
42
+ continue
43
+ raw = hints.get(f.name, f.type if hasattr(f, "type") else Any)
44
+ ts = annotation_to_typespec(raw, globalns=globalns)
45
+ ts = normalize_typespec(ts)
46
+ if f.default is not dataclasses.MISSING:
47
+ ts = TypeSpec(
48
+ kind=ts.kind,
49
+ args=ts.args,
50
+ optional=ts.optional,
51
+ nullable=ts.nullable,
52
+ default=f.default,
53
+ )
54
+ fields[f.name] = ts
55
+
56
+ src_file = None
57
+ line_number = None
58
+ try:
59
+ src_file = inspect.getsourcefile(cls)
60
+ lines, start = inspect.getsourcelines(cls)
61
+ line_number = start
62
+ except (OSError, TypeError):
63
+ pass
64
+
65
+ meta = ModelMetadata(
66
+ source_module=cls.__module__,
67
+ source_file=src_file,
68
+ line_number=line_number,
69
+ generated_by="velarium",
70
+ version=None,
71
+ )
72
+ params = getattr(cls, "__dataclass_params__", None)
73
+ frozen = bool(getattr(params, "frozen", False)) if params is not None else False
74
+ cfg = ModelConfig(frozen=frozen, extra="forbid")
75
+
76
+ return ModelSpec(name=cls.__name__, fields=fields, config=cfg, metadata=meta)
77
+
78
+
79
+ def modelspec_from_typed_dict(cls: type) -> ModelSpec:
80
+ """Extract ModelSpec from typing.TypedDict (total fields only for MVP)."""
81
+ if not is_typeddict(cls):
82
+ raise TypeError(f"{cls!r} is not a TypedDict")
83
+
84
+ globalns = _module_globals(cls)
85
+ try:
86
+ hints = get_type_hints(cls, globalns=globalns)
87
+ except Exception:
88
+ hints = {}
89
+
90
+ fields: dict[str, TypeSpec] = {}
91
+ for name, typ in getattr(cls, "__annotations__", {}).items():
92
+ ts = annotation_to_typespec(hints.get(name, typ), globalns=globalns)
93
+ fields[name] = normalize_typespec(ts)
94
+
95
+ meta = ModelMetadata(source_module=cls.__module__, generated_by="velarium")
96
+ return ModelSpec(name=cls.__name__, fields=fields, config=None, metadata=meta)
97
+
98
+
99
+ def typespec_from_object(obj: Any) -> TypeSpec:
100
+ """Convenience: typing object or type -> TypeSpec."""
101
+ return normalize_typespec(type_to_typespec(obj, optional=False))
@@ -0,0 +1,98 @@
1
+ """Normalize TypeSpec unions per docs/modelspec-ir.md (flatten, dedupe, preserve order)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from velarium.ir import TypeKind, TypeSpec
6
+
7
+
8
+ def normalize_union(ts: TypeSpec) -> TypeSpec:
9
+ """Flatten nested unions, remove duplicates, preserve first-seen order."""
10
+ if ts.kind != TypeKind.UNION:
11
+ return ts
12
+ flat: list[TypeSpec] = []
13
+ seen: list[str] = []
14
+
15
+ def json_key_obj(d: object) -> str:
16
+ import json
17
+
18
+ return json.dumps(d, sort_keys=True)
19
+
20
+ def key(x: TypeSpec) -> str:
21
+ from velarium.json_codec import typespec_to_dict
22
+
23
+ return json_key_obj(typespec_to_dict(x))
24
+
25
+ stack = list(ts.args or [])
26
+ while stack:
27
+ cur = stack.pop(0)
28
+ if cur.kind == TypeKind.UNION and cur.args:
29
+ stack = list(cur.args) + stack
30
+ continue
31
+ k = key(cur)
32
+ if k not in seen:
33
+ seen.append(k)
34
+ flat.append(cur)
35
+ if len(flat) == 1:
36
+ u = flat[0]
37
+ return TypeSpec(
38
+ kind=u.kind,
39
+ args=u.args,
40
+ optional=ts.optional or u.optional,
41
+ nullable=ts.nullable or u.nullable,
42
+ default=ts.default if ts.default is not None else u.default,
43
+ )
44
+ return TypeSpec(
45
+ kind=TypeKind.UNION,
46
+ args=flat,
47
+ optional=ts.optional,
48
+ nullable=ts.nullable,
49
+ default=ts.default,
50
+ )
51
+
52
+
53
+ def normalize_typespec(ts: TypeSpec) -> TypeSpec:
54
+ """Recursively normalize unions and nested structures."""
55
+ args = ts.args
56
+ if args:
57
+ args = [normalize_typespec(a) for a in args]
58
+ out = TypeSpec(
59
+ kind=ts.kind,
60
+ args=args,
61
+ optional=ts.optional,
62
+ nullable=ts.nullable,
63
+ default=ts.default,
64
+ )
65
+ if out.kind == TypeKind.UNION:
66
+ return normalize_union(out)
67
+ return out
68
+
69
+
70
+ def optional_to_union(ts: TypeSpec) -> TypeSpec:
71
+ """
72
+ Encode Optional[T] as Union[T, None] with optional=True per spec.
73
+ If ts already includes none, ensure optional flag is set.
74
+ """
75
+ if not ts.optional:
76
+ return ts
77
+ none = TypeSpec(kind=TypeKind.NONE)
78
+ if ts.kind == TypeKind.UNION and ts.args:
79
+ members = list(ts.args)
80
+ has_none = any(m.kind == TypeKind.NONE for m in members)
81
+ if not has_none:
82
+ members.append(none)
83
+ u = TypeSpec(
84
+ kind=TypeKind.UNION,
85
+ args=members,
86
+ optional=True,
87
+ nullable=ts.nullable,
88
+ default=ts.default,
89
+ )
90
+ return normalize_union(u)
91
+ u = TypeSpec(
92
+ kind=TypeKind.UNION,
93
+ args=[ts, none],
94
+ optional=True,
95
+ nullable=ts.nullable,
96
+ default=ts.default,
97
+ )
98
+ return normalize_union(u)
File without changes