controlled-text-transfer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Safe, auditable text-only transfer packaging."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Run the CTT CLI with ``python -m controlled_text_transfer``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from controlled_text_transfer.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,179 @@
1
+ """Remove reproducible caches and build artifacts from this repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ TOP_LEVEL_ARTIFACTS = frozenset(
14
+ {
15
+ "build",
16
+ "dist",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".ruff_cache",
20
+ ".coverage",
21
+ "reports",
22
+ }
23
+ )
24
+ PRUNED_DIRECTORIES = frozenset({".git", ".venv"}) | TOP_LEVEL_ARTIFACTS
25
+
26
+
27
+ class CleanupError(RuntimeError):
28
+ """Report an unsafe or unsuccessful cleanup request."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CleanupResult:
33
+ """Describe the deterministic outcome of a cleanup operation."""
34
+
35
+ planned: tuple[Path, ...]
36
+ removed: tuple[Path, ...]
37
+
38
+
39
+ def _sort_paths(paths: set[Path]) -> tuple[Path, ...]:
40
+ return tuple(sorted(paths, key=lambda path: path.as_posix()))
41
+
42
+
43
+ def discover_targets(root: Path, *, include_environment: bool = False) -> tuple[Path, ...]:
44
+ """Return reproducible artifacts below ``root`` without following symlinks."""
45
+ repository = root.resolve(strict=True)
46
+ if not repository.is_dir():
47
+ raise CleanupError(f"repository root is not a directory: {repository}")
48
+
49
+ targets = {
50
+ repository / name
51
+ for name in TOP_LEVEL_ARTIFACTS
52
+ if (repository / name).exists() or (repository / name).is_symlink()
53
+ }
54
+ if include_environment:
55
+ environment = repository / ".venv"
56
+ if environment.exists() or environment.is_symlink():
57
+ targets.add(environment)
58
+
59
+ for current, directories, _files in os.walk(repository, followlinks=False):
60
+ current_path = Path(current)
61
+ safe_directories: list[str] = []
62
+ for name in directories:
63
+ candidate = current_path / name
64
+ if candidate.is_symlink() or candidate.is_junction():
65
+ if name == "__pycache__" or name.endswith(".egg-info"):
66
+ targets.add(candidate)
67
+ continue
68
+ if name == "__pycache__" or name.endswith(".egg-info"):
69
+ targets.add(candidate)
70
+ continue
71
+ if current_path == repository and name in PRUNED_DIRECTORIES:
72
+ continue
73
+ safe_directories.append(name)
74
+ directories[:] = safe_directories
75
+
76
+ return _sort_paths(targets)
77
+
78
+
79
+ def _assert_safe_target(root: Path, target: Path, *, include_environment: bool) -> None:
80
+ repository = root.resolve(strict=True)
81
+ absolute_target = target.absolute()
82
+ try:
83
+ relative = absolute_target.relative_to(repository)
84
+ except ValueError as exc:
85
+ raise CleanupError(f"cleanup target is outside repository: {target}") from exc
86
+ if len(relative.parts) == 1 and relative.name in TOP_LEVEL_ARTIFACTS:
87
+ return
88
+ if include_environment and relative == Path(".venv"):
89
+ return
90
+ if relative.name == "__pycache__" or relative.name.endswith(".egg-info"):
91
+ return
92
+ raise CleanupError(f"cleanup target is not an approved artifact: {target}")
93
+
94
+
95
+ def _remove_target(target: Path) -> None:
96
+ if target.is_symlink() or target.is_file():
97
+ target.unlink()
98
+ elif target.is_junction():
99
+ target.rmdir()
100
+ elif target.is_dir():
101
+ shutil.rmtree(target)
102
+
103
+
104
+ def clean_repository(
105
+ root: Path,
106
+ *,
107
+ dry_run: bool = False,
108
+ include_environment: bool = False,
109
+ executable: Path | None = None,
110
+ ) -> CleanupResult:
111
+ """Remove approved generated artifacts and return the operation result."""
112
+ repository = root.resolve(strict=True)
113
+ targets = discover_targets(repository, include_environment=include_environment)
114
+ environment = repository / ".venv"
115
+ active_executable = (executable or Path(sys.executable)).resolve()
116
+ if include_environment and environment in targets:
117
+ try:
118
+ active_executable.relative_to(environment.resolve())
119
+ except ValueError:
120
+ pass
121
+ else:
122
+ raise CleanupError(
123
+ "refusing to remove the active Python environment; "
124
+ "run this script with a system Python interpreter"
125
+ )
126
+
127
+ for target in targets:
128
+ _assert_safe_target(repository, target, include_environment=include_environment)
129
+ if dry_run:
130
+ return CleanupResult(planned=targets, removed=())
131
+
132
+ removed: list[Path] = []
133
+ for target in targets:
134
+ try:
135
+ _remove_target(target)
136
+ except OSError as exc:
137
+ raise CleanupError(f"failed to remove {target}: {exc}") from exc
138
+ removed.append(target)
139
+ return CleanupResult(planned=targets, removed=tuple(removed))
140
+
141
+
142
+ def _parser() -> argparse.ArgumentParser:
143
+ parser = argparse.ArgumentParser(
144
+ description="Remove reproducible repository caches and build artifacts."
145
+ )
146
+ parser.add_argument("--dry-run", action="store_true", help="list targets without deleting")
147
+ parser.add_argument(
148
+ "--environment",
149
+ action="store_true",
150
+ help="also remove .venv; invoke with a Python interpreter outside .venv",
151
+ )
152
+ return parser
153
+
154
+
155
+ def main(argv: Sequence[str] | None = None) -> int:
156
+ """Run repository cleanup from the command line."""
157
+ args = _parser().parse_args(argv)
158
+ repository = Path(__file__).resolve().parents[2]
159
+ try:
160
+ result = clean_repository(
161
+ repository,
162
+ dry_run=args.dry_run,
163
+ include_environment=args.environment,
164
+ )
165
+ except CleanupError as exc:
166
+ print(f"error: {exc}", file=sys.stderr)
167
+ return 1
168
+
169
+ if not result.planned:
170
+ print("No generated artifacts found.")
171
+ return 0
172
+ action = "Would remove" if args.dry_run else "Removed"
173
+ for target in result.planned:
174
+ print(f"{action}: {target.relative_to(repository)}")
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())
@@ -0,0 +1,221 @@
1
+ """Command-line interface for Controlled Text Transfer workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from .core import Policy, TransferError, diff, preflight, prepare, restore, verify
13
+ from .signing import ManifestSigner
14
+
15
+
16
+ def _add_policy_option(parser: argparse.ArgumentParser) -> None:
17
+ """Add source-policy selection to a policy-aware command."""
18
+ parser.add_argument(
19
+ "--policy", type=Path, help="load compatibility and content rules from YAML"
20
+ )
21
+
22
+
23
+ def _add_log_option(parser: argparse.ArgumentParser) -> None:
24
+ """Add structured audit logging to a command that emits audit events."""
25
+ parser.add_argument(
26
+ "--log-json", action="store_true", help="write one JSON audit event to stderr"
27
+ )
28
+
29
+
30
+ class JsonFormatter(logging.Formatter):
31
+ """Format supported audit fields as JSON."""
32
+
33
+ def format(self, record: logging.LogRecord) -> str:
34
+ """Return one JSON object for a log record."""
35
+ return json.dumps(
36
+ {
37
+ "timestamp": datetime.now(UTC).isoformat(),
38
+ "level": record.levelname,
39
+ "event": record.getMessage(),
40
+ **{
41
+ k: v for k, v in record.__dict__.items() if k in {"files", "skipped", "dry_run"}
42
+ },
43
+ }
44
+ )
45
+
46
+
47
+ def _parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ prog="ctt", description="Prepare, verify, and restore text-only transfer packages."
50
+ )
51
+ parser.set_defaults(policy=None, log_json=False)
52
+ commands = parser.add_subparsers(dest="command", required=True)
53
+
54
+ prepare_parser = commands.add_parser(
55
+ "prepare", help="create and self-verify a transfer package"
56
+ )
57
+ _add_policy_option(prepare_parser)
58
+ _add_log_option(prepare_parser)
59
+ prepare_parser.add_argument("source", type=Path, help="directory containing source text files")
60
+ prepare_parser.add_argument("transfer", type=Path, help="new package path")
61
+ prepare_parser.add_argument(
62
+ "--dry-run", action="store_true", help="validate without writing a package"
63
+ )
64
+ prepare_parser.add_argument(
65
+ "--strict", action="store_true", help="fail if any file is rejected"
66
+ )
67
+ prepare_parser.add_argument(
68
+ "--json-report", type=Path, help="write the preflight report to a file"
69
+ )
70
+ prepare_parser.add_argument(
71
+ "--sign", action="store_true", help="sign with a trusted injected signer"
72
+ )
73
+ prepare_parser.add_argument(
74
+ "--key-label",
75
+ default="external-managed-key",
76
+ help="record a non-secret signer label in the manifest",
77
+ )
78
+
79
+ preflight_parser = commands.add_parser(
80
+ "preflight", help="evaluate source files without packaging"
81
+ )
82
+ _add_policy_option(preflight_parser)
83
+ preflight_parser.add_argument("source", type=Path, help="directory to evaluate")
84
+ preflight_parser.add_argument(
85
+ "--json", action="store_true", help="write the full report as JSON"
86
+ )
87
+
88
+ verify_parser = commands.add_parser("verify", help="check package integrity and authenticity")
89
+ _add_log_option(verify_parser)
90
+ verify_parser.add_argument("transfer", type=Path, help="package directory or archive")
91
+ verify_parser.add_argument(
92
+ "--require-signature", action="store_true", help="reject packages without a signature"
93
+ )
94
+ verify_parser.add_argument(
95
+ "--allow-unverified-signature",
96
+ action="store_true",
97
+ help="permit a signed package without an available trusted verifier",
98
+ )
99
+
100
+ restore_parser = commands.add_parser("restore", help="verify and reconstruct original files")
101
+ _add_log_option(restore_parser)
102
+ restore_parser.add_argument("transfer", type=Path, help="package directory or archive")
103
+ restore_parser.add_argument("destination", type=Path, help="new directory for restored files")
104
+ restore_parser.add_argument(
105
+ "--dry-run", action="store_true", help="verify without writing restored files"
106
+ )
107
+ restore_parser.add_argument(
108
+ "--require-signature", action="store_true", help="reject packages without a signature"
109
+ )
110
+ restore_parser.add_argument(
111
+ "--allow-unverified-signature",
112
+ action="store_true",
113
+ help="permit a signed package without an available trusted verifier",
114
+ )
115
+
116
+ diff_parser = commands.add_parser("diff", help="compare a package with a source directory")
117
+ _add_policy_option(diff_parser)
118
+ diff_parser.add_argument("transfer", type=Path, help="package directory or archive")
119
+ diff_parser.add_argument("source", type=Path, help="current source directory")
120
+ diff_parser.add_argument("--json", action="store_true", help="write comparison results as JSON")
121
+ diff_parser.add_argument(
122
+ "--require-signature", action="store_true", help="reject packages without a signature"
123
+ )
124
+ diff_parser.add_argument(
125
+ "--allow-unverified-signature",
126
+ action="store_true",
127
+ help="permit a signed package without an available trusted verifier",
128
+ )
129
+ return parser
130
+
131
+
132
+ def main(
133
+ argv: list[str] | None = None,
134
+ *,
135
+ signer: Optional[ManifestSigner] = None,
136
+ ) -> int:
137
+ """Run the CLI and return a process-compatible status code.
138
+
139
+ Trusted hosts may inject a signer; transferred data never selects commands.
140
+ """
141
+ args = _parser().parse_args(argv)
142
+ log = logging.getLogger("controlled_text_transfer")
143
+ log.setLevel(logging.INFO)
144
+ h = logging.StreamHandler()
145
+ h.setFormatter(
146
+ JsonFormatter() if args.log_json else logging.Formatter("%(levelname)s: %(message)s")
147
+ )
148
+ log.handlers[:] = [h]
149
+ try:
150
+ policy = Policy.from_file(args.policy)
151
+ if args.command == "preflight":
152
+ report = preflight(args.source, policy)
153
+ output = json.dumps(report.to_dict(), indent=2)
154
+ if args.json:
155
+ print(output)
156
+ else:
157
+ print(
158
+ f"accepted: {report.accepted_count}\n"
159
+ f"rejected: {report.rejected_count}\n"
160
+ f"total bytes: {report.total_bytes}"
161
+ )
162
+ return 0
163
+ if args.command == "prepare":
164
+ report = preflight(args.source, policy)
165
+ if args.json_report:
166
+ args.json_report.write_text(
167
+ json.dumps(report.to_dict(), indent=2) + "\n",
168
+ encoding="utf-8",
169
+ )
170
+ if args.sign and signer is None:
171
+ raise TransferError("trusted signer is required")
172
+ m = prepare(
173
+ args.source,
174
+ args.transfer,
175
+ policy,
176
+ dry_run=args.dry_run,
177
+ strict=args.strict,
178
+ signer=signer if args.sign else None,
179
+ key_label=args.key_label,
180
+ logger=log,
181
+ )
182
+ elif args.command == "verify":
183
+ m = verify(
184
+ args.transfer,
185
+ signer=signer,
186
+ require_signature=args.require_signature,
187
+ allow_unverified_signature=args.allow_unverified_signature,
188
+ logger=log,
189
+ )
190
+ elif args.command == "diff":
191
+ result = diff(
192
+ args.transfer,
193
+ args.source,
194
+ policy,
195
+ signer=signer,
196
+ require_signature=args.require_signature,
197
+ allow_unverified_signature=args.allow_unverified_signature,
198
+ )
199
+ if args.json:
200
+ print(json.dumps(result, indent=2))
201
+ else:
202
+ for category, paths in result.items():
203
+ print(f"{category}: {len(paths)}")
204
+ for path in paths:
205
+ print(f" {path}")
206
+ return 0
207
+ else:
208
+ m = restore(
209
+ args.transfer,
210
+ args.destination,
211
+ dry_run=args.dry_run,
212
+ signer=signer,
213
+ require_signature=args.require_signature,
214
+ allow_unverified_signature=args.allow_unverified_signature,
215
+ logger=log,
216
+ )
217
+ print(json.dumps({"files": len(m.files), "skipped": m.skipped}, indent=2))
218
+ return 0
219
+ except (TransferError, OSError, ValueError) as exc:
220
+ log.error(str(exc))
221
+ return 2