moat-src 0.6.0__py3-none-any.whl → 0.8.1__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,319 @@ 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)
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
+ tag,commit = self._repo.versions[self.dash]
163
+ if unchanged and self.has_changes(commit):
164
+ raise ChangedError(subsys,t,ref)
165
+ return tag,commit
166
+
167
+ def has_changes(self, tag:Commit=None) -> bool:
168
+ """
169
+ Test whether the given subsystem (or any subsystem)
170
+ changed between the head and the @tag commit
171
+ """
172
+ if tag is None:
173
+ tag,commit = self.last_tag()
174
+ else:
175
+ commit = tag
176
+ head = self._repo.head.commit
177
+ for d in head.diff(commit):
178
+ if self._repo.repo_for(d.a_path) != self.name and self._repo.repo_for(d.b_path) != self.name:
179
+ continue
180
+ return True
181
+ return False
182
+
183
+
184
+ class Repo(git.Repo,_Common):
185
+ """Amend git.Repo with tag caching and pseudo-submodule splitting"""
24
186
 
25
187
  moat_tag = None
26
- submod = None
188
+ _last_tag=None
27
189
 
28
190
  def __init__(self, *a, **k):
29
191
  super().__init__(*a, **k)
30
- self._subrepo_cache = {}
31
192
  self._commit_tags = defaultdict(list)
32
193
  self._commit_topo = {}
33
194
 
195
+ self._repos = {}
196
+ self._make_repos()
197
+
34
198
  for t in self.tags:
35
199
  self._commit_tags[t.commit].append(t)
36
200
 
37
201
  p = Path(self.working_dir)
38
202
  mi = p.parts.index("moat")
39
203
  self.moat_name = "-".join(p.parts[mi:])
204
+ with open("versions.yaml") as f:
205
+ self.versions = yload(f)
40
206
 
41
- def subrepos(self, recurse=True, depth=True, same=True):
42
- """List subrepositories (and cache them)."""
207
+ def write_tags(self):
208
+ with open("versions.yaml","w") as f:
209
+ yprint(self.versions,f)
210
+ self.index.add("versions.yaml")
43
211
 
44
- if same and recurse and not depth:
45
- yield self
46
212
 
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
213
+ def last_tag(self, unchanged:bool=False) -> Tag|None:
214
+ """
215
+ Return the most-recent tag for this repo
216
+ """
217
+ if self._last_tag is not None:
218
+ return self._last_tag,self._last_tag
219
+ for c in self._repo.commits(self.head.commit):
220
+ t = self.tagged(c)
221
+ if t is None:
222
+ continue
63
223
 
64
- if same and recurse and depth:
65
- yield self
224
+ self._last_tag = t
225
+ if unchanged and self.has_changes(c):
226
+ raise ChangedError(subsys,t)
227
+ return t,t
228
+
229
+ raise ValueError(f"No tags found")
230
+
231
+ def part(self, name):
232
+ return self._repos[dash(name)]
233
+
234
+ @property
235
+ def _repo(self):
236
+ return self
237
+
238
+ @property
239
+ def parts(self):
240
+ return self._repos.values()
241
+
242
+ def tags_of(self, c:Commit) -> Sequence[Tag]:
243
+ return self._commit_tags[c]
244
+
245
+ def _add_repo(self, name):
246
+ dn = dash(name)
247
+ pn = undash(name)
248
+ if dn in self._repos:
249
+ return self._repos[dn]
250
+
251
+ p = Package(self,pn)
252
+ self._repos[dn] = p
253
+ if "." in pn:
254
+ par,nam = pn.rsplit(".",1)
255
+ pp = self._add_repo(par)
256
+ pp.subs[nam] = p
257
+ return p
258
+
259
+ def _make_repos(self) -> dict:
260
+ """Collect subrepos"""
261
+ for fn in Path("packaging").iterdir():
262
+ if fn.name == "main":
263
+ continue
264
+ if not fn.is_dir() or "." in fn.name:
265
+ continue
266
+ self._add_repo(str(fn.name))
267
+
268
+ self._repos["main"].populate(Path("moat"))
269
+
270
+ def repo_for(self, path:Path|str) -> str:
271
+ """
272
+ Given a file path, returns the subrepo in question
273
+ """
274
+ name = "moat"
275
+ sc = self._repos["main"]
276
+ path=Path(path)
277
+ try:
278
+ if path.parts[0] == "packaging":
279
+ return path.parts[1].replace("-",".")
280
+ except KeyError:
281
+ return name
282
+
283
+ if path.parts[0] != "moat":
284
+ return name
285
+
286
+ for p in path.parts[1:]:
287
+ if p in sc.subs:
288
+ name += "."+p
289
+ sc = sc.subs[p]
290
+ else:
291
+ break
292
+ return name
66
293
 
67
294
  def commits(self, ref=None):
68
- """Iterate over topo sort of commits following @ref, or HEAD"""
295
+ """Iterate over topo sort of commits following @ref, or HEAD.
296
+
297
+ WARNING: this code does not do a true topological breadth-first
298
+ search. Doesn't matter much for simple merges that are based on
299
+ a mostly-current checkout, but don't expect correctness when branches
300
+ span tags.
301
+ """
69
302
  if ref is None:
70
303
  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
304
 
94
- n = len(res)
95
- while n:
96
- n -= 1
97
- yield res[n]
305
+ visited = set()
306
+ work = deque([ref])
307
+ while work:
308
+ ref = work.popleft()
309
+ if ref in visited:
310
+ continue
311
+ visited.add(ref)
312
+ yield ref
313
+ work.extend(ref.parents)
98
314
 
99
- def tagged(self, c=None) -> str:
100
- """Return a commit's tag.
315
+ def has_changes(self, tag:Tag=None) -> bool:
316
+ """
317
+ Test whether any subsystem changed since the "tagged" commit
318
+
319
+ """
320
+ if tag is None:
321
+ tag,commit = self.last_tag()
322
+ head = self._repo.head.commit
323
+ for d in head.diff(tag):
324
+ if self.repo_for(d.a_path) == "moat" and self.repo_for(d.b_path) == "moat":
325
+ continue
326
+ return True
327
+ return False
328
+
329
+
330
+ def tagged(self, c:Commit=None) -> Tag|None:
331
+ """Return a commit's tag name.
101
332
  Defaults to the head commit.
102
333
  Returns None if no tag, raises ValueError if more than one is found.
103
334
  """
@@ -106,9 +337,17 @@ class Repo(git.Repo):
106
337
  if c not in self._commit_tags:
107
338
  return None
108
339
  tt = self._commit_tags[c]
340
+
341
+ tt = [t for t in tt if "/" not in t.name]
342
+
343
+ if not tt:
344
+ return None
109
345
  if len(tt) > 1:
110
- raise ValueError(f"{self.working_dir}: multiple tags: {tt}")
111
- return tt[0]
346
+ if subsys is not None:
347
+ raise ValueError(f"Multiple tags for {subsys}: {tt}")
348
+ raise ValueError(f"Multiple tags: {tt}")
349
+ return tt[0].name
350
+
112
351
 
113
352
 
114
353
  @click.group(short_help="Manage MoaT itself")
@@ -116,7 +355,7 @@ async def cli():
116
355
  """
117
356
  This collection of commands is useful for managing and building MoaT itself.
118
357
  """
119
- pass # pylint: disable=unnecessary-pass
358
+ pass
120
359
 
121
360
 
122
361
  def fix_deps(deps: list[str], tags: dict[str, str]) -> bool:
@@ -132,17 +371,22 @@ def fix_deps(deps: list[str], tags: dict[str, str]) -> bool:
132
371
  return work
133
372
 
134
373
 
135
- def run_tests(repo: Repo) -> bool:
136
- """Run tests (i.e., 'tox') in this repository."""
374
+ def run_tests(pkg: str|None, *opts) -> bool:
375
+ """Run subtests for subpackage @pkg."""
376
+
377
+ if pkg is None:
378
+ tests = Path("tests")
379
+ else:
380
+ tests = dash(pkg).replace("-","_")
381
+ tests = Path("tests")/tests
137
382
 
138
- proj = Path(repo.working_dir) / "Makefile"
139
- if not proj.is_file():
140
- # No Makefile. Assume it's OK.
383
+ if not Path(tests):
384
+ # No tests. Assume it's OK.
141
385
  return True
142
386
  try:
143
- print("\n*** Testing:", repo.working_dir)
387
+ print("\n*** Testing:", pkg)
144
388
  # subprocess.run(["python3", "-mtox"], cwd=repo.working_dir, check=True)
145
- subprocess.run(["make", "test"], cwd=repo.working_dir, check=True)
389
+ subprocess.run(["python3","-mpytest", *opts, tests], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, check=True)
146
390
  except subprocess.CalledProcessError:
147
391
  return False
148
392
  else:
@@ -269,8 +513,9 @@ def encomma(proj, path):
269
513
  """list > comma-delimited string"""
270
514
  _mangle(proj, path, lambda x: ",".join(x)) # pylint: disable=unnecessary-lambda
271
515
 
516
+
272
517
  def apply_hooks(repo, force=False):
273
- h = Path(repo.git_dir)/"hooks"
518
+ h = Path(repo.git_dir) / "hooks"
274
519
  drop = set()
275
520
  seen = set()
276
521
  for f in h.iterdir():
@@ -281,12 +526,12 @@ def apply_hooks(repo, force=False):
281
526
  for f in drop:
282
527
  f.unlink()
283
528
 
284
- pt = (Path(__file__).parent / "_hooks")
529
+ pt = Path(__file__).parent / "_hooks"
285
530
  for f in pt.iterdir():
286
531
  if not force:
287
532
  if f.name in seen:
288
533
  continue
289
- t = h/f.name
534
+ t = h / f.name
290
535
  d = f.read_text()
291
536
  t.write_text(d)
292
537
  t.chmod(0o755)
@@ -382,8 +627,9 @@ def apply_templates(repo):
382
627
  txi = "\n" + txi.replace("\n\t", "\n ")
383
628
  proj["tool"]["tox"] = dict(
384
629
  legacy_tox_ini=tomlkit.items.String.from_raw(
385
- txi, type_=tomlkit.items.StringType.MLB
386
- )
630
+ txi,
631
+ type_=tomlkit.items.StringType.MLB,
632
+ ),
387
633
  )
388
634
 
389
635
  projp = Path(repo.working_dir) / "pyproject.toml"
@@ -445,386 +691,430 @@ def path_():
445
691
  print(Path(__file__).parent / "_templates")
446
692
 
447
693
 
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)
694
+ @cli.command()
695
+ @click.option("-s", "--show", is_flag=True, help="Show all tags")
696
+ @click.option("-r", "--run", is_flag=True, help="Update all stale tags")
697
+ def tags(show,run):
698
+ repo = Repo(None)
480
699
 
481
- for r in repos:
482
- apply_hooks(r, fhooks)
483
- if hooks or fhooks:
484
- print(r.working_dir)
485
- continue
700
+ if show:
701
+ if run:
702
+ raise click.UsageError("Can't display and change the tag at the same time!")
486
703
 
487
- if not is_clean(r, not no_dirty):
488
- if not no_dirty:
704
+ for r in repo.parts:
705
+ try:
706
+ tag,commit = r.last_tag()
707
+ except ValueError:
708
+ print(f"{r.dash} -")
489
709
  continue
710
+ if r.has_changes(commit):
711
+ print(f"{r.dash} {tag} STALE")
712
+ else:
713
+ print(f"{r.dash} {tag}")
714
+ return
490
715
 
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)
716
+ if repo.is_dirty(index=True, working_tree=True, untracked_files=True, submodules=False):
717
+ print("Repo is dirty. Not tagging globally.", file=sys.stderr)
718
+ return
719
+
720
+ changed=False
721
+ for r in repo.parts:
722
+ tag,commit = r.last_tag()
723
+ if not r.has_changes(commit):
724
+ print(repo.dash,tag,"UNCHANGED")
496
725
  continue
497
726
 
498
- if no_commit:
727
+ tag = r.next_tag()
728
+ print(repo.dash,tag)
729
+ if not run:
499
730
  continue
500
731
 
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,)
732
+ repo.versions[r.dash] = (tag,repo.head.commit.hexsha[:9])
733
+ changed=True
533
734
 
534
- r.index.commit(message, parent_commits=p)
735
+ if changed:
736
+ repo.write_tags()
535
737
 
536
738
 
537
739
  @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):
740
+ @click.option("-r", "--run", is_flag=True, help="actually do the tagging")
741
+ @click.option("-m", "--minor", is_flag=True, help="create a new minor version")
742
+ @click.option("-M", "--major", is_flag=True, help="create a new major version")
743
+ @click.option("-s", "--subtree", type=str, help="Tag this partial module")
744
+ @click.option("-v", "--tag", "force", type=str, help="Use this explicit tag value")
745
+ @click.option("-q", "--query","--show","show", is_flag=True, help="Show the latest tag")
746
+ @click.option("-f", "--force","FORCE", is_flag=True, help="replace an existing tag")
747
+ def tag(run,minor,major,subtree,force,FORCE,show):
544
748
  """
