mongrove 0.1.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.
Files changed (50) hide show
  1. mongrove/__init__.py +3 -0
  2. mongrove/__main__.py +7 -0
  3. mongrove/cli.py +217 -0
  4. mongrove/domain/__init__.py +30 -0
  5. mongrove/domain/connection.py +26 -0
  6. mongrove/domain/explain.py +208 -0
  7. mongrove/domain/index.py +36 -0
  8. mongrove/domain/namespace.py +28 -0
  9. mongrove/domain/pipeline.py +54 -0
  10. mongrove/domain/query.py +156 -0
  11. mongrove/domain/schema.py +26 -0
  12. mongrove/domain/session.py +89 -0
  13. mongrove/services/__init__.py +34 -0
  14. mongrove/services/bson_codec.py +74 -0
  15. mongrove/services/import_export.py +259 -0
  16. mongrove/services/mongo_gateway.py +792 -0
  17. mongrove/services/profile_store.py +148 -0
  18. mongrove/services/query_history.py +367 -0
  19. mongrove/services/schema_analyzer.py +185 -0
  20. mongrove/services/settings_store.py +56 -0
  21. mongrove/ui/__init__.py +1 -0
  22. mongrove/ui/app.py +216 -0
  23. mongrove/ui/commands.py +17 -0
  24. mongrove/ui/screens/__init__.py +1 -0
  25. mongrove/ui/screens/aggregation.py +219 -0
  26. mongrove/ui/screens/browser.py +1126 -0
  27. mongrove/ui/screens/connection.py +249 -0
  28. mongrove/ui/screens/document.py +58 -0
  29. mongrove/ui/screens/document_editor.py +124 -0
  30. mongrove/ui/screens/explain.py +185 -0
  31. mongrove/ui/screens/export.py +249 -0
  32. mongrove/ui/screens/index_confirmation.py +119 -0
  33. mongrove/ui/screens/index_editor.py +100 -0
  34. mongrove/ui/screens/indexes.py +346 -0
  35. mongrove/ui/screens/mutation_confirmation.py +133 -0
  36. mongrove/ui/screens/query_history.py +289 -0
  37. mongrove/ui/screens/query_options.py +86 -0
  38. mongrove/ui/screens/schema.py +179 -0
  39. mongrove/ui/screens/theme_picker.py +77 -0
  40. mongrove/ui/styles/app.tcss +567 -0
  41. mongrove/ui/themes.py +149 -0
  42. mongrove/ui/widgets/__init__.py +6 -0
  43. mongrove/ui/widgets/document_table.py +31 -0
  44. mongrove/ui/widgets/document_viewer.py +72 -0
  45. mongrove-0.1.0.dist-info/METADATA +315 -0
  46. mongrove-0.1.0.dist-info/RECORD +50 -0
  47. mongrove-0.1.0.dist-info/WHEEL +5 -0
  48. mongrove-0.1.0.dist-info/entry_points.txt +2 -0
  49. mongrove-0.1.0.dist-info/licenses/LICENSE +21 -0
  50. mongrove-0.1.0.dist-info/top_level.txt +2 -0
mongrove/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mongrove package."""
2
+
3
+ __version__ = "0.1.0"
mongrove/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run Mongrove with ``python -m mongrove``."""
2
+
3
+ from mongrove.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
mongrove/cli.py ADDED
@@ -0,0 +1,217 @@
1
+ """Command-line entry point for Mongrove."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Mapping, Sequence
10
+
11
+ from mongrove import __version__
12
+ from mongrove.domain.session import normalize_environment
13
+ from mongrove.ui.app import MongroveApp
14
+ from mongrove.ui.themes import CURATED_THEME_NAMES
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class StartupOptions:
19
+ """Resolved startup settings after applying CLI-over-environment precedence."""
20
+
21
+ uri: str | None
22
+ profile: str | None
23
+ database: str | None
24
+ collection: str | None
25
+ read_only: bool
26
+ no_history: bool
27
+ config_dir: Path | None
28
+ theme: str | None
29
+ environment: str | None
30
+ allow_production_writes: bool
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ """Build the command-line parser without starting a terminal UI."""
35
+
36
+ parser = argparse.ArgumentParser(
37
+ prog="mongrove",
38
+ description="A keyboard-first MongoDB workspace for the terminal.",
39
+ )
40
+ parser.add_argument(
41
+ "uri",
42
+ nargs="?",
43
+ help="MongoDB connection URI. Avoid passwords here when possible.",
44
+ )
45
+ parser.add_argument(
46
+ "--profile",
47
+ "--alias",
48
+ dest="profile",
49
+ help="Saved connection alias to preselect.",
50
+ )
51
+ parser.add_argument("--database", help="Database to open after connecting.")
52
+ parser.add_argument("--collection", help="Collection to open after connecting.")
53
+ parser.add_argument(
54
+ "--read-only",
55
+ action="store_true",
56
+ default=None,
57
+ help="Hide write controls. MongoDB roles remain the security boundary.",
58
+ )
59
+ parser.add_argument(
60
+ "--no-history",
61
+ action="store_true",
62
+ default=None,
63
+ help="Disable local query history for this session.",
64
+ )
65
+ parser.add_argument(
66
+ "--config-dir",
67
+ type=Path,
68
+ help="Override the directory containing local Mongrove configuration.",
69
+ )
70
+ parser.add_argument(
71
+ "--theme",
72
+ choices=CURATED_THEME_NAMES,
73
+ metavar="THEME",
74
+ help="Use a curated theme for this launch.",
75
+ )
76
+ parser.add_argument(
77
+ "--environment",
78
+ type=_parse_environment,
79
+ metavar="ENV",
80
+ help="Label this direct connection as development, staging, or production.",
81
+ )
82
+ parser.add_argument(
83
+ "--allow-production-writes",
84
+ action="store_true",
85
+ default=None,
86
+ help="Allow writes for a production target; each mutation still confirms explicitly.",
87
+ )
88
+ parser.add_argument(
89
+ "--version",
90
+ action="version",
91
+ version=f"%(prog)s {__version__}",
92
+ )
93
+ return parser
94
+
95
+
96
+ def main(argv: Sequence[str] | None = None) -> int:
97
+ """Parse arguments and run the interactive application."""
98
+
99
+ parser = build_parser()
100
+ args = parser.parse_args(argv)
101
+ try:
102
+ options = resolve_startup_options(args)
103
+ except ValueError as error:
104
+ parser.error(str(error))
105
+ app = MongroveApp(
106
+ startup_uri=options.uri,
107
+ startup_profile=options.profile,
108
+ startup_database=options.database,
109
+ startup_collection=options.collection,
110
+ read_only=options.read_only,
111
+ no_history=options.no_history,
112
+ config_dir=options.config_dir,
113
+ theme_name=options.theme,
114
+ environment=options.environment,
115
+ allow_production_writes=options.allow_production_writes,
116
+ )
117
+ app.run()
118
+ return 0
119
+
120
+
121
+ def resolve_startup_options(
122
+ args: argparse.Namespace,
123
+ environ: Mapping[str, str] | None = None,
124
+ ) -> StartupOptions:
125
+ """Resolve startup values with explicit command-line values taking priority.
126
+
127
+ A direct URI or alias supplied on the command line takes precedence over all
128
+ environment connection selectors. This prevents a shell's stale URI from
129
+ silently changing an explicitly requested target.
130
+ """
131
+
132
+ environment = os.environ if environ is None else environ
133
+ if args.uri:
134
+ uri = args.uri
135
+ profile = None
136
+ elif args.profile:
137
+ uri = None
138
+ profile = args.profile
139
+ else:
140
+ uri = _environment_value(environment, "MONGROVE_URI")
141
+ profile = None if uri else _environment_value(environment, "MONGROVE_PROFILE")
142
+
143
+ resolved_environment = (
144
+ args.environment
145
+ if args.environment is not None
146
+ else normalize_environment(_environment_value(environment, "MONGROVE_ENVIRONMENT"))
147
+ )
148
+ theme = args.theme or _environment_value(environment, "MONGROVE_THEME")
149
+ if theme is not None and theme not in CURATED_THEME_NAMES:
150
+ choices = ", ".join(CURATED_THEME_NAMES)
151
+ raise ValueError(f"MONGROVE_THEME must be one of: {choices}.")
152
+
153
+ config_dir = args.config_dir
154
+ if config_dir is None:
155
+ config_dir_value = _environment_value(environment, "MONGROVE_CONFIG_DIR")
156
+ config_dir = Path(config_dir_value) if config_dir_value else None
157
+
158
+ return StartupOptions(
159
+ uri=uri,
160
+ profile=profile,
161
+ database=args.database or _environment_value(environment, "MONGROVE_DATABASE"),
162
+ collection=args.collection or _environment_value(environment, "MONGROVE_COLLECTION"),
163
+ read_only=_resolve_boolean_option(
164
+ args.read_only,
165
+ environment,
166
+ "MONGROVE_READ_ONLY",
167
+ ),
168
+ no_history=_resolve_boolean_option(
169
+ args.no_history,
170
+ environment,
171
+ "MONGROVE_NO_HISTORY",
172
+ ),
173
+ config_dir=config_dir,
174
+ theme=theme,
175
+ environment=resolved_environment,
176
+ allow_production_writes=_resolve_boolean_option(
177
+ args.allow_production_writes,
178
+ environment,
179
+ "MONGROVE_ALLOW_PRODUCTION_WRITES",
180
+ ),
181
+ )
182
+
183
+
184
+ def _parse_environment(value: str) -> str:
185
+ try:
186
+ parsed = normalize_environment(value)
187
+ except ValueError as error:
188
+ raise argparse.ArgumentTypeError(str(error)) from error
189
+ if parsed is None:
190
+ raise argparse.ArgumentTypeError("Environment cannot be empty.")
191
+ return parsed
192
+
193
+
194
+ def _environment_value(environment: Mapping[str, str], name: str) -> str | None:
195
+ value = environment.get(name)
196
+ if value is None:
197
+ return None
198
+ stripped = value.strip()
199
+ return stripped or None
200
+
201
+
202
+ def _resolve_boolean_option(
203
+ command_line_value: bool | None,
204
+ environment: Mapping[str, str],
205
+ name: str,
206
+ ) -> bool:
207
+ if command_line_value is not None:
208
+ return command_line_value
209
+ value = _environment_value(environment, name)
210
+ if value is None:
211
+ return False
212
+ normalized = value.casefold()
213
+ if normalized in {"1", "true", "yes", "on"}:
214
+ return True
215
+ if normalized in {"0", "false", "no", "off"}:
216
+ return False
217
+ raise ValueError(f"{name} must be true or false.")
@@ -0,0 +1,30 @@
1
+ """Domain models and query parsing for Mongrove."""
2
+
3
+ from mongrove.domain.connection import ConnectionInfo, ConnectionProfile
4
+ from mongrove.domain.explain import ExplainFragment, ExplainResult, ExplainWarning
5
+ from mongrove.domain.index import IndexInfo, IndexUsage, IndexUsageReport
6
+ from mongrove.domain.pipeline import AggregationPipeline, PipelineValidationError, parse_pipeline
7
+ from mongrove.domain.query import FindQuery, QueryFormState, QueryValidationError
8
+ from mongrove.domain.session import SessionPolicy, normalize_environment
9
+ from mongrove.domain.schema import SchemaField, SchemaReport
10
+
11
+ __all__ = [
12
+ "AggregationPipeline",
13
+ "ConnectionInfo",
14
+ "ConnectionProfile",
15
+ "ExplainFragment",
16
+ "ExplainResult",
17
+ "ExplainWarning",
18
+ "FindQuery",
19
+ "IndexInfo",
20
+ "IndexUsage",
21
+ "IndexUsageReport",
22
+ "PipelineValidationError",
23
+ "QueryFormState",
24
+ "QueryValidationError",
25
+ "SessionPolicy",
26
+ "SchemaField",
27
+ "SchemaReport",
28
+ "normalize_environment",
29
+ "parse_pipeline",
30
+ ]
@@ -0,0 +1,26 @@
1
+ """Connection-facing domain models for Mongrove."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ConnectionProfile:
10
+ """A locally saved connection endpoint without secret credentials."""
11
+
12
+ name: str
13
+ uri: str
14
+ favorite: bool = False
15
+ default_database: str | None = None
16
+ environment: str | None = None
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ConnectionInfo:
21
+ """Connection facts collected after a successful server ping."""
22
+
23
+ display_uri: str
24
+ server_version: str | None = None
25
+ topology: str | None = None
26
+ is_writable_primary: bool | None = None
@@ -0,0 +1,208 @@
1
+ """Best-effort normalization of version-variable MongoDB explain responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ from mongrove.domain.pipeline import AggregationPipeline
10
+ from mongrove.domain.query import FindQuery
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ExplainFragment:
15
+ """One independently normalized planner fragment within an explain response."""
16
+
17
+ path: str
18
+ root_stage: str | None
19
+ stages: tuple[str, ...]
20
+ index_names: tuple[str, ...]
21
+ rejected_plan_count: int | None
22
+ optimized_pipeline: bool = False
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ExplainWarning:
27
+ """Evidence-based planner observation, never an automatic tuning claim."""
28
+
29
+ code: str
30
+ severity: Literal["info", "warning"]
31
+ message: str
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ExplainResult:
36
+ """Planner-only explain response with raw BSON-compatible fallback data."""
37
+
38
+ operation: Literal["find", "aggregation"]
39
+ elapsed_ms: int
40
+ fragments: tuple[ExplainFragment, ...]
41
+ warnings: tuple[ExplainWarning, ...]
42
+ raw: dict[str, Any]
43
+
44
+
45
+ def normalize_find_explain(raw: dict[str, Any], query: FindQuery, elapsed_ms: int) -> ExplainResult:
46
+ """Normalize a planner-only find explain and retain the raw server response."""
47
+
48
+ result = _normalize("find", raw, elapsed_ms)
49
+ warnings = list(result.warnings)
50
+ if query.limit is None:
51
+ warnings.append(
52
+ ExplainWarning(
53
+ code="unbounded-find",
54
+ severity="info",
55
+ message="The logical find query has no limit.",
56
+ )
57
+ )
58
+ return ExplainResult(
59
+ operation=result.operation,
60
+ elapsed_ms=result.elapsed_ms,
61
+ fragments=result.fragments,
62
+ warnings=tuple(warnings),
63
+ raw=result.raw,
64
+ )
65
+
66
+
67
+ def normalize_aggregation_explain(
68
+ raw: dict[str, Any],
69
+ pipeline: AggregationPipeline,
70
+ elapsed_ms: int,
71
+ ) -> ExplainResult:
72
+ """Normalize a planner-only aggregation explain and retain raw response data."""
73
+
74
+ return _normalize("aggregation", raw, elapsed_ms)
75
+
76
+
77
+ def _normalize(
78
+ operation: Literal["find", "aggregation"],
79
+ raw: dict[str, Any],
80
+ elapsed_ms: int,
81
+ ) -> ExplainResult:
82
+ planners, truncated = _planner_nodes(raw)
83
+ fragments = tuple(_fragment(path, planner, raw) for path, planner in planners)
84
+ warnings: list[ExplainWarning] = []
85
+ stages = {stage for fragment in fragments for stage in fragment.stages}
86
+ if "COLLSCAN" in stages:
87
+ warnings.append(
88
+ ExplainWarning("collection-scan", "warning", "Observed COLLSCAN in the winning plan.")
89
+ )
90
+ if "SORT" in stages:
91
+ warnings.append(
92
+ ExplainWarning("in-memory-sort", "warning", "Observed SORT in the winning plan.")
93
+ )
94
+ if "SHARD_MERGE" in stages:
95
+ warnings.append(
96
+ ExplainWarning("shard-merge", "info", "Observed SHARD_MERGE in the winning plan.")
97
+ )
98
+ rejected = sum(fragment.rejected_plan_count or 0 for fragment in fragments)
99
+ if rejected:
100
+ warnings.append(
101
+ ExplainWarning(
102
+ "rejected-plans",
103
+ "info",
104
+ f"Observed {rejected} rejected planner candidate(s).",
105
+ )
106
+ )
107
+ if any(fragment.optimized_pipeline for fragment in fragments):
108
+ warnings.append(
109
+ ExplainWarning(
110
+ "optimized-pipeline",
111
+ "info",
112
+ "Server reported an optimized aggregation pipeline.",
113
+ )
114
+ )
115
+ if not fragments:
116
+ warnings.append(
117
+ ExplainWarning(
118
+ "unrecognized-shape",
119
+ "info",
120
+ "Plan shape was not normalized; inspect Raw EJSON for server-specific details.",
121
+ )
122
+ )
123
+ if truncated:
124
+ warnings.append(
125
+ ExplainWarning(
126
+ "normalization-truncated",
127
+ "info",
128
+ "Plan normalization reached its safety bound; inspect Raw EJSON for remaining details.",
129
+ )
130
+ )
131
+ return ExplainResult(
132
+ operation=operation,
133
+ elapsed_ms=elapsed_ms,
134
+ fragments=fragments,
135
+ warnings=tuple(warnings),
136
+ raw=raw,
137
+ )
138
+
139
+
140
+ def _planner_nodes(raw: Mapping[str, Any]) -> tuple[list[tuple[str, Mapping[str, Any]]], bool]:
141
+ nodes: list[tuple[str, Mapping[str, Any]]] = []
142
+ truncated = False
143
+ visited = 0
144
+
145
+ def walk(value: Any, path: str, depth: int) -> None:
146
+ nonlocal truncated, visited
147
+ if depth > 24 or visited >= 1_000 or len(nodes) >= 32:
148
+ truncated = True
149
+ return
150
+ visited += 1
151
+ if isinstance(value, Mapping):
152
+ planner = value.get("queryPlanner")
153
+ if isinstance(planner, Mapping):
154
+ nodes.append((f"{path}.queryPlanner" if path else "queryPlanner", planner))
155
+ for key, child in value.items():
156
+ if key == "queryPlanner":
157
+ continue
158
+ walk(child, f"{path}.{key}" if path else str(key), depth + 1)
159
+ elif isinstance(value, list):
160
+ for index, child in enumerate(value):
161
+ walk(child, f"{path}[{index}]", depth + 1)
162
+
163
+ walk(raw, "", 0)
164
+ return nodes, truncated
165
+
166
+
167
+ def _fragment(path: str, planner: Mapping[str, Any], raw: Mapping[str, Any]) -> ExplainFragment:
168
+ winning = planner.get("winningPlan")
169
+ if not isinstance(winning, Mapping):
170
+ winning = {}
171
+ query_plan = winning.get("queryPlan")
172
+ root = query_plan if isinstance(query_plan, Mapping) else winning
173
+ stages, indexes = _plan_details(root)
174
+ rejected = planner.get("rejectedPlans")
175
+ return ExplainFragment(
176
+ path=path,
177
+ root_stage=stages[0] if stages else None,
178
+ stages=tuple(stages),
179
+ index_names=tuple(indexes),
180
+ rejected_plan_count=len(rejected) if isinstance(rejected, list) else None,
181
+ optimized_pipeline=bool(planner.get("optimizedPipeline") or raw.get("optimizedPipeline")),
182
+ )
183
+
184
+
185
+ def _plan_details(root: Mapping[str, Any]) -> tuple[list[str], list[str]]:
186
+ stages: list[str] = []
187
+ indexes: list[str] = []
188
+ children = ("inputStage", "inputStages", "thenStage", "elseStage", "innerStage", "outerStage")
189
+
190
+ def walk(node: Any, depth: int) -> None:
191
+ if depth > 24 or not isinstance(node, Mapping):
192
+ return
193
+ stage = node.get("stage")
194
+ if isinstance(stage, str):
195
+ stages.append(stage)
196
+ index_name = node.get("indexName")
197
+ if isinstance(index_name, str) and index_name not in indexes:
198
+ indexes.append(index_name)
199
+ for key in children:
200
+ child = node.get(key)
201
+ if isinstance(child, list):
202
+ for item in child:
203
+ walk(item, depth + 1)
204
+ else:
205
+ walk(child, depth + 1)
206
+
207
+ walk(root, 0)
208
+ return stages, indexes
@@ -0,0 +1,36 @@
1
+ """MongoDB index metadata models independent of PyMongo result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class IndexInfo:
11
+ """One server-reported collection index and its complete raw specification."""
12
+
13
+ name: str
14
+ keys: tuple[tuple[str, Any], ...]
15
+ unique: bool
16
+ sparse: bool
17
+ hidden: bool
18
+ raw: dict[str, Any]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class IndexUsage:
23
+ """Best-effort node-local $indexStats usage data for one index."""
24
+
25
+ name: str
26
+ operations: int | None
27
+ since: str | None
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class IndexUsageReport:
32
+ """Usage availability is explicit so absent privileges never look like zero."""
33
+
34
+ available: bool
35
+ usages: tuple[IndexUsage, ...] = ()
36
+ message: str | None = None
@@ -0,0 +1,28 @@
1
+ """Models used by Mongrove namespace navigation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CollectionInfo:
10
+ """A collection or view returned by a MongoDB database."""
11
+
12
+ database: str
13
+ name: str
14
+ kind: str = "collection"
15
+
16
+ @property
17
+ def namespace(self) -> str:
18
+ return f"{self.database}.{self.name}"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class NamespaceTreeItem:
23
+ """Data attached to a Textual Tree node."""
24
+
25
+ kind: str
26
+ database: str
27
+ collection: str | None = None
28
+ collection_kind: str = "collection"
@@ -0,0 +1,54 @@
1
+ """Validated BSON-aware aggregation pipeline models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from bson import json_util
9
+
10
+
11
+ class PipelineValidationError(ValueError):
12
+ """Raised when raw editor text cannot become a MongoDB pipeline."""
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class AggregationPipeline:
17
+ """Parsed pipeline plus a safety classification of its write stages."""
18
+
19
+ stages: tuple[dict[str, Any], ...]
20
+ write_stages: tuple[str, ...] = ()
21
+
22
+ @property
23
+ def has_write_stage(self) -> bool:
24
+ return bool(self.write_stages)
25
+
26
+
27
+ def parse_pipeline(text: str) -> AggregationPipeline:
28
+ """Parse a JSON/EJSON array of one-key aggregation stage documents."""
29
+
30
+ if not text.strip():
31
+ raise PipelineValidationError("Pipeline cannot be empty; use [] for no stages.")
32
+ try:
33
+ parsed = json_util.loads(text)
34
+ except Exception as error: # json_util exposes multiple parser exceptions.
35
+ raise PipelineValidationError(
36
+ f"Pipeline must be valid JSON or Extended JSON: {error}"
37
+ ) from error
38
+ if not isinstance(parsed, list):
39
+ raise PipelineValidationError("Pipeline must be a JSON array of stage documents.")
40
+
41
+ stages: list[dict[str, Any]] = []
42
+ write_stages: list[str] = []
43
+ for index, stage in enumerate(parsed, start=1):
44
+ if not isinstance(stage, dict) or len(stage) != 1:
45
+ raise PipelineValidationError(
46
+ f"Stage {index} must be a JSON document with exactly one operator."
47
+ )
48
+ operator = next(iter(stage))
49
+ if not isinstance(operator, str) or not operator.startswith("$"):
50
+ raise PipelineValidationError(f"Stage {index} must start with a $ operator.")
51
+ stages.append(stage)
52
+ if operator in {"$out", "$merge"}:
53
+ write_stages.append(operator)
54
+ return AggregationPipeline(tuple(stages), tuple(write_stages))