pullup 0.0.12__tar.gz → 0.0.13__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.13
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.13"
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,20 @@ 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'),
94
103
  'pullup.pipeline.resolve_script': ('pipeline.html#resolve_script', 'pullup/pipeline.py'),
95
- 'pullup.pipeline.uv_argv': ('pipeline.html#uv_argv', 'pullup/pipeline.py')},
104
+ 'pullup.pipeline.uv_argv': ('pipeline.html#uv_argv', 'pullup/pipeline.py'),
105
+ 'pullup.pipeline.write_changelog': ('pipeline.html#write_changelog', 'pullup/pipeline.py')},
96
106
  'pullup.project': { 'pullup.project.Project': ('project.html#project', 'pullup/project.py'),
97
107
  'pullup.project.Project.__init__': ('project.html#project.__init__', 'pullup/project.py'),
98
108
  '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, 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', 'MARKER', 'PipelineError', 'resolve_script', 'uv_argv', 'Pipeline',
16
+ 'project_version', 'changelog_state', 'claim_unreleased', 'write_changelog', 'notes_step', 'quiet',
17
+ '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,67 @@ 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
+ MARKER = '<!-- do not remove -->\n'
246
+
247
+ def project_version(root):
248
+ "The version the next release carries, from `settings.ini` or else `pyproject.toml`; `''` when neither names one."
249
+ try: text = (Path(root)/'settings.ini').read_text(encoding='utf-8', errors='replace')
250
+ except OSError: text = ''
251
+ if m := re.search(r'^version\s*=\s*(\S+)', text, re.M): return m[1]
252
+ return str((_toml(root).get('project') or {}).get('version') or '')
253
+
254
+ def _sections(txt):
255
+ "The `## ` sections of a changelog in order, as `(heading, body)`."
256
+ return [(h.strip(), body) for h, _, body in (p.partition('\n') for p in re.split(r'^## ', txt, flags=re.M)[1:])]
257
+
258
+ def _is_ver(heading, ver): return bool(ver) and re.fullmatch(rf'v?{re.escape(ver)}(?![\w.])(?:[\s(\[-].*)?', heading) is not None
259
+
260
+ def changelog_state(root, version=None):
261
+ "Whether the first section of `CHANGELOG.md`, the one nbdev publishes, is a filled entry for `version` (default: the project's)."
262
+ root = Path(root); version = version or project_version(root)
263
+ out = dict(version=version, path=str(root/'CHANGELOG.md'), has=False, kind='', heading=f'## {version}')
264
+ try: secs = _sections((root/'CHANGELOG.md').read_text(encoding='utf-8'))
265
+ except OSError: return out
266
+ if not secs or not secs[0][1].strip() or not version: return out
267
+ if _is_ver(secs[0][0], version): out |= dict(has=True, kind='version')
268
+ elif secs[0][0].lower() == 'unreleased': out |= dict(has=True, kind='unreleased')
269
+ return out
270
+
271
+ def claim_unreleased(root, version):
272
+ "Rename the leading `## Unreleased` heading to `## version`; returns the path, or None when there is no such heading."
273
+ path = Path(root)/'CHANGELOG.md'
274
+ try: txt = path.read_text(encoding='utf-8')
275
+ except OSError: return None
276
+ new, n = re.subn(r'^## Unreleased[ \t]*$', f'## {version}', txt, count=1, flags=re.M | re.I)
277
+ if n: path.write_text(new, encoding='utf-8')
278
+ return path if n else None
279
+
280
+ def write_changelog(root, version, text):
281
+ "Write `text` as the entry for `version`, where nbdev puts one; an entry already there is replaced. Returns the path."
282
+ text, path = str(text or '').strip(), Path(root)/'CHANGELOG.md'
283
+ if not version: raise PipelineError('there is no version to write release notes for')
284
+ if not text: raise PipelineError('the release notes are empty')
285
+ old = path.read_text(encoding='utf-8') if path.exists() else f'# Release notes\n\n{MARKER}'
286
+ old = re.sub(rf'^## v?{re.escape(version)}(?![\w.])[^\n]*(?:\n|\Z).*?(?=^## |\Z)', '', old, flags=re.M | re.S)
287
+ entry = f'## {version}\n\n{text}\n\n'
288
+ if MARKER in old: head, _, rest = old.partition(MARKER); head += MARKER + '\n'
289
+ elif m := re.search(r'^## ', old, re.M): head, rest = old[:m.start()].rstrip('\n') + '\n\n', old[m.start():]
290
+ else: head, rest = (old.rstrip('\n') + '\n\n' if old.strip() else ''), ''
291
+ path.write_text((head + entry + rest.lstrip('\n')).rstrip('\n') + '\n', encoding='utf-8')
292
+ return path
293
+
294
+ def notes_step(step):
295
+ "Whether `step` is the bare nbdev release command, which stops to ask questions and open an editor."
296
+ return not step.argv and step.cmd.strip() in NOTES_CMDS
297
+
298
+ def quiet(cmd):
299
+ "The nbdev release command with the flags that stop it asking for an editor or a yes."
300
+ return f'{cmd.strip()} {QUIET}'
301
+
302
+
238
303
  # %% ../nbs/02_pipeline.ipynb #e22f9231
239
304
  class Release(Pipeline):
240
305
  "The release pipeline, whose default plan is the project's release flow."
@@ -242,7 +307,24 @@ class Release(Pipeline):
242
307
  KIND = 'release'
243
308
  @staticmethod
244
309
  def defaults(root): return default_steps(root)
310
+ def _row(self, s):
311
+ row = super()._row(s)
312
+ return row | ({'notes': (self.results.get(s.id) or {}).get('notes')} if row['status'] == 'needs_notes' else {})
245
313
  def state(self):
246
314
  flow = release_flow(self.root)
247
- return super().state() | {'flow': flow, 'nbdev': flow == 'nbdev',
248
- 'fastship': flow == 'fastship', 'app': app_project(self.root)}
315
+ st = super().state()
316
+ return st | {'flow': flow, 'nbdev': flow == 'nbdev',
317
+ 'fastship': flow == 'fastship', 'app': app_project(self.root),
318
+ 'notes': first((r['notes'] for r in st['steps'] if r['status'] == 'needs_notes'))}
319
+ async def _run(self, step):
320
+ "Run `step`; the bare nbdev release command runs quiet once the changelog has its entry, and otherwise waits for notes."
321
+ if not notes_step(step): return await super()._run(step)
322
+ st = changelog_state(self.root)
323
+ if not st['has']:
324
+ self._auto = False
325
+ self.results[step.id] = {'status': 'needs_notes', 'code': None, 'started': 0, 'finished': 0, 'notes': st}
326
+ self._broadcast(f"\r\n\x1b[33m{step.label} waits for release notes for {st['version'] or 'this version'}\x1b[0m\r\n".encode())
327
+ return
328
+ if st['kind'] == 'unreleased': claim_unreleased(self.root, st['version'])
329
+ await super()._run(replace(step, cmd=quiet(step.cmd)))
330
+
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