545
- Publish modules to PyPi and/or Debian.
749
+ Tag the repository (or a subtree).
546
750
  """
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)
751
+ if minor and major:
752
+ raise click.UsageError("Can't change both minor and major!")
753
+ if force and (minor or major):
754
+ raise click.UsageError("Can't use an explicit tag with changing minor or major!")
755
+ if FORCE and (minor or major):
756
+ raise click.UsageError("Can't use an explicit tag with changing minor or major!")
757
+ if show and (run or force or minor or major):
758
+ raise click.UsageError("Can't display and change the tag at the same time!")
562
759
 
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)
760
+ repo = Repo(None)
570
761
 
762
+ if subtree:
763
+ r = repo.part(subtree)
764
+ else:
765
+ r = repo
571
766
 
572
- async def fix_main(repo):
573
- """
574
- Set "main" references to the current HEAD.
767
+ if show:
768
+ tag,commit = r.last_tag()
769
+ if r.has_changes(commit):
770
+ print(f"{tag} STALE")
771
+ else:
772
+ print(tag)
773
+ return
575
774
 
576
- Repos with a non-detached head are skipped.
577
- Reports an error if HEAD is not a direct descendant.
578
- """
775
+ if force:
776
+ tag = force
777
+ elif FORCE:
778
+ tag,_ = r.last_tag()
779
+ else:
780
+ tag = r.next_tag(major,minor)
579
781
 
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"]
782
+ if run or subtree:
783
+ if subtree:
784
+ repo.versions[r.dash] = (tag,repo.head.commit.hexsha[:9])
785
+ repo.write_tags()
587
786
  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)
787
+ git.TagReference.create(repo,tag, force=FORCE)
788
+ print(f"{tag}")
789
+ else:
790
+ print(f"{tag} DRY_RUN")
604
791
 
605
792
 
606
793
  @cli.command()
607
- async def fixref():
794
+ @click.option("-P", "--no-pypi", is_flag=True, help="don't push to PyPi")
795
+ @click.option("-D", "--no-deb", is_flag=True, help="don't debianize")
796
+ @click.option("-d", "--deb", type=str, help="Debian archive to push to (from dput.cfg)")
797
+ @click.option("-o", "--only", type=str, multiple=True, help="affect only this package")
798
+ @click.option("-s", "--skip", type=str, multiple=True, help="skip this package")
799
+ async def publish(no_pypi, no_deb, skip, only, deb):
608
800
  """
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.
801
+ Publish modules to PyPi and/or Debian.
613
802
 
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.
803
+ MoaT modules can be given as shorthand, i.e. with dashes and excluding
804
+ the "moat-" prefix.
616
805
  """
617
806
  repo = Repo(None)
618
- await fix_main(repo)
807
+ if only and skip:
808
+ raise click.UsageError("You can't both include and exclude packages.")
619
809
 
810
+ if only:
811
+ repos = (repo.subrepo(x) for x in only)
812
+ else:
813
+ s = set()
814
+ for sk in skip:
815
+ s += set(sk.split(","))
816
+ repos = (x for x in repo.parts if dash(x.name) not in sk)
620
817
 
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"""
818
+ deb_args = "-b -us -uc".split()
626
819
 
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)
820
+ for r in repos:
821
+ t,c = r.last_tag
822
+ if r.has_changes(c):
823
+ print(f"Error: changes in {r.name} since tag {t.name}")
824
+ continue
641
825
 
