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,242 @@
|
|
|
1
|
+
"""Brand palette, glyph sets, and gradient helpers for the 10xGraph terminal UI.
|
|
2
|
+
|
|
3
|
+
Everything visual in the CLI resolves through this module so a single palette
|
|
4
|
+
change restyles intros, timelines, tables, and completion screens at once.
|
|
5
|
+
Glyphs are separated from color because a terminal can support one without the
|
|
6
|
+
other: ASCII fallbacks keep the layout identical when Unicode is unavailable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# 10xGraph brand palette, from the docs site tokens (agentflow-docs/src/styles/global.css).
|
|
18
|
+
# A terminal's background is unknown, so each color sits between the docs light-theme and
|
|
19
|
+
# dark-theme value and stays legible on both. Primary text uses the terminal's own
|
|
20
|
+
# foreground (plain ``bold``), like the logo's ink, so it never vanishes on a light theme.
|
|
21
|
+
ACCENT = "#6a93f0" # docs --accent: #2f5bd3 light / #7ea6ff dark
|
|
22
|
+
AMBER = "#de8a55" # logo entry node, docs --brand-amber: #c8692f / #e9a57c
|
|
23
|
+
SUCCESS = "#3fbf98" # docs --tip: #0f7a5c / #4fd1ae
|
|
24
|
+
WARNING = "#e0a447" # docs --warn: #8a5a00 / #f0b45a
|
|
25
|
+
ERROR = "#e5666d" # docs --danger: #b4232c / #f2868a
|
|
26
|
+
MUTED = "#8691a3" # docs --muted / --faint
|
|
27
|
+
LINE = "#4f5a68" # rules and dividers
|
|
28
|
+
PENDING = "#5c6674" # not-yet-reached steps
|
|
29
|
+
|
|
30
|
+
# Surfaces painted only inside the opt-in full-screen frame, which is always dark
|
|
31
|
+
# (docs --bg / --surface / --surface-2 / --line / --text).
|
|
32
|
+
BACKGROUND = "#0b0e13"
|
|
33
|
+
SURFACE = "#10151d"
|
|
34
|
+
SURFACE_RAISED = "#161c27"
|
|
35
|
+
LINE_ON_DARK = "#2d3849"
|
|
36
|
+
INK_ON_DARK = "#eceff4"
|
|
37
|
+
|
|
38
|
+
# Motion tint: the docs accent flowing into the logo's amber entry node. Sampled
|
|
39
|
+
# continuously, so the two stops never band.
|
|
40
|
+
BRAND_RAMP: tuple[str, ...] = (ACCENT, AMBER)
|
|
41
|
+
|
|
42
|
+
# Used for rows that are done, running, and not started, respectively.
|
|
43
|
+
STATE_RAMP: tuple[str, ...] = (SUCCESS, ACCENT, PENDING)
|
|
44
|
+
|
|
45
|
+
TENXGRAPH_THEME = Theme(
|
|
46
|
+
{
|
|
47
|
+
"tenxgraph.success": f"bold {SUCCESS}",
|
|
48
|
+
"tenxgraph.error": f"bold {ERROR}",
|
|
49
|
+
"tenxgraph.warning": f"bold {WARNING}",
|
|
50
|
+
"tenxgraph.info": ACCENT,
|
|
51
|
+
"tenxgraph.muted": MUTED,
|
|
52
|
+
"tenxgraph.title": "bold",
|
|
53
|
+
"tenxgraph.brand": f"bold {AMBER}",
|
|
54
|
+
"tenxgraph.command": "bold",
|
|
55
|
+
"tenxgraph.progress": ACCENT,
|
|
56
|
+
"tenxgraph.accent": ACCENT,
|
|
57
|
+
"tenxgraph.pending": PENDING,
|
|
58
|
+
"tenxgraph.rule": LINE,
|
|
59
|
+
"tenxgraph.chip": f"bold {INK_ON_DARK} on {SURFACE_RAISED}",
|
|
60
|
+
# Header band, drawn only by the full-screen frame on its dark surface.
|
|
61
|
+
"tenxgraph.header": f"on {SURFACE}",
|
|
62
|
+
"tenxgraph.elapsed": MUTED,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Glyphs:
|
|
69
|
+
"""Symbol set for one invocation, chosen by terminal encoding support."""
|
|
70
|
+
|
|
71
|
+
check: str
|
|
72
|
+
cross: str
|
|
73
|
+
warn: str
|
|
74
|
+
info: str
|
|
75
|
+
bullet: str
|
|
76
|
+
arrow: str
|
|
77
|
+
pending: str
|
|
78
|
+
active: str
|
|
79
|
+
skipped: str
|
|
80
|
+
rule: str
|
|
81
|
+
thin_rule: str
|
|
82
|
+
node: str
|
|
83
|
+
diamond: str
|
|
84
|
+
caret: str
|
|
85
|
+
block: str
|
|
86
|
+
cursor: str
|
|
87
|
+
tree_stem: str
|
|
88
|
+
tree_branch: str
|
|
89
|
+
tree_end: str
|
|
90
|
+
spinner: tuple[str, ...]
|
|
91
|
+
shades: tuple[str, ...]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
UNICODE_GLYPHS = Glyphs(
|
|
95
|
+
check="✓",
|
|
96
|
+
cross="✗",
|
|
97
|
+
warn="▲",
|
|
98
|
+
info="•",
|
|
99
|
+
bullet="•",
|
|
100
|
+
arrow="→",
|
|
101
|
+
pending="○",
|
|
102
|
+
active="◆",
|
|
103
|
+
skipped="⊘",
|
|
104
|
+
rule="━",
|
|
105
|
+
thin_rule="─",
|
|
106
|
+
node="◉",
|
|
107
|
+
diamond="◆",
|
|
108
|
+
caret="▸",
|
|
109
|
+
block="█",
|
|
110
|
+
cursor="▌",
|
|
111
|
+
tree_stem="┃",
|
|
112
|
+
tree_branch="┣",
|
|
113
|
+
tree_end="┗",
|
|
114
|
+
spinner=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
|
|
115
|
+
shades=(" ", "░", "▒", "▓", "█"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
ASCII_GLYPHS = Glyphs(
|
|
119
|
+
check="OK",
|
|
120
|
+
cross="X",
|
|
121
|
+
warn="!",
|
|
122
|
+
info="*",
|
|
123
|
+
bullet="*",
|
|
124
|
+
arrow="->",
|
|
125
|
+
pending="o",
|
|
126
|
+
active="*",
|
|
127
|
+
skipped="-",
|
|
128
|
+
rule="=",
|
|
129
|
+
thin_rule="-",
|
|
130
|
+
node="O",
|
|
131
|
+
diamond="*",
|
|
132
|
+
caret=">",
|
|
133
|
+
block="#",
|
|
134
|
+
cursor="|",
|
|
135
|
+
tree_stem="|",
|
|
136
|
+
tree_branch="+",
|
|
137
|
+
tree_end="\\",
|
|
138
|
+
spinner=("|", "/", "-", "\\"),
|
|
139
|
+
shades=(" ", ".", ":", "+", "#"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def glyphs_for(unicode: bool) -> Glyphs:
|
|
144
|
+
"""Return the glyph set matching the terminal's encoding support."""
|
|
145
|
+
return UNICODE_GLYPHS if unicode else ASCII_GLYPHS
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
149
|
+
raw = value.lstrip("#")
|
|
150
|
+
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
|
154
|
+
return "#{:02x}{:02x}{:02x}".format(*rgb)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def sample_ramp(position: float, ramp: tuple[str, ...] = BRAND_RAMP) -> str:
|
|
158
|
+
"""Sample a continuous color from ``ramp`` at ``position`` in ``[0, 1]``.
|
|
159
|
+
|
|
160
|
+
Interpolating between stops rather than snapping to the nearest one is what
|
|
161
|
+
keeps a wide gradient bar from showing visible banding.
|
|
162
|
+
"""
|
|
163
|
+
if not ramp:
|
|
164
|
+
return INK_ON_DARK
|
|
165
|
+
if len(ramp) == 1:
|
|
166
|
+
return ramp[0]
|
|
167
|
+
|
|
168
|
+
clamped = min(max(position, 0.0), 1.0)
|
|
169
|
+
scaled = clamped * (len(ramp) - 1)
|
|
170
|
+
index = int(scaled)
|
|
171
|
+
if index >= len(ramp) - 1:
|
|
172
|
+
return ramp[-1]
|
|
173
|
+
|
|
174
|
+
blend = scaled - index
|
|
175
|
+
start = _hex_to_rgb(ramp[index])
|
|
176
|
+
end = _hex_to_rgb(ramp[index + 1])
|
|
177
|
+
return _rgb_to_hex(
|
|
178
|
+
(
|
|
179
|
+
round(start[0] + (end[0] - start[0]) * blend),
|
|
180
|
+
round(start[1] + (end[1] - start[1]) * blend),
|
|
181
|
+
round(start[2] + (end[2] - start[2]) * blend),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def dim_hex(value: str, factor: float) -> str:
|
|
187
|
+
"""Scale a hex color toward black, for pending or background elements."""
|
|
188
|
+
red, green, blue = _hex_to_rgb(value)
|
|
189
|
+
scale = min(max(factor, 0.0), 1.0)
|
|
190
|
+
return _rgb_to_hex((round(red * scale), round(green * scale), round(blue * scale)))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def gradient_text(
|
|
194
|
+
value: str,
|
|
195
|
+
*,
|
|
196
|
+
ramp: tuple[str, ...] = BRAND_RAMP,
|
|
197
|
+
bold: bool = False,
|
|
198
|
+
offset: float = 0.0,
|
|
199
|
+
span: float = 1.0,
|
|
200
|
+
) -> Text:
|
|
201
|
+
"""Paint ``value`` with a per-character sweep through ``ramp``.
|
|
202
|
+
|
|
203
|
+
``offset`` shifts the sweep so successive frames can animate a shimmer
|
|
204
|
+
without rebuilding the palette.
|
|
205
|
+
"""
|
|
206
|
+
text = Text()
|
|
207
|
+
length = max(len(value), 1)
|
|
208
|
+
weight = "bold " if bold else ""
|
|
209
|
+
for index, character in enumerate(value):
|
|
210
|
+
position = (offset + (index / length) * span) % 1.0
|
|
211
|
+
text.append(character, style=f"{weight}{sample_ramp(position, ramp)}")
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def gradient_rule(
|
|
216
|
+
width: int,
|
|
217
|
+
*,
|
|
218
|
+
glyphs: Glyphs,
|
|
219
|
+
thin: bool = False,
|
|
220
|
+
offset: float = 0.0,
|
|
221
|
+
) -> Text:
|
|
222
|
+
"""Build a full-width rule that frames branded sections.
|
|
223
|
+
|
|
224
|
+
Calm by design: a solid line with a short amber lead-in, echoing the logo's entry
|
|
225
|
+
node. ``offset`` is kept for callers that once animated the rule.
|
|
226
|
+
"""
|
|
227
|
+
del offset
|
|
228
|
+
character = glyphs.thin_rule if thin else glyphs.rule
|
|
229
|
+
span = max(width, 1)
|
|
230
|
+
lead = min(4, span)
|
|
231
|
+
text = Text(character * lead, style=AMBER)
|
|
232
|
+
text.append(character * (span - lead), style=LINE)
|
|
233
|
+
return text
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def wordmark(color: str | None = None) -> Text:
|
|
237
|
+
"""The ``10xgraph`` name as the CLI shows it: bold ink, like the logo.
|
|
238
|
+
|
|
239
|
+
Without ``color`` it uses the terminal's own foreground; pass ``INK_ON_DARK`` on a
|
|
240
|
+
surface that paints its own dark background.
|
|
241
|
+
"""
|
|
242
|
+
return Text("10xgraph", style=f"bold {color}" if color else "bold")
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Input validation utilities for the CLI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tenxgraph_api.cli.exceptions import ValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Validator:
|
|
11
|
+
"""Input validation utilities."""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def validate_port(port: int) -> int:
|
|
15
|
+
"""Validate port number.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
port: Port number to validate
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Validated port number
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValidationError: If port is invalid
|
|
25
|
+
"""
|
|
26
|
+
if not isinstance(port, int):
|
|
27
|
+
raise ValidationError("Port must be an integer", field="port")
|
|
28
|
+
|
|
29
|
+
if port < 1 or port > 65535: # noqa: PLR2004
|
|
30
|
+
raise ValidationError("Port must be between 1 and 65535", field="port")
|
|
31
|
+
|
|
32
|
+
return port
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def validate_host(host: str) -> str:
|
|
36
|
+
"""Validate host address.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
host: Host address to validate
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Validated host address
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValidationError: If host is invalid
|
|
46
|
+
"""
|
|
47
|
+
if not isinstance(host, str):
|
|
48
|
+
raise ValidationError("Host must be a string", field="host")
|
|
49
|
+
|
|
50
|
+
if not host.strip():
|
|
51
|
+
raise ValidationError("Host cannot be empty", field="host")
|
|
52
|
+
|
|
53
|
+
# Basic validation - could be enhanced with more sophisticated checks
|
|
54
|
+
if len(host) > 255: # noqa: PLR2004
|
|
55
|
+
raise ValidationError("Host address too long", field="host")
|
|
56
|
+
|
|
57
|
+
return host.strip()
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def validate_path(path: str | Path, must_exist: bool = False) -> Path:
|
|
61
|
+
"""Validate file path.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
path: Path to validate
|
|
65
|
+
must_exist: Whether the path must exist
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Validated Path object
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValidationError: If path is invalid
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
path_obj = Path(path)
|
|
75
|
+
except (TypeError, ValueError) as e:
|
|
76
|
+
raise ValidationError(f"Invalid path: {e}", field="path") from e
|
|
77
|
+
|
|
78
|
+
if must_exist and not path_obj.exists():
|
|
79
|
+
raise ValidationError(f"Path does not exist: {path_obj}", field="path")
|
|
80
|
+
|
|
81
|
+
return path_obj
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def validate_python_version(version: str) -> str:
|
|
85
|
+
"""Validate Python version string.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
version: Python version to validate
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Validated version string
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValidationError: If version is invalid
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(version, str):
|
|
97
|
+
raise ValidationError("Python version must be a string", field="python_version")
|
|
98
|
+
|
|
99
|
+
# Pattern for semantic versioning (major.minor or major.minor.patch)
|
|
100
|
+
version_pattern = r"^(\d+)\.(\d+)(?:\.(\d+))?$"
|
|
101
|
+
|
|
102
|
+
if not re.match(version_pattern, version):
|
|
103
|
+
raise ValidationError(
|
|
104
|
+
"Python version must be in format 'X.Y' or 'X.Y.Z'", field="python_version"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Extract major and minor versions
|
|
108
|
+
parts = version.split(".")
|
|
109
|
+
major, minor = int(parts[0]), int(parts[1])
|
|
110
|
+
|
|
111
|
+
# Validate Python version range (3.8+)
|
|
112
|
+
if major < 3 or (major == 3 and minor < 8): # noqa: PLR2004
|
|
113
|
+
raise ValidationError("Python version must be 3.8 or higher", field="python_version")
|
|
114
|
+
|
|
115
|
+
return version
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def validate_service_name(name: str) -> str:
|
|
119
|
+
"""Validate service name for Docker.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
name: Service name to validate
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Validated service name
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValidationError: If name is invalid
|
|
129
|
+
"""
|
|
130
|
+
if not isinstance(name, str):
|
|
131
|
+
raise ValidationError("Service name must be a string", field="service_name")
|
|
132
|
+
|
|
133
|
+
name = name.strip()
|
|
134
|
+
if not name:
|
|
135
|
+
raise ValidationError("Service name cannot be empty", field="service_name")
|
|
136
|
+
|
|
137
|
+
# Docker service name validation
|
|
138
|
+
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$", name):
|
|
139
|
+
raise ValidationError(
|
|
140
|
+
"Service name must start with alphanumeric character and "
|
|
141
|
+
"contain only alphanumeric, underscore, period, or hyphen",
|
|
142
|
+
field="service_name",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if len(name) > 63: # noqa: PLR2004
|
|
146
|
+
raise ValidationError(
|
|
147
|
+
"Service name must be 63 characters or less", field="service_name"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return name
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def validate_config_structure(config: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
"""Validate configuration structure.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
config: Configuration dictionary to validate
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Validated configuration
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValidationError: If configuration is invalid
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(config, dict):
|
|
166
|
+
raise ValidationError("Configuration must be a dictionary")
|
|
167
|
+
|
|
168
|
+
# Required fields
|
|
169
|
+
required_fields = ["agent"]
|
|
170
|
+
for field in required_fields:
|
|
171
|
+
if field not in config:
|
|
172
|
+
raise ValidationError(f"Missing required field: {field}")
|
|
173
|
+
|
|
174
|
+
# Validate agent field
|
|
175
|
+
agent = config["agent"]
|
|
176
|
+
if not isinstance(agent, str):
|
|
177
|
+
raise ValidationError("Field 'agent' must be a string")
|
|
178
|
+
|
|
179
|
+
return config
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def validate_environment_file(env_file: str | Path) -> Path:
|
|
183
|
+
"""Validate environment file.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
env_file: Path to environment file
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Validated Path object
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValidationError: If environment file is invalid
|
|
193
|
+
"""
|
|
194
|
+
env_path = Validator.validate_path(env_file, must_exist=True)
|
|
195
|
+
|
|
196
|
+
if not env_path.is_file():
|
|
197
|
+
raise ValidationError(f"Environment file is not a file: {env_path}", field="env_file")
|
|
198
|
+
|
|
199
|
+
# Basic validation of .env file format
|
|
200
|
+
try:
|
|
201
|
+
with env_path.open("r", encoding="utf-8") as f:
|
|
202
|
+
for line_num, line in enumerate(f, 1):
|
|
203
|
+
up_line = line.strip()
|
|
204
|
+
if up_line and not up_line.startswith("#") and "=" not in up_line:
|
|
205
|
+
raise ValidationError(
|
|
206
|
+
f"Invalid environment file format at line {line_num}: {up_line}",
|
|
207
|
+
field="env_file",
|
|
208
|
+
)
|
|
209
|
+
except UnicodeDecodeError as e:
|
|
210
|
+
raise ValidationError(
|
|
211
|
+
f"Environment file contains invalid characters: {e}", field="env_file"
|
|
212
|
+
) from e
|
|
213
|
+
except OSError as e:
|
|
214
|
+
raise ValidationError(f"Cannot read environment file: {e}", field="env_file") from e
|
|
215
|
+
|
|
216
|
+
return env_path
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Convenience functions for common validations
|
|
220
|
+
def validate_cli_options(
|
|
221
|
+
host: str,
|
|
222
|
+
port: int,
|
|
223
|
+
config: str | None = None,
|
|
224
|
+
python_version: str | None = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Validate common CLI options.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
host: Host address
|
|
230
|
+
port: Port number
|
|
231
|
+
config: Optional config file path
|
|
232
|
+
python_version: Optional Python version
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Dictionary of validated options
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValidationError: If any option is invalid
|
|
239
|
+
"""
|
|
240
|
+
validated = {
|
|
241
|
+
"host": Validator.validate_host(host),
|
|
242
|
+
"port": Validator.validate_port(port),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if config:
|
|
246
|
+
validated["config"] = Validator.validate_path(config)
|
|
247
|
+
|
|
248
|
+
if python_version:
|
|
249
|
+
validated["python_version"] = Validator.validate_python_version(python_version)
|
|
250
|
+
|
|
251
|
+
return validated
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Custom exceptions for the 10xGraph CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CLIError(Exception):
|
|
9
|
+
"""Base exception for all 10xGraph CLI errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
exit_code: int = 1,
|
|
15
|
+
*,
|
|
16
|
+
code: str = "AF-CMD-001",
|
|
17
|
+
hints: Sequence[str] = (),
|
|
18
|
+
docs_url: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Initialize the exception with a message and exit code.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
message: Error message to display
|
|
24
|
+
exit_code: Exit code to use when terminating
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.exit_code = exit_code
|
|
29
|
+
self.code = code
|
|
30
|
+
self.hints = tuple(hints)
|
|
31
|
+
self.docs_url = docs_url
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigurationError(CLIError):
|
|
35
|
+
"""Raised when there are configuration-related errors."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, config_path: str | None = None) -> None:
|
|
38
|
+
"""Initialize configuration error.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message: Error message
|
|
42
|
+
config_path: Path to the problematic config file
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(message, exit_code=2, code="AF-CONFIG-001")
|
|
45
|
+
self.config_path = config_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ValidationError(CLIError):
|
|
49
|
+
"""Raised when input validation fails."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, field: str | None = None) -> None:
|
|
52
|
+
"""Initialize validation error.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
message: Error message
|
|
56
|
+
field: Name of the field that failed validation
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(message, exit_code=3, code="AF-INPUT-001")
|
|
59
|
+
self.field = field
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FileOperationError(CLIError):
|
|
63
|
+
"""Raised when file operations fail."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, file_path: str | None = None) -> None:
|
|
66
|
+
"""Initialize file operation error.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
message: Error message
|
|
70
|
+
file_path: Path to the problematic file
|
|
71
|
+
"""
|
|
72
|
+
super().__init__(message, exit_code=1, code="AF-FILE-001")
|
|
73
|
+
self.file_path = file_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TemplateError(CLIError):
|
|
77
|
+
"""Raised when template operations fail."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, template_name: str | None = None) -> None:
|
|
80
|
+
"""Initialize template error.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: Error message
|
|
84
|
+
template_name: Name of the problematic template
|
|
85
|
+
"""
|
|
86
|
+
super().__init__(message, exit_code=1, code="AF-TEMPLATE-001")
|
|
87
|
+
self.template_name = template_name
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ServerError(CLIError):
|
|
91
|
+
"""Raised when server operations fail."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, message: str, host: str | None = None, port: int | None = None) -> None:
|
|
94
|
+
"""Initialize server error.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
message: Error message
|
|
98
|
+
host: Server host
|
|
99
|
+
port: Server port
|
|
100
|
+
"""
|
|
101
|
+
super().__init__(message, exit_code=1, code="AF-SERVER-001")
|
|
102
|
+
self.host = host
|
|
103
|
+
self.port = port
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DockerError(CLIError):
|
|
107
|
+
"""Raised when Docker-related operations fail."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, message: str, dockerfile_path: str | None = None) -> None:
|
|
110
|
+
"""Initialize Docker error.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
message: Error message
|
|
114
|
+
dockerfile_path: Path to the Dockerfile
|
|
115
|
+
"""
|
|
116
|
+
super().__init__(message, exit_code=1, code="AF-DOCKER-001")
|
|
117
|
+
self.dockerfile_path = dockerfile_path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DependencyError(CLIError):
|
|
121
|
+
"""Raised when an optional feature dependency is missing or incompatible."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, message: str, *, hints: Sequence[str] = ()) -> None:
|
|
124
|
+
super().__init__(
|
|
125
|
+
message,
|
|
126
|
+
exit_code=4,
|
|
127
|
+
code="AF-DEPS-001",
|
|
128
|
+
hints=hints,
|
|
129
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Centralized logging configuration for the 10xGraph CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from .constants import LOG_DATE_FORMAT, LOG_FORMAT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_LOGGER_NAME = "tenxgraph_api.cli"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CLILoggerMixin:
|
|
16
|
+
"""Mixin that obtains a child of the invocation-wide CLI logger."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_logger(
|
|
24
|
+
name: str,
|
|
25
|
+
level: int = logging.INFO,
|
|
26
|
+
stream: TextIO | None = None,
|
|
27
|
+
) -> logging.Logger:
|
|
28
|
+
"""Return a child logger governed by the shared root configuration.
|
|
29
|
+
|
|
30
|
+
A custom stream is intended for isolated tests and receives a local handler.
|
|
31
|
+
Normal command loggers propagate to exactly one root handler so verbosity
|
|
32
|
+
changes apply consistently and messages cannot be duplicated.
|
|
33
|
+
"""
|
|
34
|
+
logger = logging.getLogger(f"{_LOGGER_NAME}.{name}")
|
|
35
|
+
|
|
36
|
+
if stream is not None:
|
|
37
|
+
logger.handlers.clear()
|
|
38
|
+
logger.setLevel(level)
|
|
39
|
+
handler = logging.StreamHandler(stream)
|
|
40
|
+
handler.setLevel(level)
|
|
41
|
+
handler.setFormatter(_formatter())
|
|
42
|
+
logger.addHandler(handler)
|
|
43
|
+
logger.propagate = False
|
|
44
|
+
return logger
|
|
45
|
+
|
|
46
|
+
root_logger = logging.getLogger(_LOGGER_NAME)
|
|
47
|
+
if not root_logger.handlers:
|
|
48
|
+
setup_cli_logging(level=level)
|
|
49
|
+
logger.handlers.clear()
|
|
50
|
+
logger.setLevel(logging.NOTSET)
|
|
51
|
+
logger.propagate = True
|
|
52
|
+
return logger
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def setup_cli_logging(
|
|
56
|
+
level: int = logging.INFO,
|
|
57
|
+
quiet: bool = False,
|
|
58
|
+
verbose: bool = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Configure the one shared CLI logging handler."""
|
|
61
|
+
if quiet:
|
|
62
|
+
level = logging.ERROR
|
|
63
|
+
elif verbose:
|
|
64
|
+
level = logging.DEBUG
|
|
65
|
+
|
|
66
|
+
root_logger = logging.getLogger(_LOGGER_NAME)
|
|
67
|
+
root_logger.setLevel(level)
|
|
68
|
+
root_logger.handlers.clear()
|
|
69
|
+
|
|
70
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
71
|
+
handler.setLevel(level)
|
|
72
|
+
handler.setFormatter(_formatter())
|
|
73
|
+
root_logger.addHandler(handler)
|
|
74
|
+
root_logger.propagate = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_debug_logger(name: str) -> logging.Logger:
|
|
78
|
+
"""Return a child logger after enabling debug output globally."""
|
|
79
|
+
setup_cli_logging(level=logging.DEBUG)
|
|
80
|
+
return get_logger(name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _formatter() -> logging.Formatter:
|
|
84
|
+
return logging.Formatter(fmt=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
|