pyverge 0.2.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.
pyverge/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """pyverge - versioned models and schemas with migrations."""
2
+
3
+ from . import migration
4
+ from ._version import __version__
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "migration",
9
+ ]
pyverge/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1 @@
1
+ """The pyverge CLI subpackage."""
@@ -0,0 +1,206 @@
1
+ """Helper functions for the CLI."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+
10
+ def load_json_file(path: Path) -> Any:
11
+ """Load JSON file."""
12
+ try:
13
+ with open(path) as f:
14
+ return json.load(f)
15
+ except FileNotFoundError as e:
16
+ raise FileNotFoundError(f"Data file not found: {path}") from e
17
+ except json.JSONDecodeError as e:
18
+ raise json.JSONDecodeError(f"Invalid JSON in {path}", e.doc, e.pos) from e
19
+
20
+
21
+ def write_json_file(path: Path, data: dict[str, Any]) -> None:
22
+ """Write JSON file."""
23
+ try:
24
+ with open(path, "w") as f:
25
+ json.dump(data, f, indent=2)
26
+ f.write("\n")
27
+ except PermissionError as e:
28
+ raise PermissionError(f"Cannot write to {path}: Permission denied") from e
29
+ except OSError as e:
30
+ raise OSError(f"Failed to write {path}: {e}") from e
31
+
32
+
33
+ def create_example_models_file(path: Path) -> None:
34
+ """Create example models.py file."""
35
+ if path.exists():
36
+ typer.secho(f"Skipping: {path} already exists", fg=typer.colors.YELLOW)
37
+ return
38
+
39
+ try:
40
+ path.write_text('''"""Versioned models managed by pyverge."""
41
+
42
+ from typing import Literal
43
+
44
+ import semver
45
+ from pydantic import BaseModel
46
+
47
+ from pyverge.migration import (
48
+ MigrationSettings,
49
+ ModelManager,
50
+ PydanticModelAdapter,
51
+ )
52
+
53
+ # Create a scoped manager class
54
+ UserManager = ModelManager[semver.Version].scoped(
55
+ PydanticModelAdapter(),
56
+ settings=MigrationSettings(),
57
+ )
58
+
59
+
60
+ # Version 1: Initial user model
61
+ @UserManager.model()
62
+ class UserV1(BaseModel):
63
+ """User model v1.0.0"""
64
+ kind: Literal["User"] = "User"
65
+ version: Literal["1.0.0"] = "1.0.0"
66
+ name: str
67
+ email: str
68
+
69
+
70
+ # Version 2: Add age field
71
+ @UserManager.model()
72
+ class UserV2(BaseModel):
73
+ """User model v2.0.0"""
74
+ kind: Literal["User"] = "User"
75
+ version: Literal["2.0.0"] = "2.0.0"
76
+ name: str
77
+ email: str
78
+ age: int | None = None
79
+
80
+
81
+ # Define migration
82
+ @UserManager.migration("User", "1.0.0", "2.0.0")
83
+ def add_age_field(data: dict) -> dict:
84
+ """Add optional age field."""
85
+ return {**data, "age": None}
86
+
87
+
88
+ # Export manager instance for CLI discovery
89
+ __manager__ = UserManager()
90
+
91
+
92
+ # Alternative: Factory function pattern
93
+ # def create_manager():
94
+ # """Factory function for creating manager with custom setup."""
95
+ # return UserManager()
96
+ #
97
+ # __manager__ = create_manager
98
+ ''')
99
+ typer.secho(f"Created {path}", fg=typer.colors.GREEN)
100
+ except OSError as e:
101
+ typer.secho(f"Failed to create {path}: {e}", fg=typer.colors.RED)
102
+ raise
103
+
104
+
105
+ def create_single_manager_config(project_dir: Path, use_pyproject: bool) -> None:
106
+ """Create single manager configuration."""
107
+ try:
108
+ if use_pyproject:
109
+ config_file = project_dir / "pyproject.toml"
110
+ config_content = """[tool.pyverge]
111
+ manager = "models"
112
+ """
113
+ if config_file.exists():
114
+ typer.echo(f"\nAdd this to {config_file}:")
115
+ typer.echo(config_content)
116
+ else:
117
+ config_file.write_text(f"""[project]
118
+ name = "{project_dir.name}"
119
+ version = "0.1.0"
120
+
121
+ {config_content}""")
122
+ typer.secho(f"Created {config_file}", fg=typer.colors.GREEN)
123
+ else:
124
+ config_file = project_dir / "pyverge.toml"
125
+ if not config_file.exists():
126
+ config_file.write_text("""[pyverge]
127
+ manager = "models"
128
+
129
+ # Optional: Use factory function
130
+ # manager = "models:create_manager"
131
+
132
+ # Optional: Pass initialization arguments
133
+ # init_args = []
134
+ # [pyverge.init_kwargs]
135
+ # debug = false
136
+ """)
137
+ typer.secho(f"Created {config_file}", fg=typer.colors.GREEN)
138
+ except OSError as e:
139
+ typer.secho(f"Failed to create config: {e}", fg=typer.colors.RED)
140
+ raise
141
+
142
+
143
+ def create_multi_manager_config(project_dir: Path, use_pyproject: bool) -> None:
144
+ """Create multi-manager configuration."""
145
+ try:
146
+ if use_pyproject:
147
+ config_content = """[tool.pyverge.managers]
148
+ default = "models"
149
+ api_v1 = "api.v1.models"
150
+ api_v2 = "api.v2.models"
151
+
152
+ # Optional: Configure specific manager with init args
153
+ # [tool.pyverge.managers.api_v1]
154
+ # manager = "api.v1.models:create_manager"
155
+ # init_args = ["production"]
156
+ """
157
+ else:
158
+ config_content = """[pyverge.managers]
159
+ default = "models"
160
+ api_v1 = "api.v1.models"
161
+ api_v2 = "api.v2.models"
162
+
163
+ # Optional: Configure specific manager with init args
164
+ # [pyverge.managers.api_v1]
165
+ # manager = "api.v1.models:create_manager"
166
+ # init_args = ["production"]
167
+ """
168
+
169
+ config_file = project_dir / (
170
+ "pyproject.toml" if use_pyproject else "pyverge.toml"
171
+ )
172
+
173
+ if config_file.exists() and use_pyproject:
174
+ typer.echo(f"\nAdd this to {config_file}:")
175
+ typer.echo(config_content)
176
+ else:
177
+ if use_pyproject:
178
+ config_file.write_text(f"""[project]
179
+ name = "{project_dir.name}"
180
+ version = "0.1.0"
181
+
182
+ {config_content}""")
183
+ else:
184
+ config_file.write_text(config_content)
185
+ typer.secho(f"Created {config_file}", fg=typer.colors.GREEN)
186
+ except OSError as e:
187
+ typer.secho(f"Failed to create config: {e}", fg=typer.colors.RED)
188
+ raise
189
+
190
+
191
+ def print_next_steps(multiple: bool) -> None:
192
+ """Print next steps after initialization."""
193
+ if multiple:
194
+ typer.echo("\nMultiple managers configured:")
195
+ typer.echo(" • default (models)")
196
+ typer.echo(" • api_v1 (api.v1.models)")
197
+ typer.echo(" • api_v2 (api.v2.models)")
198
+ typer.echo("\nCommands:")
199
+ typer.echo(" pyverge managers - List all managers")
200
+ typer.echo(" pyverge info api_v1 - Show manager details")
201
+ typer.echo(" pyverge validate -M api_v1 ... - Use specific manager")
202
+ else:
203
+ typer.echo("\nNext steps:")
204
+ typer.echo(" 1. Edit models.py to add your models")
205
+ typer.echo(" 2. Run: pyverge info")
206
+ typer.echo(" 3. Run: pyverge validate -d data.json -s User -v 1.0.0")
pyverge/cli/config.py ADDED
@@ -0,0 +1,64 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ from types import ModuleType
5
+ from typing import Any
6
+
7
+ from pyverge.migration import ModelManager
8
+
9
+
10
+ class ConfigError(Exception):
11
+ """Configuration loading error."""
12
+
13
+
14
+ def resolve_manager(spec: str) -> ModelManager:
15
+ """Resolve a manager from a ``module_path:object_path`` spec."""
16
+
17
+ if ":" not in spec:
18
+ raise ConfigError(f"Manager spec must be 'module:object_path', got '{spec}'")
19
+
20
+ module_path, object_path = spec.split(":", 1)
21
+ cwd = Path.cwd()
22
+ module = _import_module(cwd, module_path)
23
+ obj: Any = module
24
+ for part in object_path.split("."):
25
+ obj = getattr(obj, part)
26
+ if not isinstance(obj, ModelManager):
27
+ raise ConfigError(
28
+ f"Manager '{spec}' resolved to {type(obj).__name__}, "
29
+ "expected a ModelManager"
30
+ )
31
+ return obj
32
+
33
+
34
+ def _import_module(cwd: Path, module_path: str) -> ModuleType:
35
+ """Import a module, falling back to a local file when not on the path."""
36
+ try:
37
+ return importlib.import_module(module_path)
38
+ except ImportError as e:
39
+ module_file = cwd / module_path.replace(".", "/")
40
+ if not module_file.suffix:
41
+ module_file = module_file.with_suffix(".py")
42
+
43
+ if not module_file.exists():
44
+ raise ConfigError(f"Cannot import module '{module_path}': {e}") from e
45
+
46
+ spec = importlib.util.spec_from_file_location(module_path, module_file)
47
+ if spec is None or spec.loader is None:
48
+ raise ConfigError(f"Cannot load module from {module_file}") from e
49
+
50
+ module = importlib.util.module_from_spec(spec)
51
+ sys.modules[module_path] = module
52
+ spec.loader.exec_module(module)
53
+ return module
54
+
55
+
56
+ def list_managers_from_module(module_path: str) -> list[str]:
57
+ """Return the names of ``ModelManager`` instances/subclasses in *module_path*."""
58
+ module = importlib.import_module(module_path)
59
+ return [
60
+ name
61
+ for name, value in vars(module).items()
62
+ if isinstance(value, ModelManager)
63
+ or (isinstance(value, type) and issubclass(value, ModelManager))
64
+ ]
pyverge/cli/main.py ADDED
@@ -0,0 +1,175 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from pydantic import ValidationError
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from ._helpers import (
11
+ load_json_file,
12
+ )
13
+ from .config import (
14
+ ConfigError,
15
+ list_managers_from_module,
16
+ resolve_manager,
17
+ )
18
+
19
+ app = typer.Typer(help="Schema evolution and migrations for versioned models")
20
+ console = Console()
21
+
22
+ ManagerOption = Annotated[
23
+ str,
24
+ typer.Option(
25
+ ...,
26
+ "--manager",
27
+ "-m",
28
+ help="Manager import path (module:object_path)",
29
+ ),
30
+ ]
31
+
32
+
33
+ @app.command()
34
+ def check(
35
+ data: Annotated[
36
+ Path, typer.Option(..., "--data", "-d", help="Path to data file (JSON)")
37
+ ],
38
+ schema: Annotated[str, typer.Option(..., "--schema", "-s", help="Schema name")],
39
+ version: Annotated[
40
+ str, typer.Option(..., "--version", "-v", help="Schema version")
41
+ ],
42
+ manager: ManagerOption,
43
+ ) -> None:
44
+ """Check a payload against a schema version."""
45
+ try:
46
+ mgr = resolve_manager(manager)
47
+ data_dict = load_json_file(data)
48
+
49
+ mgr.validate(data_dict, schema, version)
50
+
51
+ typer.secho(f"✓ Valid against {schema} v{version}", fg=typer.colors.GREEN)
52
+ raise typer.Exit(0)
53
+
54
+ except ValidationError as e:
55
+ typer.secho("✗ Validation failed", fg=typer.colors.RED)
56
+ typer.secho("\nValidation errors:", fg=typer.colors.RED)
57
+ for error in e.errors():
58
+ field = ".".join(str(loc) for loc in error["loc"])
59
+ typer.echo(f" • {field}: {error['msg']}")
60
+ raise typer.Exit(1) from e
61
+ except ConfigError as e:
62
+ typer.secho(str(e), fg=typer.colors.RED)
63
+ raise typer.Exit(1) from e
64
+ except FileNotFoundError as e:
65
+ typer.secho(f"File not found: {e}", fg=typer.colors.RED)
66
+ raise typer.Exit(1) from e
67
+ except json.JSONDecodeError as e:
68
+ typer.secho(f"Invalid JSON in {data}: {e}", fg=typer.colors.RED)
69
+ raise typer.Exit(1) from e
70
+ except typer.Exit:
71
+ raise
72
+ except Exception as e:
73
+ typer.secho(f"Validation error: {e}", fg=typer.colors.RED)
74
+ raise typer.Exit(1) from e
75
+
76
+
77
+ @app.command()
78
+ def managers(
79
+ module: Annotated[
80
+ str, typer.Argument(..., help="Module path to inspect for managers")
81
+ ],
82
+ ) -> None:
83
+ """List ModelManagers defined in a module."""
84
+ try:
85
+ names = list_managers_from_module(module)
86
+
87
+ table = Table(title=f"Managers in {module}")
88
+ table.add_column("Name", style="cyan")
89
+
90
+ for name in sorted(names):
91
+ table.add_row(name)
92
+
93
+ console.print(table)
94
+
95
+ except ImportError as e:
96
+ typer.secho(f"Cannot import module '{module}': {e}", fg=typer.colors.RED)
97
+ raise typer.Exit(1) from e
98
+
99
+
100
+ @app.command()
101
+ def info(
102
+ manager: Annotated[
103
+ str, typer.Argument(..., help="Manager import path (module:object_path)")
104
+ ],
105
+ ) -> None:
106
+ """Show information about a specific manager."""
107
+ try:
108
+ mgr = resolve_manager(manager)
109
+
110
+ typer.secho(f"Manager: {manager}", bold=True)
111
+ typer.echo("")
112
+
113
+ versions = mgr.list_versions()
114
+ models = sorted({v.model.__name__ for v in versions})
115
+
116
+ if not models:
117
+ typer.secho("No models registered", fg=typer.colors.YELLOW)
118
+ return
119
+
120
+ typer.secho("Registered Models:", bold=True)
121
+ typer.echo("")
122
+
123
+ for model_name in models:
124
+ typer.secho(f" {model_name}", bold=True)
125
+ for v in versions:
126
+ if v.model.__name__ == model_name:
127
+ typer.echo(f" • v{v.version[1]}")
128
+
129
+ typer.echo(f"\nTotal: {len(models)} models")
130
+
131
+ except ConfigError as e:
132
+ typer.secho(str(e), fg=typer.colors.RED)
133
+ raise typer.Exit(1) from e
134
+ except typer.Exit:
135
+ raise
136
+ except Exception as e:
137
+ typer.secho(str(e), fg=typer.colors.RED)
138
+ raise typer.Exit(1) from e
139
+
140
+
141
+ @app.command()
142
+ def diff(
143
+ schema: Annotated[str, typer.Option(..., "--schema", "-s", help="Schema name")],
144
+ from_version: Annotated[
145
+ str, typer.Option(..., "--from", "-f", help="Source version")
146
+ ],
147
+ to_version: Annotated[str, typer.Option(..., "--to", "-t", help="Target version")],
148
+ manager: ManagerOption,
149
+ format: Annotated[
150
+ str, typer.Option(..., "--format", help="Output format (json)")
151
+ ] = "json",
152
+ ) -> None:
153
+ """Show differences between schema versions."""
154
+ try:
155
+ mgr = resolve_manager(manager)
156
+ diff_result = mgr.diff(schema, from_version, to_version)
157
+
158
+ if format == "json":
159
+ typer.echo(json.dumps(diff_result.render(), indent=2, default=str))
160
+ else:
161
+ typer.secho(f"Unknown format: {format}", fg=typer.colors.RED)
162
+ raise typer.Exit(1)
163
+
164
+ except ConfigError as e:
165
+ typer.secho(str(e), fg=typer.colors.RED)
166
+ raise typer.Exit(1) from e
167
+ except typer.Exit:
168
+ raise
169
+ except Exception as e:
170
+ typer.secho(f"Diff error: {e}", fg=typer.colors.RED)
171
+ raise typer.Exit(1) from e
172
+
173
+
174
+ if __name__ == "__main__":
175
+ app()
@@ -0,0 +1,86 @@
1
+ from . import types
2
+ from .adapters import PydanticModelAdapter
3
+ from .diff import PydanticDiff
4
+ from .engine import Engine
5
+ from .exceptions import (
6
+ DiscoveryError,
7
+ DiscoveryValidationError,
8
+ EngineError,
9
+ MaxDepthExceededError,
10
+ MigrationAlreadyRegisteredError,
11
+ MigrationError,
12
+ MigrationMissingFieldError,
13
+ MigrationNotFoundError,
14
+ MigrationPathIntegrityError,
15
+ ModelAlreadyRegisteredError,
16
+ ModelNotFoundError,
17
+ RegistryError,
18
+ VersionedModelError,
19
+ )
20
+ from .executor import LevelParallelExecutor, SequentialExecutor, StepExecutor
21
+ from .graph import GraphBuilder, GraphEntry, MigrationGraph
22
+ from .hooks import MigrationHook, OTELHook
23
+ from .manager import ModelManager
24
+ from .models import (
25
+ DiscoverySettings,
26
+ MigrationSettings,
27
+ VersioningSettings,
28
+ )
29
+ from .policy import (
30
+ earliest_target_resolver,
31
+ fixed_target_resolver,
32
+ latest_target_resolver,
33
+ multi_target_resolver,
34
+ skip_target_resolver,
35
+ )
36
+ from .registry import Registry
37
+ from .strategy import DefaultEntryMigration, EntryMigration
38
+ from .versioning import SentinelEdge, SentinelNode, VersionEdge, VersionNode
39
+ from .walker import CompoundKeyWalker, PydanticWalker
40
+
41
+ __all__ = [
42
+ "CompoundKeyWalker",
43
+ "DefaultEntryMigration",
44
+ "DiscoveryError",
45
+ "DiscoverySettings",
46
+ "DiscoveryValidationError",
47
+ "Engine",
48
+ "EngineError",
49
+ "EntryMigration",
50
+ "GraphBuilder",
51
+ "GraphEntry",
52
+ "LevelParallelExecutor",
53
+ "MaxDepthExceededError",
54
+ "MigrationAlreadyRegisteredError",
55
+ "MigrationError",
56
+ "MigrationGraph",
57
+ "MigrationHook",
58
+ "MigrationMissingFieldError",
59
+ "MigrationNotFoundError",
60
+ "MigrationPathIntegrityError",
61
+ "MigrationSettings",
62
+ "ModelAlreadyRegisteredError",
63
+ "ModelManager",
64
+ "ModelNotFoundError",
65
+ "OTELHook",
66
+ "PydanticDiff",
67
+ "PydanticModelAdapter",
68
+ "PydanticWalker",
69
+ "Registry",
70
+ "RegistryError",
71
+ "SentinelEdge",
72
+ "SentinelNode",
73
+ "SequentialExecutor",
74
+ "StepExecutor",
75
+ "VersionEdge",
76
+ "VersionNode",
77
+ "VersionedModelError",
78
+ "VersioningSettings",
79
+ "compile_target_spec",
80
+ "earliest_target_resolver",
81
+ "fixed_target_resolver",
82
+ "latest_target_resolver",
83
+ "multi_target_resolver",
84
+ "skip_target_resolver",
85
+ "types",
86
+ ]