826
+ print(f"Processing {r.name}, tag: {t.name}")
827
+ r.copy()
828
+ rd=PACK/r.dash
642
829
 
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"""
830
+ if not no_deb:
831
+ p = rd / "debian"
832
+ if not p.is_dir():
833
+ continue
834
+ subprocess.run(["debuild"] + deb_args, cwd=rd, check=True)
649
835
 
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)
836
+ if not no_pypi:
837
+ for r in repos:
838
+ p = Path(r.working_dir) / "pyproject.toml"
839
+ if not p.is_file():
840
+ continue
841
+ print(r.working_dir)
842
+ subprocess.run(["make", "pypi"], cwd=r.working_dir, check=True)
676
843
 
677
844
 
678
- @cli.command()
679
- @click.option("-T", "--no-test", is_flag=True, help="Skip testing")
845
+ @cli.command(epilog="""
846
+ The default for building Debian packages is '--no-sign --build=binary'.
847
+ '--no-sign' is dropped when you use '--deb'.
848
+ The binary-only build is currently unconditional.
849
+
850
+ The default for uploading to Debian via 'dput' is '--unchecked ext';
851
+ it is dropped when you use '--dput'.
852
+ """)
853
+ @click.option("-f", "--no-dirty", is_flag=True, help="don't check for dirtiness (DANGER)")
854
+ @click.option("-F", "--no-tag", is_flag=True, help="don't check for tag uptodate-ness (DANGER)")
855
+ @click.option("-D", "--no-deb", is_flag=True, help="don't build Debian packages")
856
+ @click.option("-C", "--no-commit", is_flag=True, help="don't commit the result")
857
+ @click.option("-V", "--no-version", is_flag=True, help="don't update dependency versions in pyproject files")
858
+ @click.option("-P", "--no-pypi", is_flag=True, help="don't push to PyPI")
859
+ @click.option("-T", "--no-test", is_flag=True, help="don't run tests")
860
+ @click.option("-o", "--pytest", "pytest_opts", type=str,multiple=True, help="Options for pytest")
861
+ @click.option("-d", "--deb", "deb_opts", type=str,multiple=True, help="Options for debuild")
862
+ @click.option("-p", "--dput", "dput_opts", type=str,multiple=True, help="Options for dput")
863
+ @click.option("-r", "--run", is_flag=True, help="actually do the tagging")
864
+ @click.option("-s", "--skip", "skip_", type=str,multiple=True, help="skip these repos")
865
+ @click.option("-m", "--minor", is_flag=True, help="create a new minor version")
866
+ @click.option("-M", "--major", is_flag=True, help="create a new major version")
867
+ @click.option("-t", "--tag", "forcetag", type=str, help="Use this explicit tag value")
680
868
  @click.option(
681
869
  "-v",
682
870
  "--version",
683
871
  type=(str, str),
684
872
  multiple=True,
685
- help="Update external dep version",
873
+ help="Update external dependency",
686
874
  )
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):
875
+ @click.argument("parts", nargs=-1)
876
+ 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
877
  """
692
878
  Rebuild all modified packages.
693
879
  """
694
- bad = False
695
880
  repo = Repo(None)
881
+
696
882
  tags = dict(version)
697
883
  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
884
+ for s in skip_:
885
+ for sn in s.split(","):
886
+ skip.add(dash(sn))
887
+ parts = set(dash(s) for s in parts)
888
+ debversion={}
889
+
890
+ if no_tag and not no_version:
891
+ print("Warning: not updating moat versions in pyproject files", file=sys.stderr)
892
+ if minor and major:
893
+ raise click.UsageError("Can't change both minor and major!")
894
+ if forcetag and (minor or major):
895
+ raise click.UsageError("Can't use an explicit tag with changing minor or major!")
896
+
897
+ if forcetag is None:
898
+ forcetag = repo.next_tag(major,minor)
899
+
900
+ full = False
901
+ if parts:
902
+ repos = [ repo.part(x) for x in parts ]
903
+ else:
904
+ if not skip:
905
+ full = True
906
+ 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
907
 
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
908
+ for name in PACK.iterdir():
909
+ if name.suffix != ".changes":
727
910
  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
911
+ name=name.stem
912
+ name,vers,_ = name.split("_")
913
+ if name.startswith("moat-"):
914
+ name = name[5:]
749
915
  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()
916
+ name = "ext-"+name
917
+ debversion[name]=vers.rsplit("-",1)[0]
918
+
919
+
920
+ # Step 0: basic check
921
+ if not no_dirty:
922
+ if repo.is_dirty(index=False, working_tree=True, untracked_files=True, submodules=False):
923
+ if not run:
924
+ print("*** Repository is not clean.", file=sys.stderr)
925
+ else:
926
+ print("Please commit changes and try again.", file=sys.stderr)
927
+ return
762
928
 
763
- check = True
929
+ # Step 1: check for changed files since last tagging
930
+ if not no_tag:
931
+ err = set()
932
+ for r in repos:
933
+ try:
934
+ tag,commit = r.last_tag()
935
+ except KeyError:
936
+ rd = PACK/r.dash
937
+ p = rd / "pyproject.toml"
938
+ if not p.is_file():
939
+ continue
940
+ raise
941
+ tags[r.mdash] = tag
942
+ if r.has_changes(commit):
943
+ err.add(r.dash)
944
+ if err:
945
+ if not run:
946
+ print("*** Untagged changes:", file=sys.stderr)
947
+ print("***", *err, file=sys.stderr)
948
+ else:
949
+ print("Untagged changes:", file=sys.stderr)
950
+ print(*err, file=sys.stderr)
951
+ print("Please tag (moat src tag -s PACKAGE) and try again.", file=sys.stderr)
952
+ return
764
953
 
765
- while check:
766
- check = False
954
+ # Step 2: run tests
955
+ if not no_test:
956
+ fails = set()
957
+ for p in parts:
958
+ if not run_tests(p, *pytest_opts):
959
+ fails.add(p.name)
960
+ if fails:
961
+ if not run:
962
+ print(f"*** Tests failed:", *fails, file=sys.stderr)
963
+ else:
964
+ print(f"Failed tests:", *fails, file=sys.stderr)
965
+ print(f"Fix and try again.", file=sys.stderr)
966
+ return
767
967
 
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)
968
+ # Step 3: set version and fix versioned dependencies
969
+ for r in repos:
970
+ rd = PACK/r.dash
971
+ p = rd / "pyproject.toml"
972
+ if not p.is_file():
973
+ # bad=True
974
+ print("Skip:", r.name, file=sys.stderr)
975
+ continue
976
+ with p.open("r") as f:
977
+ pr = tomlkit.load(f)
978
+ pr["project"]["version"] = r.last_tag()[0]
779
979
 
780
- work = False
980
+ if not no_version:
781
981
  try:
782
982
  deps = pr["project"]["dependencies"]
783
983
  except KeyError:
784
984
  pass
785
985
  else:
786
- work = fix_deps(deps, tags) | work
986
+ fix_deps(deps, tags)
787
987
  try:
788
988
  deps = pr["project"]["optional_dependencies"]
789
989
  except KeyError:
790
990
  pass
791
991
  else:
792
992
  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
993
+ fix_deps(v, tags)
810
994
 
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
- ):
995
+ p.write_text(pr.as_string())
996
+ repo.index.add(p)
997
+
998
+ # Step 3: copy to packaging dir
999
+ for r in repos:
1000
+ r.copy()
1001
+
1002
+ # Step 4: build Debian package
1003
+ if not no_deb:
1004
+ if not deb_opts:
1005
+ deb_opts = ["--no-sign"]
1006
+
1007
+ for r in repos:
1008
+ rd=PACK/r.dash
1009
+ p = rd / "debian"
1010
+ if not p.is_dir():
816
1011
  continue
1012
+ try:
1013
+ res = subprocess.run(["dpkg-parsechangelog","-l","debian/changelog","-S","version"], cwd=rd, check=True, stdout=subprocess.PIPE)
1014
+ tag = res.stdout.strip().decode("utf-8").rsplit("-",1)[0]
1015
+ ltag = r.last_tag()[0]
1016
+ if tag != ltag:
1017
+ subprocess.run(["debchange", "--distribution","unstable", "--newversion",ltag+"-1",f"New release for {forcetag}"] , cwd=rd, check=True)
1018
+ repo.index.add(p/"changelog")
1019
+
1020
+ if debversion.get(r.dash,"") != ltag:
1021
+ subprocess.run(["debuild", "--build=binary"] + deb_opts, cwd=rd, check=True)
1022
+ except subprocess.CalledProcessError:
1023
+ if not run:
1024
+ print("*** Failure packaging",r.name,file=sys.stderr)
1025
+ else:
1026
+ print("Failure packaging",r.name,file=sys.stderr)
1027
+ return
817
1028
 
818
- if r in dirty:
819
- r.index.commit("Update MoaT requirements")
1029
+ # Step 5: build PyPI package
1030
+ if not no_pypi:
1031
+ err=set()
1032
+ up=set()
1033
+ for r in repos:
1034
+ rd=PACK/r.dash
1035
+ p = rd / "pyproject.toml"
1036
+ if not p.is_file():
1037
+ continue
1038
+ tag = r.last_tag()[0]
1039
+ name = r.dash
1040
+ if name.startswith("ext-"):
1041
+ name=name[4:]
1042
+ else:
1043
+ name="moat-"+r.dash
1044
+
1045
+ targz = rd/"dist"/f"{r.under}-{tag}.tar.gz"
1046
+ done = rd/"dist"/f"{r.under}-{tag}.done"
1047
+ if targz.is_file():
1048
+ print(f"{name}: Source package exists.")
1049
+ if not done.exists():
1050
+ up.add(r)
1051
+ else:
1052
+ try:
1053
+ subprocess.run(["python3", "-mbuild", "-snw"], cwd=rd, check=True)
1054
+ except subprocess.CalledProcessError:
1055
+ err.add(r.name)
1056
+ if err:
1057
+ if not run:
1058
+ print("*** Build errors:", file=sys.stderr)
1059
+ print("***", *err, file=sys.stderr)
1060
+ else:
1061
+ print("Build errors:", file=sys.stderr)
1062
+ print(*err, file=sys.stderr)
1063
+ print("Please fix and try again.", file=sys.stderr)
1064
+ return
1065
+
1066
+ # Step 6: upload PyPI package
1067
+ if run:
1068
+ err=set()
1069
+ for p in up:
1070
+ rd=PACK/r.dash
1071
+ p = rd / "pyproject.toml"
1072
+ if not p.is_file():
1073
+ continue
1074
+ tag = r.last_tag()[0]
1075
+ name = r.dash
1076
+ if name.startswith("ext-"):
1077
+ name=name[4:]
1078
+ else:
1079
+ name="moat-"+r.dash
1080
+ targz = Path("dist")/f"{name}-{tag}.tar.gz"
1081
+ whl = Path("dist")/f"{name}-{tag}-py3-none-any.whl"
1082
+ try:
1083
+ subprocess.run(["twine", "upload", str(targz), str(whl)], cwd=rd, check=True)
1084
+ except subprocess.CalledProcessError:
1085
+ err.add(r.name)
1086
+ else:
1087
+ done = rd/"dist"/f"{r.under}-{tag}.done"
1088
+ done.touch()
1089
+ if err:
1090
+ print("Upload errors:", file=sys.stderr)
1091
+ print(*err, file=sys.stderr)
1092
+ print("Please fix(?) and try again.", file=sys.stderr)
1093
+ return
820
1094
 
821
- for rr in r.subrepos(recurse=False):
822
- r.git.add(rr.working_dir)
823
- r.index.commit("Submodule Update")
1095
+ # Step 7: upload Debian package
1096
+ if run and not no_deb:
1097
+ err = set()
1098
+ if not dput_opts:
1099
+ dput_opts = ["-u","ext"]
1100
+ for r in repos:
1101
+ ltag = r.last_tag()[0]
1102
+ changes = PACK/f"{r.mdash}_{ltag}-1_{ARCH}.changes"
1103
+ try:
1104
+ subprocess.run(["dput", *dput_opts, str(changes)], cwd=PACK, check=True)
1105
+ except subprocess.CalledProcessError:
1106
+ err.add(r.name)
1107
+ if err:
1108
+ print("Upload errors:", file=sys.stderr)
1109
+ print(*err, file=sys.stderr)
1110
+ print("Please fix(?) and try again.", file=sys.stderr)
1111
+ return
824
1112
 
825
- t = tags[r.moat_name]
826
- if isinstance(t, str):
827
- r.create_tag(t)
1113
+ # Step 8: commit the result
1114
+ if run and not no_commit:
1115
+ repo.write_tags()
1116
+ repo.index.commit(f"Build version {forcetag}")
1117
+ git.TagReference.create(repo, forcetag)
828
1118
 
829
1119
 
830
1120
  add_repr(tomlkit.items.String)