uv-refresh 0.1.0__tar.gz

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,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: uv-refresh
3
+ Version: 0.1.0
4
+ Summary: Baut die pyproject.toml eines uv-Projekts neu auf, damit alle Dependencies frisch aufgelöst und geupdatet werden
5
+ Keywords: uv,pyproject,dependencies,upgrade
6
+ Author: Zora
7
+ License-Expression: MIT
8
+ Requires-Dist: packaging>=26.3
9
+ Requires-Python: >=3.11
10
+ Project-URL: Homepage, https://github.com/Zorakidd/uv-refresh
11
+ Description-Content-Type: text/markdown
12
+
13
+ # uv-refresh
14
+
15
+ Baut die `pyproject.toml` eines uv-Projekts neu auf, damit alle Dependencies
16
+ frisch aufgelöst werden statt alte Versionsbindungen mitzuschleppen.
17
+
18
+ ## Ablauf
19
+
20
+ 1. `pyproject.toml` lesen, Dependencies ohne Versionsangabe einsammeln
21
+ (Extras und Environment-Marker bleiben erhalten; `include-group`-Einträge
22
+ aus `dependency-groups` werden dabei aufgelöst, nicht verworfen)
23
+ 2. `pyproject.toml` und `uv.lock` nach `.uv-refresh-backup/<zeitstempel>/` sichern
24
+ 3. `uv init --bare` + `uv add <pakete>` in einem Temp-Verzeichnis neben dem
25
+ Projekt ausführen -- die echte `pyproject.toml` bleibt dabei unangetastet
26
+ 4. Ergebnis atomar an die Stelle der alten `pyproject.toml`/`uv.lock` setzen
27
+
28
+ Schlägt ein Schritt fehl -- auch per Ctrl+C --, wurde die echte
29
+ `pyproject.toml` nie verändert, weil der komplette Aufbau im
30
+ Temp-Verzeichnis geschah. Das Backup liegt zusätzlich als Referenz bereit.
31
+
32
+ ## Installation
33
+
34
+ uv tool install git+https://github.com/Zorakidd/uv-refresh
35
+
36
+ Oder ohne Installation, direkt aus dem Repo:
37
+
38
+ uvx --from git+https://github.com/Zorakidd/uv-refresh uv-refresh --dry-run
39
+
40
+ ## Benutzung
41
+
42
+ uv-refresh --dry-run # nur anzeigen, nichts verändern
43
+ uv-refresh # mit Rückfrage durchziehen
44
+ uv-refresh -y # ohne Rückfrage
45
+
46
+ ## Optionen
47
+
48
+ | Flag | Wirkung |
49
+ | --- | --- |
50
+ | `--path PFAD` | anderes Projektverzeichnis (Default: aktuelles) |
51
+ | `--dry-run` | zeigt nur, was passieren würde |
52
+ | `-y`, `--yes` | keine Rückfrage |
53
+ | `-v`, `--verbose` | die komplette neue `pyproject.toml` am Ende ausgeben |
54
+ | `--timeout SEK` | Timeout je `uv`-Aufruf, Default 300s |
55
+ | `--raw` | Pakete ganz ohne Versionsgrenze eintragen |
56
+ | `--bounds {lower,major,minor,exact}` | Art der Versionsgrenze, die `uv add` setzt |
57
+ | `--keep-lock` | `uv.lock` behalten (dann bevorzugt uv die alten Versionen) |
58
+ | `--no-groups` | optional-dependencies und dependency-groups ignorieren |
59
+ | `--drop-extras` | `fastapi[standard]` zu `fastapi` eindampfen |
60
+ | `--drop-markers` | Environment-Marker verwerfen |
61
+ | `--version` | Version von uv-refresh anzeigen |
62
+
63
+ ## Achtung
64
+
65
+ Alles außer `[project]` und `[dependency-groups]` geht verloren, also
66
+ `[build-system]`, `[tool.ruff]`, `[project.scripts]` und so weiter. Das Tool
67
+ warnt vorher und listet die betroffenen Blöcke auf. Zurückkopieren musst du
68
+ sie von Hand aus dem Backup.
@@ -0,0 +1,56 @@
1
+ # uv-refresh
2
+
3
+ Baut die `pyproject.toml` eines uv-Projekts neu auf, damit alle Dependencies
4
+ frisch aufgelöst werden statt alte Versionsbindungen mitzuschleppen.
5
+
6
+ ## Ablauf
7
+
8
+ 1. `pyproject.toml` lesen, Dependencies ohne Versionsangabe einsammeln
9
+ (Extras und Environment-Marker bleiben erhalten; `include-group`-Einträge
10
+ aus `dependency-groups` werden dabei aufgelöst, nicht verworfen)
11
+ 2. `pyproject.toml` und `uv.lock` nach `.uv-refresh-backup/<zeitstempel>/` sichern
12
+ 3. `uv init --bare` + `uv add <pakete>` in einem Temp-Verzeichnis neben dem
13
+ Projekt ausführen -- die echte `pyproject.toml` bleibt dabei unangetastet
14
+ 4. Ergebnis atomar an die Stelle der alten `pyproject.toml`/`uv.lock` setzen
15
+
16
+ Schlägt ein Schritt fehl -- auch per Ctrl+C --, wurde die echte
17
+ `pyproject.toml` nie verändert, weil der komplette Aufbau im
18
+ Temp-Verzeichnis geschah. Das Backup liegt zusätzlich als Referenz bereit.
19
+
20
+ ## Installation
21
+
22
+ uv tool install git+https://github.com/Zorakidd/uv-refresh
23
+
24
+ Oder ohne Installation, direkt aus dem Repo:
25
+
26
+ uvx --from git+https://github.com/Zorakidd/uv-refresh uv-refresh --dry-run
27
+
28
+ ## Benutzung
29
+
30
+ uv-refresh --dry-run # nur anzeigen, nichts verändern
31
+ uv-refresh # mit Rückfrage durchziehen
32
+ uv-refresh -y # ohne Rückfrage
33
+
34
+ ## Optionen
35
+
36
+ | Flag | Wirkung |
37
+ | --- | --- |
38
+ | `--path PFAD` | anderes Projektverzeichnis (Default: aktuelles) |
39
+ | `--dry-run` | zeigt nur, was passieren würde |
40
+ | `-y`, `--yes` | keine Rückfrage |
41
+ | `-v`, `--verbose` | die komplette neue `pyproject.toml` am Ende ausgeben |
42
+ | `--timeout SEK` | Timeout je `uv`-Aufruf, Default 300s |
43
+ | `--raw` | Pakete ganz ohne Versionsgrenze eintragen |
44
+ | `--bounds {lower,major,minor,exact}` | Art der Versionsgrenze, die `uv add` setzt |
45
+ | `--keep-lock` | `uv.lock` behalten (dann bevorzugt uv die alten Versionen) |
46
+ | `--no-groups` | optional-dependencies und dependency-groups ignorieren |
47
+ | `--drop-extras` | `fastapi[standard]` zu `fastapi` eindampfen |
48
+ | `--drop-markers` | Environment-Marker verwerfen |
49
+ | `--version` | Version von uv-refresh anzeigen |
50
+
51
+ ## Achtung
52
+
53
+ Alles außer `[project]` und `[dependency-groups]` geht verloren, also
54
+ `[build-system]`, `[tool.ruff]`, `[project.scripts]` und so weiter. Das Tool
55
+ warnt vorher und listet die betroffenen Blöcke auf. Zurückkopieren musst du
56
+ sie von Hand aus dem Backup.
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "uv-refresh"
3
+ version = "0.1.0"
4
+ description = "Baut die pyproject.toml eines uv-Projekts neu auf, damit alle Dependencies frisch aufgelöst und geupdatet werden"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = [
8
+ "uv",
9
+ "pyproject",
10
+ "dependencies",
11
+ "upgrade",
12
+ ]
13
+ requires-python = ">=3.11"
14
+ dependencies = ["packaging>=26.3"]
15
+
16
+ [[project.authors]]
17
+ name = "Zora"
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/Zorakidd/uv-refresh"
21
+
22
+ [project.scripts]
23
+ uv-refresh = "uv_refresh.cli:main"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.12.5,<0.13.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.1.1",
32
+ "ruff>=0.16.3",
33
+ ]
34
+
35
+ [tool.ruff]
36
+ target-version = "py311"
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "uv-refresh"
3
+ version = "0.1.0"
4
+ description = "Baut die pyproject.toml eines uv-Projekts neu auf, damit alle Dependencies frisch aufgelöst und geupdatet werden"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Zora" }]
8
+ keywords = ["uv", "pyproject", "dependencies", "upgrade"]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "packaging>=26.3",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/Zorakidd/uv-refresh"
16
+
17
+ [project.scripts]
18
+ uv-refresh = "uv_refresh.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.5,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=9.1.1",
27
+ "ruff>=0.16.3",
28
+ ]
29
+
30
+ [tool.ruff]
31
+ target-version = "py311"
@@ -0,0 +1,3 @@
1
+ """uv-refresh: pyproject.toml neu aufbauen und Dependencies frisch aufloesen."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,447 @@
1
+ """uv-refresh
2
+ ==========
3
+
4
+ Baut die pyproject.toml eines uv-Projekts neu auf, damit alle Dependencies
5
+ frisch aufgeloest werden statt alte Versionsbindungen mitzuschleppen.
6
+
7
+ Ablauf:
8
+ 1. pyproject.toml lesen und die Dependencies ohne Versionsangabe einsammeln
9
+ (Extras und Environment-Marker bleiben erhalten, siehe --drop-extras)
10
+ 2. pyproject.toml + uv.lock in einen Backup-Ordner sichern
11
+ 3. uv init --bare + uv add <namen> in einem Temp-Verzeichnis neben dem
12
+ Projekt ausfuehren -- die echte pyproject.toml bleibt dabei unangetastet
13
+ 4. Ergebnis atomar an die Stelle der alten pyproject.toml/uv.lock setzen
14
+
15
+ Schlaegt irgendein Schritt fehl (auch per Ctrl+C), wurde die echte
16
+ pyproject.toml nie veraendert -- der komplette Aufbau geschah im
17
+ Temp-Verzeichnis. Das Backup bleibt zusaetzlich als Referenz liegen.
18
+
19
+ Benutzung:
20
+ uv-refresh # im Projektverzeichnis, mit Rueckfrage
21
+ uv-refresh --dry-run # nur zeigen, nichts anfassen
22
+ uv-refresh --raw # ganz ohne Versionsangabe eintragen
23
+ uv-refresh --path ../other # anderes Projektverzeichnis
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import contextlib
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import tomllib
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import NoReturn
40
+
41
+ from . import __version__
42
+
43
+ try: # bevorzugt der offizielle PEP-508-Parser
44
+ from packaging.requirements import InvalidRequirement, Requirement
45
+ except ModuleNotFoundError: # Fallback, damit das Skript auch nackt laeuft
46
+ Requirement = None # type: ignore[assignment]
47
+ InvalidRequirement = ValueError # type: ignore[assignment,misc]
48
+
49
+ _SPEC_RE = re.compile(r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?P<extras>\[[^\]]*\])?")
50
+ _VERSION_LINE_RE = re.compile(r'(?m)^version\s*=\s*"[^"]*"')
51
+
52
+ C_OK, C_WARN, C_ERR, C_DIM, C_OFF = "\033[32m", "\033[33m", "\033[31m", "\033[2m", "\033[0m"
53
+
54
+
55
+ def _color_enabled(stream) -> bool:
56
+ return stream.isatty() and os.environ.get("NO_COLOR") is None
57
+
58
+
59
+ def say(msg: str, color: str = "") -> None:
60
+ # Fehler/Warnungen auf stderr: sonst verschwinden sie beim Umleiten von
61
+ # stdout (z. B. 'uv-refresh --dry-run > log.txt') spurlos.
62
+ stream = sys.stderr if color in (C_WARN, C_ERR) else sys.stdout
63
+ text = f"{color}{msg}{C_OFF}" if color and _color_enabled(stream) else msg
64
+ print(text, file=stream)
65
+
66
+
67
+ def die(msg: str) -> NoReturn:
68
+ say(f"FEHLER: {msg}", C_ERR)
69
+ sys.exit(1)
70
+
71
+
72
+ def strip_version(spec: str, keep_extras: bool = True, keep_markers: bool = True) -> str | None:
73
+ """Schneidet NUR die Versionsangabe ab.
74
+
75
+ 'fastapi[standard]>=0.110; python_version<"3.13"'
76
+ -> 'fastapi[standard]; python_version < "3.13"'
77
+
78
+ Extras und Marker sind keine Versionsinformation: Extras bestimmen, WAS
79
+ installiert wird, Marker WO. Beides bleibt darum per Default erhalten.
80
+ Direkte Quellen ('paket @ git+https://...') bleiben komplett unveraendert,
81
+ sonst waere das Package hinterher nicht mehr auffindbar.
82
+ """
83
+ spec = spec.strip()
84
+ if not spec:
85
+ return None
86
+
87
+ if Requirement is not None:
88
+ try:
89
+ req = Requirement(spec)
90
+ except InvalidRequirement:
91
+ req = None
92
+ if req is not None:
93
+ if req.url:
94
+ return spec
95
+ out = req.name
96
+ if keep_extras and req.extras:
97
+ out += "[" + ",".join(sorted(req.extras)) + "]"
98
+ if keep_markers and req.marker:
99
+ out += f"; {req.marker}"
100
+ return out
101
+
102
+ # Fallback, falls packaging fehlt
103
+ head, sep, marker = spec.partition(";")
104
+ if "@" in head:
105
+ return spec
106
+ m = _SPEC_RE.match(head)
107
+ if not m:
108
+ return None
109
+ out = m.group("name")
110
+ if keep_extras and m.group("extras"):
111
+ out += m.group("extras").replace(" ", "")
112
+ if keep_markers and sep and marker.strip():
113
+ out += f"; {marker.strip()}"
114
+ return out
115
+
116
+
117
+ def specs_from(entries: list, keep_extras: bool, keep_markers: bool) -> list[str]:
118
+ out: list[str] = []
119
+ seen: set[str] = set()
120
+ for entry in entries:
121
+ if not isinstance(entry, str): # z. B. {include-group = "..."}
122
+ say(f" uebersprungen (kein PEP-508-String): {entry!r}", C_WARN)
123
+ continue
124
+ spec = strip_version(entry, keep_extras, keep_markers)
125
+ if not spec or spec.lower() in seen:
126
+ continue
127
+ seen.add(spec.lower())
128
+ if "@" in spec:
129
+ say(f" {spec}: direkte Quelle, bleibt unveraendert", C_WARN)
130
+ out.append(spec)
131
+ return out
132
+
133
+
134
+ def resolve_groups(raw_groups: dict, keep_extras: bool, keep_markers: bool) -> dict[str, list[str]]:
135
+ """Loest 'include-group'-Eintraege (PEP 735) auf, bevor Specs eingesammelt werden.
136
+
137
+ Ohne das hier wuerde specs_from() jeden {include-group = "..."}-Eintrag nur
138
+ ueberspringen (mit Warnung) und die darueber eingebundenen Pakete
139
+ stillschweigend aus der neuen Gruppe verlieren.
140
+ """
141
+ flat: dict[str, list] = {}
142
+
143
+ def expand(grp: str, chain: tuple[str, ...] = ()) -> list:
144
+ if grp in flat:
145
+ return flat[grp]
146
+ if grp in chain:
147
+ raise RuntimeError(f"dependency-groups: Zirkel bei include-group '{grp}'")
148
+ if grp not in raw_groups:
149
+ raise RuntimeError(f"dependency-groups: include-group '{grp}' existiert nicht")
150
+ out: list = []
151
+ for entry in raw_groups[grp]:
152
+ if isinstance(entry, dict) and set(entry) == {"include-group"}:
153
+ out += expand(entry["include-group"], (*chain, grp))
154
+ else:
155
+ out.append(entry)
156
+ flat[grp] = out
157
+ return out
158
+
159
+ return {grp: specs_from(expand(grp), keep_extras, keep_markers) for grp in raw_groups}
160
+
161
+
162
+ def ensure_backup_ignored(root: Path) -> None:
163
+ """Traegt '.uv-refresh-backup/' in die .gitignore ein, falls root ein Git-Repo ist.
164
+
165
+ Das Backup kann unveraendert uebernommene Direktquellen enthalten, z. B.
166
+ 'pkg @ git+https://user:token@...' (siehe strip_version) -- ohne Eintrag
167
+ landet das leicht im naechsten 'git add .'.
168
+ """
169
+ if not (root / ".git").is_dir():
170
+ return
171
+ entry = ".uv-refresh-backup/"
172
+ gitignore = root / ".gitignore"
173
+ text = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
174
+ if entry in text.splitlines():
175
+ return
176
+ with gitignore.open("a", encoding="utf-8") as f:
177
+ if text and not text.endswith("\n"):
178
+ f.write("\n")
179
+ f.write(f"{entry}\n")
180
+ say(f" {entry} zur .gitignore hinzugefuegt (Backup kann Zugangsdaten enthalten)", C_WARN)
181
+
182
+
183
+ def build_init_cmd(name: str | None, requires_python: str | None, description: str | None) -> list[str]:
184
+ """Baut den 'uv init'-Aufruf.
185
+
186
+ Bindet Werte mit '=' statt als eigenes Argv-Token: eine description, die
187
+ mit '-' beginnt (z. B. '--experimental'), ist sonst nicht von einem Flag
188
+ zu unterscheiden und uv/clap lehnt den Aufruf mit 'unexpected argument' ab.
189
+ """
190
+ cmd = ["uv", "init", "--bare", "--no-workspace"]
191
+ if name:
192
+ cmd.append(f"--name={name}")
193
+ if requires_python:
194
+ cmd.append(f"--python={requires_python}")
195
+ if description:
196
+ cmd.append(f"--description={description}")
197
+ return cmd
198
+
199
+
200
+ def run(cmd: list[str], cwd: Path, dry: bool, timeout: float | None = None) -> None:
201
+ say(f" $ {' '.join(cmd)}", C_DIM)
202
+ if dry:
203
+ return
204
+ try:
205
+ result = subprocess.run(cmd, cwd=cwd, check=False, timeout=timeout)
206
+ except subprocess.TimeoutExpired as e:
207
+ raise RuntimeError(
208
+ f"Befehl lief laenger als {timeout:.0f}s und wurde abgebrochen: {' '.join(cmd)}"
209
+ ) from e
210
+ if result.returncode != 0:
211
+ raise RuntimeError(f"Befehl fehlgeschlagen: {' '.join(cmd)}")
212
+
213
+
214
+ def _toml_string(value: str) -> str:
215
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
216
+
217
+
218
+ def restore_fields(text: str, version: str | None, readme: str | None) -> str:
219
+ """Setzt version/readme zurueck, die 'uv init --bare' nicht uebernimmt.
220
+
221
+ uv init setzt version IMMER auf "0.1.0" (kein --version-Flag vorhanden)
222
+ und schreibt readme nie (nur als Datei, nicht als Feld). Ohne das hier
223
+ wuerde eine bestehende Versionsnummer sonst still auf 0.1.0 zurueckfallen.
224
+
225
+ Ersetzt ueber eine Lambda-Funktion statt einen Text-String: re.sub()
226
+ interpretiert String-Replacements selbst wieder als Muster (\\1, \\g<...>);
227
+ eine Lambda gibt ihren Rueckgabewert dagegen immer woertlich ein. Und
228
+ .subn() statt .sub(): passt das Muster in einer kuenftigen uv-Version
229
+ nicht mehr (anderes Anfuehrungszeichen, andere Formatierung), gibt
230
+ re.sub() den Text sonst kommentarlos unveraendert zurueck -- die
231
+ Versionsnummer waere still auf 0.1.0 zurueckgefallen, ohne jede Meldung.
232
+ """
233
+ if version:
234
+ text, n = _VERSION_LINE_RE.subn(lambda _m: f"version = {_toml_string(version)}", text, count=1)
235
+ if n == 0:
236
+ say(" WARNUNG: version-Feld konnte nicht wiederhergestellt werden "
237
+ "(unerwartetes Format in der neuen pyproject.toml)", C_WARN)
238
+ if readme:
239
+ text, n = re.subn(r"(?m)^(version\s*=.*)$",
240
+ lambda m: f"{m.group(1)}\nreadme = {_toml_string(readme)}",
241
+ text, count=1)
242
+ if n == 0:
243
+ say(" WARNUNG: readme-Feld konnte nicht wiederhergestellt werden "
244
+ "(unerwartetes Format in der neuen pyproject.toml)", C_WARN)
245
+ return text
246
+
247
+
248
+ def main() -> int:
249
+ p = argparse.ArgumentParser(prog="uv-refresh", description=__doc__,
250
+ formatter_class=argparse.RawDescriptionHelpFormatter)
251
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
252
+ p.add_argument("--path", default=".", help="Projektverzeichnis (Default: aktuelles)")
253
+ p.add_argument("--dry-run", action="store_true", help="nur anzeigen, nichts veraendern")
254
+ p.add_argument("--yes", "-y", action="store_true", help="ohne Rueckfrage durchziehen")
255
+ p.add_argument("--verbose", "-v", action="store_true",
256
+ help="komplette neue pyproject.toml am Ende ausgeben")
257
+ p.add_argument("--timeout", type=float, default=300.0,
258
+ help="Timeout in Sekunden je uv-Aufruf (Default: 300)")
259
+ p.add_argument("--keep-lock", action="store_true",
260
+ help="uv.lock behalten (dann bevorzugt uv die alten Versionen!)")
261
+ p.add_argument("--no-groups", action="store_true",
262
+ help="optional-dependencies und dependency-groups nicht uebernehmen")
263
+ p.add_argument("--drop-extras", action="store_true",
264
+ help="Extras verwerfen: fastapi[standard] wird zu fastapi (selten sinnvoll!)")
265
+ p.add_argument("--drop-markers", action="store_true",
266
+ help="Environment-Marker verwerfen, z. B. ; sys_platform == 'win32'")
267
+ bounds_group = p.add_mutually_exclusive_group()
268
+ bounds_group.add_argument("--raw", action="store_true",
269
+ help="Namen komplett ohne Versionsgrenze eintragen (uv add --raw)")
270
+ bounds_group.add_argument("--bounds", choices=["lower", "major", "minor", "exact"],
271
+ help="Art der Versionsgrenze, die uv add setzt (Preview-Feature von uv)")
272
+ args = p.parse_args()
273
+
274
+ root = Path(args.path).resolve()
275
+ pyproject = root / "pyproject.toml"
276
+ lock = root / "uv.lock"
277
+
278
+ if not shutil.which("uv"):
279
+ die("uv ist nicht im PATH.")
280
+ if not pyproject.is_file():
281
+ die(f"Keine pyproject.toml in {root}")
282
+
283
+ # ---- 1. lesen ---------------------------------------------------------
284
+ try:
285
+ with pyproject.open("rb") as f:
286
+ data = tomllib.load(f)
287
+ except tomllib.TOMLDecodeError as e:
288
+ die(f"pyproject.toml ist kein gueltiges TOML: {e}")
289
+ except UnicodeDecodeError as e:
290
+ die(f"pyproject.toml ist nicht UTF-8-kodiert: {e}")
291
+
292
+ project = data.get("project", {})
293
+ if not project:
294
+ die("Kein [project]-Abschnitt gefunden. Ist das ein uv-/PEP-621-Projekt?")
295
+
296
+ name = project.get("name")
297
+ requires_python = project.get("requires-python")
298
+ description = project.get("description")
299
+ version = project.get("version")
300
+ readme = project.get("readme")
301
+ keep_extras, keep_markers = not args.drop_extras, not args.drop_markers
302
+ main_deps = specs_from(project.get("dependencies", []), keep_extras, keep_markers)
303
+
304
+ extras: dict[str, list[str]] = {}
305
+ groups: dict[str, list[str]] = {}
306
+ if not args.no_groups:
307
+ for grp, specs in (project.get("optional-dependencies") or {}).items():
308
+ extras[grp] = specs_from(specs, keep_extras, keep_markers)
309
+ raw_groups = data.get("dependency-groups") or {}
310
+ if raw_groups:
311
+ try:
312
+ groups = resolve_groups(raw_groups, keep_extras, keep_markers)
313
+ except RuntimeError as e:
314
+ die(str(e))
315
+
316
+ if not main_deps and not extras and not groups:
317
+ die("Keine Dependencies gefunden. Nichts zu tun.")
318
+
319
+ say(f"\nProjekt: {name or '(kein Name)'} [{root}]", C_OK)
320
+ say(f" dependencies : {', '.join(main_deps) or '-'}")
321
+ for g, n in extras.items():
322
+ say(f" optional [{g}] : {', '.join(n)}")
323
+ for g, n in groups.items():
324
+ say(f" group [{g}] : {', '.join(n)}")
325
+
326
+ # ---- Was verloren geht ------------------------------------------------
327
+ # version/description werden aktiv wiederhergestellt (s. u.), readme nur
328
+ # als einfacher String -- als Tabelle ({file=..., text=...}) nicht.
329
+ safe_keys = {"name", "version", "description", "requires-python",
330
+ "dependencies", "optional-dependencies"}
331
+ if isinstance(readme, str):
332
+ safe_keys.add("readme")
333
+ lost = [k for k in project if k not in safe_keys]
334
+ other_tables = [k for k in data if k not in {"project", "dependency-groups"}]
335
+ if lost or other_tables:
336
+ say("\nACHTUNG, diese Konfiguration wird NICHT wiederhergestellt:", C_WARN)
337
+ for k in lost:
338
+ say(f" [project].{k}", C_WARN)
339
+ for k in other_tables:
340
+ say(f" [{k}]", C_WARN)
341
+ say(" Das Backup liegt daneben, du musst diese Bloecke von Hand zurueckkopieren.", C_WARN)
342
+
343
+ if args.dry_run:
344
+ say("\n--dry-run: ab hier wuerde passieren:", C_DIM)
345
+ elif not args.yes:
346
+ try:
347
+ answer = input("\nWirklich neu aufbauen? [j/y/N] ").strip().lower()
348
+ except EOFError:
349
+ die("Keine Eingabe moeglich (kein Terminal). Mit --yes ohne Rueckfrage ausfuehren.")
350
+ if answer not in {"j", "y"}:
351
+ say("Abgebrochen.", C_DIM)
352
+ return 1
353
+
354
+ # ---- 2. Backup --------------------------------------------------------
355
+ stamp = datetime.now(UTC).astimezone().strftime("%Y%m%d-%H%M%S")
356
+ backup = root / ".uv-refresh-backup" / stamp
357
+ say(f"\nBackup -> {backup}", C_DIM)
358
+ say("Aufbau erfolgt in einem Temp-Verzeichnis; deine echte pyproject.toml/"
359
+ "uv.lock bleiben bis zum letzten Schritt unangetastet.", C_DIM)
360
+
361
+ build_ctx = (tempfile.TemporaryDirectory(dir=root, prefix=".uv-refresh-tmp-")
362
+ if not args.dry_run else contextlib.nullcontext(root))
363
+
364
+ try:
365
+ if not args.dry_run:
366
+ ensure_backup_ignored(root)
367
+ backup.mkdir(parents=True, exist_ok=True)
368
+ try:
369
+ os.chmod(backup, 0o700)
370
+ except OSError:
371
+ pass
372
+ shutil.copy2(pyproject, backup / "pyproject.toml")
373
+ if lock.is_file():
374
+ shutil.copy2(lock, backup / "uv.lock")
375
+
376
+ with build_ctx as build_dir_raw:
377
+ build_dir = Path(build_dir_raw)
378
+ if not args.dry_run and args.keep_lock and lock.is_file():
379
+ shutil.copy2(lock, build_dir / "uv.lock")
380
+
381
+ # ---- 3. uv init -----------------------------------------------
382
+ init = build_init_cmd(name, requires_python, description)
383
+ try:
384
+ run(init, build_dir, args.dry_run, args.timeout)
385
+ except RuntimeError:
386
+ if not any(f.startswith("--python=") for f in init):
387
+ raise
388
+ say(" uv init mit --python fehlgeschlagen, versuche es ohne", C_WARN)
389
+ run([f for f in init if not f.startswith("--python=")],
390
+ build_dir, args.dry_run, args.timeout)
391
+
392
+ # ---- 4. uv add --------------------------------------------------
393
+ flags: list[str] = []
394
+ if args.raw:
395
+ flags.append("--raw")
396
+ if args.bounds:
397
+ flags += ["--bounds", args.bounds]
398
+
399
+ if main_deps:
400
+ run(["uv", "add", *flags, *main_deps], build_dir, args.dry_run, args.timeout)
401
+ for grp, deps in extras.items():
402
+ if deps:
403
+ run(["uv", "add", "--optional", grp, *flags, *deps],
404
+ build_dir, args.dry_run, args.timeout)
405
+ for grp, deps in groups.items():
406
+ if deps:
407
+ run(["uv", "add", "--group", grp, *flags, *deps],
408
+ build_dir, args.dry_run, args.timeout)
409
+
410
+ # ---- 5. version/readme zuruecksetzen -----------------------------
411
+ if not args.dry_run and (version or isinstance(readme, str)):
412
+ text = restore_fields((build_dir / "pyproject.toml").read_text(encoding="utf-8"),
413
+ version, readme if isinstance(readme, str) else None)
414
+ (build_dir / "pyproject.toml").write_text(text, encoding="utf-8")
415
+
416
+ # ---- 6. atomarer Tausch -------------------------------------------
417
+ # root blieb bis hierher unveraendert: schlaegt oben etwas fehl
418
+ # (auch per Ctrl+C), gibt es nichts zurueckzuholen.
419
+ if not args.dry_run:
420
+ os.replace(build_dir / "pyproject.toml", pyproject)
421
+ new_lock = build_dir / "uv.lock"
422
+ if new_lock.is_file():
423
+ os.replace(new_lock, lock)
424
+ else:
425
+ say(" $ (pyproject.toml/uv.lock wuerden jetzt atomar ersetzt)", C_DIM)
426
+
427
+ except (Exception, KeyboardInterrupt) as e: # noqa: BLE001 -- Notbremse: bei
428
+ # JEDEM Fehler (uv, Dateisystem, Interrupt, ...) klar melden. Der Aufbau
429
+ # geschah in einem Temp-Verzeichnis, root ist daher normalerweise
430
+ # unveraendert; das Backup bleibt als zusaetzliches Netz trotzdem liegen.
431
+ say(f"\n{e}", C_ERR)
432
+ if not args.dry_run:
433
+ say(f"pyproject.toml unveraendert. Backup liegt in {backup}.", C_WARN)
434
+ return 1
435
+
436
+ if args.dry_run:
437
+ say("\n--dry-run: nichts wurde veraendert.", C_OK)
438
+ return 0
439
+
440
+ say("\nFertig.", C_OK)
441
+ if args.verbose and pyproject.is_file():
442
+ say(pyproject.read_text(encoding="utf-8"), C_DIM)
443
+ return 0
444
+
445
+
446
+ if __name__ == "__main__":
447
+ sys.exit(main())