tangle-cli 0.0.1a1__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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Trust controls for hydration features that can execute local Python code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
_TRUSTED_PYTHON_SOURCES: list[str] = []
|
|
13
|
+
_ALLOW_ALL_HYDRATION = False
|
|
14
|
+
_PACKAGE_CONFIG = Path(__file__).with_name("trusted_hydration.yaml")
|
|
15
|
+
_USER_CONFIGS = (
|
|
16
|
+
Path.home() / ".config" / "tangle" / "trusted_hydration.yaml",
|
|
17
|
+
Path.home() / ".tangle" / "trusted_hydration.yaml",
|
|
18
|
+
)
|
|
19
|
+
_GLOB_CHARS = set("*?[")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_trusted_python_source(source: str | os.PathLike[str]) -> None:
|
|
23
|
+
"""Register a trusted Python-source root or glob pattern.
|
|
24
|
+
|
|
25
|
+
Sources are matched against canonical resolved paths. A non-glob source
|
|
26
|
+
trusts the exact file if it resolves to a file (or ends in ``.py``), and a
|
|
27
|
+
directory subtree otherwise. Glob sources are resolved up to their first
|
|
28
|
+
glob segment and matched against resolved candidate paths.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
text = str(source).strip()
|
|
32
|
+
if text:
|
|
33
|
+
_TRUSTED_PYTHON_SOURCES.append(text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_allow_all_hydration(allow: bool = True) -> None:
|
|
37
|
+
"""Set a process-wide escape hatch for trusted hydration execution."""
|
|
38
|
+
|
|
39
|
+
global _ALLOW_ALL_HYDRATION
|
|
40
|
+
_ALLOW_ALL_HYDRATION = bool(allow)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_trusted_python_source(
|
|
44
|
+
path: str | os.PathLike[str],
|
|
45
|
+
*,
|
|
46
|
+
base_dirs: Iterable[str | os.PathLike[str] | None] | None = None,
|
|
47
|
+
trusted_sources: Iterable[str | os.PathLike[str]] | None = None,
|
|
48
|
+
allow_all: bool = False,
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""Return whether *path* may be executed for ``local_from_python``.
|
|
51
|
+
|
|
52
|
+
The candidate path and every root/pattern prefix are canonicalized with
|
|
53
|
+
:meth:`Path.resolve` before matching so ``..`` traversal and symlink escapes
|
|
54
|
+
cannot extend trust outside the intended boundary.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
if allow_all or _ALLOW_ALL_HYDRATION or _env_allow_all():
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
candidate = _canonical(path)
|
|
61
|
+
if candidate is None:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
for base_dir in base_dirs or ():
|
|
65
|
+
if base_dir and _is_within(candidate, _canonical(base_dir)):
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
for source in _all_configured_sources(trusted_sources):
|
|
69
|
+
if _matches_source(candidate, str(source)):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configured_trusted_python_sources(
|
|
76
|
+
extra_sources: Iterable[str | os.PathLike[str]] | None = None,
|
|
77
|
+
) -> list[str]:
|
|
78
|
+
"""Return trusted Python-source patterns from registry/config/env/extras."""
|
|
79
|
+
|
|
80
|
+
return [str(source) for source in _all_configured_sources(extra_sources)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _all_configured_sources(
|
|
84
|
+
extra_sources: Iterable[str | os.PathLike[str]] | None = None,
|
|
85
|
+
) -> list[str]:
|
|
86
|
+
sources: list[str] = []
|
|
87
|
+
sources.extend(_TRUSTED_PYTHON_SOURCES)
|
|
88
|
+
sources.extend(_load_configured_sources())
|
|
89
|
+
sources.extend(_env_trusted_sources())
|
|
90
|
+
if extra_sources:
|
|
91
|
+
sources.extend(str(source) for source in extra_sources if str(source).strip())
|
|
92
|
+
return sources
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _env_allow_all() -> bool:
|
|
96
|
+
value = os.environ.get("TANGLE_TRUSTED_HYDRATION_ALLOW_ALL")
|
|
97
|
+
return bool(value and value.strip().lower() in {"1", "true", "yes", "on"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _env_trusted_sources() -> list[str]:
|
|
101
|
+
value = os.environ.get("TANGLE_TRUSTED_PYTHON_SOURCES", "")
|
|
102
|
+
if not value.strip():
|
|
103
|
+
return []
|
|
104
|
+
parts: list[str] = []
|
|
105
|
+
for chunk in value.replace(",", os.pathsep).split(os.pathsep):
|
|
106
|
+
chunk = chunk.strip()
|
|
107
|
+
if chunk:
|
|
108
|
+
parts.append(chunk)
|
|
109
|
+
return parts
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_configured_sources() -> list[str]:
|
|
113
|
+
sources: list[str] = []
|
|
114
|
+
for path in _config_paths():
|
|
115
|
+
sources.extend(_load_sources_from_file(path))
|
|
116
|
+
return sources
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _config_paths() -> list[Path]:
|
|
120
|
+
paths = [_PACKAGE_CONFIG, *_USER_CONFIGS]
|
|
121
|
+
override = os.environ.get("TANGLE_TRUSTED_HYDRATION_CONFIG")
|
|
122
|
+
if override:
|
|
123
|
+
paths.append(Path(override).expanduser())
|
|
124
|
+
return paths
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_sources_from_file(path: Path) -> list[str]:
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return []
|
|
130
|
+
try:
|
|
131
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
132
|
+
except Exception:
|
|
133
|
+
return []
|
|
134
|
+
trusted = data.get("trusted_hydration", data) if isinstance(data, dict) else data
|
|
135
|
+
if not isinstance(trusted, dict):
|
|
136
|
+
return []
|
|
137
|
+
raw = trusted.get("trusted_python_sources", [])
|
|
138
|
+
if isinstance(raw, str):
|
|
139
|
+
return [raw]
|
|
140
|
+
if isinstance(raw, list):
|
|
141
|
+
return [str(item) for item in raw if str(item).strip()]
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _canonical(path: str | os.PathLike[str] | None) -> Path | None:
|
|
146
|
+
if path is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
return Path(path).expanduser().resolve()
|
|
150
|
+
except OSError:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_within(candidate: Path, root: Path | None) -> bool:
|
|
155
|
+
if root is None:
|
|
156
|
+
return False
|
|
157
|
+
return candidate == root or root in candidate.parents
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _matches_source(candidate: Path, source: str) -> bool:
|
|
161
|
+
source = os.path.expandvars(source.strip())
|
|
162
|
+
if not source:
|
|
163
|
+
return False
|
|
164
|
+
if any(char in source for char in _GLOB_CHARS):
|
|
165
|
+
return _matches_glob_source(candidate, source)
|
|
166
|
+
root = _canonical(source)
|
|
167
|
+
if root is None:
|
|
168
|
+
return False
|
|
169
|
+
source_path = Path(source)
|
|
170
|
+
if root.is_file() or source_path.suffix == ".py":
|
|
171
|
+
return candidate == root
|
|
172
|
+
return _is_within(candidate, root)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _matches_glob_source(candidate: Path, source: str) -> bool:
|
|
176
|
+
raw = Path(source).expanduser()
|
|
177
|
+
parts = raw.parts
|
|
178
|
+
first_glob = next(
|
|
179
|
+
(index for index, part in enumerate(parts) if any(char in part for char in _GLOB_CHARS)),
|
|
180
|
+
None,
|
|
181
|
+
)
|
|
182
|
+
if first_glob is None:
|
|
183
|
+
return _matches_source(candidate, source)
|
|
184
|
+
prefix_parts = parts[:first_glob]
|
|
185
|
+
suffix_parts = parts[first_glob:]
|
|
186
|
+
if prefix_parts:
|
|
187
|
+
prefix = Path(*prefix_parts).resolve()
|
|
188
|
+
else:
|
|
189
|
+
prefix = Path.cwd().resolve()
|
|
190
|
+
try:
|
|
191
|
+
relative_candidate = candidate.relative_to(prefix)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return False
|
|
194
|
+
return _match_path_parts(relative_candidate.parts, suffix_parts)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _match_path_parts(candidate_parts: tuple[str, ...], pattern_parts: tuple[str, ...]) -> bool:
|
|
198
|
+
if not pattern_parts:
|
|
199
|
+
return not candidate_parts
|
|
200
|
+
pattern = pattern_parts[0]
|
|
201
|
+
remaining_patterns = pattern_parts[1:]
|
|
202
|
+
if pattern == "**":
|
|
203
|
+
return any(
|
|
204
|
+
_match_path_parts(candidate_parts[index:], remaining_patterns)
|
|
205
|
+
for index in range(len(candidate_parts) + 1)
|
|
206
|
+
)
|
|
207
|
+
if not candidate_parts:
|
|
208
|
+
return False
|
|
209
|
+
return fnmatchcase(candidate_parts[0], pattern) and _match_path_parts(candidate_parts[1:], remaining_patterns)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def trusted_python_source_guidance(path: str | os.PathLike[str]) -> str:
|
|
213
|
+
"""Human-readable refusal guidance for an untrusted Python source."""
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
f"Refusing to execute untrusted local_from_python source {Path(path).expanduser()}. "
|
|
217
|
+
"Add an allowlisted trusted Python source with --trusted-source, "
|
|
218
|
+
"trusted_hydration.trusted_python_sources in config, "
|
|
219
|
+
"TANGLE_TRUSTED_PYTHON_SOURCES, or register_trusted_python_source(); "
|
|
220
|
+
"or use --trusted-hydration / set_allow_all_hydration() for trusted inputs. "
|
|
221
|
+
"You can also pre-hydrate trusted specs and submit them with --no-hydrate."
|
|
222
|
+
)
|
tangle_cli/logger.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Structured logging for tangle-cli.
|
|
2
|
+
|
|
3
|
+
Provides an injectable logger abstraction so library code never calls print()
|
|
4
|
+
directly. CLI entry points use the default :class:`ConsoleLogger` (same
|
|
5
|
+
behaviour as bare ``print``). Wrappers that need to capture output (an MCP
|
|
6
|
+
server, a test harness, etc.) inject a :class:`CaptureLogger` that
|
|
7
|
+
accumulates messages in memory and returns them as a single string.
|
|
8
|
+
|
|
9
|
+
CLI commands use :func:`run_with_logging` to handle the ``--log-type`` flag
|
|
10
|
+
uniformly::
|
|
11
|
+
|
|
12
|
+
run_with_logging(log_type, lambda logger: my_core_func(..., logger=logger))
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from typing import Any, Callable, Protocol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Logger(Protocol):
|
|
24
|
+
"""Minimal logging protocol for Tangle tooling."""
|
|
25
|
+
|
|
26
|
+
def info(self, msg: str) -> None: ...
|
|
27
|
+
def warn(self, msg: str) -> None: ...
|
|
28
|
+
def error(self, msg: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConsoleLogger:
|
|
32
|
+
"""Default logger — prints to stderr so structured output on stdout stays clean."""
|
|
33
|
+
|
|
34
|
+
def info(self, msg: str) -> None:
|
|
35
|
+
print(msg, file=sys.stderr, flush=True)
|
|
36
|
+
|
|
37
|
+
def warn(self, msg: str) -> None:
|
|
38
|
+
print(msg, file=sys.stderr, flush=True)
|
|
39
|
+
|
|
40
|
+
def error(self, msg: str) -> None:
|
|
41
|
+
print(msg, file=sys.stderr, flush=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CaptureLogger:
|
|
45
|
+
"""Logger for MCP: accumulates messages in memory.
|
|
46
|
+
|
|
47
|
+
Use :meth:`get_logs` to retrieve the collected output as a single string.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._messages: list[str] = []
|
|
52
|
+
|
|
53
|
+
def info(self, msg: str) -> None:
|
|
54
|
+
self._messages.append(msg)
|
|
55
|
+
|
|
56
|
+
def warn(self, msg: str) -> None:
|
|
57
|
+
self._messages.append(msg)
|
|
58
|
+
|
|
59
|
+
def error(self, msg: str) -> None:
|
|
60
|
+
self._messages.append(f"[error] {msg}")
|
|
61
|
+
|
|
62
|
+
def get_logs(self) -> str | None:
|
|
63
|
+
"""Return accumulated logs as a single string, or None if empty."""
|
|
64
|
+
text = "\n".join(self._messages).strip()
|
|
65
|
+
return text if text else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NullLogger:
|
|
69
|
+
"""Logger that discards all messages. Used by MCP when include_logs is False."""
|
|
70
|
+
|
|
71
|
+
def info(self, msg: str) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def warn(self, msg: str) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def error(self, msg: str) -> None:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_default_logger = ConsoleLogger()
|
|
82
|
+
_null_logger = NullLogger()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_default_logger() -> ConsoleLogger:
|
|
86
|
+
"""Return the module-level default :class:`ConsoleLogger`."""
|
|
87
|
+
return _default_logger
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Valid log_type values for CLI commands. Keep this as ``str`` because Typer
|
|
91
|
+
# does not support Literal annotations for option parameters.
|
|
92
|
+
CliLogType = str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LogFinalizer(Protocol):
|
|
96
|
+
def __call__(self) -> None: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def logger_for_log_type(log_type: CliLogType) -> tuple[Logger, LogFinalizer]:
|
|
100
|
+
"""Return a logger/finalizer pair for TD-compatible CLI log types.
|
|
101
|
+
|
|
102
|
+
``console`` logs to stderr, ``none`` discards logs, and ``file`` captures
|
|
103
|
+
logs to a temporary file whose path is printed to stderr by the finalizer.
|
|
104
|
+
Callers that need custom structured stdout handling can use this lower-level
|
|
105
|
+
helper instead of :func:`run_with_logging`.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
if log_type == "console":
|
|
109
|
+
return _default_logger, lambda: None
|
|
110
|
+
if log_type == "none":
|
|
111
|
+
return _null_logger, lambda: None
|
|
112
|
+
if log_type == "file":
|
|
113
|
+
capture = CaptureLogger()
|
|
114
|
+
|
|
115
|
+
def finalize() -> None:
|
|
116
|
+
if logs := capture.get_logs():
|
|
117
|
+
with tempfile.NamedTemporaryFile(
|
|
118
|
+
mode="w", suffix=".log", prefix="tangle_", delete=False,
|
|
119
|
+
) as f:
|
|
120
|
+
f.write(logs)
|
|
121
|
+
print(f"\nLogs written to: {f.name}", file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
return capture, finalize
|
|
124
|
+
raise SystemExit("--log-type must be one of: console, none, file")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _print_result(result: Any) -> None:
|
|
128
|
+
"""Print a function result as JSON (dicts) or plain text.
|
|
129
|
+
|
|
130
|
+
Uses plain :func:`print` so this module has no CLI-framework
|
|
131
|
+
dependency. Concrete CLI wrappers built on top of ``tangle-cli``
|
|
132
|
+
can wrap this with ``typer.echo`` / ``click.echo`` if they need
|
|
133
|
+
terminal-aware encoding handling.
|
|
134
|
+
"""
|
|
135
|
+
if result is None:
|
|
136
|
+
return
|
|
137
|
+
if isinstance(result, dict):
|
|
138
|
+
print(json.dumps(result, indent=2, default=str))
|
|
139
|
+
else:
|
|
140
|
+
print(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_with_logging(
|
|
144
|
+
log_type: CliLogType,
|
|
145
|
+
fn: Callable[[Logger], dict[str, Any] | Any],
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Run *fn* with the appropriate logger for *log_type*, then handle output.
|
|
148
|
+
|
|
149
|
+
This is the universal CLI wrapper for the ``--log-type`` flag:
|
|
150
|
+
|
|
151
|
+
- **console** (default): logs stream to stdout/stderr via :class:`ConsoleLogger`.
|
|
152
|
+
If *fn* returns a non-None result, it is printed as JSON after the logs.
|
|
153
|
+
- **none**: logs are discarded. The function result is printed as JSON.
|
|
154
|
+
- **file**: logs are captured and written to a temp file whose path is
|
|
155
|
+
printed to stderr. The function result is printed as JSON.
|
|
156
|
+
|
|
157
|
+
*fn* receives a :class:`Logger` and should return a dict (or any value).
|
|
158
|
+
Return ``None`` to suppress result output (useful when the logs *are* the output).
|
|
159
|
+
"""
|
|
160
|
+
logger, finalize = logger_for_log_type(log_type)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = fn(logger)
|
|
164
|
+
_print_result(result)
|
|
165
|
+
finally:
|
|
166
|
+
finalize()
|