superseed 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.
- superseed/__init__.py +21 -0
- superseed/cli.py +104 -0
- superseed/compiler.py +163 -0
- superseed/decorator.py +39 -0
- superseed/errors.py +10 -0
- superseed/plugin.py +123 -0
- superseed/py.typed +0 -0
- superseed/registry.py +229 -0
- superseed/scan/__init__.py +0 -0
- superseed/scan/cypher_hints.py +46 -0
- superseed/scan/python_repos.py +118 -0
- superseed/scan/runner.py +131 -0
- superseed/schema.py +107 -0
- superseed/seeder.py +29 -0
- superseed-0.2.0.dist-info/METADATA +460 -0
- superseed-0.2.0.dist-info/RECORD +19 -0
- superseed-0.2.0.dist-info/WHEEL +4 -0
- superseed-0.2.0.dist-info/entry_points.txt +5 -0
- superseed-0.2.0.dist-info/licenses/LICENSE +21 -0
superseed/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SuperSeed — declarative Neo4j fixtures for pytest."""
|
|
2
|
+
|
|
3
|
+
from superseed.compiler import CypherSeedPlan, compile_scenario
|
|
4
|
+
from superseed.decorator import get_superseed_marker, super_seed
|
|
5
|
+
from superseed.registry import Registry, load_registry
|
|
6
|
+
from superseed.schema import Schema, load_schema
|
|
7
|
+
from superseed.seeder import Neo4jSeeder
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CypherSeedPlan",
|
|
13
|
+
"Neo4jSeeder",
|
|
14
|
+
"Registry",
|
|
15
|
+
"Schema",
|
|
16
|
+
"compile_scenario",
|
|
17
|
+
"get_superseed_marker",
|
|
18
|
+
"load_registry",
|
|
19
|
+
"load_schema",
|
|
20
|
+
"super_seed",
|
|
21
|
+
]
|
superseed/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from superseed.errors import SuperSeedError
|
|
8
|
+
from superseed.registry import load_registry
|
|
9
|
+
from superseed.scan.runner import default_schema_path, run_scan, validate_registry
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
help="SuperSeed — declarative Neo4j fixtures for pytest.",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_config(config: Path | None) -> Path:
|
|
18
|
+
if config is not None:
|
|
19
|
+
if not config.is_file():
|
|
20
|
+
raise typer.BadParameter(f"Config file not found: {config}")
|
|
21
|
+
return config
|
|
22
|
+
|
|
23
|
+
discovered = Path("superseed.yaml")
|
|
24
|
+
if discovered.is_file():
|
|
25
|
+
return discovered
|
|
26
|
+
|
|
27
|
+
raise typer.BadParameter("Pass --config/-c or run from a directory with superseed.yaml")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("validate")
|
|
31
|
+
def validate_cmd(
|
|
32
|
+
config: Path | None = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--config",
|
|
35
|
+
"-c",
|
|
36
|
+
help="Path to superseed.yaml",
|
|
37
|
+
),
|
|
38
|
+
schema: Path | None = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--schema",
|
|
41
|
+
help="Optional schema YAML (defaults to movies.schema.yaml beside config)",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Parse superseed.yaml and optional schema; exit 1 on error."""
|
|
45
|
+
config_path = _resolve_config(config)
|
|
46
|
+
schema_path = schema or default_schema_path(config_path)
|
|
47
|
+
try:
|
|
48
|
+
registry = validate_registry(config_path, schema_path)
|
|
49
|
+
except SuperSeedError as exc:
|
|
50
|
+
typer.secho(f"Validation failed: {exc}", fg=typer.colors.RED, err=True)
|
|
51
|
+
raise typer.Exit(code=1) from exc
|
|
52
|
+
|
|
53
|
+
typer.echo(f"Valid: {config_path} ({len(registry.scenarios)} scenarios)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("list")
|
|
57
|
+
def list_cmd(
|
|
58
|
+
config: Path | None = typer.Option(
|
|
59
|
+
None,
|
|
60
|
+
"--config",
|
|
61
|
+
"-c",
|
|
62
|
+
help="Path to superseed.yaml",
|
|
63
|
+
),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Print scenario names and descriptions."""
|
|
66
|
+
config_path = _resolve_config(config)
|
|
67
|
+
registry = load_registry(config_path)
|
|
68
|
+
for name in sorted(registry.scenarios):
|
|
69
|
+
scenario = registry.scenarios[name]
|
|
70
|
+
description = scenario.description or "(no description)"
|
|
71
|
+
typer.echo(f"{name}\t{description}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("scan")
|
|
75
|
+
def scan_cmd(
|
|
76
|
+
path: Path = typer.Argument(..., help="Repository file or directory to scan"),
|
|
77
|
+
output: Path = typer.Option(
|
|
78
|
+
Path(".superseed"),
|
|
79
|
+
"--output",
|
|
80
|
+
"-o",
|
|
81
|
+
help="Directory for suggested YAML and repo_index.json",
|
|
82
|
+
),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Extract Cypher from repository modules and write suggested scenario stubs."""
|
|
85
|
+
if not path.exists():
|
|
86
|
+
raise typer.BadParameter(f"Scan path not found: {path}")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
queries, suggested_dir, index_path = run_scan(path, output)
|
|
90
|
+
except SuperSeedError as exc:
|
|
91
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
92
|
+
raise typer.Exit(code=1) from exc
|
|
93
|
+
|
|
94
|
+
typer.echo(f"Scanned {len(queries)} repository method(s)")
|
|
95
|
+
typer.echo(f"Suggested stubs: {suggested_dir}")
|
|
96
|
+
typer.echo(f"Repo index: {index_path}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
app()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
superseed/compiler.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from superseed.errors import SuperSeedError
|
|
8
|
+
from superseed.registry import Scenario, ScenarioNode, ScenarioRelationship
|
|
9
|
+
from superseed.schema import Schema, validate_scenario_nodes
|
|
10
|
+
|
|
11
|
+
PARAM_PATTERN = re.compile(r"\$\{([a-z][a-z0-9_]*)\}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CypherStatement:
|
|
16
|
+
query: str
|
|
17
|
+
parameters: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CypherSeedPlan:
|
|
22
|
+
run_id: str
|
|
23
|
+
statements: list[CypherStatement] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _interpolate_params(value: Any, params: dict[str, Any]) -> Any:
|
|
27
|
+
if isinstance(value, str):
|
|
28
|
+
|
|
29
|
+
def repl(match: re.Match[str]) -> str:
|
|
30
|
+
name = match.group(1)
|
|
31
|
+
if name not in params:
|
|
32
|
+
msg = f"Unknown scenario parameter: {name}"
|
|
33
|
+
raise SuperSeedError(msg)
|
|
34
|
+
return str(params[name])
|
|
35
|
+
|
|
36
|
+
return PARAM_PATTERN.sub(repl, value)
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
return {key: _interpolate_params(item, params) for key, item in value.items()}
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return [_interpolate_params(item, params) for item in value]
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolved_node_props(
|
|
45
|
+
node: ScenarioNode,
|
|
46
|
+
params: dict[str, Any],
|
|
47
|
+
defaults: dict[str, dict[str, Any]],
|
|
48
|
+
schema: Schema | None,
|
|
49
|
+
*,
|
|
50
|
+
run_id: str,
|
|
51
|
+
index: int,
|
|
52
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
53
|
+
key = _interpolate_params(node.key, params)
|
|
54
|
+
props: dict[str, Any] = {}
|
|
55
|
+
if schema is not None:
|
|
56
|
+
label_schema = schema.labels.get(node.label)
|
|
57
|
+
if label_schema is not None:
|
|
58
|
+
props.update(label_schema.defaults)
|
|
59
|
+
props.update(defaults.get(node.label, {}))
|
|
60
|
+
props.update(_interpolate_params(node.props, params))
|
|
61
|
+
|
|
62
|
+
merged = {**props, **key}
|
|
63
|
+
if "id" not in merged:
|
|
64
|
+
merged["id"] = f"{run_id}-{node.label.lower()}-{index}"
|
|
65
|
+
|
|
66
|
+
non_key_props = {name: value for name, value in merged.items() if name not in key}
|
|
67
|
+
return key, non_key_props
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _compile_node(
|
|
71
|
+
node: ScenarioNode,
|
|
72
|
+
*,
|
|
73
|
+
params: dict[str, Any],
|
|
74
|
+
defaults: dict[str, dict[str, Any]],
|
|
75
|
+
schema: Schema | None,
|
|
76
|
+
run_id: str,
|
|
77
|
+
index: int,
|
|
78
|
+
) -> CypherStatement:
|
|
79
|
+
key, props = _resolved_node_props(
|
|
80
|
+
node,
|
|
81
|
+
params,
|
|
82
|
+
defaults,
|
|
83
|
+
schema,
|
|
84
|
+
run_id=run_id,
|
|
85
|
+
index=index,
|
|
86
|
+
)
|
|
87
|
+
key_match = ", ".join(f"{prop}: ${prop}" for prop in key)
|
|
88
|
+
set_clauses = [f"n.{prop} = ${prop}" for prop in props]
|
|
89
|
+
set_clauses.append("n.testRunId = $run_id")
|
|
90
|
+
query = f"MERGE (n:{node.label} {{{key_match}}})\nSET {', '.join(set_clauses)}"
|
|
91
|
+
parameters = {**key, **props, "run_id": run_id}
|
|
92
|
+
return CypherStatement(query=query, parameters=parameters)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _compile_relationship(
|
|
96
|
+
relationship: ScenarioRelationship,
|
|
97
|
+
*,
|
|
98
|
+
params: dict[str, Any],
|
|
99
|
+
run_id: str,
|
|
100
|
+
index: int,
|
|
101
|
+
) -> CypherStatement:
|
|
102
|
+
from_key = _interpolate_params(relationship.from_.key, params)
|
|
103
|
+
to_key = _interpolate_params(relationship.to.key, params)
|
|
104
|
+
rel_props = _interpolate_params(relationship.props, params)
|
|
105
|
+
|
|
106
|
+
from_match = ", ".join(f"{prop}: $from_{prop}" for prop in from_key)
|
|
107
|
+
to_match = ", ".join(f"{prop}: $to_{prop}" for prop in to_key)
|
|
108
|
+
from_params = {f"from_{prop}": value for prop, value in from_key.items()}
|
|
109
|
+
to_params = {f"to_{prop}": value for prop, value in to_key.items()}
|
|
110
|
+
|
|
111
|
+
set_clauses = [f"r.{prop} = $rel_{prop}" for prop in rel_props]
|
|
112
|
+
rel_params = {f"rel_{prop}": value for prop, value in rel_props.items()}
|
|
113
|
+
set_statement = f"SET {', '.join(set_clauses)}" if set_clauses else ""
|
|
114
|
+
|
|
115
|
+
query = f"""
|
|
116
|
+
MATCH (a:{relationship.from_.label} {{{from_match}}})
|
|
117
|
+
MATCH (b:{relationship.to.label} {{{to_match}}})
|
|
118
|
+
MERGE (a)-[r:{relationship.type}]->(b)
|
|
119
|
+
{set_statement}
|
|
120
|
+
""".strip()
|
|
121
|
+
|
|
122
|
+
return CypherStatement(
|
|
123
|
+
query=query,
|
|
124
|
+
parameters={**from_params, **to_params, **rel_params},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def compile_scenario(
|
|
129
|
+
scenario: Scenario,
|
|
130
|
+
*,
|
|
131
|
+
defaults: dict[str, dict[str, Any]] | None = None,
|
|
132
|
+
overrides: dict[str, Any] | None = None,
|
|
133
|
+
run_id: str,
|
|
134
|
+
schema: Schema | None = None,
|
|
135
|
+
) -> CypherSeedPlan:
|
|
136
|
+
registry_defaults = defaults or {}
|
|
137
|
+
params = {**scenario.parameters, **(overrides or {})}
|
|
138
|
+
validate_scenario_nodes(scenario, registry_defaults=registry_defaults, schema=schema)
|
|
139
|
+
|
|
140
|
+
statements: list[CypherStatement] = []
|
|
141
|
+
for index, node in enumerate(scenario.nodes):
|
|
142
|
+
statements.append(
|
|
143
|
+
_compile_node(
|
|
144
|
+
node,
|
|
145
|
+
params=params,
|
|
146
|
+
defaults=registry_defaults,
|
|
147
|
+
schema=schema,
|
|
148
|
+
run_id=run_id,
|
|
149
|
+
index=index,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
for index, relationship in enumerate(scenario.relationships):
|
|
154
|
+
statements.append(
|
|
155
|
+
_compile_relationship(
|
|
156
|
+
relationship,
|
|
157
|
+
params=params,
|
|
158
|
+
run_id=run_id,
|
|
159
|
+
index=index,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return CypherSeedPlan(run_id=run_id, statements=statements)
|
superseed/decorator.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def super_seed(scenario: str, /, **overrides: Any) -> Callable[[F], F]:
|
|
12
|
+
def wrap(fn: F) -> F:
|
|
13
|
+
setattr(
|
|
14
|
+
fn,
|
|
15
|
+
"__superseed__",
|
|
16
|
+
{
|
|
17
|
+
"mode": "explicit",
|
|
18
|
+
"scenario": scenario,
|
|
19
|
+
"overrides": overrides,
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
return fn
|
|
23
|
+
|
|
24
|
+
return wrap
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_superseed_marker(item: pytest.Item) -> dict[str, Any] | None:
|
|
28
|
+
if not hasattr(item, "obj") or item.obj is None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
meta = getattr(item.obj, "__superseed__", None)
|
|
32
|
+
if meta is None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"mode": meta.get("mode", "explicit"),
|
|
37
|
+
"scenario": meta["scenario"],
|
|
38
|
+
"overrides": meta.get("overrides", {}),
|
|
39
|
+
}
|
superseed/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class SuperSeedError(Exception):
|
|
2
|
+
"""Base error for SuperSeed."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ScenarioNotFoundError(SuperSeedError):
|
|
6
|
+
"""Raised when a named scenario is missing from the registry."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SchemaValidationError(SuperSeedError):
|
|
10
|
+
"""Raised when scenario data violates the label schema."""
|
superseed/plugin.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from superseed.compiler import compile_scenario
|
|
9
|
+
from superseed.decorator import get_superseed_marker
|
|
10
|
+
from superseed.errors import ScenarioNotFoundError
|
|
11
|
+
from superseed.registry import get_scenario, load_registry
|
|
12
|
+
from superseed.schema import Schema, load_schema
|
|
13
|
+
from superseed.seeder import Neo4jSeeder
|
|
14
|
+
|
|
15
|
+
RUN_ID_ATTR = "_superseed_run_id"
|
|
16
|
+
REGISTRY_ATTR = "_superseed_registry"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_upward(start: Path, filename: str) -> Path | None:
|
|
20
|
+
for directory in (start, *start.parents):
|
|
21
|
+
candidate = directory / filename
|
|
22
|
+
if candidate.is_file():
|
|
23
|
+
return candidate
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_config_path(item: pytest.Item, config: pytest.Config) -> Path:
|
|
28
|
+
option = config.getoption("--superseed-config")
|
|
29
|
+
configured = Path(option)
|
|
30
|
+
if configured.is_file():
|
|
31
|
+
return configured
|
|
32
|
+
|
|
33
|
+
start = Path(str(item.fspath)).parent
|
|
34
|
+
discovered = find_upward(start, option)
|
|
35
|
+
if discovered is not None:
|
|
36
|
+
return discovered
|
|
37
|
+
|
|
38
|
+
msg = f"SuperSeed config not found: {option} (searched upward from {start})"
|
|
39
|
+
raise pytest.UsageError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_schema_path(config_path: Path, pytest_config: pytest.Config) -> Path | None:
|
|
43
|
+
option = pytest_config.getoption("--superseed-schema")
|
|
44
|
+
if option:
|
|
45
|
+
schema_path = Path(option)
|
|
46
|
+
if schema_path.is_file():
|
|
47
|
+
return schema_path
|
|
48
|
+
msg = f"SuperSeed schema file not found: {schema_path}"
|
|
49
|
+
raise pytest.UsageError(msg)
|
|
50
|
+
|
|
51
|
+
for name in ("movies.schema.yaml", "superseed.schema.yaml"):
|
|
52
|
+
candidate = config_path.parent / name
|
|
53
|
+
if candidate.is_file():
|
|
54
|
+
return candidate
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_schema_for_config(config_path: Path, pytest_config: pytest.Config) -> Schema | None:
|
|
59
|
+
schema_path = resolve_schema_path(config_path, pytest_config)
|
|
60
|
+
if schema_path is None:
|
|
61
|
+
return None
|
|
62
|
+
return load_schema(schema_path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
66
|
+
parser.addoption(
|
|
67
|
+
"--superseed-config",
|
|
68
|
+
action="store",
|
|
69
|
+
default="superseed.yaml",
|
|
70
|
+
help="Path to superseed.yaml or filename to search upward from the test module",
|
|
71
|
+
)
|
|
72
|
+
parser.addoption(
|
|
73
|
+
"--superseed-schema",
|
|
74
|
+
action="store",
|
|
75
|
+
default=None,
|
|
76
|
+
help="Optional schema YAML path (defaults to movies.schema.yaml beside the config)",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
81
|
+
config.addinivalue_line(
|
|
82
|
+
"markers",
|
|
83
|
+
"superseed: marks a test that seeds Neo4j data via @super_seed (dynamically applied)",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.hookimpl(tryfirst=True)
|
|
88
|
+
def pytest_runtest_setup(item: pytest.Item) -> None:
|
|
89
|
+
marker = get_superseed_marker(item)
|
|
90
|
+
if marker is None:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
config_path = resolve_config_path(item, item.config)
|
|
94
|
+
registry = load_registry(config_path)
|
|
95
|
+
scenario_name = marker["scenario"]
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
scenario = get_scenario(registry, scenario_name)
|
|
99
|
+
except ScenarioNotFoundError as exc:
|
|
100
|
+
raise exc
|
|
101
|
+
|
|
102
|
+
schema = load_schema_for_config(config_path, item.config)
|
|
103
|
+
run_id = str(uuid.uuid4())
|
|
104
|
+
plan = compile_scenario(
|
|
105
|
+
scenario,
|
|
106
|
+
defaults=registry.defaults,
|
|
107
|
+
overrides=marker["overrides"],
|
|
108
|
+
run_id=run_id,
|
|
109
|
+
schema=schema,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
setattr(item, RUN_ID_ATTR, run_id)
|
|
113
|
+
setattr(item, REGISTRY_ATTR, registry)
|
|
114
|
+
Neo4jSeeder(registry.neo4j).seed(plan)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.hookimpl(trylast=True)
|
|
118
|
+
def pytest_runtest_teardown(item: pytest.Item) -> None:
|
|
119
|
+
run_id = getattr(item, RUN_ID_ATTR, None)
|
|
120
|
+
registry = getattr(item, REGISTRY_ATTR, None)
|
|
121
|
+
if run_id is None or registry is None:
|
|
122
|
+
return
|
|
123
|
+
Neo4jSeeder(registry.neo4j).cleanup(run_id)
|
superseed/py.typed
ADDED
|
File without changes
|
superseed/registry.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from superseed.errors import ScenarioNotFoundError, SuperSeedError
|
|
12
|
+
|
|
13
|
+
ENV_PATTERN = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(?::-([^}]*))?\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Neo4jConfig:
|
|
18
|
+
uri: str
|
|
19
|
+
user: str
|
|
20
|
+
password: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class NodeEndpoint:
|
|
25
|
+
label: str
|
|
26
|
+
key: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ScenarioNode:
|
|
31
|
+
label: str
|
|
32
|
+
key: dict[str, Any]
|
|
33
|
+
props: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ScenarioRelationship:
|
|
38
|
+
type: str
|
|
39
|
+
from_: NodeEndpoint
|
|
40
|
+
to: NodeEndpoint
|
|
41
|
+
props: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Scenario:
|
|
46
|
+
name: str
|
|
47
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
nodes: list[ScenarioNode] = field(default_factory=list)
|
|
49
|
+
relationships: list[ScenarioRelationship] = field(default_factory=list)
|
|
50
|
+
description: str | None = None
|
|
51
|
+
linked_repository: str | None = None
|
|
52
|
+
required_labels: list[str] = field(default_factory=list)
|
|
53
|
+
required_relationships: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Registry:
|
|
58
|
+
neo4j: Neo4jConfig
|
|
59
|
+
defaults: dict[str, dict[str, Any]]
|
|
60
|
+
scenarios: dict[str, Scenario]
|
|
61
|
+
path: Path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_env_vars(value: Any) -> Any:
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
def repl(match: re.Match[str]) -> str:
|
|
67
|
+
name = match.group(1)
|
|
68
|
+
default = match.group(2)
|
|
69
|
+
if name in os.environ:
|
|
70
|
+
return os.environ[name]
|
|
71
|
+
if default is not None:
|
|
72
|
+
return default
|
|
73
|
+
msg = f"Missing environment variable: {name}"
|
|
74
|
+
raise SuperSeedError(msg)
|
|
75
|
+
|
|
76
|
+
return ENV_PATTERN.sub(repl, value)
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
return {key: resolve_env_vars(item) for key, item in value.items()}
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [resolve_env_vars(item) for item in value]
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_endpoint(raw: dict[str, Any]) -> NodeEndpoint:
|
|
85
|
+
label = raw.get("label")
|
|
86
|
+
key = raw.get("key")
|
|
87
|
+
if not isinstance(label, str) or not isinstance(key, dict):
|
|
88
|
+
msg = "Relationship endpoint requires string label and dict key"
|
|
89
|
+
raise SuperSeedError(msg)
|
|
90
|
+
return NodeEndpoint(label=label, key=key)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_scenario(name: str, raw: dict[str, Any]) -> Scenario:
|
|
94
|
+
nodes: list[ScenarioNode] = []
|
|
95
|
+
for node in raw.get("nodes", []):
|
|
96
|
+
if not isinstance(node, dict):
|
|
97
|
+
msg = f"Scenario {name!r} has invalid node entry"
|
|
98
|
+
raise SuperSeedError(msg)
|
|
99
|
+
label = node.get("label")
|
|
100
|
+
key = node.get("key")
|
|
101
|
+
if not isinstance(label, str) or not isinstance(key, dict):
|
|
102
|
+
msg = f"Scenario {name!r} node requires label and key"
|
|
103
|
+
raise SuperSeedError(msg)
|
|
104
|
+
props = node.get("props", {})
|
|
105
|
+
if not isinstance(props, dict):
|
|
106
|
+
msg = f"Scenario {name!r} node props must be a mapping"
|
|
107
|
+
raise SuperSeedError(msg)
|
|
108
|
+
nodes.append(ScenarioNode(label=label, key=key, props=props))
|
|
109
|
+
|
|
110
|
+
relationships: list[ScenarioRelationship] = []
|
|
111
|
+
for rel in raw.get("relationships", []):
|
|
112
|
+
if not isinstance(rel, dict):
|
|
113
|
+
msg = f"Scenario {name!r} has invalid relationship entry"
|
|
114
|
+
raise SuperSeedError(msg)
|
|
115
|
+
rel_type = rel.get("type")
|
|
116
|
+
from_raw = rel.get("from")
|
|
117
|
+
to_raw = rel.get("to")
|
|
118
|
+
if (
|
|
119
|
+
not isinstance(rel_type, str)
|
|
120
|
+
or not isinstance(from_raw, dict)
|
|
121
|
+
or not isinstance(to_raw, dict)
|
|
122
|
+
):
|
|
123
|
+
msg = f"Scenario {name!r} relationship requires type, from, and to"
|
|
124
|
+
raise SuperSeedError(msg)
|
|
125
|
+
props = rel.get("props", {})
|
|
126
|
+
if not isinstance(props, dict):
|
|
127
|
+
msg = f"Scenario {name!r} relationship props must be a mapping"
|
|
128
|
+
raise SuperSeedError(msg)
|
|
129
|
+
relationships.append(
|
|
130
|
+
ScenarioRelationship(
|
|
131
|
+
type=rel_type,
|
|
132
|
+
from_=_parse_endpoint(from_raw),
|
|
133
|
+
to=_parse_endpoint(to_raw),
|
|
134
|
+
props=props,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
parameters = raw.get("parameters", {})
|
|
139
|
+
if not isinstance(parameters, dict):
|
|
140
|
+
msg = f"Scenario {name!r} parameters must be a mapping"
|
|
141
|
+
raise SuperSeedError(msg)
|
|
142
|
+
|
|
143
|
+
description = raw.get("description")
|
|
144
|
+
if description is not None and not isinstance(description, str):
|
|
145
|
+
msg = f"Scenario {name!r} description must be a string"
|
|
146
|
+
raise SuperSeedError(msg)
|
|
147
|
+
|
|
148
|
+
linked_repository = raw.get("linked_repository")
|
|
149
|
+
if linked_repository is not None and not isinstance(linked_repository, str):
|
|
150
|
+
msg = f"Scenario {name!r} linked_repository must be a string"
|
|
151
|
+
raise SuperSeedError(msg)
|
|
152
|
+
|
|
153
|
+
required_labels = raw.get("required_labels", [])
|
|
154
|
+
if not isinstance(required_labels, list) or not all(
|
|
155
|
+
isinstance(item, str) for item in required_labels
|
|
156
|
+
):
|
|
157
|
+
msg = f"Scenario {name!r} required_labels must be a list of strings"
|
|
158
|
+
raise SuperSeedError(msg)
|
|
159
|
+
|
|
160
|
+
required_relationships = raw.get("required_relationships", [])
|
|
161
|
+
if not isinstance(required_relationships, list) or not all(
|
|
162
|
+
isinstance(item, str) for item in required_relationships
|
|
163
|
+
):
|
|
164
|
+
msg = f"Scenario {name!r} required_relationships must be a list of strings"
|
|
165
|
+
raise SuperSeedError(msg)
|
|
166
|
+
|
|
167
|
+
return Scenario(
|
|
168
|
+
name=name,
|
|
169
|
+
parameters=parameters,
|
|
170
|
+
nodes=nodes,
|
|
171
|
+
relationships=relationships,
|
|
172
|
+
description=description,
|
|
173
|
+
linked_repository=linked_repository,
|
|
174
|
+
required_labels=required_labels,
|
|
175
|
+
required_relationships=required_relationships,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def load_registry(path: str | Path) -> Registry:
|
|
180
|
+
config_path = Path(path)
|
|
181
|
+
raw_text = config_path.read_text(encoding="utf-8")
|
|
182
|
+
raw = yaml.safe_load(raw_text)
|
|
183
|
+
if not isinstance(raw, dict):
|
|
184
|
+
msg = f"Invalid registry file: {config_path}"
|
|
185
|
+
raise SuperSeedError(msg)
|
|
186
|
+
|
|
187
|
+
resolved = resolve_env_vars(raw)
|
|
188
|
+
|
|
189
|
+
neo4j_raw = resolved.get("neo4j", {})
|
|
190
|
+
if not isinstance(neo4j_raw, dict):
|
|
191
|
+
msg = "Registry requires a neo4j mapping"
|
|
192
|
+
raise SuperSeedError(msg)
|
|
193
|
+
|
|
194
|
+
uri = neo4j_raw.get("uri")
|
|
195
|
+
user = neo4j_raw.get("user")
|
|
196
|
+
password = neo4j_raw.get("password")
|
|
197
|
+
if not isinstance(uri, str) or not isinstance(user, str) or not isinstance(password, str):
|
|
198
|
+
msg = "neo4j.uri, neo4j.user, and neo4j.password must be strings"
|
|
199
|
+
raise SuperSeedError(msg)
|
|
200
|
+
|
|
201
|
+
defaults = resolved.get("defaults", {})
|
|
202
|
+
if not isinstance(defaults, dict):
|
|
203
|
+
msg = "Registry defaults must be a mapping"
|
|
204
|
+
raise SuperSeedError(msg)
|
|
205
|
+
|
|
206
|
+
scenarios_raw = resolved.get("scenarios", {})
|
|
207
|
+
if not isinstance(scenarios_raw, dict):
|
|
208
|
+
msg = "Registry scenarios must be a mapping"
|
|
209
|
+
raise SuperSeedError(msg)
|
|
210
|
+
|
|
211
|
+
scenarios = {
|
|
212
|
+
name: _parse_scenario(name, scenario_raw)
|
|
213
|
+
for name, scenario_raw in scenarios_raw.items()
|
|
214
|
+
if isinstance(scenario_raw, dict)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return Registry(
|
|
218
|
+
neo4j=Neo4jConfig(uri=uri, user=user, password=password),
|
|
219
|
+
defaults=defaults,
|
|
220
|
+
scenarios=scenarios,
|
|
221
|
+
path=config_path,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_scenario(registry: Registry, name: str) -> Scenario:
|
|
226
|
+
try:
|
|
227
|
+
return registry.scenarios[name]
|
|
228
|
+
except KeyError as exc:
|
|
229
|
+
raise ScenarioNotFoundError(f"Scenario not found: {name}") from exc
|
|
File without changes
|