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.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. 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()