pullup 0.0.12__tar.gz → 0.0.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pullup
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: build, release, and deploy projects
5
5
  Project-URL: Repository, https://github.com/vedicreader/pullup
6
6
  Project-URL: Documentation, https://vedicreader.github.io/pullup
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.12"
1
+ __version__ = "0.0.14"
2
2
 
3
3
  # The names a caller reaches for. The modules stay importable for everything else.
4
4
  from .env import EnvError, EnvStore, venv_env
@@ -89,10 +89,22 @@ d = { 'settings': { 'branch': 'main',
89
89
  'pullup.pipeline.Pipeline.write': ('pipeline.html#pipeline.write', 'pullup/pipeline.py'),
90
90
  'pullup.pipeline.PipelineError': ('pipeline.html#pipelineerror', 'pullup/pipeline.py'),
91
91
  'pullup.pipeline.Release': ('pipeline.html#release', 'pullup/pipeline.py'),
92
+ 'pullup.pipeline.Release._row': ('pipeline.html#release._row', 'pullup/pipeline.py'),
93
+ 'pullup.pipeline.Release._run': ('pipeline.html#release._run', 'pullup/pipeline.py'),
92
94
  'pullup.pipeline.Release.defaults': ('pipeline.html#release.defaults', 'pullup/pipeline.py'),
93
95
  'pullup.pipeline.Release.state': ('pipeline.html#release.state', 'pullup/pipeline.py'),
96
+ 'pullup.pipeline._is_ver': ('pipeline.html#_is_ver', 'pullup/pipeline.py'),
97
+ 'pullup.pipeline._sections': ('pipeline.html#_sections', 'pullup/pipeline.py'),
98
+ 'pullup.pipeline.changelog_state': ('pipeline.html#changelog_state', 'pullup/pipeline.py'),
99
+ 'pullup.pipeline.claim_unreleased': ('pipeline.html#claim_unreleased', 'pullup/pipeline.py'),
100
+ 'pullup.pipeline.notes_step': ('pipeline.html#notes_step', 'pullup/pipeline.py'),
101
+ 'pullup.pipeline.project_version': ('pipeline.html#project_version', 'pullup/pipeline.py'),
102
+ 'pullup.pipeline.quiet': ('pipeline.html#quiet', 'pullup/pipeline.py'),
103
+ 'pullup.pipeline.release_cmd': ('pipeline.html#release_cmd', 'pullup/pipeline.py'),
94
104
  'pullup.pipeline.resolve_script': ('pipeline.html#resolve_script', 'pullup/pipeline.py'),
95
- 'pullup.pipeline.uv_argv': ('pipeline.html#uv_argv', 'pullup/pipeline.py')},
105
+ 'pullup.pipeline.tree_clean': ('pipeline.html#tree_clean', 'pullup/pipeline.py'),
106
+ 'pullup.pipeline.uv_argv': ('pipeline.html#uv_argv', 'pullup/pipeline.py'),
107
+ 'pullup.pipeline.write_changelog': ('pipeline.html#write_changelog', 'pullup/pipeline.py')},
96
108
  'pullup.project': { 'pullup.project.Project': ('project.html#project', 'pullup/project.py'),
97
109
  'pullup.project.Project.__init__': ('project.html#project.__init__', 'pullup/project.py'),
98
110
  'pullup.project.Project.__repr__': ('project.html#project.__repr__', 'pullup/project.py'),
@@ -2,16 +2,19 @@
2
2
 
3
3
  # %% ../nbs/02_pipeline.ipynb #d7426f18
4
4
  from __future__ import annotations
5
- import asyncio, json, os, shlex, time
5
+ import asyncio, json, os, re, shlex, subprocess, time
6
+ from dataclasses import replace
6
7
  from shutil import which
7
8
  from fastcore.all import L, Path, first
8
9
  from fastcore.ansi import strip_ansi
9
10
 
10
11
  from .env import EnvStore, venv_env
11
- from .project import Step, default_steps, release_flow, app_project
12
+ from .project import Step, default_steps, release_flow, app_project, _toml
12
13
 
13
14
  # %% auto #0
14
- __all__ = ['SCROLLBACK', 'DIR', 'PipelineError', 'resolve_script', 'uv_argv', 'Pipeline', 'Release']
15
+ __all__ = ['SCROLLBACK', 'DIR', 'NOTES_CMDS', 'QUIET', 'CLEAN_CMD', 'MARKER', 'PipelineError', 'resolve_script', 'uv_argv',
16
+ 'Pipeline', 'project_version', 'changelog_state', 'claim_unreleased', 'write_changelog', 'notes_step',
17
+ 'quiet', 'tree_clean', 'release_cmd', 'Release']
15
18
 
16
19
  # %% ../nbs/02_pipeline.ipynb #18ee2a74
17
20
  SCROLLBACK = 200_000
@@ -108,6 +111,7 @@ class Pipeline:
108
111
  try: env.update({k: str(v) for k, v in self.env.values(self.needs()).items()})
109
112
  except Exception: pass
110
113
  env.setdefault('PYTHONUNBUFFERED', '1')
114
+ if env.get('TERM') in (None, '', 'dumb'): env['TERM'] = 'xterm-256color'
111
115
  return env
112
116
  def _row(self, s):
113
117
  "One step as a caller sees it: the plan, plus how the last run of it went."
@@ -235,6 +239,78 @@ class Pipeline:
235
239
  return {}
236
240
  return attribute(self.tail(400), checkouts=checkouts, family=family)
237
241
 
242
+ # %% ../nbs/02_pipeline.ipynb #7a1bd528
243
+ NOTES_CMDS = ('nbdev-release-gh', 'nbdev_release_gh')
244
+ QUIET = '--no-changelog --no-editor --yes'
245
+ CLEAN_CMD = 'nbdev-release-git'
246
+ MARKER = '<!-- do not remove -->\n'
247
+
248
+ def project_version(root):
249
+ "The version the next release carries, from `settings.ini` or else `pyproject.toml`; `''` when neither names one."
250
+ try: text = (Path(root)/'settings.ini').read_text(encoding='utf-8', errors='replace')
251
+ except OSError: text = ''
252
+ if m := re.search(r'^version\s*=\s*(\S+)', text, re.M): return m[1]
253
+ return str((_toml(root).get('project') or {}).get('version') or '')
254
+
255
+ def _sections(txt):
256
+ "The `## ` sections of a changelog in order, as `(heading, body)`."
257
+ return [(h.strip(), body) for h, _, body in (p.partition('\n') for p in re.split(r'^## ', txt, flags=re.M)[1:])]
258
+
259
+ def _is_ver(heading, ver): return bool(ver) and re.fullmatch(rf'v?{re.escape(ver)}(?![\w.])(?:[\s(\[-].*)?', heading) is not None
260
+
261
+ def changelog_state(root, version=None):
262
+ "Whether the first section of `CHANGELOG.md`, the one nbdev publishes, is a filled entry for `version` (default: the project's)."
263
+ root = Path(root); version = version or project_version(root)
264
+ out = dict(version=version, path=str(root/'CHANGELOG.md'), has=False, kind='', heading=f'## {version}')
265
+ try: secs = _sections((root/'CHANGELOG.md').read_text(encoding='utf-8'))
266
+ except OSError: return out
267
+ if not secs or not secs[0][1].strip() or not version: return out
268
+ if _is_ver(secs[0][0], version): out |= dict(has=True, kind='version')
269
+ elif secs[0][0].lower() == 'unreleased': out |= dict(has=True, kind='unreleased')
270
+ return out
271
+
272
+ def claim_unreleased(root, version):
273
+ "Rename the leading `## Unreleased` heading to `## version`; returns the path, or None when there is no such heading."
274
+ path = Path(root)/'CHANGELOG.md'
275
+ try: txt = path.read_text(encoding='utf-8')
276
+ except OSError: return None
277
+ new, n = re.subn(r'^## Unreleased[ \t]*$', f'## {version}', txt, count=1, flags=re.M | re.I)
278
+ if n: path.write_text(new, encoding='utf-8')
279
+ return path if n else None
280
+
281
+ def write_changelog(root, version, text):
282
+ "Write `text` as the entry for `version`, where nbdev puts one; an entry already there is replaced. Returns the path."
283
+ text, path = str(text or '').strip(), Path(root)/'CHANGELOG.md'
284
+ if not version: raise PipelineError('there is no version to write release notes for')
285
+ if not text: raise PipelineError('the release notes are empty')
286
+ old = path.read_text(encoding='utf-8') if path.exists() else f'# Release notes\n\n{MARKER}'
287
+ old = re.sub(rf'^## v?{re.escape(version)}(?![\w.])[^\n]*(?:\n|\Z).*?(?=^## |\Z)', '', old, flags=re.M | re.S)
288
+ entry = f'## {version}\n\n{text}\n\n'
289
+ if MARKER in old: head, _, rest = old.partition(MARKER); head += MARKER + '\n'
290
+ elif m := re.search(r'^## ', old, re.M): head, rest = old[:m.start()].rstrip('\n') + '\n\n', old[m.start():]
291
+ else: head, rest = (old.rstrip('\n') + '\n\n' if old.strip() else ''), ''
292
+ path.write_text((head + entry + rest.lstrip('\n')).rstrip('\n') + '\n', encoding='utf-8')
293
+ return path
294
+
295
+ def notes_step(step):
296
+ "Whether `step` is the bare nbdev release command, which stops to ask questions and open an editor."
297
+ return not step.argv and step.cmd.strip() in NOTES_CMDS
298
+
299
+ def quiet(cmd):
300
+ "The nbdev release command with the flags that stop it asking for an editor or a yes."
301
+ return f'{cmd.strip()} {QUIET}'
302
+
303
+ def tree_clean(root):
304
+ "Whether git has no tracked change for `nbdev-release-gh` to commit; a folder git cannot read counts as not clean."
305
+ try: r = subprocess.run(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=str(root), capture_output=True, text=True, stdin=subprocess.DEVNULL)
306
+ except OSError: return False
307
+ return r.returncode == 0 and not r.stdout.strip()
308
+
309
+ def release_cmd(root, cmd):
310
+ "`nbdev-release-git` when there is nothing to commit, as `nbdev-release-gh` would stop there; else the quiet `cmd`."
311
+ return CLEAN_CMD if tree_clean(root) else quiet(cmd)
312
+
313
+
238
314
  # %% ../nbs/02_pipeline.ipynb #e22f9231
239
315
  class Release(Pipeline):
240
316
  "The release pipeline, whose default plan is the project's release flow."
@@ -242,7 +318,24 @@ class Release(Pipeline):
242
318
  KIND = 'release'
243
319
  @staticmethod
244
320
  def defaults(root): return default_steps(root)
321
+ def _row(self, s):
322
+ row = super()._row(s)
323
+ return row | ({'notes': (self.results.get(s.id) or {}).get('notes')} if row['status'] == 'needs_notes' else {})
245
324
  def state(self):
246
325
  flow = release_flow(self.root)
247
- return super().state() | {'flow': flow, 'nbdev': flow == 'nbdev',
248
- 'fastship': flow == 'fastship', 'app': app_project(self.root)}
326
+ st = super().state()
327
+ return st | {'flow': flow, 'nbdev': flow == 'nbdev',
328
+ 'fastship': flow == 'fastship', 'app': app_project(self.root),
329
+ 'notes': first((r['notes'] for r in st['steps'] if r['status'] == 'needs_notes'))}
330
+ async def _run(self, step):
331
+ "Run `step`; the bare nbdev release command runs quiet once the changelog has its entry (as `nbdev-release-git` when the tree is clean), and otherwise waits for notes."
332
+ if not notes_step(step): return await super()._run(step)
333
+ st = changelog_state(self.root)
334
+ if not st['has']:
335
+ self._auto = False
336
+ self.results[step.id] = {'status': 'needs_notes', 'code': None, 'started': 0, 'finished': 0, 'notes': st}
337
+ self._broadcast(f"\r\n\x1b[33m{step.label} waits for release notes for {st['version'] or 'this version'}\x1b[0m\r\n".encode())
338
+ return
339
+ if st['kind'] == 'unreleased': claim_unreleased(self.root, st['version'])
340
+ await super()._run(replace(step, cmd=release_cmd(self.root, step.cmd)))
341
+
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes