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