10xgraph-api 0.7.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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Read and atomically write the ``10xgraph.json`` file being edited."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigConflictError(Exception):
|
|
16
|
+
"""The file changed on disk after the editor loaded it."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LoadedConfig:
|
|
21
|
+
config: dict[str, Any] | None
|
|
22
|
+
version: str | None # sha256 of the file bytes, None when the file does not exist
|
|
23
|
+
parse_error: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigFileStore:
|
|
27
|
+
"""Owns one config file: loads it, detects concurrent edits, and saves it safely.
|
|
28
|
+
|
|
29
|
+
``seed_path`` is read only while ``path`` does not exist: the editor starts from its
|
|
30
|
+
contents, and the first save creates ``path``. This is how a project that still has the
|
|
31
|
+
legacy ``agentflow.json`` moves to ``10xgraph.json`` without the old file being touched.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, path: Path, seed_path: Path | None = None) -> None:
|
|
35
|
+
self.path = path
|
|
36
|
+
self.seed_path = seed_path
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def backup_path(self) -> Path:
|
|
40
|
+
return self.path.with_name(f"{self.path.name}.bak")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def seeded(self) -> bool:
|
|
44
|
+
"""True while the editor starts from ``seed_path`` because ``path`` is missing."""
|
|
45
|
+
return (
|
|
46
|
+
not self.path.exists()
|
|
47
|
+
and self.seed_path is not None
|
|
48
|
+
and _read(self.seed_path).config is not None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def load(self) -> LoadedConfig:
|
|
52
|
+
loaded = _read(self.path)
|
|
53
|
+
if loaded.version is None and self.seed_path is not None:
|
|
54
|
+
seed = _read(self.seed_path)
|
|
55
|
+
if seed.config is not None:
|
|
56
|
+
# Version stays None: the target does not exist yet, so a save creates it.
|
|
57
|
+
return LoadedConfig(config=seed.config, version=None)
|
|
58
|
+
return loaded
|
|
59
|
+
|
|
60
|
+
def save(self, config: dict[str, Any], expected_version: str | None) -> str:
|
|
61
|
+
"""Write ``config`` and return the new version.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ConfigConflictError: The file on disk no longer matches ``expected_version``.
|
|
65
|
+
"""
|
|
66
|
+
current = _read(self.path)
|
|
67
|
+
if current.version != expected_version:
|
|
68
|
+
raise ConfigConflictError(
|
|
69
|
+
f"{self.path.name} changed on disk after it was loaded. Reload to see the "
|
|
70
|
+
"latest version before saving."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# A new file keeps the key order of the file it was seeded from.
|
|
74
|
+
ordered = _preserve_key_order(config, current.config or self.load().config or {})
|
|
75
|
+
payload = (json.dumps(ordered, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
76
|
+
|
|
77
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
if self.path.exists():
|
|
79
|
+
shutil.copy2(self.path, self.backup_path)
|
|
80
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
81
|
+
prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent
|
|
82
|
+
)
|
|
83
|
+
temporary_path = Path(temporary_name)
|
|
84
|
+
try:
|
|
85
|
+
with os.fdopen(fd, "wb") as handle:
|
|
86
|
+
handle.write(payload)
|
|
87
|
+
handle.flush()
|
|
88
|
+
os.fsync(handle.fileno())
|
|
89
|
+
temporary_path.replace(self.path)
|
|
90
|
+
finally:
|
|
91
|
+
if temporary_path.exists():
|
|
92
|
+
temporary_path.unlink()
|
|
93
|
+
return _digest(payload)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read(path: Path) -> LoadedConfig:
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return LoadedConfig(config=None, version=None)
|
|
99
|
+
raw = path.read_bytes()
|
|
100
|
+
version = _digest(raw)
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(raw.decode("utf-8"))
|
|
103
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
104
|
+
return LoadedConfig(config=None, version=version, parse_error=str(exc))
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return LoadedConfig(
|
|
107
|
+
config=None, version=version, parse_error="The file is not a JSON object."
|
|
108
|
+
)
|
|
109
|
+
return LoadedConfig(config=data, version=version)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _preserve_key_order(new: dict[str, Any], old: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
"""Keep the file's existing top-level key order; append new keys at the end."""
|
|
114
|
+
ordered = {key: new[key] for key in old if key in new}
|
|
115
|
+
ordered.update({key: value for key, value in new.items() if key not in ordered})
|
|
116
|
+
return ordered
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _digest(raw: bytes) -> str:
|
|
120
|
+
return hashlib.sha256(raw).hexdigest()
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Validate an ``10xgraph.json`` document the same way the CLI and API server read it.
|
|
2
|
+
|
|
3
|
+
Errors block a save because the server would refuse to start (or silently misbehave).
|
|
4
|
+
Warnings describe things that are valid but probably not what the user meant, such as
|
|
5
|
+
an import path whose module file cannot be found next to the config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from tenxgraph_api.cli.config_editor.schema import (
|
|
18
|
+
BUILTIN_AUTHORIZATION,
|
|
19
|
+
KNOWN_TOP_LEVEL_KEYS,
|
|
20
|
+
section_for_key,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Issue = dict[str, str]
|
|
25
|
+
|
|
26
|
+
_IMPORT_PATH = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_][\w.]*$")
|
|
27
|
+
_OBSERVABILITY_LEVELS = ("spans", "standard", "full")
|
|
28
|
+
_MAX_PERCENT = 100
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Collector:
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.issues: list[Issue] = []
|
|
34
|
+
|
|
35
|
+
def add(self, level: str, path: str, message: str) -> None:
|
|
36
|
+
top = path.split(".", 1)[0].split("[", 1)[0]
|
|
37
|
+
self.issues.append(
|
|
38
|
+
{
|
|
39
|
+
"level": level,
|
|
40
|
+
"section": section_for_key(top) if path else "core",
|
|
41
|
+
"path": path,
|
|
42
|
+
"message": message,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def error(self, path: str, message: str) -> None:
|
|
47
|
+
self.add("error", path, message)
|
|
48
|
+
|
|
49
|
+
def warning(self, path: str, message: str) -> None:
|
|
50
|
+
self.add("warning", path, message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_config(config: Any, base_dir: Path | None = None) -> list[Issue]:
|
|
54
|
+
"""Return every error and warning for ``config``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config: Parsed ``10xgraph.json`` content.
|
|
58
|
+
base_dir: Directory that holds the config file. Enables checks that
|
|
59
|
+
referenced modules and files exist.
|
|
60
|
+
"""
|
|
61
|
+
out = _Collector()
|
|
62
|
+
if not isinstance(config, dict):
|
|
63
|
+
out.error("", "The configuration must be a JSON object.")
|
|
64
|
+
return out.issues
|
|
65
|
+
|
|
66
|
+
_check_agent(config, out, base_dir)
|
|
67
|
+
_check_env(config, out, base_dir)
|
|
68
|
+
_check_auth(config, out, base_dir)
|
|
69
|
+
_check_authorization(config, out, base_dir)
|
|
70
|
+
_check_rate_limit(config, out)
|
|
71
|
+
_check_websocket(config, out)
|
|
72
|
+
_check_ag_ui(config, out)
|
|
73
|
+
_check_observability(config, out)
|
|
74
|
+
_check_plugins(config, out, base_dir)
|
|
75
|
+
_check_remote_tools(config, out)
|
|
76
|
+
_check_test(config, out)
|
|
77
|
+
_check_evaluation(config, out)
|
|
78
|
+
|
|
79
|
+
for key in config:
|
|
80
|
+
if key not in KNOWN_TOP_LEVEL_KEYS:
|
|
81
|
+
out.warning(key, f"'{key}' is not read by 10xGraph. It is kept as-is.")
|
|
82
|
+
return out.issues
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_errors(issues: list[Issue]) -> bool:
|
|
86
|
+
return any(issue["level"] == "error" for issue in issues)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Individual sections
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_agent(config: dict, out: _Collector, base_dir: Path | None) -> None:
|
|
95
|
+
agent = config.get("agent")
|
|
96
|
+
if agent is None or agent == "":
|
|
97
|
+
out.error("agent", "agent is required, for example 'graph.react:app'.")
|
|
98
|
+
return
|
|
99
|
+
_check_import_path("agent", agent, out, base_dir)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_env(config: dict, out: _Collector, base_dir: Path | None) -> None:
|
|
103
|
+
env = config.get("env")
|
|
104
|
+
if env is None:
|
|
105
|
+
return
|
|
106
|
+
if not isinstance(env, str):
|
|
107
|
+
out.error("env", "env must be a file path string or null.")
|
|
108
|
+
return
|
|
109
|
+
if env and base_dir is not None and not (base_dir / env).exists():
|
|
110
|
+
out.warning("env", f"Env file '{env}' does not exist yet.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_auth(config: dict, out: _Collector, base_dir: Path | None) -> None:
|
|
114
|
+
auth = config.get("auth")
|
|
115
|
+
if not auth:
|
|
116
|
+
return
|
|
117
|
+
if isinstance(auth, str):
|
|
118
|
+
if "jwt" not in auth:
|
|
119
|
+
out.error("auth", f"Unsupported auth '{auth}'. Use 'jwt' or a custom object.")
|
|
120
|
+
return
|
|
121
|
+
if isinstance(auth, dict):
|
|
122
|
+
method, path = auth.get("method"), auth.get("path")
|
|
123
|
+
if method != "custom":
|
|
124
|
+
out.error("auth.method", 'auth.method must be \'custom\'. For JWT use "auth": "jwt".')
|
|
125
|
+
if not path:
|
|
126
|
+
out.error("auth.path", "auth.path is required for custom auth.")
|
|
127
|
+
else:
|
|
128
|
+
_check_import_path("auth.path", path, out, base_dir)
|
|
129
|
+
return
|
|
130
|
+
out.error("auth", 'auth must be null, \'jwt\', or {"method": "custom", "path": ...}.')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _check_authorization(config: dict, out: _Collector, base_dir: Path | None) -> None:
|
|
134
|
+
value = config.get("authorization")
|
|
135
|
+
if value is None:
|
|
136
|
+
return
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
if ":" in value:
|
|
139
|
+
_check_import_path("authorization", value, out, base_dir)
|
|
140
|
+
elif value.strip().lower() not in BUILTIN_AUTHORIZATION:
|
|
141
|
+
out.error(
|
|
142
|
+
"authorization",
|
|
143
|
+
f"Unknown authorization '{value}'. Use one of "
|
|
144
|
+
f"{', '.join(BUILTIN_AUTHORIZATION)} or a module:attribute path.",
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
if not isinstance(value, dict):
|
|
148
|
+
out.error("authorization", "authorization must be a string, an RBAC object, or null.")
|
|
149
|
+
return
|
|
150
|
+
_check_rbac(value, out)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_rbac(value: dict, out: _Collector) -> None:
|
|
154
|
+
backend = str(value.get("backend") or value.get("type") or "").lower()
|
|
155
|
+
if backend not in ("rbac", "role_based", "roles"):
|
|
156
|
+
out.error("authorization.backend", "authorization.backend must be 'rbac'.")
|
|
157
|
+
roles = value.get("roles") if "roles" in value else value.get("role_scopes")
|
|
158
|
+
if roles is not None:
|
|
159
|
+
if not isinstance(roles, dict):
|
|
160
|
+
out.error("authorization.roles", "roles must map role names to scope lists.")
|
|
161
|
+
else:
|
|
162
|
+
for role, scopes in roles.items():
|
|
163
|
+
if not _is_str_list(scopes):
|
|
164
|
+
out.error(f"authorization.roles.{role}", "Scopes must be a list of strings.")
|
|
165
|
+
if not roles:
|
|
166
|
+
out.warning("authorization.roles", "No roles defined. Only default scopes apply.")
|
|
167
|
+
default_scopes = value.get("default_scopes")
|
|
168
|
+
if default_scopes is not None and not _is_str_list(default_scopes):
|
|
169
|
+
out.error("authorization.default_scopes", "default_scopes must be a list of strings.")
|
|
170
|
+
isolation = value.get("isolation", "owner")
|
|
171
|
+
if isolation not in ("owner", "none"):
|
|
172
|
+
out.error("authorization.isolation", "isolation must be 'owner' or 'none'.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_rate_limit(config: dict, out: _Collector) -> None:
|
|
176
|
+
data = config.get("rate_limit")
|
|
177
|
+
if data is None:
|
|
178
|
+
return
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
out.error("rate_limit", "rate_limit must be an object.")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
|
|
184
|
+
|
|
185
|
+
probe = dict(data)
|
|
186
|
+
redis_value = probe.get("redis")
|
|
187
|
+
redis_url = redis_value.get("url") if isinstance(redis_value, dict) else redis_value
|
|
188
|
+
if isinstance(redis_url, str) and _has_unresolved_env(redis_url):
|
|
189
|
+
# The server expands $VAR at startup; it is fine for it to be unset while editing.
|
|
190
|
+
out.warning(
|
|
191
|
+
"rate_limit.redis.url",
|
|
192
|
+
f"{redis_url} is not set in this shell. Make sure it is set when the API runs.",
|
|
193
|
+
)
|
|
194
|
+
placeholder = "redis://placeholder"
|
|
195
|
+
probe["redis"] = (
|
|
196
|
+
{**redis_value, "url": placeholder} if isinstance(redis_value, dict) else placeholder
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
config_obj = _quietly(lambda: RateLimitConfig.from_dict(probe), out, "rate_limit")
|
|
200
|
+
if config_obj is None:
|
|
201
|
+
return
|
|
202
|
+
if config_obj.backend == "redis" and not redis_url:
|
|
203
|
+
out.warning(
|
|
204
|
+
"rate_limit.redis.url",
|
|
205
|
+
"The redis backend needs a URL unless a Redis client is bound in InjectQ.",
|
|
206
|
+
)
|
|
207
|
+
if config_obj.enabled and config_obj.backend == "memory":
|
|
208
|
+
out.warning(
|
|
209
|
+
"rate_limit.backend",
|
|
210
|
+
"The memory backend counts per process. Use redis with more than one worker.",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _check_websocket(config: dict, out: _Collector) -> None:
|
|
215
|
+
data = config.get("websocket")
|
|
216
|
+
if data is None:
|
|
217
|
+
return
|
|
218
|
+
from tenxgraph_api.src.app.core.config.graph_config import WebSocketConfig
|
|
219
|
+
|
|
220
|
+
_quietly(lambda: WebSocketConfig.from_dict(data), out, "websocket")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _check_ag_ui(config: dict, out: _Collector) -> None:
|
|
224
|
+
data = config.get("ag_ui")
|
|
225
|
+
if data is None:
|
|
226
|
+
return
|
|
227
|
+
from tenxgraph_api.src.app.core.config.graph_config import AgUiConfig
|
|
228
|
+
|
|
229
|
+
_quietly(lambda: AgUiConfig.from_dict(data), out, "ag_ui")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _check_observability(config: dict, out: _Collector) -> None:
|
|
233
|
+
data = config.get("observability")
|
|
234
|
+
if data is None:
|
|
235
|
+
return
|
|
236
|
+
if not isinstance(data, dict):
|
|
237
|
+
out.error("observability", "observability must be an object.")
|
|
238
|
+
return
|
|
239
|
+
level = data.get("level", "standard")
|
|
240
|
+
if level not in _OBSERVABILITY_LEVELS:
|
|
241
|
+
out.error(
|
|
242
|
+
"observability.level", f"level must be one of {', '.join(_OBSERVABILITY_LEVELS)}."
|
|
243
|
+
)
|
|
244
|
+
any_enabled = False
|
|
245
|
+
for backend in ("logfire", "langsmith"):
|
|
246
|
+
block = data.get(backend)
|
|
247
|
+
if block is None:
|
|
248
|
+
continue
|
|
249
|
+
if not isinstance(block, dict):
|
|
250
|
+
out.error(f"observability.{backend}", f"{backend} must be an object.")
|
|
251
|
+
continue
|
|
252
|
+
enabled = block.get("enabled", False)
|
|
253
|
+
if not isinstance(enabled, bool):
|
|
254
|
+
out.error(f"observability.{backend}.enabled", "enabled must be true or false.")
|
|
255
|
+
any_enabled = any_enabled or enabled is True
|
|
256
|
+
if not any_enabled:
|
|
257
|
+
out.warning(
|
|
258
|
+
"observability", "Neither Logfire nor LangSmith is enabled, so nothing is traced."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _check_plugins(config: dict, out: _Collector, base_dir: Path | None) -> None:
|
|
263
|
+
for key in ("injectq", "store", "checkpointer", "thread_name_generator"):
|
|
264
|
+
value = config.get(key)
|
|
265
|
+
if value is None or value == "":
|
|
266
|
+
continue
|
|
267
|
+
_check_import_path(key, value, out, base_dir)
|
|
268
|
+
if config.get("checkpointer"):
|
|
269
|
+
out.warning(
|
|
270
|
+
"checkpointer",
|
|
271
|
+
"The API server does not apply this key yet. Pass the checkpointer to "
|
|
272
|
+
"graph.compile() instead.",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
redis = config.get("redis")
|
|
276
|
+
if redis is None or redis == "":
|
|
277
|
+
return
|
|
278
|
+
if not isinstance(redis, str):
|
|
279
|
+
out.error("redis", "redis must be a URL string.")
|
|
280
|
+
elif "$" in redis:
|
|
281
|
+
out.warning("redis", "Top-level redis is used as-is; $VAR is not expanded.")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _check_remote_tools(config: dict, out: _Collector) -> None:
|
|
285
|
+
if "remote_tools" not in config or config["remote_tools"] is None:
|
|
286
|
+
return
|
|
287
|
+
from tenxgraph_api.src.app.core.config.graph_config import validate_remote_tools
|
|
288
|
+
|
|
289
|
+
_quietly(lambda: validate_remote_tools(config["remote_tools"]), out, "remote_tools")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_test(config: dict, out: _Collector) -> None:
|
|
293
|
+
data = config.get("test")
|
|
294
|
+
if data is None:
|
|
295
|
+
return
|
|
296
|
+
if not isinstance(data, dict):
|
|
297
|
+
out.error("test", "test must be an object.")
|
|
298
|
+
return
|
|
299
|
+
if "path" in data and not isinstance(data["path"], str):
|
|
300
|
+
out.error("test.path", "path must be a string.")
|
|
301
|
+
if "coverage" in data and not isinstance(data["coverage"], bool):
|
|
302
|
+
out.error("test.coverage", "coverage must be true or false.")
|
|
303
|
+
threshold = data.get("coverage_threshold")
|
|
304
|
+
if threshold is not None and not (_is_int(threshold) and 0 <= threshold <= _MAX_PERCENT):
|
|
305
|
+
out.error("test.coverage_threshold", "coverage_threshold must be a whole number 0-100.")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _check_evaluation(config: dict, out: _Collector) -> None:
|
|
309
|
+
data = config.get("evaluation")
|
|
310
|
+
if data is None:
|
|
311
|
+
return
|
|
312
|
+
if not isinstance(data, dict):
|
|
313
|
+
out.error("evaluation", "evaluation must be an object.")
|
|
314
|
+
return
|
|
315
|
+
for key in ("directory", "output_dir"):
|
|
316
|
+
if key in data and not isinstance(data[key], str):
|
|
317
|
+
out.error(f"evaluation.{key}", f"{key} must be a string.")
|
|
318
|
+
threshold = data.get("threshold")
|
|
319
|
+
if threshold is not None and not (_is_number(threshold) and 0 <= threshold <= 1):
|
|
320
|
+
out.error("evaluation.threshold", "threshold must be a number between 0 and 1.")
|
|
321
|
+
if "parallel" in data and not isinstance(data["parallel"], bool):
|
|
322
|
+
out.error("evaluation.parallel", "parallel must be true or false.")
|
|
323
|
+
concurrency = data.get("max_concurrency")
|
|
324
|
+
if concurrency is not None and not (_is_int(concurrency) and concurrency >= 1):
|
|
325
|
+
out.error("evaluation.max_concurrency", "max_concurrency must be a whole number >= 1.")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Helpers
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_import_path(path: str, value: Any, out: _Collector, base_dir: Path | None) -> None:
|
|
334
|
+
if not isinstance(value, str) or not _IMPORT_PATH.match(value):
|
|
335
|
+
out.error(path, f"Expected module:attribute, got {value!r}.")
|
|
336
|
+
return
|
|
337
|
+
if base_dir is None:
|
|
338
|
+
return
|
|
339
|
+
module = value.split(":", 1)[0]
|
|
340
|
+
candidate = base_dir.joinpath(*module.split("."))
|
|
341
|
+
if not (candidate.with_suffix(".py").exists() or (candidate / "__init__.py").exists()):
|
|
342
|
+
out.warning(path, f"Module '{module}' was not found next to 10xgraph.json.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _quietly(parse: Callable[[], Any], out: _Collector, path: str) -> Any:
|
|
346
|
+
"""Run a server-side parser, turning its exceptions into an error issue.
|
|
347
|
+
|
|
348
|
+
The parsers log operational warnings (for example about the memory rate-limit
|
|
349
|
+
backend); those are reported as issues here, so the logger is muted meanwhile.
|
|
350
|
+
"""
|
|
351
|
+
api_logger = logging.getLogger("tenxgraph_api")
|
|
352
|
+
previous = api_logger.disabled
|
|
353
|
+
api_logger.disabled = True
|
|
354
|
+
try:
|
|
355
|
+
return parse()
|
|
356
|
+
except (TypeError, ValueError) as exc:
|
|
357
|
+
out.error(path, str(exc))
|
|
358
|
+
return None
|
|
359
|
+
finally:
|
|
360
|
+
api_logger.disabled = previous
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _has_unresolved_env(value: str) -> bool:
|
|
364
|
+
# Same rule as graph_config._expand_env, which raises for these at startup.
|
|
365
|
+
return os.path.expandvars(value) == value and (value.startswith("$") or "${" in value)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _is_str_list(value: Any) -> bool:
|
|
369
|
+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_int(value: Any) -> bool:
|
|
373
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_number(value: Any) -> bool:
|
|
377
|
+
return isinstance(value, int | float) and not isinstance(value, bool)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CLI constants and configuration values."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_PACKAGE_NAME: Final[str] = "10xgraph-api"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_version() -> str:
|
|
14
|
+
"""Resolve the package version from installed metadata, falling back to pyproject.toml.
|
|
15
|
+
|
|
16
|
+
Single source of truth: the installed distribution's version. When running from a
|
|
17
|
+
source checkout that is not installed, read ``pyproject.toml`` instead.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
return _pkg_version(_PACKAGE_NAME)
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
23
|
+
try:
|
|
24
|
+
with pyproject.open("rb") as f:
|
|
25
|
+
return tomllib.load(f).get("project", {}).get("version", "unknown")
|
|
26
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
27
|
+
return "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Version information (single-sourced from package metadata / pyproject.toml)
|
|
31
|
+
CLI_VERSION: Final[str] = _resolve_version()
|
|
32
|
+
|
|
33
|
+
# Default configuration values
|
|
34
|
+
DEFAULT_HOST: Final[str] = "127.0.0.1"
|
|
35
|
+
DEFAULT_PORT: Final[int] = 8000
|
|
36
|
+
DEFAULT_CONFIG_FILE: Final[str] = "10xgraph.json"
|
|
37
|
+
# Pre-rename config file name. Still read when no 10xgraph.json sits beside it.
|
|
38
|
+
LEGACY_CONFIG_FILE: Final[str] = "agentflow.json"
|
|
39
|
+
DEFAULT_PLAYGROUND_URL: Final[str] = "https://playground-463bd.web.app"
|
|
40
|
+
DEFAULT_PYTHON_VERSION: Final[str] = "3.13"
|
|
41
|
+
DEFAULT_SERVICE_NAME: Final[str] = "10xgraph-api"
|
|
42
|
+
|
|
43
|
+
# File paths and names
|
|
44
|
+
# Discovery order within one directory: the first match wins.
|
|
45
|
+
CONFIG_FILENAMES: Final[list[str]] = [
|
|
46
|
+
DEFAULT_CONFIG_FILE,
|
|
47
|
+
LEGACY_CONFIG_FILE,
|
|
48
|
+
".agentflow.json",
|
|
49
|
+
"agentflow.config.json",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
REQUIREMENTS_PATHS: Final[list[str]] = [
|
|
53
|
+
"requirements.txt",
|
|
54
|
+
"requirements/requirements.txt",
|
|
55
|
+
"requirements/base.txt",
|
|
56
|
+
"requirements/production.txt",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Docker and container configuration
|
|
60
|
+
DOCKERFILE_NAME: Final[str] = "Dockerfile"
|
|
61
|
+
DOCKER_COMPOSE_NAME: Final[str] = "docker-compose.yml"
|
|
62
|
+
HEALTH_CHECK_ENDPOINT: Final[str] = "/ping"
|
|
63
|
+
|
|
64
|
+
# Logging configuration
|
|
65
|
+
LOG_FORMAT: Final[str] = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
66
|
+
LOG_DATE_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S"
|
|
67
|
+
|
|
68
|
+
# Environment variables
|
|
69
|
+
ENV_GRAPH_PATH: Final[str] = "GRAPH_PATH"
|
|
70
|
+
ENV_PYTHONPATH: Final[str] = "PYTHONPATH"
|
|
71
|
+
ENV_PYTHONDONTWRITEBYTECODE: Final[str] = "PYTHONDONTWRITEBYTECODE"
|
|
72
|
+
ENV_PYTHONUNBUFFERED: Final[str] = "PYTHONUNBUFFERED"
|
|
73
|
+
|
|
74
|
+
# Exit codes
|
|
75
|
+
EXIT_SUCCESS: Final[int] = 0
|
|
76
|
+
EXIT_FAILURE: Final[int] = 1
|
|
77
|
+
EXIT_CONFIG_ERROR: Final[int] = 2
|
|
78
|
+
EXIT_VALIDATION_ERROR: Final[int] = 3
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Output styling
|
|
82
|
+
class Colors:
|
|
83
|
+
"""ANSI color codes for terminal output."""
|
|
84
|
+
|
|
85
|
+
RESET: Final[str] = "\033[0m"
|
|
86
|
+
RED: Final[str] = "\033[91m"
|
|
87
|
+
GREEN: Final[str] = "\033[92m"
|
|
88
|
+
YELLOW: Final[str] = "\033[93m"
|
|
89
|
+
BLUE: Final[str] = "\033[94m"
|
|
90
|
+
MAGENTA: Final[str] = "\033[95m"
|
|
91
|
+
CYAN: Final[str] = "\033[96m"
|
|
92
|
+
WHITE: Final[str] = "\033[97m"
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def colorize(cls, text: str, color: str) -> str:
|
|
96
|
+
"""Apply color to text."""
|
|
97
|
+
color_code = getattr(cls, color.upper(), cls.RESET)
|
|
98
|
+
return f"{color_code}{text}{cls.RESET}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Emoji and symbols for output
|
|
102
|
+
EMOJI_SUCCESS: Final[str] = "✅"
|
|
103
|
+
EMOJI_ERROR: Final[str] = "⚠️"
|
|
104
|
+
EMOJI_INFO: Final[str] = "📋"
|
|
105
|
+
EMOJI_SPARKLE: Final[str] = "✨"
|
|
106
|
+
EMOJI_ROCKET: Final[str] = "🚀"
|
|
107
|
+
EMOJI_PACKAGE: Final[str] = "📦"
|
|
108
|
+
|
|
109
|
+
# Project structure
|
|
110
|
+
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
|
111
|
+
CLI_ROOT: Final[Path] = Path(__file__).parent
|
|
112
|
+
TEMPLATES_DIR: Final[Path] = CLI_ROOT / "templates"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared runtime context for 10xGraph CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from tenxgraph_api.cli.capabilities import TerminalCapabilities
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CLIContext:
|
|
14
|
+
"""Invocation-scoped CLI state passed through Typer's context object."""
|
|
15
|
+
|
|
16
|
+
capabilities: TerminalCapabilities
|
|
17
|
+
cwd: Path
|
|
18
|
+
verbosity: int = 0
|
|
19
|
+
quiet: bool = False
|
|
20
|
+
debug: bool = False
|
|
21
|
+
yes: bool = False
|
|
22
|
+
non_interactive: bool = False
|
|
23
|
+
invocation_id: str = ""
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def create(
|
|
27
|
+
cls,
|
|
28
|
+
*,
|
|
29
|
+
capabilities: TerminalCapabilities,
|
|
30
|
+
cwd: Path,
|
|
31
|
+
verbosity: int = 0,
|
|
32
|
+
quiet: bool = False,
|
|
33
|
+
debug: bool = False,
|
|
34
|
+
yes: bool = False,
|
|
35
|
+
non_interactive: bool = False,
|
|
36
|
+
) -> CLIContext:
|
|
37
|
+
return cls(
|
|
38
|
+
capabilities=capabilities,
|
|
39
|
+
cwd=cwd,
|
|
40
|
+
verbosity=verbosity,
|
|
41
|
+
quiet=quiet,
|
|
42
|
+
debug=debug,
|
|
43
|
+
yes=yes,
|
|
44
|
+
non_interactive=non_interactive,
|
|
45
|
+
invocation_id=uuid4().hex[:12],
|
|
46
|
+
)
|
|
File without changes
|