moat-src 0.8.2__py3-none-any.whl → 0.8.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1113 @@
1
+ # command line interface
2
+ # pylint: disable=missing-module-docstring
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ import subprocess
8
+ import sys
9
+ from collections import defaultdict, deque
10
+ from configparser import RawConfigParser
11
+ from pathlib import Path
12
+
13
+ import asyncclick as click
14
+ import git
15
+ import tomlkit
16
+ from anyio import run_process
17
+ from moat.util import P, add_repr, attrdict, make_proc, yload, yprint
18
+ from packaging.requirements import Requirement
19
+ from attrs import define,field
20
+ from shutil import rmtree,copyfile,copytree
21
+ from contextlib import suppress
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ PACK=Path("packaging")
26
+ ARCH=subprocess.check_output(["dpkg","--print-architecture"]).decode("utf-8").strip()
27
+
28
+ def dash(n:str) -> str:
29
+ """
30
+ moat.foo.bar > foo-bar
31
+ foo.bar > ext-foo-bar
32
+ """
33
+ if n in ("main","moat"):
34
+ return "main"
35
+ if "." not in n: # also applies to single-name packages
36
+ return n
37
+
38
+ if not n.startswith("moat."):
39
+ return "ext-"+n.replace("-",".")
40
+ return n.replace(".","-")[5:]
41
+
42
+ def undash(n:str) -> str:
43
+ """
44
+ foo-bar > moat.foo.bar
45
+ ext-foo-bar > foo.bar
46
+ """
47
+ if "." in n:
48
+ return n
49
+
50
+ if n in ("main","moat"):
51
+ return "moat"
52
+ if n.startswith("ext-"):
53
+ return n.replace("-",".")[4:]
54
+ return "moat."+n.replace("-",".")
55
+
56
+ class ChangedError(RuntimeError):
57
+ def __init__(subsys,tag,head):
58
+ self.subsys = subsys
59
+ self.tag = tag
60
+ self.head = head
61
+ def __str__(self):
62
+ s = self.subsys or "Something"
63
+ if head is None:
64
+ head="HEAD"
65
+ else:
66
+ head = head.hexsha[:9]
67
+ return f"{s} changed between {tag.name} and {head}"
68
+
69
+ class _Common:
70
+
71
+ def next_tag(self,major:bool=False,minor:bool=False):
72
+ tag = self.last_tag
73
+ try:
74
+ n = [ int(x) for x in tag.split('.') ]
75
+ if len(n) != 3:
76
+ raise ValueError(n)
77
+ except ValueError:
78
+ raise ValueError(f"Tag {tag} not in major#.minor#.patch# form.") from None
79
+
80
+ if major:
81
+ n = [n[0]+1,0,0]
82
+ elif minor:
83
+ n = [n[0],n[1]+1,0]
84
+ else:
85
+ n = [n[0],n[1],n[2]+1]
86
+ return ".".join(str(x) for x in n)
87
+
88
+ @define
89
+ class Package(_Common):
90
+ _repo:Repo = field(repr=False)
91
+ name:str = field()
92
+ under:str = field(init=False,repr=False)
93
+ path:Path = field(init=False,repr=False)
94
+ files:set(Path) = field(init=False,factory=set,repr=False)
95
+ subs:dict[str,Package] = field(factory=dict,init=False,repr=False)
96
+ hidden:bool = field(init=False,repr=False)
97
+
98
+ def __init__(self, repo, name):
99
+ self.__attrs_init__(repo,name)
100
+ self.under = name.replace(".","_")
101
+ self.path = Path(*name.split("."))
102
+ self.hidden = not (PACK/self.dash).exists()
103
+
104
+ @property
105
+ def dash(self):
106
+ return dash(self.name)
107
+
108
+ def __eq__(self, other):
109
+ return self.name==other.name
110
+
111
+ def __hash__(self):
112
+ return hash(self.name)
113
+
114
+ @property
115
+ def vers(self):
116
+ v = self._repo.versions[self.dash]
117
+ if not isinstance(v,dict):
118
+ tag,commit = v
119
+ v = attrdict(
120
+ tag=tag,
121
+ pkg=1,
122
+ rev=commit,
123
+ )
124
+ self._repo.versions[self.dash] = v
125
+ return v
126
+
127
+ @vers.setter
128
+ def vers(self,d):
129
+ v = self.vers
130
+ v.update(d)
131
+ return v
132
+
133
+ @property
134
+ def last_tag(self):
135
+ return self.vers.tag
136
+
137
+ @property
138
+ def last_commit(self):
139
+ return self.vers.rev
140
+
141
+ @property
142
+ def mdash(self):
143
+ d=dash(self.name)
144
+ if d.startswith("ext-"):
145
+ return d[4:]
146
+ else:
147
+ return "moat-"+d
148
+
149
+ def populate(self, path:Path, real=None):
150
+ """
151
+ Collect this package's file names.
152
+ """
153
+ self.path = path
154
+ for fn in path.iterdir():
155
+ if fn.name == "__pycache__":
156
+ continue
157
+ if (sb := self.subs.get(fn.name,None)) is not None:
158
+ sb.populate(fn, real=self if sb.hidden else None)
159
+ else:
160
+ (real or self).files.add(fn)
161
+
162
+ def copy(self) -> None:
163
+ """
164
+ Copies the current version of this subsystem to its packaging area.
165
+ """
166
+ if not self.files:
167
+ raise ValueError(f"No files in {self.name}?")
168
+ p = Path("packaging")/self.dash
169
+ with suppress(FileNotFoundError):
170
+ rmtree(p/"moat")
171
+ dest = p/self.path
172
+ dest.mkdir(parents=True)
173
+ for f in self.files:
174
+ pf=p/f
175
+ pf.parent.mkdir(parents=True,exist_ok=True)
176
+ if f.is_dir():
177
+ copytree(f, pf, symlinks=False)
178
+ else:
179
+ copyfile(f, pf, follow_symlinks=True)
180
+ licd = p/"LICENSE.txt"
181
+ if not licd.exists():
182
+ copyfile("LICENSE.txt", licd)
183
+
184
+ def has_changes(self, main:bool|None=None) -> bool:
185
+ """
186
+ Test whether the given subsystem changed
187
+ between the head and the @tag commit
188
+ """
189
+ commit = self.last_commit
190
+ head = self._repo.head.commit
191
+ for d in head.diff(self.last_commit):
192
+ if self._repo.repo_for(d.a_path, main) != self.name and self._repo.repo_for(d.b_path, main) != self.name:
193
+ continue
194
+ return True
195
+ return False
196
+
197
+
198
+ class Repo(git.Repo,_Common):
199
+ """Amend git.Repo with tag caching and pseudo-submodule splitting"""
200
+
201
+ moat_tag = None
202
+ _last_tag=None
203
+
204
+ def __init__(self, *a, **k):
205
+ super().__init__(*a, **k)
206
+ self._commit_tags = defaultdict(list)
207
+ self._commit_topo = {}
208
+
209
+ self._repos = {}
210
+ self._make_repos()
211
+
212
+ for t in self.tags:
213
+ self._commit_tags[t.commit].append(t)
214
+
215
+ p = Path(self.working_dir)
216
+ mi = p.parts.index("moat")
217
+ self.moat_name = "-".join(p.parts[mi:])
218
+ with open("versions.yaml") as f:
219
+ self.versions = yload(f, attr=True)
220
+
221
+ def write_tags(self):
222
+ with open("versions.yaml","w") as f:
223
+ yprint(self.versions,f)
224
+ self.index.add("versions.yaml")
225
+
226
+
227
+ @property
228
+ def last_tag(self) -> Tag|None:
229
+ """
230
+ Return the most-recent tag for this repo
231
+ """
232
+ if self._last_tag is not None:
233
+ return self._last_tag
234
+
235
+ for c in self._repo.commits(self.head.commit):
236
+ t = self.tagged(c)
237
+ if t is None:
238
+ continue
239
+
240
+ self._last_tag = t
241
+ return t
242
+
243
+ raise ValueError(f"No tags found")
244
+
245
+ @property
246
+ def last_commit(self) -> str:
247
+ t = self.last_tag
248
+ c = self.tags[t].commit
249
+ return c.hexsha
250
+
251
+ def part(self, name):
252
+ return self._repos[dash(name)]
253
+
254
+ @property
255
+ def _repo(self):
256
+ return self
257
+
258
+ @property
259
+ def parts(self):
260
+ return self._repos.values()
261
+
262
+ def tags_of(self, c:Commit) -> Sequence[Tag]:
263
+ return self._commit_tags[c]
264
+
265
+ def _add_repo(self, name):
266
+ dn = dash(name)
267
+ pn = undash(name)
268
+ if dn in self._repos:
269
+ return self._repos[dn]
270
+
271
+ p = Package(self,pn)
272
+ self._repos[dn] = p
273
+ if "." in pn:
274
+ par,nam = pn.rsplit(".",1)
275
+ pp = self._add_repo(par)
276
+ pp.subs[nam] = p
277
+ return p
278
+
279
+ def _make_repos(self) -> dict:
280
+ """Collect subrepos"""
281
+ for fn in Path("packaging").iterdir():
282
+ if fn.name == "main":
283
+ continue
284
+ if not fn.is_dir() or "." in fn.name:
285
+ continue
286
+ self._add_repo(str(fn.name))
287
+
288
+ self._repos["main"].populate(Path("moat"))
289
+
290
+ def repo_for(self, path:Path|str, main:bool|None) -> str:
291
+ """
292
+ Given a file path, returns the subrepo in question
293
+ """
294
+ sc = self._repos["main"]
295
+ path=Path(path)
296
+
297
+ if main is not False and path.parts[0] == "moat":
298
+ name = "moat"
299
+ for p in path.parts[1:]:
300
+ if p in sc.subs:
301
+ name += "."+p
302
+ sc = sc.subs[p]
303
+ else:
304
+ break
305
+ return name
306
+
307
+ if main is not True and path.parts[0] == "packaging":
308
+ try:
309
+ return undash(path.parts[1])
310
+ except IndexError:
311
+ return None
312
+
313
+ return None
314
+
315
+
316
+ def commits(self, ref=None):
317
+ """Iterate over topo sort of commits following @ref, or HEAD.
318
+
319
+ WARNING: this code does not do a true topological breadth-first
320
+ search. Doesn't matter much for simple merges that are based on
321
+ a mostly-current checkout, but don't expect correctness when branches
322
+ span tags.
323
+ """
324
+ if ref is None:
325
+ ref = self.head.commit
326
+
327
+ visited = set()
328
+ work = deque([ref])
329
+ while work:
330
+ ref = work.popleft()
331
+ if ref in visited:
332
+ continue
333
+ visited.add(ref)
334
+ yield ref
335
+ work.extend(ref.parents)
336
+
337
+ def has_changes(self, main:bool|None=None) -> bool:
338
+ """
339
+ Test whether any subsystem changed since the "tagged" commit
340
+
341
+ """
342
+ if tag is None:
343
+ tag = self.last_tag
344
+ head = self._repo.head.commit
345
+ for d in head.diff(tag):
346
+ if self.repo_for(d.a_path, main) == "moat" and self.repo_for(d.b_path, main) == "moat":
347
+ continue
348
+ return True
349
+ return False
350
+
351
+
352
+ def tagged(self, c:Commit=None) -> Tag|None:
353
+ """Return a commit's tag name.
354
+ Defaults to the head commit.
355
+ Returns None if no tag, raises ValueError if more than one is found.
356
+ """
357
+ if c is None:
358
+ c = self.head.commit
359
+ if c not in self._commit_tags:
360
+ return None
361
+ tt = self._commit_tags[c]
362
+
363
+ tt = [t for t in tt if "/" not in t.name]
364
+
365
+ if not tt:
366
+ return None
367
+ if len(tt) > 1:
368
+ if subsys is not None:
369
+ raise ValueError(f"Multiple tags for {subsys}: {tt}")
370
+ raise ValueError(f"Multiple tags: {tt}")
371
+ return tt[0].name
372
+
373
+
374
+
375
+ @click.group(short_help="Manage MoaT itself")
376
+ async def cli():
377
+ """
378
+ This collection of commands is useful for managing and building MoaT itself.
379
+ """
380
+ pass
381
+
382
+
383
+ def fix_deps(deps: list[str], tags: dict[str, str]) -> bool:
384
+ """Adjust dependencies"""
385
+ work = False
386
+ for i, dep in enumerate(deps):
387
+ r = Requirement(dep)
388
+ if r.name in tags:
389
+ dep = f"{r.name} ~= {tags[r.name]}"
390
+ if deps[i] != dep:
391
+ deps[i] = dep
392
+ work = True
393
+ return work
394
+
395
+
396
+ def run_tests(pkg: str|None, *opts) -> bool:
397
+ """Run subtests for subpackage @pkg."""
398
+
399
+ if pkg is None:
400
+ tests = Path("tests")
401
+ else:
402
+ tests = dash(pkg).replace("-","_")
403
+ tests = Path("tests")/tests
404
+
405
+ if not Path(tests):
406
+ # No tests. Assume it's OK.
407
+ return True
408
+ try:
409
+ print("\n*** Testing:", pkg)
410
+ # subprocess.run(["python3", "-mtox"], cwd=repo.working_dir, check=True)
411
+ subprocess.run(["python3","-mpytest", *opts, tests], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, check=True)
412
+ except subprocess.CalledProcessError:
413
+ return False
414
+ else:
415
+ return True
416
+
417
+
418
+ class Replace:
419
+ """Encapsulates a series of string replacements."""
420
+
421
+ def __init__(self, **kw):
422
+ self.changes = kw
423
+
424
+ def __call__(self, s):
425
+ if isinstance(s, str):
426
+ for k, v in self.changes.items():
427
+ s = s.replace(k, v)
428
+ return s
429
+
430
+
431
+ _l_t = (list, tuple)
432
+
433
+
434
+ def default_dict(a, b, c, cls=dict, repl=lambda x: x) -> dict:
435
+ """
436
+ Returns a dict with all keys+values of all dict arguments.
437
+ The first found value wins.
438
+
439
+ This operation is recursive and non-destructive.
440
+
441
+ Args:
442
+ cls (type): a class to instantiate the result with. Default: dict.
443
+ Often used: :class:`attrdict`.
444
+ """
445
+ keys = defaultdict(list)
446
+ mod = False
447
+
448
+ for kv in a, b, c:
449
+ if kv is None:
450
+ continue
451
+ for k, v in kv.items():
452
+ keys[k].append(v)
453
+
454
+ for k, v in keys.items():
455
+ va = a.get(k, None)
456
+ vb = b.get(k, None)
457
+ vc = c.get(k, None)
458
+ if isinstance(va, str) and va == "DELETE":
459
+ if vc is None:
460
+ try:
461
+ del b[k]
462
+ except KeyError:
463
+ pass
464
+ else:
465
+ mod = True
466
+ continue
467
+ else:
468
+ b[k] = {} if isinstance(vc, dict) else [] if isinstance(vc, _l_t) else 0
469
+ vb = b[k]
470
+ va = None
471
+ if isinstance(va, dict) or isinstance(vb, dict) or isinstance(vc, dict):
472
+ if vb is None:
473
+ b[k] = {}
474
+ vb = b[k]
475
+ mod = True
476
+ mod = default_dict(va or {}, vb, vc or {}, cls=cls, repl=repl) or mod
477
+ elif isinstance(va, _l_t) or isinstance(vb, _l_t) or isinstance(vc, _l_t):
478
+ if vb is None:
479
+ b[k] = []
480
+ vb = b[k]
481
+ mod = True
482
+ if va:
483
+ for vv in va:
484
+ vv = repl(vv)
485
+ if vv not in vb:
486
+ vb.insert(0, vv)
487
+ mod = True
488
+ if vc:
489
+ for vv in vc:
490
+ vv = repl(vv)
491
+ if vv not in vb:
492
+ vb.insert(0, vv)
493
+ mod = True
494
+ else:
495
+ v = repl(va) or vb or repl(vc)
496
+ if vb != v:
497
+ b[k] = v
498
+ mod = True
499
+ return mod
500
+
501
+
502
+ def is_clean(repo: Repo, skip: bool = True) -> bool:
503
+ """Check if this repository is clean."""
504
+ skips = " Skipping." if skip else ""
505
+ if repo.head.is_detached:
506
+ print(f"{repo.working_dir}: detached.{skips}")
507
+ return False
508
+ if repo.head.ref.name not in {"main", "moat"}:
509
+ print(f"{repo.working_dir}: on branch {repo.head.ref.name}.{skips}")
510
+ return False
511
+ elif repo.is_dirty(index=True, working_tree=True, untracked_files=True, submodules=False):
512
+ print(f"{repo.working_dir}: Dirty.{skips}")
513
+ return False
514
+ return True
515
+
516
+
517
+ def _mangle(proj, path, mangler):
518
+ try:
519
+ for k in path[:-1]:
520
+ proj = proj[k]
521
+ k = path[-1]
522
+ v = proj[k]
523
+ except KeyError:
524
+ return
525
+ v = mangler(v)
526
+ proj[k] = v
527
+
528
+
529
+ def decomma(proj, path):
530
+ """comma-delimited string > list"""
531
+ _mangle(proj, path, lambda x: x.split(","))
532
+
533
+
534
+ def encomma(proj, path):
535
+ """list > comma-delimited string"""
536
+ _mangle(proj, path, lambda x: ",".join(x)) # pylint: disable=unnecessary-lambda
537
+
538
+
539
+ def apply_hooks(repo, force=False):
540
+ h = Path(repo.git_dir) / "hooks"
541
+ drop = set()
542
+ seen = set()
543
+ for f in h.iterdir():
544
+ if f.suffix == ".sample":
545
+ drop.add(f)
546
+ continue
547
+ seen.add(f.name)
548
+ for f in drop:
549
+ f.unlink()
550
+
551
+ pt = Path(__file__).parent / "_hooks"
552
+ for f in pt.iterdir():
553
+ if not force:
554
+ if f.name in seen:
555
+ continue
556
+ t = h / f.name
557
+ d = f.read_text()
558
+ t.write_text(d)
559
+ t.chmod(0o755)
560
+
561
+
562
+ def apply_templates(repo):
563
+ """
564
+ Apply templates to this repo.
565
+ """
566
+ commas = (
567
+ P("tool.tox.tox.envlist"),
568
+ P("tool.pylint.messages_control.enable"),
569
+ P("tool.pylint.messages_control.disable"),
570
+ )
571
+
572
+ rpath = Path(repo.working_dir)
573
+ mti = rpath.parts.index("moat")
574
+ mtp = rpath.parts[mti:]
575
+
576
+ rname = "-".join(mtp)
577
+ rdot = ".".join(mtp)
578
+ rpath = "/".join(mtp)
579
+ runder = "_".join(mtp)
580
+ repl = Replace(
581
+ SUBNAME=rname,
582
+ SUBDOT=rdot,
583
+ SUBPATH=rpath,
584
+ SUBUNDER=runder,
585
+ )
586
+ pt = (Path(__file__).parent / "_templates").joinpath
587
+ pr = Path(repo.working_dir).joinpath
588
+ with pt("pyproject.forced.yaml").open("r") as f:
589
+ t1 = yload(f)
590
+ with pt("pyproject.default.yaml").open("r") as f:
591
+ t2 = yload(f)
592
+ try:
593
+ with pr("pyproject.toml").open("r") as f:
594
+ proj = tomlkit.load(f)
595
+ try:
596
+ tx = proj["tool"]["tox"]["legacy_tox_ini"]
597
+ except KeyError:
598
+ pass
599
+ else:
600
+ txp = RawConfigParser()
601
+ txp.read_string(tx)
602
+ td = {}
603
+ for k, v in txp.items():
604
+ td[k] = ttd = dict()
605
+ for kk, vv in v.items():
606
+ if isinstance(vv, str) and vv[0] == "\n":
607
+ vv = [x.strip() for x in vv.strip().split("\n")]
608
+ ttd[kk] = vv
609
+ proj["tool"]["tox"] = td
610
+
611
+ for p in commas:
612
+ decomma(proj, p)
613
+
614
+ except FileNotFoundError:
615
+ proj = tomlkit.TOMLDocument()
616
+ mod = default_dict(t1, proj, t2, repl=repl, cls=tomlkit.items.Table)
617
+ try:
618
+ proc = proj["tool"]["moat"]["fixup"]
619
+ except KeyError:
620
+ p = proj
621
+ else:
622
+ del proj["tool"]["moat"]["fixup"]
623
+ proc = make_proc(proc, ("toml",), f"{pr('pyproject.toml')}:tool.moat.fixup")
624
+ s1 = proj.as_string()
625
+ proc(proj)
626
+ s2 = proj.as_string()
627
+ mod |= s1 != s2
628
+
629
+ if mod:
630
+ for p in commas:
631
+ encomma(proj, p)
632
+
633
+ try:
634
+ tx = proj["tool"]["tox"]
635
+ except KeyError:
636
+ pass
637
+ else:
638
+ txi = io.StringIO()
639
+ txp = RawConfigParser()
640
+ for k, v in tx.items():
641
+ if k != "DEFAULT":
642
+ txp.add_section(k)
643
+ for kk, vv in v.items():
644
+ if isinstance(vv, (tuple, list)):
645
+ vv = "\n " + "\n ".join(str(x) for x in vv)
646
+ txp.set(k, kk, vv)
647
+ txp.write(txi)
648
+ txi = txi.getvalue()
649
+ txi = "\n" + txi.replace("\n\t", "\n ")
650
+ proj["tool"]["tox"] = dict(
651
+ legacy_tox_ini=tomlkit.items.String.from_raw(
652
+ txi,
653
+ type_=tomlkit.items.StringType.MLB,
654
+ ),
655
+ )
656
+
657
+ projp = Path(repo.working_dir) / "pyproject.toml"
658
+ projp.write_text(proj.as_string())
659
+ repo.index.add(projp)
660
+
661
+ mkt = repl(pt("Makefile").read_text())
662
+ try:
663
+ mk = pr("Makefile").read_text()
664
+ except FileNotFoundError:
665
+ mk = ""
666
+ if mkt != mk:
667
+ pr("Makefile").write_text(mkt)
668
+ repo.index.add(pr("Makefile"))
669
+
670
+ init = repl(pt("moat", "__init__.py").read_text())
671
+ try:
672
+ mk = pr("moat", "__init__.py").read_text()
673
+ except FileNotFoundError:
674
+ mk = ""
675
+ if mkt != mk:
676
+ if not pr("moat").is_dir():
677
+ pr("moat").mkdir(mode=0o755)
678
+ pr("moat", "__init__.py").write_text(init)
679
+ repo.index.add(pr("moat", "__init__.py"))
680
+
681
+ tst = pr("tests")
682
+ if not tst.is_dir():
683
+ tst.mkdir()
684
+ for n in tst.iterdir():
685
+ if n.name.startswith("test_"):
686
+ break
687
+ else:
688
+ tp = pt("test_basic_py").read_text()
689
+ tb = pr("tests") / "test_basic.py"
690
+ tb.write_text(repl(tp))
691
+ repo.index.add(tb)
692
+
693
+ try:
694
+ with pr(".gitignore").open("r") as f:
695
+ ign = f.readlines()
696
+ except FileNotFoundError:
697
+ ign = []
698
+ o = len(ign)
699
+ with pt("gitignore").open("r") as f:
700
+ for li in f:
701
+ if li not in ign:
702
+ ign.append(li)
703
+ if len(ign) != o:
704
+ with pr(".gitignore").open("w") as f:
705
+ for li in ign:
706
+ f.write(li)
707
+ repo.index.add(pr(".gitignore"))
708
+
709
+
710
+ @cli.command("path")
711
+ def path_():
712
+ "Path to source templates"
713
+ print(Path(__file__).parent / "_templates")
714
+
715
+
716
+ @cli.command()
717
+ def tags():
718
+ """
719
+ List all tags
720
+ """
721
+ repo = Repo(None)
722
+
723
+ for r in repo.parts:
724
+ try:
725
+ tag = r.last_tag
726
+ except KeyError:
727
+ continue
728
+ if r.has_changes(True):
729
+ print(f"{r.dash} {tag} STALE")
730
+ elif r.has_changes(True):
731
+ print(f"{r.dash} {tag} REBUILD")
732
+ else:
733
+ print(f"{r.dash} {tag}")
734
+
735
+ @cli.command()
736
+ @click.option("-r", "--run", is_flag=True, help="actually do the tagging")
737
+ @click.option("-m", "--minor", is_flag=True, help="create a new minor version")
738
+ @click.option("-M", "--major", is_flag=True, help="create a new major version")
739
+ @click.option("-s", "--subtree", type=str, help="Tag this partial module")
740
+ @click.option("-v", "--tag", "force", type=str, help="Use this explicit tag value")
741
+ @click.option("-q", "--query","--show","show", is_flag=True, help="Show the latest tag")
742
+ @click.option("-f", "--force","FORCE", is_flag=True, help="replace an existing tag")
743
+ @click.option("-b", "--build", is_flag=True, help="set/increment the build number")
744
+ def tag(run,minor,major,subtree,force,FORCE,show,build):
745
+ """
746
+ Tag the repository (or a subtree).
747
+
748
+ MoaT versions are of the form ``a.b.c``. Binaries also have a build
749
+ number. This command auto-increments ``c`` and sets the build to ``1``,
750
+ except when you use ``-M|-m|-b``.
751
+ """
752
+ if minor and major:
753
+ raise click.UsageError("Can't change both minor and major!")
754
+ if force and (minor or major):
755
+ raise click.UsageError("Can't use an explicit tag with changing minor or major!")
756
+ if FORCE and (minor or major):
757
+ raise click.UsageError("Can't reuse a tag and also change minor or major!")
758
+ if (build or force) and (minor or major or (build and force)):
759
+ raise click.UsageError("Can't update both build and tag!")
760
+ if show and (run or force or minor or major):
761
+ raise click.UsageError("Can't display and change the tag at the same time!")
762
+ if build and not subtree:
763
+ raise click.UsageError("The main release number doesn't have a build")
764
+
765
+ repo = Repo(None)
766
+
767
+ if subtree:
768
+ r = repo.part(subtree)
769
+ else:
770
+ r = repo
771
+
772
+ if show:
773
+ tag = r.last_tag
774
+ if r.has_changes():
775
+ print(f"{tag} STALE")
776
+ else:
777
+ print(tag)
778
+ return
779
+
780
+ if force:
781
+ tag = force
782
+ elif FORCE or build:
783
+ tag = r.last_tag
784
+ else:
785
+ tag = r.next_tag(major,minor)
786
+
787
+ if run or subtree:
788
+ if subtree:
789
+ sb = repo.part(r.dash)
790
+ if build:
791
+ sb.vers.pkg += 1
792
+ sb.vers.rev=repo.head.commit.hexsha
793
+ else:
794
+ sb.vers = attrdict(
795
+ tag=tag,
796
+ pkg=1,
797
+ rev=repo.head.commit.hexsha,
798
+ )
799
+ repo.write_tags()
800
+ else:
801
+ git.TagReference.create(repo,tag, force=FORCE)
802
+ print(f"{tag}")
803
+ else:
804
+ print(f"{tag} DRY_RUN")
805
+
806
+
807
+ @cli.command(epilog="""
808
+ The default for building Debian packages is '--no-sign --build=binary'.
809
+ '--no-sign' is dropped when you use '--deb'.
810
+ The binary-only build is currently unconditional.
811
+
812
+ The default for uploading to Debian via 'dput' is '--unchecked ext';
813
+ it is dropped when you use '--dput'.
814
+ """)
815
+ @click.option("-f", "--no-dirty", is_flag=True, help="don't check for dirtiness (DANGER)")
816
+ @click.option("-F", "--no-tag", is_flag=True, help="don't check for tag uptodate-ness (DANGER)")
817
+ @click.option("-D", "--no-deb", is_flag=True, help="don't build Debian packages")
818
+ @click.option("-C", "--no-commit", is_flag=True, help="don't commit the result")
819
+ @click.option("-V", "--no-version", is_flag=True, help="don't update dependency versions in pyproject files")
820
+ @click.option("-P", "--no-pypi", is_flag=True, help="don't push to PyPI")
821
+ @click.option("-T", "--no-test", is_flag=True, help="don't run tests")
822
+ @click.option("-o", "--pytest", "pytest_opts", type=str,multiple=True, help="Options for pytest")
823
+ @click.option("-d", "--deb", "deb_opts", type=str,multiple=True, help="Options for debuild")
824
+ @click.option("-p", "--dput", "dput_opts", type=str,multiple=True, help="Options for dput")
825
+ @click.option("-r", "--run", is_flag=True, help="actually do the tagging")
826
+ @click.option("-s", "--skip", "skip_", type=str,multiple=True, help="skip these repos")
827
+ @click.option("-m", "--minor", is_flag=True, help="create a new minor version")
828
+ @click.option("-M", "--major", is_flag=True, help="create a new major version")
829
+ @click.option("-t", "--tag", "forcetag", type=str, help="Use this explicit tag value")
830
+ @click.option("-a", "--auto-tag", "autotag", is_flag=True, help="Auto-retag updated packages")
831
+ @click.option(
832
+ "-v",
833
+ "--version",
834
+ type=(str, str),
835
+ multiple=True,
836
+ help="Update external dependency",
837
+ )
838
+ @click.argument("parts", nargs=-1)
839
+ async def build(no_commit, no_dirty, no_test, no_tag, no_pypi, parts, dput_opts, pytest_opts, deb_opts, run, version, no_version, no_deb, skip_, major,minor,forcetag,autotag):
840
+ """
841
+ Rebuild all modified packages.
842
+ """
843
+ repo = Repo(None)
844
+
845
+ tags = dict(version)
846
+ skip = set()
847
+ for s in skip_:
848
+ for sn in s.split(","):
849
+ skip.add(dash(sn))
850
+ parts = set(dash(s) for s in parts)
851
+ debversion={}
852
+
853
+ if no_tag and not no_version:
854
+ print("Warning: not updating moat versions in pyproject files", file=sys.stderr)
855
+ if minor and major:
856
+ raise click.UsageError("Can't change both minor and major!")
857
+ if autotag and no_tag:
858
+ raise click.UsageError("Can't change tags without verifying them!")
859
+ if forcetag and (minor or major):
860
+ raise click.UsageError("Can't use an explicit tag with changing minor or major!")
861
+
862
+ if forcetag is None:
863
+ forcetag = repo.next_tag(major,minor)
864
+
865
+ full = False
866
+ if parts:
867
+ repos = [ repo.part(x) for x in parts ]
868
+ else:
869
+ if not skip:
870
+ full = True
871
+ repos = [ x for x in repo.parts if not x.hidden and x.dash not in skip and not (PACK/x.dash/"SKIP").exists() ]
872
+
873
+ for name in PACK.iterdir():
874
+ if name.suffix != ".changes":
875
+ continue
876
+ name=name.stem
877
+ name,vers,_ = name.split("_")
878
+ if name.startswith("moat-"):
879
+ name = name[5:]
880
+ else:
881
+ name = "ext-"+name
882
+ debversion[name]=vers.rsplit("-",1)[0]
883
+
884
+
885
+ # Step 0: basic check
886
+ if not no_dirty:
887
+ if repo.is_dirty(index=False, working_tree=True, untracked_files=True, submodules=False):
888
+ if not run:
889
+ print("*** Repository is not clean.", file=sys.stderr)
890
+ else:
891
+ print("Please commit changes and try again.", file=sys.stderr)
892
+ return
893
+
894
+ # Step 1: check for changed files since last tagging
895
+ if autotag:
896
+ for r in repos:
897
+ if r.has_changes(True):
898
+ r.vers = attrdict(
899
+ tag=r.next_tag(),
900
+ pkg=1,
901
+ rev=repo.head.commit.hexsha,
902
+ )
903
+ elif r.has_changes(False):
904
+ r.vers.pkg += 1
905
+ r.vers.rev=repo.head.commit.hexsha
906
+
907
+ elif not no_tag:
908
+ err = set()
909
+ for r in repos:
910
+ try:
911
+ tag = r.last_tag
912
+ except KeyError:
913
+ rd = PACK/r.dash
914
+ p = rd / "pyproject.toml"
915
+ if not p.is_file():
916
+ continue
917
+ raise
918
+ tags[r.mdash] = tag
919
+ if r.has_changes():
920
+ err.add(r.dash)
921
+ if err:
922
+ if not run:
923
+ print("*** Untagged changes:", file=sys.stderr)
924
+ print("***", *err, file=sys.stderr)
925
+ else:
926
+ print("Untagged changes:", file=sys.stderr)
927
+ print(*err, file=sys.stderr)
928
+ print("Please tag (moat src tag -s PACKAGE) and try again.", file=sys.stderr)
929
+ return
930
+
931
+ # Step 2: run tests
932
+ if not no_test:
933
+ fails = set()
934
+ for p in parts:
935
+ if not run_tests(p, *pytest_opts):
936
+ fails.add(p.name)
937
+ if fails:
938
+ if not run:
939
+ print(f"*** Tests failed:", *fails, file=sys.stderr)
940
+ else:
941
+ print(f"Failed tests:", *fails, file=sys.stderr)
942
+ print(f"Fix and try again.", file=sys.stderr)
943
+ return
944
+
945
+ # Step 3: set version and fix versioned dependencies
946
+ for r in repos:
947
+ rd = PACK/r.dash
948
+ p = rd / "pyproject.toml"
949
+ if not p.is_file():
950
+ # bad=True
951
+ print("Skip:", r.name, file=sys.stderr)
952
+ continue
953
+ with p.open("r") as f:
954
+ pr = tomlkit.load(f)
955
+ pr["project"]["version"] = r.last_tag
956
+
957
+ if not no_version:
958
+ try:
959
+ deps = pr["project"]["dependencies"]
960
+ except KeyError:
961
+ pass
962
+ else:
963
+ fix_deps(deps, tags)
964
+ try:
965
+ deps = pr["project"]["optional_dependencies"]
966
+ except KeyError:
967
+ pass
968
+ else:
969
+ for v in deps.values():
970
+ fix_deps(v, tags)
971
+
972
+ p.write_text(pr.as_string())
973
+ repo.index.add(p)
974
+
975
+ # Step 3: copy to packaging dir
976
+ for r in repos:
977
+ r.copy()
978
+
979
+ # Step 4: build Debian package
980
+ if not no_deb:
981
+ if not deb_opts:
982
+ deb_opts = ["--no-sign"]
983
+
984
+ for r in repos:
985
+ rd=PACK/r.dash
986
+ p = rd / "debian"
987
+ if not p.is_dir():
988
+ continue
989
+ try:
990
+ res = subprocess.run(["dpkg-parsechangelog","-l","debian/changelog","-S","version"], cwd=rd, check=True, stdout=subprocess.PIPE)
991
+ tag = res.stdout.strip().decode("utf-8").rsplit("-",1)[0]
992
+ ltag = r.last_tag
993
+ if tag != ltag:
994
+ subprocess.run(["debchange", "--distribution","unstable", "--newversion",f"{ltag}-{r.vers.pkg}",f"New release for {forcetag}"] , cwd=rd, check=True)
995
+ repo.index.add(p/"changelog")
996
+
997
+ if debversion.get(r.dash,"") != ltag:
998
+ subprocess.run(["debuild", "--build=binary"] + deb_opts, cwd=rd, check=True)
999
+ except subprocess.CalledProcessError:
1000
+ if not run:
1001
+ print("*** Failure packaging",r.name,file=sys.stderr)
1002
+ else:
1003
+ print("Failure packaging",r.name,file=sys.stderr)
1004
+ return
1005
+
1006
+ # Step 5: build PyPI package
1007
+ if not no_pypi:
1008
+ err=set()
1009
+ up=set()
1010
+ for r in repos:
1011
+ rd=PACK/r.dash
1012
+ p = rd / "pyproject.toml"
1013
+ if not p.is_file():
1014
+ continue
1015
+ tag = r.last_tag
1016
+ name = r.dash
1017
+ if name.startswith("ext-"):
1018
+ name=name[4:]
1019
+ else:
1020
+ name="moat-"+r.dash
1021
+
1022
+ targz = rd/"dist"/f"{r.under}-{tag}.tar.gz"
1023
+ done = rd/"dist"/f"{r.under}-{tag}.done"
1024
+ if targz.is_file():
1025
+ if not done.exists():
1026
+ up.add(r)
1027
+ else:
1028
+ try:
1029
+ subprocess.run(["python3", "-mbuild", "-snw"], cwd=rd, check=True)
1030
+ except subprocess.CalledProcessError:
1031
+ err.add(r.name)
1032
+ else:
1033
+ up.add(r)
1034
+ if err:
1035
+ if not run:
1036
+ print("*** Build errors:", file=sys.stderr)
1037
+ print("***", *err, file=sys.stderr)
1038
+ else:
1039
+ print("Build errors:", file=sys.stderr)
1040
+ print(*err, file=sys.stderr)
1041
+ print("Please fix and try again.", file=sys.stderr)
1042
+ return
1043
+
1044
+ # Step 6: upload PyPI package
1045
+ if run:
1046
+ err=set()
1047
+ for r in up:
1048
+ rd=PACK/r.dash
1049
+ p = rd / "pyproject.toml"
1050
+ if not p.is_file():
1051
+ continue
1052
+ tag = r.last_tag
1053
+ name = r.dash
1054
+ if name.startswith("ext-"):
1055
+ name=name[4:]
1056
+ else:
1057
+ name="moat-"+r.dash
1058
+ targz = Path("dist")/f"{r.under}-{tag}.tar.gz"
1059
+ whl = Path("dist")/f"{r.under}-{tag}-py3-none-any.whl"
1060
+ try:
1061
+ res = subprocess.run(["twine", "upload", str(targz), str(whl)], cwd=rd, check=True)
1062
+ except subprocess.CalledProcessError:
1063
+ err.add(r.name)
1064
+ else:
1065
+ done = rd/"dist"/f"{r.under}-{tag}.done"
1066
+ done.touch()
1067
+ if err:
1068
+ print("Upload errors:", file=sys.stderr)
1069
+ print(*err, file=sys.stderr)
1070
+ print("Please fix(?) and try again.", file=sys.stderr)
1071
+ return
1072
+
1073
+ # Step 7: upload Debian package
1074
+ if run and not no_deb:
1075
+ err = set()
1076
+ if not dput_opts:
1077
+ dput_opts = ["-u","ext"]
1078
+ for r in repos:
1079
+ ltag = r.last_tag
1080
+ if not (PACK/r.dash/"debian").is_dir():
1081
+ continue
1082
+ changes = PACK/f"{r.mdash}_{ltag}-{r.vers.pkg}_{ARCH}.changes"
1083
+ done = PACK/f"{r.mdash}_{ltag}-{r.vers.pkg}_{ARCH}.done"
1084
+ if done.exists():
1085
+ continue
1086
+ try:
1087
+ subprocess.run(["dput", *dput_opts, str(changes)], check=True)
1088
+ except subprocess.CalledProcessError:
1089
+ err.add(r.name)
1090
+ else:
1091
+ done.touch()
1092
+ if err:
1093
+ print("Upload errors:", file=sys.stderr)
1094
+ print(*err, file=sys.stderr)
1095
+ print("Please fix(?) and try again.", file=sys.stderr)
1096
+ return
1097
+
1098
+ # Step 8: commit the result
1099
+ if run:
1100
+ for r in repos:
1101
+ r.vers.rev = repo.head.commit.hexsha
1102
+ repo.write_tags()
1103
+
1104
+ if not no_commit:
1105
+ repo.index.commit(f"Build version {forcetag}")
1106
+ git.TagReference.create(repo, forcetag)
1107
+
1108
+
1109
+ add_repr(tomlkit.items.String)
1110
+ add_repr(tomlkit.items.Integer)
1111
+ add_repr(tomlkit.items.Bool, bool)
1112
+ add_repr(tomlkit.items.AbstractTable)
1113
+ add_repr(tomlkit.items.Array)