nbdevAuto 0.3.64__tar.gz → 0.3.66__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdevAuto
3
- Version: 0.3.64
3
+ Version: 0.3.66
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1 @@
1
+ __version__ = "0.3.66"
@@ -5,11 +5,23 @@ d = { 'settings': { 'branch': 'main',
5
5
  'doc_host': 'https://bthek1.github.io',
6
6
  'git_url': 'https://github.com/bthek1/nbdevAuto',
7
7
  'lib_path': 'nbdevAuto'},
8
- 'syms': { 'nbdevAuto.automate': { 'nbdevAuto.automate._banner': ('automate.html#_banner', 'nbdevAuto/automate.py'),
8
+ 'syms': { 'nbdevAuto.automate': { 'nbdevAuto.automate.StepFailed': ('automate.html#stepfailed', 'nbdevAuto/automate.py'),
9
+ 'nbdevAuto.automate.StepFailed.__init__': ( 'automate.html#stepfailed.__init__',
10
+ 'nbdevAuto/automate.py'),
11
+ 'nbdevAuto.automate._auto_msg': ('automate.html#_auto_msg', 'nbdevAuto/automate.py'),
12
+ 'nbdevAuto.automate._banner': ('automate.html#_banner', 'nbdevAuto/automate.py'),
13
+ 'nbdevAuto.automate._commit_summary': ('automate.html#_commit_summary', 'nbdevAuto/automate.py'),
9
14
  'nbdevAuto.automate._done': ('automate.html#_done', 'nbdevAuto/automate.py'),
10
15
  'nbdevAuto.automate._dur': ('automate.html#_dur', 'nbdevAuto/automate.py'),
16
+ 'nbdevAuto.automate._push_summary': ('automate.html#_push_summary', 'nbdevAuto/automate.py'),
17
+ 'nbdevAuto.automate._quiet': ('automate.html#_quiet', 'nbdevAuto/automate.py'),
18
+ 'nbdevAuto.automate._rule': ('automate.html#_rule', 'nbdevAuto/automate.py'),
19
+ 'nbdevAuto.automate._run': ('automate.html#_run', 'nbdevAuto/automate.py'),
20
+ 'nbdevAuto.automate._skip': ('automate.html#_skip', 'nbdevAuto/automate.py'),
11
21
  'nbdevAuto.automate._status_table': ('automate.html#_status_table', 'nbdevAuto/automate.py'),
12
22
  'nbdevAuto.automate._step': ('automate.html#_step', 'nbdevAuto/automate.py'),
23
+ 'nbdevAuto.automate._test_summary': ('automate.html#_test_summary', 'nbdevAuto/automate.py'),
24
+ 'nbdevAuto.automate._version_summary': ('automate.html#_version_summary', 'nbdevAuto/automate.py'),
13
25
  'nbdevAuto.automate.gacp': ('automate.html#gacp', 'nbdevAuto/automate.py'),
14
26
  'nbdevAuto.automate.help_output': ('automate.html#help_output', 'nbdevAuto/automate.py'),
15
27
  'nbdevAuto.automate.prep': ('automate.html#prep', 'nbdevAuto/automate.py'),
@@ -18,6 +30,25 @@ d = { 'settings': { 'branch': 'main',
18
30
  'nbdevAuto.automate.release_pypi': ('automate.html#release_pypi', 'nbdevAuto/automate.py'),
19
31
  'nbdevAuto.automate.status': ('automate.html#status', 'nbdevAuto/automate.py'),
20
32
  'nbdevAuto.automate.upload': ('automate.html#upload', 'nbdevAuto/automate.py')},
33
+ 'nbdevAuto.fleet': { 'nbdevAuto.fleet.Outcome': ('fleet.html#outcome', 'nbdevAuto/fleet.py'),
34
+ 'nbdevAuto.fleet.Report': ('fleet.html#report', 'nbdevAuto/fleet.py'),
35
+ 'nbdevAuto.fleet.Report.code': ('fleet.html#report.code', 'nbdevAuto/fleet.py'),
36
+ 'nbdevAuto.fleet.Report.failed': ('fleet.html#report.failed', 'nbdevAuto/fleet.py'),
37
+ 'nbdevAuto.fleet.Report.ran': ('fleet.html#report.ran', 'nbdevAuto/fleet.py'),
38
+ 'nbdevAuto.fleet.Report.skip_counts': ('fleet.html#report.skip_counts', 'nbdevAuto/fleet.py'),
39
+ 'nbdevAuto.fleet._classify': ('fleet.html#_classify', 'nbdevAuto/fleet.py'),
40
+ 'nbdevAuto.fleet._fail_reason': ('fleet.html#_fail_reason', 'nbdevAuto/fleet.py'),
41
+ 'nbdevAuto.fleet._render': ('fleet.html#_render', 'nbdevAuto/fleet.py'),
42
+ 'nbdevAuto.fleet.finish': ('fleet.html#finish', 'nbdevAuto/fleet.py'),
43
+ 'nbdevAuto.fleet.fleet': ('fleet.html#fleet', 'nbdevAuto/fleet.py'),
44
+ 'nbdevAuto.fleet.fleet_upload': ('fleet.html#fleet_upload', 'nbdevAuto/fleet.py'),
45
+ 'nbdevAuto.fleet.format_summary': ('fleet.html#format_summary', 'nbdevAuto/fleet.py'),
46
+ 'nbdevAuto.fleet.parse_verb': ('fleet.html#parse_verb', 'nbdevAuto/fleet.py'),
47
+ 'nbdevAuto.fleet.run_verb': ('fleet.html#run_verb', 'nbdevAuto/fleet.py'),
48
+ 'nbdevAuto.fleet.script_path': ('fleet.html#script_path', 'nbdevAuto/fleet.py'),
49
+ 'nbdevAuto.fleet.upload_one': ('fleet.html#upload_one', 'nbdevAuto/fleet.py'),
50
+ 'nbdevAuto.fleet.usage': ('fleet.html#usage', 'nbdevAuto/fleet.py'),
51
+ 'nbdevAuto.fleet.walk': ('fleet.html#walk', 'nbdevAuto/fleet.py')},
21
52
  'nbdevAuto.functions': { 'nbdevAuto.functions.classify_images': ('functions.html#classify_images', 'nbdevAuto/functions.py'),
22
53
  'nbdevAuto.functions.create_data_folder': ( 'functions.html#create_data_folder',
23
54
  'nbdevAuto/functions.py'),
@@ -0,0 +1,341 @@
1
+ """Automate
2
+
3
+ Docs: https://bthek1.github.io/nbdevAuto/automate.html.md"""
4
+
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_Automate.ipynb.
6
+
7
+ # %% auto #0
8
+ __all__ = ['prep', 'gacp', 'status', 'upload', 'release_git', 'release_pypi', 'release', 'help_output']
9
+
10
+ # %% ../nbs/01_Automate.ipynb #6d1499d1-aa55-47d8-92fd-fa056b1fb135
11
+ from fastcore.script import *
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.rule import Rule
16
+ from rich.table import Table
17
+
18
+ # %% ../nbs/01_Automate.ipynb #7c1e5a90-3f52-4d21-9b6a-2c0f4e8d1a37
19
+ import re
20
+ import subprocess
21
+ from contextlib import contextmanager, redirect_stderr, redirect_stdout
22
+ from io import StringIO
23
+ from time import perf_counter
24
+
25
+ from nbdevAuto import __version__
26
+
27
+ console = Console()
28
+
29
+ # A rule drawn across a 200-column terminal is a wall of dashes. 88 reads as a header.
30
+ _RULE_W = 88
31
+
32
+ # `git status -s` codes: colour, the word for the third column, and the verb a commit
33
+ # subject gets when every staged path carries that same code.
34
+ _CODE_STYLE = {"M":"yellow", "A":"green", "D":"red", "R":"cyan", "C":"cyan", "U":"magenta", "?":"dim"}
35
+ _CODE_LABEL = {"M":"modified", "A":"added", "D":"deleted", "R":"renamed", "C":"copied",
36
+ "U":"conflict", "?":"untracked"}
37
+ _CODE_VERB = {"M":"update", "A":"add", "D":"remove", "R":"rename", "C":"copy",
38
+ "U":"merge", "?":"add"}
39
+
40
+ _RE_VERSION = re.compile(r"^(Old|New) version:\s*(\S+)", re.M)
41
+ _RE_TIMING = re.compile(r"^(?P<nb>.+?):\s+(?P<secs>[\d.]+)\s*secs?$")
42
+ _RE_COMMIT = re.compile(r"^\[(?P<branch>\S+)\s+(?P<sha>[0-9a-f]+)\]")
43
+ _RE_COUNTS = re.compile(r"(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?")
44
+ _RE_WROTE = re.compile(r"Writing objects:.*?,\s*(?P<size>[\d.]+\s*[KMG]?i?B)")
45
+
46
+ class StepFailed(Exception):
47
+ "A stage exited non-zero. Carries the output so `_step` can show what went wrong"
48
+ def __init__(self, cmd, code, output=""):
49
+ self.cmd, self.code, self.output = cmd, code, output
50
+ super().__init__(f"{cmd} exited {code}")
51
+
52
+ def _dur(t0):
53
+ "Elapsed time since `t0`, at a readable scale"
54
+ s = perf_counter() - t0
55
+ if s < 1: return f"{s*1000:.0f}ms"
56
+ if s < 60: return f"{s:.1f}s"
57
+ return f"{int(s)//60}m{int(s)%60:02d}s"
58
+
59
+ def _rule(text, style):
60
+ "A rule capped at `_RULE_W`; `console.rule` otherwise spans the whole terminal"
61
+ console.print(Rule(text, style=style), width=min(console.width, _RULE_W))
62
+
63
+ def _banner(cmd):
64
+ "Command header, with the package version alongside"
65
+ _rule(f"[bold cyan]{cmd}[/bold cyan] [dim]nbdevAuto {__version__}[/dim]", "cyan")
66
+
67
+ def _done(cmd, t0):
68
+ "Closing summary for a multi-stage command"
69
+ _rule(f"[green]{cmd} finished[/green] [dim]in {_dur(t0)}[/dim]", "green")
70
+
71
+ @contextmanager
72
+ def _step(n, total, label):
73
+ """Run one stage, reporting its outcome and duration.
74
+
75
+ Yields a list: append short strings and they print under the step, which is how a
76
+ stage says what it did without letting the underlying tool print it all. Re-raises
77
+ so nothing is swallowed, and a `StepFailed` shows the output it carries."""
78
+ console.print(f"[dim]{n}/{total}[/dim] [bold]{label}[/bold]")
79
+ t0, detail = perf_counter(), []
80
+ try:
81
+ yield detail
82
+ except StepFailed as e:
83
+ console.print(f" [bold red]FAIL[/bold red] [dim]{_dur(t0)}, exit {e.code}[/dim]")
84
+ for line in e.output.splitlines():
85
+ if line.strip(): console.print(f" [red]{escape(line.rstrip())}[/red]")
86
+ console.print()
87
+ raise
88
+ except BaseException:
89
+ console.print(f" [bold red]FAIL[/bold red] [dim]{_dur(t0)}[/dim]\n")
90
+ raise
91
+ for line in detail: console.print(f" [dim]{escape(line)}[/dim]")
92
+ console.print(f" [green]OK[/green] [dim]{_dur(t0)}[/dim]\n")
93
+
94
+ def _skip(n, total, label, why):
95
+ "Report a stage that was deliberately not run"
96
+ console.print(f"[dim]{n}/{total}[/dim] [bold]{label}[/bold]")
97
+ console.print(f" [yellow]SKIP[/yellow] [dim]{why}[/dim]\n")
98
+
99
+ def _run(*cmd):
100
+ """Run `cmd` with output captured, returning stdout+stderr.
101
+
102
+ A bare `subprocess.run` returns an exit code nobody reads, so a failed `git push`
103
+ still printed a green OK. Raising is what makes that OK mean something."""
104
+ r = subprocess.run(cmd, capture_output=True, text=True)
105
+ out = f"{r.stdout or ''}{r.stderr or ''}"
106
+ if r.returncode: raise StepFailed(" ".join(cmd), r.returncode, out)
107
+ return out
108
+
109
+ @contextmanager
110
+ def _quiet():
111
+ "Collect what a wrapped nbdev call prints, so the step can summarise it instead"
112
+ buf = StringIO()
113
+ try:
114
+ with redirect_stdout(buf), redirect_stderr(buf): yield buf
115
+ except BaseException:
116
+ # A failing stage is the one time every raw line is worth having.
117
+ text = buf.getvalue().strip()
118
+ if text: console.print(f"[dim]{escape(text)}[/dim]")
119
+ raise
120
+
121
+ def _version_summary(out):
122
+ "The two lines `nbdev_bump_version` prints, as one arrow"
123
+ v = dict(_RE_VERSION.findall(out))
124
+ if "Old" in v and "New" in v: return [f"{v['Old']} -> {v['New']}"]
125
+ return [l.strip() for l in out.splitlines() if l.strip()]
126
+
127
+ def _test_summary(out, slow_secs=1.0, keep=3):
128
+ "`nbdev_test` prints a line per notebook; keep the count and only the genuinely slow ones"
129
+ times = []
130
+ for line in out.splitlines():
131
+ m = _RE_TIMING.match(line.strip())
132
+ if m:
133
+ try: times.append((m["nb"], float(m["secs"])))
134
+ except ValueError: pass
135
+ if not times: return [l.strip() for l in out.splitlines() if l.strip()][-3:]
136
+ head = f"{len(times)} notebook{'' if len(times) == 1 else 's'}"
137
+ slow = [t for t in sorted(times, key=lambda t: -t[1]) if t[1] >= slow_secs][:keep]
138
+ if not slow: return [f"{head}, none slower than {slow_secs:g}s"]
139
+ return [f"{head}, slowest:"] + [f" {nb} {s:.1f}s" for nb, s in slow]
140
+
141
+ def _commit_summary(out):
142
+ "Where the commit landed and how big it was, without echoing the subject back"
143
+ lines = [l.strip() for l in out.splitlines() if l.strip()]
144
+ if not lines: return []
145
+ m, c = _RE_COMMIT.match(lines[0]), _RE_COUNTS.search(out)
146
+ head = f"{m['branch']} {m['sha']}" if m else lines[0]
147
+ if not c: return [head]
148
+ files, ins, dels = c.group(1), c.group(2) or "0", c.group(3) or "0"
149
+ return [f"{head} {files} file{'' if files == '1' else 's'}, +{ins} -{dels}"]
150
+
151
+ def _push_summary(out):
152
+ "What moved and how much went over the wire; the progress meters are noise"
153
+ lines = [l.rstrip() for l in out.splitlines() if l.strip()]
154
+ if not lines: return ["nothing to push"]
155
+ if any("Everything up-to-date" in l for l in lines): return ["everything up-to-date"]
156
+ detail = []
157
+ for i, l in enumerate(lines):
158
+ if l.startswith("To ") and i + 1 < len(lines):
159
+ detail += [lines[i + 1].strip(), f"to {l[3:].strip()}"]
160
+ break
161
+ m = _RE_WROTE.search(out)
162
+ if m: detail.append(f"{m['size'].strip()} written")
163
+ return detail or lines[-1:]
164
+
165
+ def _auto_msg(porcelain, limit=3):
166
+ "A commit subject built from the staged paths. The raw status output made a poor one"
167
+ rows = [(l[:2].strip()[:1], l[3:].strip()) for l in porcelain.splitlines() if l.strip()]
168
+ if not rows: return "update"
169
+ verb = _CODE_VERB.get(rows[0][0], "update") if len({c for c, _ in rows}) == 1 else "update"
170
+ names = [p.split(" -> ")[-1] for _, p in rows]
171
+ head = ", ".join(names[:limit])
172
+ if len(names) > limit: head += f" and {len(names) - limit} more"
173
+ return f"{verb} {head}"
174
+
175
+ def _status_table(porcelain):
176
+ "Render `git status -s` output as a table, one row per path"
177
+ t = Table(box=box.SIMPLE, show_header=False, pad_edge=False, expand=False)
178
+ t.add_column("code", no_wrap=True, justify="right")
179
+ t.add_column("path", overflow="fold")
180
+ t.add_column("what", style="dim", no_wrap=True)
181
+ n = 0
182
+ for line in porcelain.splitlines():
183
+ if not line.strip(): continue
184
+ code, path = line[:2].strip(), line[3:]
185
+ style = _CODE_STYLE.get(code[:1], "white")
186
+ t.add_row(f"[{style}]{code}[/{style}]", escape(path), _CODE_LABEL.get(code[:1], ""))
187
+ n += 1
188
+ return t, n
189
+
190
+ # %% ../nbs/01_Automate.ipynb #3a6f81d2-5c47-4e19-8b03-9d2a7f6c0e15
191
+ @call_parse
192
+ def prep(
193
+ p:int = 2, # Increment Part
194
+ ):
195
+ "Bump version part `p`, then export, test and clean the notebooks, refreshing _quarto.yml and README"
196
+
197
+ import nbdev.test, nbdev.clean, nbdev.quarto, nbdev.release
198
+ _banner("prep")
199
+ t0 = perf_counter()
200
+ with _step(1, 6, f"bump version (part {p})") as d, _quiet() as out:
201
+ nbdev.release.nbdev_bump_version(p)
202
+ d += _version_summary(out.getvalue())
203
+ with _step(2, 6, "nbdev_export"), _quiet():
204
+ nbdev.quarto.nbdev_export.__wrapped__()
205
+ with _step(3, 6, "nbdev_test") as d, _quiet() as out:
206
+ nbdev.test.nbdev_test.__wrapped__(
207
+ n_workers = 8, # Number of workers
208
+ timing = True, # Time each notebook to see which are slow
209
+ )
210
+ d += _test_summary(out.getvalue())
211
+ with _step(4, 6, "nbdev_clean"), _quiet():
212
+ nbdev.clean.nbdev_clean.__wrapped__()
213
+ with _step(5, 6, "refresh_quarto_yml"), _quiet():
214
+ nbdev.quarto.refresh_quarto_yml()
215
+ with _step(6, 6, "nbdev_readme"), _quiet():
216
+ nbdev.quarto.nbdev_readme.__wrapped__(chk_time=True)
217
+ _done("prep", t0)
218
+
219
+ # %% ../nbs/01_Automate.ipynb #f66ef35f-b7fa-4039-aa07-e9b782d204bb
220
+ @call_parse
221
+ def gacp(
222
+ m:str = '', # Commit message
223
+ ):
224
+ "git add, commit and push. Without `-m` the message is built from the staged paths"
225
+
226
+ _banner("gacp")
227
+ t0 = perf_counter()
228
+ with _step(1, 3, "git add"):
229
+ _run("git", "add", ".")
230
+ # NOT via `_run`: `git status -s` pads the code to two columns, and the capture has
231
+ # to keep that leading space or the first path shifts by one.
232
+ status = subprocess.check_output(["git", "status", "-s"]).decode('utf-8')
233
+ staged = bool(status.strip())
234
+ if staged:
235
+ table, n = _status_table(status)
236
+ console.print(table)
237
+ console.print(f"[dim]{n} path{'' if n == 1 else 's'} staged[/dim]\n")
238
+ else:
239
+ console.print("[dim]nothing staged, the tree is clean[/dim]\n")
240
+
241
+ if staged:
242
+ with _step(2, 3, "git commit") as d:
243
+ d += _commit_summary(_run("git", "commit", "-m", m if m != '' else _auto_msg(status)))
244
+ else:
245
+ # `git commit` exits 1 with nothing staged, which is now a hard failure rather
246
+ # than a silent one. There may still be unpushed commits, so go on to the push.
247
+ _skip(2, 3, "git commit", "nothing staged")
248
+ with _step(3, 3, "git push") as d:
249
+ d += _push_summary(_run("git", "push"))
250
+ _done("gacp", t0)
251
+
252
+ # %% ../nbs/01_Automate.ipynb #88017a1b-55c1-4833-aeb6-277a7e5b6314
253
+ def status():
254
+ "Show the working tree state"
255
+ import subprocess
256
+
257
+ def _git(*a, **kw): return subprocess.check_output(["git", *a], **kw).decode('utf-8').strip()
258
+
259
+ _banner("status")
260
+ branch = _git("branch", "--show-current") or "DETACHED"
261
+ line = f"on [bold]{branch}[/bold]"
262
+ try:
263
+ behind, ahead = _git("rev-list", "--left-right", "--count", "@{upstream}...HEAD",
264
+ stderr=subprocess.DEVNULL).split()
265
+ if behind != "0": line += f" [yellow]behind {behind}[/yellow]"
266
+ if ahead != "0": line += f" [cyan]ahead {ahead}[/cyan]"
267
+ if behind == ahead == "0": line += " [dim]in step with upstream[/dim]"
268
+ except subprocess.CalledProcessError:
269
+ line += " [dim]no upstream[/dim]"
270
+ console.print(line)
271
+
272
+ # NOT via _git: `git status -s` pads the code to two columns, so stripping the
273
+ # output would eat the leading space of the first line and shift its path by one.
274
+ porcelain = subprocess.check_output(["git", "status", "-s"]).decode('utf-8')
275
+ if not porcelain:
276
+ console.print("[green]clean[/green]")
277
+ return
278
+ table, n = _status_table(porcelain)
279
+ console.print(table)
280
+ console.print(f"[dim]{n} path{'' if n == 1 else 's'} changed[/dim]")
281
+
282
+ # %% ../nbs/01_Automate.ipynb #8d777a41-0160-47f8-b51c-b14360b62da4
283
+ @call_parse
284
+ def upload(
285
+ m:str = '', # Commit message
286
+ p:int = 2, # Increment part
287
+ ):
288
+ "prep then gacp: the everyday command for shipping a notebook change"
289
+ prep(p)
290
+ gacp(m)
291
+
292
+ # %% ../nbs/01_Automate.ipynb #dee37b06-c946-4b40-be0f-056e133751ea
293
+ def release_git():
294
+ "Bump the MINOR version, then tag and create a GitHub release"
295
+ import nbdev.release
296
+ _banner("gitrelease")
297
+ t0 = perf_counter()
298
+ with _step(1, 2, "bump minor version") as d, _quiet() as out:
299
+ nbdev.release.nbdev_bump_version(1)
300
+ d += _version_summary(out.getvalue())
301
+ with _step(2, 2, "tag and create the GitHub release"):
302
+ nbdev.release.release_git()
303
+ _done("gitrelease", t0)
304
+
305
+ # %% ../nbs/01_Automate.ipynb #288708c6-194a-4059-9ff0-ae330079c199
306
+ def release_pypi():
307
+ "Build the sdist and wheel, then upload to PyPI with twine. CI publishes on push, so this is the manual path"
308
+ import nbdev.release
309
+ _banner("piprelease")
310
+ t0 = perf_counter()
311
+ # Deliberately not quieted: a twine upload is worth watching line by line.
312
+ with _step(1, 1, "build and upload to PyPI"):
313
+ nbdev.release.release_pypi()
314
+ _done("piprelease", t0)
315
+
316
+ # %% ../nbs/01_Automate.ipynb #dd798178-9d58-457e-969a-d86a5e8bf875
317
+ def release():
318
+ "release_git then release_pypi"
319
+ release_git()
320
+ release_pypi()
321
+
322
+ # %% ../nbs/01_Automate.ipynb #c4d2bd52-04c4-43fe-890b-b4cb5da8d206
323
+ def help_output():
324
+ "Print every console script this package installs, with its help"
325
+ from importlib.metadata import distribution
326
+ try:
327
+ eps = [e for e in distribution("nbdevAuto").entry_points if e.group == "console_scripts"]
328
+ except Exception:
329
+ # Running from a source tree with nothing installed: fall back to fastcore.
330
+ from fastcore.xtras import console_help
331
+ return console_help('nbdevAuto')
332
+ _banner("commands")
333
+ t = Table(box=box.SIMPLE, show_header=False, pad_edge=False, expand=False)
334
+ t.add_column("command", style="bold cyan", no_wrap=True)
335
+ t.add_column("does", overflow="fold")
336
+ for e in sorted(eps, key=lambda e: e.name):
337
+ try: doc = escape((e.load().__doc__ or "").strip().splitlines()[0])
338
+ except Exception: doc = "[red]could not load[/red]"
339
+ t.add_row(e.name, doc)
340
+ console.print(t)
341
+ console.print(escape("ghstatus needs the gh extra: pip install 'nbdevAuto[gh]'"), style="dim")
@@ -0,0 +1,201 @@
1
+ """Run one command across every submodule of a superproject
2
+
3
+ Docs: https://bthek1.github.io/nbdevAuto/fleet.html.md"""
4
+
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_Fleet.ipynb.
6
+
7
+ # %% auto #0
8
+ __all__ = ['VERBS', 'Outcome', 'Report', 'format_summary', 'walk', 'finish', 'script_path', 'upload_one', 'fleet_upload',
9
+ 'parse_verb', 'usage', 'run_verb', 'fleet']
10
+
11
+ # %% ../nbs/04_Fleet.ipynb #d361ee5f-9f09-464f-aec6-0e27cec90fe9
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from fastcore.script import *
20
+
21
+ # The rendering layer lives in `automate`, the git/discovery layer in `github`. This
22
+ # module is the only one that knows about MANY repos; those two stay single-repo.
23
+ from .automate import StepFailed, console, escape
24
+ from .github import find_root, git, parse_gitmodules
25
+
26
+ # %% ../nbs/04_Fleet.ipynb #4e6b24c5-cd71-434c-84e6-e6994d5959c7
27
+ @dataclass
28
+ class Outcome:
29
+ "What happened in one submodule"
30
+ path: str
31
+ state: str # ran | skipped | failed
32
+ reason: str = "" # why it was skipped, or how it failed
33
+ detail: list = field(default_factory=list) # short lines to show under the path
34
+
35
+ @dataclass
36
+ class Report:
37
+ "Everything a `walk` did, and the exit code it implies"
38
+ outcomes: list = field(default_factory=list)
39
+
40
+ @property
41
+ def ran(self): return [o for o in self.outcomes if o.state == "ran"]
42
+ @property
43
+ def failed(self): return [o for o in self.outcomes if o.state == "failed"]
44
+ @property
45
+ def code(self): return 1 if self.failed else 0
46
+
47
+ def skip_counts(self):
48
+ "Skip reason -> count, in the order the reasons were first seen"
49
+ counts = {}
50
+ for o in self.outcomes:
51
+ if o.state == "skipped": counts[o.reason] = counts.get(o.reason, 0) + 1
52
+ return counts
53
+
54
+ def format_summary(rep, verb="ran"):
55
+ "The one line that replaces a message per submodule. Quiet submodules are counted, not narrated"
56
+ n = len(rep.outcomes)
57
+ parts = [f"{len(rep.ran)} {verb}"]
58
+ parts += [f"{c} {reason}" for reason, c in rep.skip_counts().items()]
59
+ if rep.failed: parts.append(f"{len(rep.failed)} FAILED")
60
+ return f"{n} submodule{'' if n == 1 else 's'}: " + ", ".join(parts)
61
+
62
+ # %% ../nbs/04_Fleet.ipynb #099242e8-c9ad-4b48-8ebc-7a818e5f8f81
63
+ def _classify(base, sub, need_nbs, only_dirty):
64
+ "The reason to skip this submodule, or None to run it"
65
+ p = base/sub.path
66
+ # An uninitialised submodule is an empty directory: present, but with no .git
67
+ if not (p/".git").exists(): return "not initialised"
68
+ if need_nbs and not (p/"nbs").is_dir(): return "without nbs"
69
+ if only_dirty and not git(p, "status", "--porcelain"): return "clean"
70
+ return None
71
+
72
+ def _fail_reason(e):
73
+ "One line describing why a submodule's callable did not finish"
74
+ if isinstance(e, StepFailed): return f"{e.cmd} exited {e.code}"
75
+ return f"{type(e).__name__}: {e}"
76
+
77
+ def walk(fn, *, root=None, only_dirty=False, need_nbs=False, workers=1, header=True):
78
+ """Run `fn(path, sub)` in each submodule, returning a `Report`.
79
+
80
+ `fn` may return a list of short strings to show under the submodule's name. Only
81
+ submodules that actually do something get printed; the rest are counted by the caller
82
+ through `format_summary`. Sequential runs print the header BEFORE the callable so its
83
+ own output streams under it; parallel runs print after, so lines cannot interleave."""
84
+ base = Path(root) if root else find_root()
85
+ rep, todo = Report(), []
86
+ for s in parse_gitmodules(base):
87
+ why = _classify(base, s, need_nbs, only_dirty)
88
+ if why: rep.outcomes.append(Outcome(s.path, "skipped", why))
89
+ else: todo.append(s)
90
+
91
+ def _one(s):
92
+ try: return Outcome(s.path, "ran", detail=list(fn(base/s.path, s) or []))
93
+ except BaseException as e: return Outcome(s.path, "failed", _fail_reason(e))
94
+
95
+ def _head(path): console.print(f"\n[bold cyan]==> {escape(path)}[/bold cyan]")
96
+
97
+ if workers > 1 and len(todo) > 1:
98
+ # Results are rendered in submodule order, not completion order, so two runs of
99
+ # the same fleet read the same way.
100
+ with ThreadPoolExecutor(max_workers=workers) as ex: done = list(ex.map(_one, todo))
101
+ for o in done:
102
+ if header: _head(o.path)
103
+ _render(o)
104
+ rep.outcomes.append(o)
105
+ else:
106
+ for s in todo:
107
+ if header: _head(s.path)
108
+ o = _one(s)
109
+ _render(o)
110
+ rep.outcomes.append(o)
111
+ rep.outcomes.sort(key=lambda o: o.path)
112
+ return rep
113
+
114
+ def _render(o):
115
+ "A submodule's own lines, or why it failed"
116
+ if o.state == "failed":
117
+ console.print(f" [bold red]FAIL[/bold red] [dim]{escape(o.reason)}[/dim]")
118
+ for line in o.detail: console.print(f" [dim]{escape(line)}[/dim]")
119
+
120
+ def finish(rep, verb):
121
+ "Print the counted summary, name any failures, and return the exit code"
122
+ console.print(f"\n[bold]{escape(format_summary(rep, verb))}[/bold]")
123
+ for o in rep.failed:
124
+ console.print(f"[bold red]FAILED[/bold red] {escape(o.path)}: [dim]{escape(o.reason)}[/dim]")
125
+ return rep.code
126
+
127
+ # %% ../nbs/04_Fleet.ipynb #99e2c4fe-c885-4880-b9df-31da51e61f3a
128
+ def script_path(name):
129
+ """The console script `name` from the environment this is running in.
130
+
131
+ Taking it from `sys.executable`'s directory is what makes this work under a
132
+ non-interactive shell that never put the venv on PATH."""
133
+ cand = Path(sys.executable).parent/name
134
+ if cand.is_file(): return str(cand)
135
+ found = shutil.which(name)
136
+ if found: return found
137
+ raise RuntimeError(f"Cannot find the '{name}' console script. Run `uv sync` first")
138
+
139
+ def upload_one(path, sub=None, args=()):
140
+ """Run the single-repo `upload` in `path`, letting its own rich output through.
141
+
142
+ A subprocess rather than an in-process call: `prep` reads nbdev's config from the cwd
143
+ and caches it, and `nbdev_test` spawns its own process pool, so nineteen repos in one
144
+ interpreter is a trap. The subprocess keeps this tty, so colour survives."""
145
+ r = subprocess.run([script_path("upload"), *args], cwd=str(path))
146
+ if r.returncode: raise StepFailed("upload", r.returncode)
147
+ return []
148
+
149
+ # %% ../nbs/04_Fleet.ipynb #2ef9f1e5-239a-4790-8cec-43fffb8bc422
150
+ def fleet_upload(
151
+ all:bool = False, # Upload every submodule with an nbs/ folder, not just the changed ones
152
+ root:str = None, # Superproject root (default: nearest ancestor with .gitmodules)
153
+ ):
154
+ "Run `upload` in each submodule that has local changes"
155
+ # Sequential on purpose: `upload` runs nbdev_test with 8 workers, and knowledge-lab
156
+ # has 4 vCPU. Parallel uploads would oversubscribe the box and interleave the output.
157
+ rep = walk(upload_one, root=root, need_nbs=True, only_dirty=not all, workers=1)
158
+ sys.exit(finish(rep, "uploaded"))
159
+
160
+ # %% ../nbs/04_Fleet.ipynb #0281bd2b-91ba-4e49-90dc-d1a95d54d5a6
161
+ VERBS = {"upload": fleet_upload}
162
+
163
+ def parse_verb(argv):
164
+ "(verb, remaining args) for `fleet ...`, or None when `argv` does not name a verb"
165
+ if not argv or argv[0].startswith("-"): return None
166
+ return (argv[0], argv[1:]) if argv[0] in VERBS else None
167
+
168
+ def usage():
169
+ "The verb list, for --help and for an unknown verb"
170
+ lines = ["Usage: fleet <verb> [options]", "", "Verbs:"]
171
+ lines += [f" {name:<10} {(fn.__doc__ or '').strip()}" for name, fn in sorted(VERBS.items())]
172
+ lines += ["", "Any verb takes --help for its own options."]
173
+ return "\n".join(lines)
174
+
175
+ def run_verb(name, argv):
176
+ """Parse `argv` against the verb's own annotations, then call it.
177
+
178
+ Deliberately NOT `@call_parse`: fastcore runs the first decorated function in a module
179
+ the moment that module is `__main__`, which is exactly what makes `python -m
180
+ nbdevAuto.github` work with its single verb. With several verbs it would run whichever
181
+ happened to be defined first, so the verbs stay plain functions and the parser is built
182
+ here. `anno_parser` is the same one `call_parse` uses, so `--help` is unchanged."""
183
+ fn = VERBS[name]
184
+ args = vars(anno_parser(fn, prog=f"fleet {name}").parse_args(argv))
185
+ for k in ("pdb", "xtra"): args.pop(k, None)
186
+ return fn(**args)
187
+
188
+ def fleet():
189
+ """Dispatch `fleet <verb>` to the matching command.
190
+
191
+ `upload` and `status` are already console scripts bound to the single-repo versions in
192
+ `automate`, so the fleet-wide ones cannot claim those names. One script with verbs
193
+ sidesteps the collision and keeps `--help` working per verb."""
194
+ picked = parse_verb(sys.argv[1:])
195
+ if not picked:
196
+ print(usage())
197
+ sys.exit(0 if sys.argv[1:2] in ([], ["-h"], ["--help"]) else 2)
198
+ return run_verb(*picked)
199
+
200
+ # %% ../nbs/04_Fleet.ipynb #7adb89c6-6788-4241-acff-53cb4566b5ce
201
+ if __name__ == "__main__": fleet()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdevAuto
3
- Version: 0.3.64
3
+ Version: 0.3.66
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -6,6 +6,7 @@ nbdevAuto/__init__.py
6
6
  nbdevAuto/_modidx.py
7
7
  nbdevAuto/automate.py
8
8
  nbdevAuto/core.py
9
+ nbdevAuto/fleet.py
9
10
  nbdevAuto/functions.py
10
11
  nbdevAuto/github.py
11
12
  nbdevAuto/pdf.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ fleet = nbdevAuto.fleet:fleet
2
3
  gacp = nbdevAuto.automate:gacp
3
4
  ghstatus = nbdevAuto.github:ghstatus
4
5
  gitrelease = nbdevAuto.automate:release_git
@@ -51,6 +51,7 @@ piprelease = "nbdevAuto.automate:release_pypi"
51
51
  release = "nbdevAuto.automate:release"
52
52
  h = "nbdevAuto.automate:help_output"
53
53
  ghstatus = "nbdevAuto.github:ghstatus"
54
+ fleet = "nbdevAuto.fleet:fleet"
54
55
 
55
56
  [tool.setuptools.dynamic]
56
57
  version = {attr = "nbdevAuto.__version__"}
@@ -1 +0,0 @@
1
- __version__ = "0.3.64"
@@ -1,209 +0,0 @@
1
- """Automate
2
-
3
- Docs: https://bthek1.github.io/nbdevAuto/automate.html.md"""
4
-
5
- # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_Automate.ipynb.
6
-
7
- # %% auto #0
8
- __all__ = ['prep', 'gacp', 'status', 'upload', 'release_git', 'release_pypi', 'release', 'help_output']
9
-
10
- # %% ../nbs/01_Automate.ipynb #6d1499d1-aa55-47d8-92fd-fa056b1fb135
11
- from fastcore.script import *
12
- from rich import box
13
- from rich.console import Console
14
- from rich.markup import escape
15
- from rich.table import Table
16
-
17
- # %% ../nbs/01_Automate.ipynb #7c1e5a90-3f52-4d21-9b6a-2c0f4e8d1a37
18
- from contextlib import contextmanager
19
- from time import perf_counter
20
-
21
- from nbdevAuto import __version__
22
-
23
- console = Console()
24
-
25
- # `git status -s` codes: colour, and the word for the third column.
26
- _CODE_STYLE = {"M":"yellow", "A":"green", "D":"red", "R":"cyan", "C":"cyan", "U":"magenta", "?":"dim"}
27
- _CODE_LABEL = {"M":"modified", "A":"added", "D":"deleted", "R":"renamed", "C":"copied",
28
- "U":"conflict", "?":"untracked"}
29
-
30
- def _dur(t0):
31
- "Elapsed time since `t0`, at a readable scale"
32
- s = perf_counter() - t0
33
- if s < 1: return f"{s*1000:.0f}ms"
34
- if s < 60: return f"{s:.1f}s"
35
- return f"{int(s)//60}m{int(s)%60:02d}s"
36
-
37
- def _banner(cmd):
38
- "Command header, with the package version alongside"
39
- console.rule(f"[bold cyan]{cmd}[/bold cyan] [dim]nbdevAuto {__version__}[/dim]", style="cyan")
40
-
41
- def _done(cmd, t0):
42
- "Closing summary for a multi-stage command"
43
- console.rule(f"[green]{cmd} finished[/green] [dim]in {_dur(t0)}[/dim]", style="green")
44
-
45
- @contextmanager
46
- def _step(n, total, label):
47
- "Run one stage, reporting its outcome and duration. Re-raises so nothing is swallowed"
48
- console.print(f"[dim]{n}/{total}[/dim] [bold]{label}[/bold]")
49
- t0 = perf_counter()
50
- try:
51
- yield
52
- except BaseException:
53
- console.print(f" [bold red]FAIL[/bold red] [dim]{_dur(t0)}[/dim]\n")
54
- raise
55
- console.print(f" [green]OK[/green] [dim]{_dur(t0)}[/dim]\n")
56
-
57
- def _status_table(porcelain):
58
- "Render `git status -s` output as a table, one row per path"
59
- t = Table(box=box.SIMPLE, show_header=False, pad_edge=False, expand=False)
60
- t.add_column("code", no_wrap=True, justify="right")
61
- t.add_column("path", overflow="fold")
62
- t.add_column("what", style="dim", no_wrap=True)
63
- n = 0
64
- for line in porcelain.splitlines():
65
- if not line.strip(): continue
66
- code, path = line[:2].strip(), line[3:]
67
- style = _CODE_STYLE.get(code[:1], "white")
68
- t.add_row(f"[{style}]{code}[/{style}]", escape(path), _CODE_LABEL.get(code[:1], ""))
69
- n += 1
70
- return t, n
71
-
72
- # %% ../nbs/01_Automate.ipynb #3a6f81d2-5c47-4e19-8b03-9d2a7f6c0e15
73
- @call_parse
74
- def prep(
75
- p:int = 2, # Increment Part
76
- ):
77
- "Bump version part `p`, then export, test and clean the notebooks, refreshing _quarto.yml and README"
78
-
79
- import nbdev.test, nbdev.clean, nbdev.quarto, nbdev.release
80
- _banner("prep")
81
- t0 = perf_counter()
82
- with _step(1, 6, f"bump version (part {p})"):
83
- nbdev.release.nbdev_bump_version(p)
84
- with _step(2, 6, "nbdev_export"):
85
- nbdev.quarto.nbdev_export.__wrapped__()
86
- with _step(3, 6, "nbdev_test"):
87
- nbdev.test.nbdev_test.__wrapped__(
88
- n_workers = 8, # Number of workers
89
- timing = True, # Time each notebook to see which are slow
90
- )
91
- with _step(4, 6, "nbdev_clean"):
92
- nbdev.clean.nbdev_clean.__wrapped__()
93
- with _step(5, 6, "refresh_quarto_yml"):
94
- nbdev.quarto.refresh_quarto_yml()
95
- with _step(6, 6, "nbdev_readme"):
96
- nbdev.quarto.nbdev_readme.__wrapped__(chk_time=True)
97
- _done("prep", t0)
98
-
99
- # %% ../nbs/01_Automate.ipynb #f66ef35f-b7fa-4039-aa07-e9b782d204bb
100
- @call_parse
101
- def gacp(
102
- m:str = '', # Commit message
103
- ):
104
- "git add, commit and push. Without `-m` the commit message is the status output"
105
-
106
- import subprocess
107
- _banner("gacp")
108
- t0 = perf_counter()
109
- with _step(1, 3, "git add"):
110
- subprocess.run(["git", "add", "."])
111
- status = subprocess.check_output(["git", "status", "-s"]).decode('utf-8')
112
- if status.strip():
113
- table, n = _status_table(status)
114
- console.print(table)
115
- console.print(f"[dim]{n} path{'' if n == 1 else 's'} staged[/dim]\n")
116
- else:
117
- console.print("[dim]nothing staged, the tree is clean[/dim]\n")
118
-
119
- msg = m if m != '' else status
120
- with _step(2, 3, "git commit"):
121
- subprocess.run(["git", "commit", "-m", msg])
122
- with _step(3, 3, "git push"):
123
- subprocess.run(["git", "push"])
124
- _done("gacp", t0)
125
-
126
- # %% ../nbs/01_Automate.ipynb #88017a1b-55c1-4833-aeb6-277a7e5b6314
127
- def status():
128
- "Show the working tree state"
129
- import subprocess
130
-
131
- def _git(*a, **kw): return subprocess.check_output(["git", *a], **kw).decode('utf-8').strip()
132
-
133
- _banner("status")
134
- branch = _git("branch", "--show-current") or "DETACHED"
135
- line = f"on [bold]{branch}[/bold]"
136
- try:
137
- behind, ahead = _git("rev-list", "--left-right", "--count", "@{upstream}...HEAD",
138
- stderr=subprocess.DEVNULL).split()
139
- if behind != "0": line += f" [yellow]behind {behind}[/yellow]"
140
- if ahead != "0": line += f" [cyan]ahead {ahead}[/cyan]"
141
- if behind == ahead == "0": line += " [dim]in step with upstream[/dim]"
142
- except subprocess.CalledProcessError:
143
- line += " [dim]no upstream[/dim]"
144
- console.print(line)
145
-
146
- # NOT via _git: `git status -s` pads the code to two columns, so stripping the
147
- # output would eat the leading space of the first line and shift its path by one.
148
- porcelain = subprocess.check_output(["git", "status", "-s"]).decode('utf-8')
149
- if not porcelain:
150
- console.print("[green]clean[/green]")
151
- return
152
- table, n = _status_table(porcelain)
153
- console.print(table)
154
- console.print(f"[dim]{n} path{'' if n == 1 else 's'} changed[/dim]")
155
-
156
- # %% ../nbs/01_Automate.ipynb #8d777a41-0160-47f8-b51c-b14360b62da4
157
- @call_parse
158
- def upload(
159
- m:str = '', # Commit message
160
- p:int = 2, # Increment part
161
- ):
162
- "prep then gacp: the everyday command for shipping a notebook change"
163
- prep(p)
164
- gacp(m)
165
-
166
- # %% ../nbs/01_Automate.ipynb #dee37b06-c946-4b40-be0f-056e133751ea
167
- def release_git():
168
- "Bump the MINOR version, then tag and create a GitHub release"
169
- import nbdev.release
170
- console.rule("[bold]gitrelease")
171
- with _step("bump minor version"):
172
- nbdev.release.nbdev_bump_version(1)
173
- with _step("tag and create the GitHub release"):
174
- nbdev.release.release_git()
175
-
176
- # %% ../nbs/01_Automate.ipynb #288708c6-194a-4059-9ff0-ae330079c199
177
- def release_pypi():
178
- "Build the sdist and wheel, then upload to PyPI with twine. CI publishes on push, so this is the manual path"
179
- import nbdev.release
180
- console.rule("[bold]piprelease")
181
- with _step("build and upload to PyPI"):
182
- nbdev.release.release_pypi()
183
-
184
- # %% ../nbs/01_Automate.ipynb #dd798178-9d58-457e-969a-d86a5e8bf875
185
- def release():
186
- "release_git then release_pypi"
187
- release_git()
188
- release_pypi()
189
-
190
- # %% ../nbs/01_Automate.ipynb #c4d2bd52-04c4-43fe-890b-b4cb5da8d206
191
- def help_output():
192
- "Print every console script this package installs, with its help"
193
- from importlib.metadata import distribution
194
- try:
195
- eps = [e for e in distribution("nbdevAuto").entry_points if e.group == "console_scripts"]
196
- except Exception:
197
- # Running from a source tree with nothing installed: fall back to fastcore.
198
- from fastcore.xtras import console_help
199
- return console_help('nbdevAuto')
200
- _banner("commands")
201
- t = Table(box=box.SIMPLE, show_header=False, pad_edge=False, expand=False)
202
- t.add_column("command", style="bold cyan", no_wrap=True)
203
- t.add_column("does", overflow="fold")
204
- for e in sorted(eps, key=lambda e: e.name):
205
- try: doc = escape((e.load().__doc__ or "").strip().splitlines()[0])
206
- except Exception: doc = "[red]could not load[/red]"
207
- t.add_row(e.name, doc)
208
- console.print(t)
209
- console.print(escape("ghstatus needs the gh extra: pip install 'nbdevAuto[gh]'"), style="dim")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes