nbdevAuto 0.3.71__tar.gz → 0.3.74__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.71
3
+ Version: 0.3.74
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -72,7 +72,7 @@ from nbdevAuto.pdf import PDFreader
72
72
  | [Functions](00_Functions.ipynb) | Dataset and image helpers: reading a list out of a text file, downloading single images or whole search-driven datasets, verifying and resizing what came back, building the folder layout a classifier expects, running a fastai learner over one image, Kaggle competition and dataset shortcuts, and [`graph`](https://bthek1.github.io/nbdevAuto/functions.html#graph), a `graphviz.Digraph` subclass preloaded with a rounded, filled style |
73
73
  | [Automate](01_Automate.ipynb) | The command line surface: the prep/commit/push pipeline, the GitHub and PyPI release halves, the help output that lists them all, and the one-line-per-stage console grid every command reports through |
74
74
  | [PDF Reader](02_PDF_reader.ipynb) | [`PDFreader`](https://bthek1.github.io/nbdevAuto/pdf_reader.html#pdfreader), a class that converts a PDF through `pdf2image` and renders page ranges inline when you slice it (`pdf[0:5]`) |
75
- | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and `newest_run`, which picks a run by `run_number` because the API’s page order is not a contract |
75
+ | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and [`newest_run`](https://bthek1.github.io/nbdevAuto/github.html#newest_run), which picks a run by `run_number` because the API’s page order is not a contract |
76
76
  | [Fleet](04_Fleet.ipynb) | [`fleet`](https://bthek1.github.io/nbdevAuto/fleet.html#fleet): running one command across every submodule of a superproject. A walker that reports only what did work and counts the rest, plus the verbs built on it - upload, push, copy, status, update and sync |
77
77
 
78
78
  ------------------------------------------------------------------------
@@ -42,7 +42,7 @@ from nbdevAuto.pdf import PDFreader
42
42
  | [Functions](00_Functions.ipynb) | Dataset and image helpers: reading a list out of a text file, downloading single images or whole search-driven datasets, verifying and resizing what came back, building the folder layout a classifier expects, running a fastai learner over one image, Kaggle competition and dataset shortcuts, and [`graph`](https://bthek1.github.io/nbdevAuto/functions.html#graph), a `graphviz.Digraph` subclass preloaded with a rounded, filled style |
43
43
  | [Automate](01_Automate.ipynb) | The command line surface: the prep/commit/push pipeline, the GitHub and PyPI release halves, the help output that lists them all, and the one-line-per-stage console grid every command reports through |
44
44
  | [PDF Reader](02_PDF_reader.ipynb) | [`PDFreader`](https://bthek1.github.io/nbdevAuto/pdf_reader.html#pdfreader), a class that converts a PDF through `pdf2image` and renders page ranges inline when you slice it (`pdf[0:5]`) |
45
- | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and `newest_run`, which picks a run by `run_number` because the API’s page order is not a contract |
45
+ | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and [`newest_run`](https://bthek1.github.io/nbdevAuto/github.html#newest_run), which picks a run by `run_number` because the API’s page order is not a contract |
46
46
  | [Fleet](04_Fleet.ipynb) | [`fleet`](https://bthek1.github.io/nbdevAuto/fleet.html#fleet): running one command across every submodule of a superproject. A walker that reports only what did work and counts the rest, plus the verbs built on it - upload, push, copy, status, update and sync |
47
47
 
48
48
  ------------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ __version__ = "0.3.74"
@@ -44,6 +44,8 @@ d = { 'settings': { 'branch': 'main',
44
44
  'nbdevAuto.fleet._count': ('fleet.html#_count', 'nbdevAuto/fleet.py'),
45
45
  'nbdevAuto.fleet._fail_parts': ('fleet.html#_fail_parts', 'nbdevAuto/fleet.py'),
46
46
  'nbdevAuto.fleet._git_ok': ('fleet.html#_git_ok', 'nbdevAuto/fleet.py'),
47
+ 'nbdevAuto.fleet._hits': ('fleet.html#_hits', 'nbdevAuto/fleet.py'),
48
+ 'nbdevAuto.fleet._line_of': ('fleet.html#_line_of', 'nbdevAuto/fleet.py'),
47
49
  'nbdevAuto.fleet._porcelain': ('fleet.html#_porcelain', 'nbdevAuto/fleet.py'),
48
50
  'nbdevAuto.fleet._render': ('fleet.html#_render', 'nbdevAuto/fleet.py'),
49
51
  'nbdevAuto.fleet.ahead_of_remote': ('fleet.html#ahead_of_remote', 'nbdevAuto/fleet.py'),
@@ -54,16 +56,22 @@ d = { 'settings': { 'branch': 'main',
54
56
  'nbdevAuto.fleet.fleet': ('fleet.html#fleet', 'nbdevAuto/fleet.py'),
55
57
  'nbdevAuto.fleet.fleet_copy': ('fleet.html#fleet_copy', 'nbdevAuto/fleet.py'),
56
58
  'nbdevAuto.fleet.fleet_push': ('fleet.html#fleet_push', 'nbdevAuto/fleet.py'),
59
+ 'nbdevAuto.fleet.fleet_scan': ('fleet.html#fleet_scan', 'nbdevAuto/fleet.py'),
57
60
  'nbdevAuto.fleet.fleet_status': ('fleet.html#fleet_status', 'nbdevAuto/fleet.py'),
58
61
  'nbdevAuto.fleet.fleet_sync': ('fleet.html#fleet_sync', 'nbdevAuto/fleet.py'),
59
62
  'nbdevAuto.fleet.fleet_update': ('fleet.html#fleet_update', 'nbdevAuto/fleet.py'),
60
63
  'nbdevAuto.fleet.fleet_upload': ('fleet.html#fleet_upload', 'nbdevAuto/fleet.py'),
61
64
  'nbdevAuto.fleet.format_summary': ('fleet.html#format_summary', 'nbdevAuto/fleet.py'),
62
65
  'nbdevAuto.fleet.init_new': ('fleet.html#init_new', 'nbdevAuto/fleet.py'),
66
+ 'nbdevAuto.fleet.is_published': ('fleet.html#is_published', 'nbdevAuto/fleet.py'),
67
+ 'nbdevAuto.fleet.looks_secret': ('fleet.html#looks_secret', 'nbdevAuto/fleet.py'),
68
+ 'nbdevAuto.fleet.notebook_text': ('fleet.html#notebook_text', 'nbdevAuto/fleet.py'),
63
69
  'nbdevAuto.fleet.parse_verb': ('fleet.html#parse_verb', 'nbdevAuto/fleet.py'),
64
70
  'nbdevAuto.fleet.pull_root': ('fleet.html#pull_root', 'nbdevAuto/fleet.py'),
65
71
  'nbdevAuto.fleet.push_one': ('fleet.html#push_one', 'nbdevAuto/fleet.py'),
66
72
  'nbdevAuto.fleet.run_verb': ('fleet.html#run_verb', 'nbdevAuto/fleet.py'),
73
+ 'nbdevAuto.fleet.scan_file': ('fleet.html#scan_file', 'nbdevAuto/fleet.py'),
74
+ 'nbdevAuto.fleet.scan_one': ('fleet.html#scan_one', 'nbdevAuto/fleet.py'),
67
75
  'nbdevAuto.fleet.script_path': ('fleet.html#script_path', 'nbdevAuto/fleet.py'),
68
76
  'nbdevAuto.fleet.status_one': ('fleet.html#status_one', 'nbdevAuto/fleet.py'),
69
77
  'nbdevAuto.fleet.update_one': ('fleet.html#update_one', 'nbdevAuto/fleet.py'),
@@ -5,12 +5,15 @@ Docs: https://bthek1.github.io/nbdevAuto/fleet.html.md"""
5
5
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_Fleet.ipynb.
6
6
 
7
7
  # %% auto #0
8
- __all__ = ['VERBS', 'Skip', 'Outcome', 'Report', 'format_summary', 'walk', 'finish', 'script_path', 'upload_one', 'fleet_upload',
9
- 'ahead_of_remote', 'push_one', 'copy_one', 'fleet_push', 'fleet_copy', 'status_one', 'fleet_status',
10
- 'fetch_all', 'update_one', 'dirty_submodules', 'init_new', 'pull_root', 'fleet_update', 'fleet_sync',
11
- 'parse_verb', 'usage', 'run_verb', 'fleet']
8
+ __all__ = ['RAW_DOC_SUFFIXES', 'KNOWN_TOKEN', 'SECRET_PATTERNS', 'ALLOW', 'RENDERED_SUFFIXES', 'TEXT_SUFFIXES', 'EMAIL',
9
+ 'RESERVED_DOMAINS', 'BULK_EMAIL_MIN', 'VERBS', 'Skip', 'Outcome', 'Report', 'format_summary', 'walk',
10
+ 'finish', 'script_path', 'upload_one', 'fleet_upload', 'ahead_of_remote', 'push_one', 'copy_one',
11
+ 'fleet_push', 'fleet_copy', 'status_one', 'fleet_status', 'fetch_all', 'update_one', 'dirty_submodules',
12
+ 'init_new', 'pull_root', 'fleet_update', 'fleet_sync', 'looks_secret', 'notebook_text', 'is_published',
13
+ 'scan_file', 'scan_one', 'fleet_scan', 'parse_verb', 'usage', 'run_verb', 'fleet']
12
14
 
13
15
  # %% ../nbs/04_Fleet.ipynb #d361ee5f-9f09-464f-aec6-0e27cec90fe9
16
+ import json
14
17
  import re
15
18
  import shutil
16
19
  import subprocess
@@ -397,9 +400,178 @@ def fleet_sync(
397
400
  rep = walk(lambda p, s: update_one(p, s, dry_run=dry_run), root=base, workers=1, style="line")
398
401
  sys.exit(finish(rep, "would update" if dry_run else "updated"))
399
402
 
403
+ # %% ../nbs/04_Fleet.ipynb #a2907233
404
+ # A finding's severity is which of these two lists a file falls in. `_quarto.yml`
405
+ # renders every notebook EXCEPT `p_*`, so "published" is a filename test, not a
406
+ # guess: a hit in a published file is live on the web, a hit in a `p_` one is
407
+ # only in git.
408
+ RAW_DOC_SUFFIXES = {".pdf", ".csv", ".ods", ".xlsx", ".xls", ".db", ".sqlite3"}
409
+
410
+ # Vendor prefixes that are a secret by construction, whatever they are assigned to.
411
+ KNOWN_TOKEN = re.compile(
412
+ r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}"
413
+ r"|sk-[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b")
414
+
415
+ SECRET_PATTERNS = [
416
+ # The header alone is not a key. Every VPN and SSH page in the fleet shows the
417
+ # armour wrapped around a "(Insert Client Key)" placeholder, so real base64
418
+ # key material has to follow before this counts.
419
+ ("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----\s*[\\n\s]*"
420
+ r"[A-Za-z0-9+/]{40,}")),
421
+ ("known token", KNOWN_TOKEN),
422
+ ("password hash", re.compile(r"\b(?:pbkdf2_sha256|argon2[a-z]*)\$"
423
+ r"[^\s\"']*\$[A-Za-z0-9+/]{16,}")),
424
+ ("tax file no", re.compile(r"(?i)\btax file number\b[^\n]{0,20}?\b\d{8,9}\b")),
425
+ ("passport no", re.compile(r"(?i)\bpassport\b[^\n]{0,30}?\b[A-Z]{1,2}\d{7}\b")),
426
+ ("hex secret", re.compile(r"(?i)\b(?:secret|token|api[_ -]?key|client[_ -]?id)\b"
427
+ r"\W{0,4}\b([0-9a-f]{32,})\b")),
428
+ ("credential", re.compile(r"(?i)\b(?:password|passwd|api[_ -]?key|secret|token)\b"
429
+ r"\s*[:=]\s*[\"']?([^\s\"',}{]{8,})")),
430
+ ]
431
+
432
+ def looks_secret(v):
433
+ """Is this captured value a literal secret, or just code that mentions one?
434
+
435
+ The credential regex cannot tell `token = 'b4bTbQ...'` from
436
+ `password = forms.CharField(...)`, and the second is what every Django,
437
+ Stripe and Grafana page in the fleet is FULL of. Ruling those out is the
438
+ whole difference between a scan worth running and one everybody ignores.
439
+
440
+ So the value has to survive four filters: be a plain literal rather than an
441
+ expression, not be an identifier or env-var name, not already be masked, and
442
+ mix character classes the way a generated secret does and a word does not."""
443
+ if len(v) < 16: return False
444
+ if not re.fullmatch(r"[A-Za-z0-9_\-+/=.~]+", v): return False # an expression
445
+ if re.fullmatch(r"[A-Z0-9_]+", v): return False # ENV_VAR_NAME
446
+ if re.fullmatch(r"[A-Za-z_][\w.]*", v) and "." in v:return False # settings.API_KEY
447
+ if re.search(r"[*x]{4,}|\.{3,}", v): return False # already masked
448
+ classes = sum(bool(re.search(c, v)) for c in (r"[a-z]", r"[A-Z]", r"\d"))
449
+ return classes >= 3
450
+
451
+ # An escape hatch for the deliberately secret-shaped: this module's own test
452
+ # vectors, and a doc that has to show what a real token looks like. Put the
453
+ # marker on the same line. It is a line comment, so it survives `nbdev-export`.
454
+ ALLOW = "scan:allow"
455
+
456
+ def _line_of(text, pos):
457
+ "The whole line containing offset `pos`, so an inline marker can be seen"
458
+ return text[text.rfind("\n", 0, pos) + 1 : (text.find("\n", pos) + 1 or None)]
459
+
460
+ def _hits(text):
461
+ "Every secret-pattern name matching `text`, in pattern order, without duplicates"
462
+ out = []
463
+ for name, rx in SECRET_PATTERNS:
464
+ for m in rx.finditer(text):
465
+ if ALLOW in _line_of(text, m.start()): continue
466
+ # A pattern with a group captures the VALUE, so it can be judged.
467
+ # A pattern without one matched something already unambiguous.
468
+ if m.groups() and name == "credential" and not looks_secret(m.group(1)): continue
469
+ out.append(name); break
470
+ return out
471
+
472
+ def notebook_text(path):
473
+ """Cell sources plus TEXT outputs, with image payloads dropped.
474
+
475
+ Scanning outputs is the point, not an extra: a code cell rewritten to drop its
476
+ figures still carries the OLD numbers in its stored output, and Quarto
477
+ publishes that output verbatim. That is the exact leak issue #18 found.
478
+
479
+ Skipping `image/*` is not just speed. A notebook of plots is mostly base64,
480
+ and feeding megabytes of it to these regexes manufactures false positives out
481
+ of pure entropy."""
482
+ try: nb = json.loads(path.read_text(encoding="utf-8", errors="replace"))
483
+ except Exception: return ""
484
+ parts = []
485
+ for c in nb.get("cells", []):
486
+ parts.append("".join(c.get("source", [])))
487
+ for o in c.get("outputs", []):
488
+ parts.append("".join(o.get("text", [])))
489
+ for mime, val in (o.get("data") or {}).items():
490
+ if not mime.startswith("image/"):
491
+ parts.append("".join(val) if isinstance(val, list) else str(val))
492
+ return "\n".join(parts)
493
+
494
+ # Rendered by `_quarto.yml`, so a hit here is on the public web. Everything else
495
+ # tracked is only in git, which is a different and smaller problem.
496
+ RENDERED_SUFFIXES = {".ipynb", ".md"}
497
+ # Read for content. A binary document cannot be scanned without dragging in a
498
+ # parser, so it gets counted instead of judged.
499
+ TEXT_SUFFIXES = RENDERED_SUFFIXES | {".py", ".yml", ".yaml", ".txt", ".json", ".csv",
500
+ ".ini", ".cfg", ".toml", ".sh", ".env"}
501
+
502
+ EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
503
+ # RFC 2606 reserves these for documentation, so a page full of them is a tutorial,
504
+ # not a contact list. Without this every mail and auth page in the fleet reports.
505
+ RESERVED_DOMAINS = re.compile(r"(?i)@(?:[\w.-]+\.)?(?:example\.(?:com|org|net)|"
506
+ r"test|localhost|invalid|example)$")
507
+ # One address is a contact detail; a column of them is somebody's user table.
508
+ BULK_EMAIL_MIN = 5
509
+
510
+ def is_published(path):
511
+ "Does the site render this file? `p_*` is excluded by `project.render` in `_quarto.yml`"
512
+ return path.suffix in RENDERED_SUFFIXES and not path.name.startswith("p_")
513
+
514
+ def scan_file(path):
515
+ "Pattern names found in `path`, or [] for nothing. Non-text files are never scanned"
516
+ if path.suffix.lower() not in TEXT_SUFFIXES: return []
517
+ text = notebook_text(path) if path.suffix == ".ipynb" else \
518
+ path.read_text(encoding="utf-8", errors="replace")
519
+ names = _hits(text)
520
+ addrs = {m.group(0).lower() for m in EMAIL.finditer(text)
521
+ if not RESERVED_DOMAINS.search(m.group(0))}
522
+ if len(addrs) >= BULK_EMAIL_MIN: names.append(f"{len(addrs)} email addresses")
523
+ return names
524
+
525
+
526
+ # %% ../nbs/04_Fleet.ipynb #55d1232c
527
+ def scan_one(path, sub=None, detail=False, nbs="nbs"):
528
+ """Secret and personal-data findings for one submodule.
529
+
530
+ Only TRACKED files are scanned. An untracked file is on this disk and nowhere
531
+ else, which is exactly where `.gitignore` is supposed to put a payslip, so
532
+ flagging it would punish the fix.
533
+
534
+ Binary documents are counted rather than read. The count is informational and
535
+ never fails the scan: a tracked PDF may be a payslip or may be a datasheet,
536
+ and this cannot tell without a parser it has no business importing."""
537
+ root = path/nbs
538
+ if not root.is_dir(): raise Skip("without nbs")
539
+ tracked = [l for l in git(path, "ls-files", "-z", nbs).split("\0") if l]
540
+ pub, tracked_hits, docs = [], [], 0
541
+ for rel in sorted(tracked):
542
+ f = path/rel
543
+ if not f.is_file(): continue
544
+ if f.suffix.lower() in RAW_DOC_SUFFIXES and f.suffix.lower() != ".csv":
545
+ docs += 1; continue
546
+ names = scan_file(f)
547
+ if not names: continue
548
+ (pub if is_published(f) else tracked_hits).append(f"{rel}: {', '.join(names)}")
549
+ if not (pub or tracked_hits): raise Skip("clean")
550
+ line = f"{len(pub):>3} published {len(tracked_hits):>3} tracked {docs:>4} binary docs"
551
+ if not detail: return [line]
552
+ return [line] + [f"PUBLISHED {h}" for h in pub] + [f"tracked {h}" for h in tracked_hits]
553
+
554
+ def fleet_scan(
555
+ detail:bool = False, # List every finding, not just the per-submodule counts
556
+ strict:bool = False, # Exit 1 on any finding, not only on a published one
557
+ root:str = None, # Superproject root (default: nearest ancestor with .gitmodules)
558
+ ):
559
+ "Find personal data and credentials in tracked files, published ones first"
560
+ style = "block" if detail else "line"
561
+ rep = walk(lambda p, s: scan_one(p, s, detail=detail), root=root,
562
+ workers=8, style=style)
563
+ code = finish(rep, "with findings")
564
+ if code: sys.exit(code)
565
+ # A published hit is live on the web and always fails. A tracked-but-unpublished
566
+ # one is a cleanup job, so it only fails under --strict.
567
+ published = sum(int(o.detail[0].split()[0]) for o in rep.ran if o.detail)
568
+ sys.exit(1 if published or (strict and rep.ran) else 0)
569
+
570
+
400
571
  # %% ../nbs/04_Fleet.ipynb #0281bd2b-91ba-4e49-90dc-d1a95d54d5a6
401
572
  VERBS = {"upload": fleet_upload, "push": fleet_push, "copy": fleet_copy,
402
- "status": fleet_status, "update": fleet_update, "sync": fleet_sync}
573
+ "status": fleet_status, "update": fleet_update, "sync": fleet_sync,
574
+ "scan": fleet_scan}
403
575
 
404
576
  def parse_verb(argv):
405
577
  "(verb, remaining args) for `fleet ...`, or None when `argv` does not name a verb"
@@ -104,17 +104,30 @@ def newest_run(runs):
104
104
  numbered = [r for r in runs if getattr(r, "run_number", None) is not None]
105
105
  return max(numbered, key=lambda r: r.run_number) if numbered else runs[0]
106
106
 
107
- async def _latest_run(gh, sub, candidates, branch="main"):
108
- "Newest run of the first workflow in `candidates` that exists, or None"
107
+ async def _latest_run(gh, sub, candidates, branch="main", head_sha=None):
108
+ """Newest run of the first workflow in `candidates` that exists, or None.
109
+
110
+ With `head_sha`, the run for that exact commit wins, and the branch-wide page is only
111
+ the fallback for a commit that has no run yet. `newest_run` alone is not enough: on
112
+ 2026-10-05 the branch query for DL_methods returned a page whose highest run was #143
113
+ (2026-05-13, failure) while #161 (success) was the newest, so the max of a bad page was
114
+ still a six-month-old FAILURE. Asking for `main`'s own commit cannot be answered with
115
+ another commit's run."""
109
116
  from githubkit.exception import RequestFailed
110
117
  for wf in candidates:
111
118
  try:
112
- r = await gh.rest.actions.async_list_workflow_runs(
113
- sub.owner, sub.repo, wf, branch=branch, per_page=10)
119
+ runs = []
120
+ if head_sha:
121
+ r = await gh.rest.actions.async_list_workflow_runs(
122
+ sub.owner, sub.repo, wf, head_sha=head_sha, per_page=10)
123
+ runs = r.parsed_data.workflow_runs
124
+ if not runs:
125
+ r = await gh.rest.actions.async_list_workflow_runs(
126
+ sub.owner, sub.repo, wf, branch=branch, per_page=10)
127
+ runs = r.parsed_data.workflow_runs
114
128
  except RequestFailed as e:
115
129
  if _missing(e): continue
116
130
  raise
117
- runs = r.parsed_data.workflow_runs
118
131
  if runs:
119
132
  run = newest_run(runs)
120
133
  # conclusion is None while a run is still going, status covers that case
@@ -132,7 +145,7 @@ async def _pages_build(gh, sub):
132
145
  return r.parsed_data.status
133
146
 
134
147
  async def _remote_sha(gh, sub, branch="main", n=7):
135
- "Short sha that `branch` points at on the remote, or None"
148
+ "Sha that `branch` points at on the remote (first `n` chars, all when None), or None"
136
149
  from githubkit.exception import RequestFailed
137
150
  try: r = await gh.rest.git.async_get_ref(sub.owner, sub.repo, f"heads/{branch}")
138
151
  except RequestFailed as e:
@@ -143,11 +156,13 @@ async def _remote_sha(gh, sub, branch="main", n=7):
143
156
  # %% ../nbs/03_GitHub.ipynb #4e0878a3-56be-4568-98f8-57ed92512e50
144
157
  async def repo_status(gh, sub, root=None):
145
158
  "One row: CI run, Pages deploy run, Pages build, and local vs remote sha"
146
- ci, deploy, built, remote = await asyncio.gather(
147
- _latest_run(gh, sub, CI_WORKFLOWS), _latest_run(gh, sub, PAGES_WORKFLOWS),
148
- _pages_build(gh, sub), _remote_sha(gh, sub))
159
+ # main's full sha first, so the run lookups can ask about that exact commit
160
+ head = await _remote_sha(gh, sub, n=None)
161
+ ci, deploy, built = await asyncio.gather(
162
+ _latest_run(gh, sub, CI_WORKFLOWS, head_sha=head),
163
+ _latest_run(gh, sub, PAGES_WORKFLOWS, head_sha=head), _pages_build(gh, sub))
149
164
  return {"path": sub.path, "owner": sub.owner, "repo": sub.repo,
150
- "ci": ci, "deploy": deploy, "built": built, "remote": remote,
165
+ "ci": ci, "deploy": deploy, "built": built, "remote": head and head[:7],
151
166
  "local": local_sha(Path(root or find_root())/sub.path)}
152
167
 
153
168
  def client(token=None):
@@ -179,7 +194,8 @@ async def gather_status(subs, token=None, root=None):
179
194
 
180
195
  # %% ../nbs/03_GitHub.ipynb #d4d8fd92-b4a2-4479-bd76-df3cc7fc9f77
181
196
  FAILED = {"failure", "timed_out", "cancelled", "action_required", "startup_failure"}
182
- RUNNING = {"in_progress", "queued", "requested", "waiting", "pending"}
197
+ RUNNING = {"in_progress", "queued", "requested", "waiting", "pending",
198
+ "building"} # "building" is the Pages build in flight
183
199
  BOLD, RED, YELLOW, CYAN, DIM, OFF = "\033[1m", "\033[31m", "\033[33m", "\033[36m", "\033[2m", "\033[0m"
184
200
 
185
201
  def _state(v):
@@ -214,7 +230,12 @@ def failures(rows):
214
230
 
215
231
  def has_failure(rows):
216
232
  "True when any workflow failed or a Pages build errored"
217
- return bool(failures(rows)) or any(r["built"] not in (None, "built") for r in rows)
233
+ # A Pages build in flight is not a verdict: "building" is the state every
234
+ # healthy deploy passes through, and counting it red made `just ci` exit 1
235
+ # on a fleet that was merely mid-build, with no red row in the table to
236
+ # explain it. Same shape as the `_latest_run` bug in issue #8: a transient
237
+ # state read as a result. Unknown states still count as failures.
238
+ return bool(failures(rows)) or any(r["built"] not in (None, "built", *RUNNING) for r in rows)
218
239
 
219
240
  # %% ../nbs/03_GitHub.ipynb #25434173-a101-4ecd-802a-f349f6233fa3
220
241
  def git(path, *args):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdevAuto
3
- Version: 0.3.71
3
+ Version: 0.3.74
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -72,7 +72,7 @@ from nbdevAuto.pdf import PDFreader
72
72
  | [Functions](00_Functions.ipynb) | Dataset and image helpers: reading a list out of a text file, downloading single images or whole search-driven datasets, verifying and resizing what came back, building the folder layout a classifier expects, running a fastai learner over one image, Kaggle competition and dataset shortcuts, and [`graph`](https://bthek1.github.io/nbdevAuto/functions.html#graph), a `graphviz.Digraph` subclass preloaded with a rounded, filled style |
73
73
  | [Automate](01_Automate.ipynb) | The command line surface: the prep/commit/push pipeline, the GitHub and PyPI release halves, the help output that lists them all, and the one-line-per-stage console grid every command reports through |
74
74
  | [PDF Reader](02_PDF_reader.ipynb) | [`PDFreader`](https://bthek1.github.io/nbdevAuto/pdf_reader.html#pdfreader), a class that converts a PDF through `pdf2image` and renders page ranges inline when you slice it (`pdf[0:5]`) |
75
- | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and `newest_run`, which picks a run by `run_number` because the API’s page order is not a contract |
75
+ | [GitHub](03_GitHub.ipynb) | [`ghstatus`](https://bthek1.github.io/nbdevAuto/github.html#ghstatus): reading `.gitmodules`, resolving a token, and asking the GitHub API about every submodule at once - CI runs, Pages deploys, Pages builds, remote `main` shas - plus the `--report` and `--audit` views, the exit codes that keep “absent” apart from “cannot ask”, and [`newest_run`](https://bthek1.github.io/nbdevAuto/github.html#newest_run), which picks a run by `run_number` because the API’s page order is not a contract |
76
76
  | [Fleet](04_Fleet.ipynb) | [`fleet`](https://bthek1.github.io/nbdevAuto/fleet.html#fleet): running one command across every submodule of a superproject. A walker that reports only what did work and counts the rest, plus the verbs built on it - upload, push, copy, status, update and sync |
77
77
 
78
78
  ------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- __version__ = "0.3.71"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes