netizen-cli 0.10.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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,526 @@
1
+ """Finite, read-only package update preparation.
2
+
3
+ This module never installs packages. It produces a private, serializable plan for
4
+ the external update worker. Native pip report v1 and uv exit statuses are used;
5
+ installer diagnostics are never parsed. Private uv-tool receipt identity and
6
+ cache-layout refusal rules are tested against uv 0.12.20; ordinary uv-pip uses
7
+ the documented public commands and non-mutating capability checks.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tomllib
21
+ from typing import Any, Mapping
22
+
23
+
24
+ DISTRIBUTION = "netizen-cli"
25
+ SUPPORTED_UV_TOOL_VERSIONS = frozenset({"0.12.20"})
26
+
27
+
28
+ class PackageUpdateError(RuntimeError):
29
+ """Preflight could not safely identify or resolve this installation."""
30
+
31
+ def __init__(self, message: str, *, diagnostic: str = "") -> None:
32
+ super().__init__(message)
33
+ self.diagnostic = diagnostic # Private diagnostics, never used for decisions.
34
+
35
+
36
+ # Executed in a new isolated interpreter, not imported from the installation that
37
+ # the worker is replacing. importlib.metadata and sysconfig are public APIs.
38
+ _PROBE_BODY = r'''
39
+ import importlib, importlib.metadata as m, importlib.util, json, os, pathlib, sys, sysconfig
40
+ P = pathlib.Path
41
+ distributions = list(m.distributions(name="netizen-cli"))
42
+ if len(distributions) != 1:
43
+ raise RuntimeError("installation lacks a unique netizen-cli distribution")
44
+ d = distributions[0]
45
+ files = list(d.files or ())
46
+ metadata = [P(d.locate_file(f)).resolve() for f in files
47
+ if f.name == "METADATA" and f.parent.name.endswith(".dist-info")]
48
+ if len(metadata) != 1:
49
+ raise RuntimeError("installation lacks unique wheel metadata")
50
+ spec = importlib.util.find_spec("netizen_cli")
51
+ if spec is None or spec.origin is None:
52
+ raise RuntimeError("netizen_cli module is missing")
53
+ entries = [e.value for e in d.entry_points if e.group == "console_scripts" and e.name == "netizen"]
54
+ if entries != ["netizen_cli.cli:main"]:
55
+ raise RuntimeError("netizen entry point does not match the installed CLI")
56
+ prefix = P(sys.prefix).resolve()
57
+ package = P(spec.origin).resolve().parent
58
+ info = metadata[0].parent
59
+ scripts = P(sysconfig.get_path("scripts")).resolve()
60
+ sites = {P(sysconfig.get_path(k)).resolve() for k in ("purelib", "platlib")}
61
+ if not all(p.is_relative_to(prefix) for p in (*sites, scripts)):
62
+ raise RuntimeError("installation uses external site-packages")
63
+ if not any(package.parent == s and info.parent == s for s in sites):
64
+ raise RuntimeError("package or metadata is outside this environment's site-packages")
65
+ raw_url = d.read_text("direct_url.json")
66
+ if raw_url:
67
+ raise RuntimeError("direct URL, local source and editable installs require manual maintenance")
68
+ if not isinstance(d.version, str) or not d.version.strip():
69
+ raise RuntimeError("installed distribution version is missing")
70
+ pip_spec = importlib.util.find_spec("pip")
71
+ local_pip = (pip_spec is not None and pip_spec.origin is not None
72
+ and P(pip_spec.origin).resolve().is_relative_to(prefix))
73
+ result = {"environment_python": os.path.abspath(sys.executable), "prefix": str(prefix),
74
+ "base_prefix": str(P(sys.base_prefix).resolve()), "package_dir": str(package),
75
+ "dist_info": str(info), "version": d.version,
76
+ "installer": (d.read_text("INSTALLER") or "").strip(),
77
+ "pip_available": local_pip,
78
+ "scripts_dir": str(scripts),
79
+ "externally_managed": (P(sysconfig.get_path("stdlib")) / "EXTERNALLY-MANAGED").exists()}
80
+ if len(sys.argv) > 1:
81
+ expected = json.loads(sys.argv[1])
82
+ for key, value in expected.items():
83
+ if result.get(key) != value:
84
+ raise RuntimeError("installation identity changed: " + key)
85
+ if len(sys.argv) > 2:
86
+ for module in ("netizen_cli.cli", "netizen_cli.cli_services", "netizen_cli.package_resources"):
87
+ importlib.import_module(module)
88
+ print(json.dumps(result))
89
+ '''
90
+ _PROBE = ("try:\n" + "".join(" " + line + "\n" for line in _PROBE_BODY.splitlines())
91
+ + "except Exception as error:\n"
92
+ " import json\n"
93
+ " print(json.dumps({'error': str(error)}))\n"
94
+ " raise SystemExit(1)\n")
95
+
96
+
97
+ def _run(
98
+ argv: list[str], *, env: Mapping[str, str], cwd: Path,
99
+ timeout: int = 180,
100
+ ) -> subprocess.CompletedProcess[str]:
101
+ try:
102
+ return subprocess.run(
103
+ argv, env=dict(env), cwd=cwd, text=True, capture_output=True,
104
+ stdin=subprocess.DEVNULL, timeout=timeout, check=False,
105
+ )
106
+ except (OSError, subprocess.TimeoutExpired) as exc:
107
+ raise PackageUpdateError(
108
+ f"Package preflight could not execute {Path(argv[0]).name}: {exc}. "
109
+ "No services have been stopped; repair the tool or update manually."
110
+ ) from exc
111
+
112
+
113
+ def _require_success(result: subprocess.CompletedProcess[str], operation: str) -> None:
114
+ if result.returncode:
115
+ # Tool output is diagnostic, not a machine protocol. In particular a
116
+ # nonzero uv --check must not be interpreted as a successful plan.
117
+ raise PackageUpdateError(
118
+ f"{operation} failed (exit {result.returncode}). No services have been stopped. "
119
+ "Run the same package manager manually to diagnose its resolution or installation error.",
120
+ diagnostic=result.stderr or result.stdout,
121
+ )
122
+
123
+
124
+ def _object(text: str, operation: str) -> dict[str, Any]:
125
+ try:
126
+ data = json.loads(text)
127
+ except (ValueError, TypeError) as exc:
128
+ raise PackageUpdateError(f"{operation} returned an invalid machine response.") from exc
129
+ if not isinstance(data, dict):
130
+ raise PackageUpdateError(f"{operation} returned an invalid machine response.")
131
+ return data
132
+
133
+
134
+ def _digest(path: Path) -> str:
135
+ try:
136
+ return hashlib.sha256(path.read_bytes()).hexdigest()
137
+ except OSError as exc:
138
+ raise PackageUpdateError(f"Cannot read installation evidence {path}: {exc}") from exc
139
+
140
+
141
+ def _identity(path: Path) -> list[int]:
142
+ try:
143
+ st = path.stat()
144
+ except OSError as exc:
145
+ raise PackageUpdateError(f"Cannot identify installation path {path}: {exc}") from exc
146
+ return [st.st_dev, st.st_ino]
147
+
148
+
149
+ def _canonical_name(value: str) -> str:
150
+ return re.sub(r"[-_.]+", "-", value).lower()
151
+
152
+
153
+ def _config_paths(backend: str, prefix: Path, env: Mapping[str, str]) -> set[Path]:
154
+ """Bounded documented config locations, not a replacement config parser."""
155
+ home = Path.home()
156
+ config_home = Path(env.get("XDG_CONFIG_HOME") or home / ".config")
157
+ if backend == "pip":
158
+ paths = {home / ".pip/pip.conf", config_home / "pip/pip.conf", prefix / "pip.conf"}
159
+ paths.add(Path("/etc/pip.conf"))
160
+ for root in (env.get("XDG_CONFIG_DIRS") or "/etc/xdg").split(os.pathsep):
161
+ paths.add(Path(root) / "pip/pip.conf")
162
+ if sys.platform == "darwin":
163
+ # pip 26.2 also honors XDG data paths on macOS. Keep legacy and
164
+ # fallback locations in this conservative check across pip versions.
165
+ paths.update({Path("/Library/Application Support/pip/pip.conf"),
166
+ home / "Library/Application Support/pip/pip.conf",
167
+ home / ".config/pip/pip.conf"})
168
+ if env.get("XDG_DATA_HOME"):
169
+ paths.add(Path(env["XDG_DATA_HOME"]) / "pip/pip.conf")
170
+ for root in (env.get("XDG_DATA_DIRS") or "").split(os.pathsep):
171
+ if root:
172
+ paths.add(Path(root) / "pip/pip.conf")
173
+ else:
174
+ paths = {config_home / "uv/uv.toml", Path("/etc/uv/uv.toml")}
175
+ return paths
176
+
177
+
178
+ def _check_tool_config(backend: str, prefix: Path, env: Mapping[str, str]) -> None:
179
+ variable = "PIP_CONFIG_FILE" if backend == "pip" else "UV_CONFIG_FILE"
180
+ explicit = env.get(variable)
181
+ # pip explicitly documents the null device as disabling all configuration.
182
+ if backend == "pip" and explicit == os.devnull:
183
+ return
184
+ if backend != "pip" and env.get("UV_NO_CONFIG", "").lower() in ("1", "true", "yes"):
185
+ return
186
+ if explicit:
187
+ raise PackageUpdateError(
188
+ f"{variable} selects custom package configuration; automatic update cannot verify "
189
+ "its target settings. Use the owning package manager manually."
190
+ )
191
+ existing = sorted(str(p) for p in _config_paths(backend, prefix, env) if p.exists())
192
+ if backend != "pip":
193
+ for directory in (Path.cwd(), *Path.cwd().parents):
194
+ config = directory / "uv.toml"
195
+ if config.exists():
196
+ existing.append(str(config))
197
+ project = directory / "pyproject.toml"
198
+ if project.is_file():
199
+ try:
200
+ settings = tomllib.loads(project.read_text())
201
+ except (OSError, ValueError) as exc:
202
+ raise PackageUpdateError(f"Cannot verify project configuration {project}; update manually.") from exc
203
+ if "uv" in settings.get("tool", {}):
204
+ existing.append(str(project))
205
+ if existing:
206
+ raise PackageUpdateError(
207
+ "Custom package configuration requires manual update in this release: "
208
+ + ", ".join(existing)
209
+ + ". Automatic update does not silently ignore indexes, constraints or target settings."
210
+ )
211
+
212
+
213
+ def _check_environment_options(backend: str, env: Mapping[str, str]) -> None:
214
+ if backend == "pip":
215
+ denied_values = (
216
+ "PIP_TARGET", "PIP_PREFIX", "PIP_ROOT", "PIP_PYTHON",
217
+ "PIP_REQUIREMENT", "PIP_EDITABLE", "PIP_REPORT",
218
+ )
219
+ denied_flags = (
220
+ "PIP_USER", "PIP_BREAK_SYSTEM_PACKAGES", "PIP_DRY_RUN",
221
+ "PIP_IGNORE_INSTALLED", "PIP_FORCE_REINSTALL",
222
+ "PIP_NO_DEPS", "PIP_IGNORE_REQUIRES_PYTHON",
223
+ )
224
+ else:
225
+ denied_values = (
226
+ "UV_TARGET", "UV_PREFIX",
227
+ "UV_PYTHON", "UV_PROJECT_ENVIRONMENT", "UV_WORKING_DIR", "UV_PROJECT",
228
+ "UV_REINSTALL_PACKAGE",
229
+ )
230
+ denied_flags = (
231
+ "UV_SYSTEM_PYTHON", "UV_BREAK_SYSTEM_PACKAGES", "UV_NO_DEPS",
232
+ "UV_EXACT", "UV_REINSTALL",
233
+ )
234
+ for key in (*denied_values, *denied_flags):
235
+ value = env.get(key, "")
236
+ # A path or package literally named "false" is still a redirect. Only
237
+ # boolean options may interpret such a value as disabling the option.
238
+ if value and (key in denied_values or value.lower() not in ("0", "false", "no")):
239
+ raise PackageUpdateError(
240
+ f"{key} changes the package update target or contract; clear it or update manually."
241
+ )
242
+ for key in ("UV_TOOL_DIR", "UV_TOOL_BIN_DIR", "UV_CACHE_DIR"):
243
+ if env.get(key) and not Path(env[key]).is_absolute():
244
+ raise PackageUpdateError(f"{key} must be absolute for an unambiguous update target.")
245
+ # The worker runs outside the package environment. Never reinterpret a user's
246
+ # relative constraint/index path against that unrelated working directory.
247
+ for key in ("PIP_CONSTRAINT", "PIP_FIND_LINKS", "UV_CONSTRAINT", "UV_OVERRIDE", "UV_FIND_LINKS"):
248
+ if not env.get(key):
249
+ continue
250
+ try:
251
+ paths = shlex.split(env[key])
252
+ except ValueError as exc:
253
+ raise PackageUpdateError(f"Cannot interpret {key}; update manually.") from exc
254
+ if any("://" not in p and not Path(p).is_absolute() for p in paths):
255
+ raise PackageUpdateError(
256
+ f"{key} contains a relative path. Use absolute paths or update manually."
257
+ )
258
+
259
+
260
+ def _check_environment_layout(info: dict[str, Any], env: Mapping[str, str]) -> list[Path]:
261
+ prefix = Path(info["prefix"])
262
+ _reject_uv_cache_environment(prefix)
263
+ evidence: list[Path] = []
264
+ if prefix == Path(info["base_prefix"]) and info["externally_managed"]:
265
+ raise PackageUpdateError("This Python is externally managed; use its owning package manager.")
266
+ if not os.access(Path(info["dist_info"]).parent, os.W_OK):
267
+ raise PackageUpdateError("This installation is not writable; update it manually without Netizen privilege escalation.")
268
+ cfg = prefix / "pyvenv.cfg"
269
+ if cfg.exists():
270
+ evidence.append(cfg)
271
+ try:
272
+ settings = dict(line.split("=", 1) for line in cfg.read_text().splitlines() if "=" in line)
273
+ except OSError as exc:
274
+ raise PackageUpdateError(f"Cannot inspect {cfg}: {exc}") from exc
275
+ if any(k.strip().lower() == "include-system-site-packages" and v.strip().lower() == "true"
276
+ for k, v in settings.items()):
277
+ raise PackageUpdateError("Shared system-site-packages environments require manual update.")
278
+ for marker in (prefix / "pipx_metadata.json", prefix / "conda-meta"):
279
+ if marker.exists():
280
+ raise PackageUpdateError(f"Environment is managed by another tool ({marker.name}); update it manually.")
281
+ if (prefix.parent / "uv.lock").exists() or (prefix.parent / "poetry.lock").exists():
282
+ raise PackageUpdateError("A project-managed environment must be updated through its project manager.")
283
+ if env.get("UV_PROJECT_ENVIRONMENT"):
284
+ raise PackageUpdateError("UV_PROJECT_ENVIRONMENT makes ownership ambiguous; use the project manager.")
285
+ return evidence
286
+
287
+
288
+ def _uv_executable(env: Mapping[str, str]) -> str:
289
+ selected = shutil.which("uv", path=env.get("PATH", os.defpath))
290
+ if not selected:
291
+ raise PackageUpdateError("uv is not available; install/manage uv yourself or update manually.")
292
+ # Unlike Python, resolving the uv binary does not erase venv identity.
293
+ return str(Path(selected).resolve())
294
+
295
+
296
+ def _uv_directory(uv: str, kind: str, env: Mapping[str, str], cwd: Path) -> Path:
297
+ result = _run([uv, "--no-config", kind, "dir"], env=env, cwd=cwd)
298
+ _require_success(result, f"uv {kind}-directory query")
299
+ raw = result.stdout.strip()
300
+ if not raw or "\n" in raw or not Path(raw).is_absolute():
301
+ raise PackageUpdateError(f"uv {kind} dir returned an invalid absolute directory.")
302
+ return Path(raw).resolve()
303
+
304
+
305
+ def _reject_uv_cache_environment(prefix: Path, cache_root: Path | None = None) -> None:
306
+ # The public cache directory covers current and future buckets. The one
307
+ # observed archive bucket is also refused when UV_CACHE_DIR changed, the
308
+ # original configuration is lost, or `uvx --no-cache` uses a temporary root.
309
+ # This is a refusal rule, never permission to write any cache layout.
310
+ if ((cache_root is not None and prefix.is_relative_to(cache_root))
311
+ or prefix.parent.name == "archive-v0"):
312
+ raise PackageUpdateError(
313
+ "This Python belongs to a disposable uv cache/tool-run environment; "
314
+ "install netizen-cli in a persistent environment before updating. "
315
+ "No services have been stopped."
316
+ )
317
+
318
+
319
+ def _uv_context(uv: str, env: Mapping[str, str], cwd: Path) -> tuple[str, Path, Path]:
320
+ result = _run([uv, "--no-config", "self", "version", "--output-format", "json"], env=env, cwd=cwd)
321
+ _require_success(result, "uv version query")
322
+ version = _object(result.stdout, "uv version query").get("version")
323
+ if not isinstance(version, str) or not version.strip():
324
+ raise PackageUpdateError("uv version query returned an invalid version.")
325
+ return (version, _uv_directory(uv, "tool", env, cwd),
326
+ _uv_directory(uv, "cache", env, cwd))
327
+
328
+
329
+ def _require_uv_tool_version(version: str) -> None:
330
+ # Reading private receipts is version-gated independently of ordinary pip
331
+ # maintenance, which proves its public capabilities during preflight.
332
+ if version not in SUPPORTED_UV_TOOL_VERSIONS:
333
+ raise PackageUpdateError(
334
+ f"uv {version!r} is outside the tested uv-tool layout adapter versions "
335
+ f"({', '.join(sorted(SUPPORTED_UV_TOOL_VERSIONS))}); use uv manually. Normal Netizen operation is unaffected."
336
+ )
337
+
338
+
339
+ def _tool_receipt(prefix: Path, info: dict[str, Any], env: Mapping[str, str]) -> tuple[Path, Path]:
340
+ receipt = prefix / "uv-receipt.toml"
341
+ try:
342
+ if receipt.is_symlink():
343
+ raise ValueError("receipt symlink is not a supported ownership marker")
344
+ document = tomllib.loads(receipt.read_text())
345
+ tool = document["tool"]
346
+ requirement = tool["requirements"][0]
347
+ # Only the tested modern name object is accepted, not a PEP 508 parser.
348
+ if not isinstance(requirement, dict) or _canonical_name(requirement["name"]) != DISTRIBUTION:
349
+ raise ValueError("wrong requirement identity")
350
+ entries = [e for e in tool["entrypoints"] if e.get("name") == "netizen"]
351
+ if len(entries) != 1 or _canonical_name(entries[0].get("from", DISTRIBUTION)) != DISTRIBUTION:
352
+ raise ValueError("wrong command identity")
353
+ destination = Path(entries[0]["install-path"])
354
+ installed_script = Path(info["scripts_dir"]) / "netizen"
355
+ if not installed_script.resolve().is_relative_to(prefix):
356
+ raise ValueError("environment command points outside the environment")
357
+ if not destination.is_absolute() or not destination.samefile(installed_script):
358
+ raise ValueError("command destination does not refer to this environment")
359
+ requested_bin = env.get("UV_TOOL_BIN_DIR")
360
+ if requested_bin and Path(requested_bin).resolve() != destination.parent.resolve():
361
+ raise ValueError("UV_TOOL_BIN_DIR differs from the installed command location")
362
+ except (OSError, KeyError, IndexError, TypeError, ValueError, AttributeError) as exc:
363
+ raise PackageUpdateError(
364
+ f"uv tool identity is missing, damaged or unsupported at {receipt}; "
365
+ "repair the tool installation or update it manually."
366
+ ) from exc
367
+ return receipt, destination.parent.resolve()
368
+
369
+
370
+ def _pip_changes(command: list[str], env: Mapping[str, str], cwd: Path) -> bool:
371
+ report = cwd / "pip-preflight-report.json"
372
+ if report.exists():
373
+ raise PackageUpdateError(f"Preflight report already exists at {report}; retry with a fresh work directory.")
374
+ result = _run([*command, "--dry-run", "--report", str(report)], env=env, cwd=cwd)
375
+ _require_success(result, "pip dependency preflight")
376
+ try:
377
+ data = _object(report.read_text(), "pip installation report")
378
+ except OSError as exc:
379
+ raise PackageUpdateError("pip did not produce its documented installation report.") from exc
380
+ if data.get("version") != "1" or not isinstance(data.get("install"), list):
381
+ raise PackageUpdateError("Unsupported pip installation-report version or install list; update manually.")
382
+ for item in data["install"]:
383
+ if (not isinstance(item, dict) or not isinstance(item.get("metadata"), dict)
384
+ or not isinstance(item["metadata"].get("name"), str)
385
+ or not isinstance(item["metadata"].get("version"), str)):
386
+ raise PackageUpdateError("pip returned an incomplete installation plan; update manually.")
387
+ return bool(data["install"])
388
+
389
+
390
+ def _uv_pip_changes(command: list[str], env: Mapping[str, str], cwd: Path) -> bool:
391
+ check = _run([*command, "--check"], env=env, cwd=cwd)
392
+ if check.returncode == 0:
393
+ return False
394
+ if check.returncode != 1:
395
+ _require_success(check, "uv pip no-change preflight")
396
+ # Exit 1 includes resolution failure. Require a successful *subsequent*
397
+ # resolution before stopping anything. No state is inferred from log text.
398
+ resolved = _run([*command, "--dry-run"], env=env, cwd=cwd)
399
+ _require_success(resolved, "uv pip dependency preflight")
400
+ return True
401
+
402
+
403
+ def prepare_update(
404
+ *, work_dir: Path, backend: str | None = None, python: str | None = None,
405
+ ) -> dict[str, Any]:
406
+ """Identify this installation and resolve a non-mutating update plan.
407
+
408
+ ``backend`` is explicit maintenance intent for an ordinary self-managed
409
+ environment, never permission to override conflicting ownership. INSTALLER
410
+ is only the default choice between pip and uv-pip in such an environment.
411
+ The returned command must only run after all affected services have stopped.
412
+ This function does not create ``work_dir``; it must be private, fresh and
413
+ outside the installation. Environment values are inherited, not serialized;
414
+ only nonsecret target-fixing overrides are included in the plan.
415
+ """
416
+ if backend not in (None, "pip", "uv-pip", "uv-tool"):
417
+ raise PackageUpdateError("Unsupported package backend; choose pip, uv-pip or uv-tool.")
418
+ cwd = Path(work_dir).resolve()
419
+ if not cwd.is_dir():
420
+ raise PackageUpdateError("Package preflight requires an existing private work directory.")
421
+ executable = os.path.abspath(python or sys.executable) # Never realpath a venv interpreter.
422
+ env = dict(os.environ)
423
+ probe = _run([executable, "-I", "-c", _PROBE, "{}", "import"], env=env, cwd=cwd)
424
+ if probe.returncode:
425
+ detail = _object(probe.stdout, "Installed Python/package identity check").get("error")
426
+ if isinstance(detail, str):
427
+ raise PackageUpdateError(
428
+ f"Installed Python/package identity check failed: {detail}. "
429
+ "No services have been stopped; repair this installation or update manually.",
430
+ diagnostic=probe.stderr,
431
+ )
432
+ _require_success(probe, "Installed Python/package identity check")
433
+ info = _object(probe.stdout, "Installed Python/package identity check")
434
+ required = ("prefix", "base_prefix", "package_dir", "dist_info", "version", "installer", "scripts_dir")
435
+ if any(not isinstance(info.get(k), str) for k in required):
436
+ raise PackageUpdateError("Incomplete installed package identity; update manually.")
437
+ prefix = Path(info["prefix"])
438
+ if cwd.is_relative_to(prefix):
439
+ raise PackageUpdateError("Update work directory must be outside the replaced Python environment.")
440
+ # The default entry must really be the installed module, not a checkout
441
+ # shadowing another installed distribution via cwd/PYTHONPATH.
442
+ if python is None and (Path(sys.prefix).resolve() != prefix
443
+ or Path(__file__).resolve().parent != Path(info["package_dir"])):
444
+ raise PackageUpdateError("The running CLI does not match its installed distribution; use the installed CLI, not a source checkout.")
445
+ evidence = _check_environment_layout(info, env)
446
+ info_dir = Path(info["dist_info"])
447
+ evidence.extend([info_dir / "METADATA", info_dir / "entry_points.txt"])
448
+ if (info_dir / "INSTALLER").exists():
449
+ evidence.append(info_dir / "INSTALLER")
450
+ overrides: dict[str, str] = {}
451
+ paths = {str(prefix): _identity(prefix), executable: _identity(Path(executable)),
452
+ str(Path(executable).parent): _identity(Path(executable).parent)}
453
+ uv: str | None = None
454
+ uv_version: str | None = None
455
+ tool_root: Path | None = None
456
+ receipt_exists = (prefix / "uv-receipt.toml").exists()
457
+ if info["installer"] == "uv" or receipt_exists or backend in ("uv-pip", "uv-tool"):
458
+ uv = _uv_executable(env)
459
+ uv_version, tool_root, cache_root = _uv_context(uv, env, cwd)
460
+ _reject_uv_cache_environment(prefix, cache_root)
461
+ tool_matches = tool_root is not None and (tool_root / DISTRIBUTION).resolve() == prefix
462
+ if (not tool_matches and not receipt_exists and info["installer"] == "uv"
463
+ and prefix.name == DISTRIBUTION):
464
+ raise PackageUpdateError("This tool-shaped environment has no verifiable uv receipt or tool root; repair its ownership metadata or update manually.")
465
+ if receipt_exists or tool_matches:
466
+ if not tool_matches:
467
+ raise PackageUpdateError("This looks like a uv tool environment but UV_TOOL_DIR does not match it; specify the original tool directory or update manually.")
468
+ if backend not in (None, "uv-tool"):
469
+ raise PackageUpdateError("This environment belongs to uv tool; another backend cannot take it over.")
470
+ if (tool_root / DISTRIBUTION).is_symlink():
471
+ raise PackageUpdateError("A symlinked whole uv tool environment is not a tested update layout; use uv manually.")
472
+ assert uv_version is not None
473
+ _require_uv_tool_version(uv_version)
474
+ receipt, bin_dir = _tool_receipt(prefix, info, env)
475
+ evidence.append(receipt)
476
+ evidence.append(Path(info["scripts_dir"]) / "netizen")
477
+ paths[str(tool_root)] = _identity(tool_root)
478
+ overrides.update({"UV_TOOL_DIR": str(tool_root), "UV_TOOL_BIN_DIR": str(bin_dir)})
479
+ selected = "uv-tool"
480
+ else:
481
+ if backend == "uv-tool":
482
+ raise PackageUpdateError("The selected uv tool directory does not own this Python environment.")
483
+ known = {"pip": "pip", "uv": "uv-pip"}.get(info["installer"])
484
+ if info["installer"] and known is None:
485
+ raise PackageUpdateError(f"Installer {info['installer']!r} is unsupported; use its owning package manager.")
486
+ selected = backend or known
487
+ if selected is None:
488
+ raise PackageUpdateError("Installer identity is absent. Explicitly choose --via pip or --via uv-pip for a self-managed environment, or update manually.")
489
+ _check_environment_options(selected, env)
490
+ _check_tool_config(selected, prefix, env)
491
+ if selected == "pip":
492
+ if not info.get("pip_available"):
493
+ raise PackageUpdateError("This environment has no pip. Netizen will not install one or choose an unrelated pip; update manually.")
494
+ overrides.update({"PIP_DISABLE_PIP_VERSION_CHECK": "1", "PIP_CONFIG_FILE": os.devnull})
495
+ command = [executable, "-I", "-m", "pip", "install", "--upgrade", "--no-input", DISTRIBUTION]
496
+ elif selected == "uv-pip":
497
+ assert uv is not None
498
+ command = [uv, "--no-config", "--no-python-downloads", "pip", "install", "--python", executable,
499
+ "--upgrade", DISTRIBUTION]
500
+ else:
501
+ assert uv is not None
502
+ command = [uv, "--no-config", "--no-python-downloads", "tool", "upgrade", DISTRIBUTION]
503
+ env.update(overrides)
504
+ if uv is not None:
505
+ paths[uv] = _identity(Path(uv))
506
+ fingerprints = {str(p): _digest(p) for p in evidence}
507
+ changes = (_pip_changes(command, env, cwd) if selected == "pip" else
508
+ _uv_pip_changes(command, env, cwd) if selected == "uv-pip" else True)
509
+ if any(_digest(Path(p)) != digest for p, digest in fingerprints.items()):
510
+ raise PackageUpdateError("Installation metadata changed during preflight; retry after external maintenance finishes.")
511
+ if any(_identity(Path(p)) != identity for p, identity in paths.items()):
512
+ raise PackageUpdateError("Installation target changed during preflight; retry after external maintenance finishes.")
513
+ expected = {"prefix": str(prefix), "package_dir": info["package_dir"]}
514
+ before_expected = {**expected, "version": info["version"]}
515
+ return {
516
+ "environment_python": executable, "prefix": str(prefix), "backend": selected,
517
+ "before_version": info["version"], "command": command,
518
+ "command_env": overrides, "command_cwd": str(cwd), "changes_required": changes,
519
+ "revalidation_files": fingerprints,
520
+ "revalidation_paths": paths,
521
+ "revalidation_command": [executable, "-I", "-c", _PROBE, json.dumps(before_expected), "import"],
522
+ "validation_command": [executable, "-I", "-c", _PROBE, json.dumps(expected), "import"],
523
+ "uv_version": uv_version,
524
+ "notice": ("uv tool has no equivalent dry-run; originally running instances may restart even without a package change."
525
+ if selected == "uv-tool" else "Preflight does not reserve candidates against external package/index changes."),
526
+ }