nbdevAuto 0.3.70__tar.gz → 0.3.73__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.
- {nbdevauto-0.3.70/nbdevAuto.egg-info → nbdevauto-0.3.73}/PKG-INFO +1 -1
- nbdevauto-0.3.73/nbdevAuto/__init__.py +1 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/_modidx.py +8 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/fleet.py +177 -5
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/github.py +8 -2
- {nbdevauto-0.3.70 → nbdevauto-0.3.73/nbdevAuto.egg-info}/PKG-INFO +1 -1
- nbdevauto-0.3.70/nbdevAuto/__init__.py +0 -1
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/LICENSE +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/MANIFEST.in +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/README.md +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/automate.py +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/core.py +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/functions.py +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto/pdf.py +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto.egg-info/SOURCES.txt +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto.egg-info/dependency_links.txt +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto.egg-info/entry_points.txt +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto.egg-info/requires.txt +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/nbdevAuto.egg-info/top_level.txt +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/pyproject.toml +0 -0
- {nbdevauto-0.3.70 → nbdevauto-0.3.73}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.73"
|
|
@@ -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__ = ['
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
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"
|
|
@@ -179,7 +179,8 @@ async def gather_status(subs, token=None, root=None):
|
|
|
179
179
|
|
|
180
180
|
# %% ../nbs/03_GitHub.ipynb #d4d8fd92-b4a2-4479-bd76-df3cc7fc9f77
|
|
181
181
|
FAILED = {"failure", "timed_out", "cancelled", "action_required", "startup_failure"}
|
|
182
|
-
RUNNING = {"in_progress", "queued", "requested", "waiting", "pending"
|
|
182
|
+
RUNNING = {"in_progress", "queued", "requested", "waiting", "pending",
|
|
183
|
+
"building"} # "building" is the Pages build in flight
|
|
183
184
|
BOLD, RED, YELLOW, CYAN, DIM, OFF = "\033[1m", "\033[31m", "\033[33m", "\033[36m", "\033[2m", "\033[0m"
|
|
184
185
|
|
|
185
186
|
def _state(v):
|
|
@@ -214,7 +215,12 @@ def failures(rows):
|
|
|
214
215
|
|
|
215
216
|
def has_failure(rows):
|
|
216
217
|
"True when any workflow failed or a Pages build errored"
|
|
217
|
-
|
|
218
|
+
# A Pages build in flight is not a verdict: "building" is the state every
|
|
219
|
+
# healthy deploy passes through, and counting it red made `just ci` exit 1
|
|
220
|
+
# on a fleet that was merely mid-build, with no red row in the table to
|
|
221
|
+
# explain it. Same shape as the `_latest_run` bug in issue #8: a transient
|
|
222
|
+
# state read as a result. Unknown states still count as failures.
|
|
223
|
+
return bool(failures(rows)) or any(r["built"] not in (None, "built", *RUNNING) for r in rows)
|
|
218
224
|
|
|
219
225
|
# %% ../nbs/03_GitHub.ipynb #25434173-a101-4ecd-802a-f349f6233fa3
|
|
220
226
|
def git(path, *args):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.70"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|