-
Notifications
You must be signed in to change notification settings - Fork 517
Expand file tree
/
Copy pathrelease.py
More file actions
395 lines (343 loc) · 16.6 KB
/
Copy pathrelease.py
File metadata and controls
395 lines (343 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""Auto-generated tagged releases and release notes from GitHub issues
Docs: https://nbdev.fast.ai/api/release.html.md"""
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/18_release.ipynb.
# %% auto #0
__all__ = ['GH_HOST', 'CONDA_WARNING', 'update_changelog', 'Release', 'changelog', 'push_release', 'release_git', 'release_gh',
'pypi_json', 'latest_pypi', 'pypi_details', 'conda_output_path', 'write_conda_meta', 'write_requirements',
'anaconda_upload', 'release_conda', 'chk_conda_rel', 'release_pypi', 'release_both', 'nbdev_bump_version']
# %% ../nbs/api/18_release.ipynb #c35cc2b8
from fastcore.all import *
from ghapi.core import *
from datetime import datetime
from packaging.version import Version
import shutil,subprocess,asyncio
from .doclinks import *
# %% ../nbs/api/18_release.ipynb #7e554523
GH_HOST = "https://api.github.com"
# %% ../nbs/api/18_release.ipynb #e220cefa
def _find_config(): return get_config()
# %% ../nbs/api/18_release.ipynb #1972609a
def _issue_txt(issue):
res = '- {} ([#{}]({}))'.format(issue.title.strip(), issue.number, issue.html_url)
if hasattr(issue, 'pull_request'): res += ', thanks to [@{}]({})'.format(issue.user.login, issue.user.html_url)
return res
def _issues_txt(iss, label):
if not iss: return ''
res = f"### {label}\n\n"
return res + '\n'.join(map(_issue_txt, iss))
def _load_json(cfg, k):
try: return json.loads(cfg[k])
except json.JSONDecodeError as e: raise Exception(f"Key: `{k}` in .ini file is not a valid JSON string: {e}")
# %% ../nbs/api/18_release.ipynb #e5861010
def _require_release_branch(branch):
if not branch: raise SystemExit('Cannot release from detached HEAD; create a maintenance branch first')
return branch
def _release_branch(): return _require_release_branch(run('git branch --show-current').strip())
def _is_ancestor(ref): return subprocess.run(['git', 'merge-base', '--is-ancestor', ref, 'HEAD'], capture_output=True).returncode == 0
def _remote_shas(s): return {o.split()[0] for o in s.splitlines()}
def _check_changelog_base(tag):
if _is_ancestor(tag): return
raise SystemExit(f'HEAD does not contain the latest release ({tag}). Write CHANGELOG.md manually, then run with --no_changelog.')
def _release_head():
_release_branch()
head = run('git rev-parse HEAD').strip()
if head not in _remote_shas(run('git ls-remote --heads origin')): raise SystemExit(f'Release commit {head[:7]} is not on origin; push the current branch first')
return head
# %% ../nbs/api/18_release.ipynb #dcaba486
def update_changelog(txt, ver, notes, marker='<!-- do not remove -->\n'):
"Insert `notes` into changelog `txt` after `marker`, replacing any existing section for `ver`"
txt = re.sub(rf'\n## {re.escape(ver)}\n.*?(?=\n## |\Z)', '', txt, flags=re.S)
return txt.replace(marker, marker+notes.rstrip('\n')+'\n\n').rstrip('\n')+'\n'
# %% ../nbs/api/18_release.ipynb #0b36471a
class Release:
def __init__(self, owner=None, repo=None, token=None, **groups):
"Create CHANGELOG.md from GitHub issues"
self.cfg = _find_config()
self.changefile = self.cfg.config_path/'CHANGELOG.md'
if not groups:
default_groups=dict(breaking="Breaking Changes", enhancement="New Features", bug="Bugs Squashed")
groups=_load_json(self.cfg, 'label_groups') if 'label_groups' in self.cfg else default_groups
os.chdir(self.cfg.config_path)
if repo and '/' in repo: owner,repo = repo.split('/', 1)
owner,repo = owner or self.cfg.user, repo or self.cfg.repo
token = ifnone(token, os.getenv('NBDEV_TOKEN',None))
if not token and Path('token').exists(): token = Path('token').read_text().strip()
token = ifnone(token, os.getenv('GITHUB_TOKEN',None))
if not token: raise Exception('Failed to find token')
self.gh = GhApi(owner, repo, token)
self.groups = groups
async def _issues(self, label):
return await self.gh.issues.list_for_repo(state='closed', sort='created', filter='all', since=self.commit_date, labels=label)
async def _issue_groups(self): return await asyncio.gather(*map(self._issues, self.groups.keys()))
# %% ../nbs/api/18_release.ipynb #aa22dfe3
@patch
async def changelog(self:Release,
debug=False): # Just print the latest changes, instead of updating file
"Create the CHANGELOG.md file, or return the proposed text if `debug` is `True`"
if not self.changefile.exists(): self.changefile.write_text("# Release notes\n\n<!-- do not remove -->\n")
marker = '<!-- do not remove -->\n'
try: self.commit_date = (lr:=await self.gh.repos.get_latest_release()).published_at
except APIError as e:
if e.status_code != 404: raise
lr,self.commit_date = None,'2000-01-01T00:00:004Z'
if lr:
run('git fetch --tags --quiet')
_check_changelog_base(lr.tag_name)
if Version(self.cfg.version) <= Version(lr.tag_name):
print(f'Error: Version bump required: expected: >{lr.tag_name}, got: {self.cfg.version}.')
raise SystemExit(1)
res = f"\n## {self.cfg.version}\n\n"
issues = await self._issue_groups()
sections = (_issues_txt(*o) for o in zip(issues, self.groups.values()))
res += '\n\n'.join(filter(None, sections))
if debug: return res
res = update_changelog(self.changefile.read_text(), self.cfg.version, res, marker)
shutil.copy(self.changefile, self.changefile.with_suffix(".bak"))
self.changefile.write_text(res)
run(f'git add {self.changefile}')
# %% ../nbs/api/18_release.ipynb #068421c6
@patch
async def release(self:Release):
"Tag and create a release in GitHub for the current version"
ver = self.cfg.version
notes = self.latest_notes()
default = (await self.gh.repos.get()).default_branch
latest = 'true' if _release_branch()==default else 'false'
await self.gh.create_release(ver, branch=_release_head(), body=notes, make_latest=latest)
return ver
# %% ../nbs/api/18_release.ipynb #22101171
@patch
def latest_notes(self:Release):
"Latest CHANGELOG entry"
if not self.changefile.exists(): return ''
its = re.split(r'^## ', self.changefile.read_text(), flags=re.MULTILINE)
if not len(its)>0: return ''
return '\n'.join(its[1].splitlines()[1:]).strip()
# %% ../nbs/api/18_release.ipynb #01cb1ca4
@call_parse
async def changelog(
debug:store_true=False, # Print info to be added to CHANGELOG, instead of updating file
repo:str=None, # "repo" or "owner/repo" to use instead of pyproject.toml values
token:str=None, # Optional GitHub token (otherwise `token` file is used)
):
"Create a CHANGELOG.md file from closed and labeled GitHub issues"
res = await Release(repo=repo, token=token).changelog(debug=debug)
if debug: print(res)
# %% ../nbs/api/18_release.ipynb #15b66643
async def push_release(token:str=None, repo:str=None):
"Create a GitHub release (changelog should already be committed/pushed). Returns the release."
return await Release(repo=repo, token=token).release()
# %% ../nbs/api/18_release.ipynb #6d3c5cd8
@call_parse
async def release_git(token:str=None):
"Tag and create a release in GitHub for the current version"
print(f"Released {await push_release(token)}")
# %% ../nbs/api/18_release.ipynb #94ee72b1
@call_parse
async def release_gh(
token:str=None, # Optional GitHub token (otherwise `token` file is used)
repo:str=None, # "repo" or "owner/repo" to use instead of pyproject.toml values
no_changelog:store_true=False, # Skip changelog creation (assumes CHANGELOG.md is up to date)
no_editor:store_true=False, # Skip opening CHANGELOG.md in an editor
yes:store_true=False # Release without asking for confirmation
):
"Create the changelog, optionally edit it, then push and create the GitHub release"
cfg = _find_config()
_release_branch()
if not no_changelog: await Release(repo=repo).changelog()
if not no_editor: subprocess.run([os.environ.get('EDITOR','nano'), cfg.config_path/'CHANGELOG.md'])
if not yes and not input("Make release now? (y/n) ").lower().startswith('y'): sys.exit(1)
run('git commit -am release')
run('git push --set-upstream origin HEAD')
print(f"Released {await push_release(token, repo=repo)}")
# %% ../nbs/api/18_release.ipynb #5b4d4aa2
from fastcore.all import *
from .config import *
from .cli import *
import yaml,subprocess,glob,platform
from os import system
try: from packaging.version import parse
except ImportError: from pip._vendor.packaging.version import parse
_PYPI_URL = 'https://pypi.org/pypi/'
# %% ../nbs/api/18_release.ipynb #4070bbef
def pypi_json(s):
"Dictionary decoded JSON for PYPI path `s`"
return urljson(f'{_PYPI_URL}{s}/json')
# %% ../nbs/api/18_release.ipynb #1985c8b1
def latest_pypi(name):
"Latest version of `name` on pypi"
return max(parse(r) for r,o in pypi_json(name)['releases'].items()
if not parse(r).is_prerelease and not nested_idx(o, 0, 'yanked'))
# %% ../nbs/api/18_release.ipynb #ab0bfd9a
def pypi_details(name):
"Version, URL, and SHA256 for `name` from pypi"
ver = str(latest_pypi(name))
pypi = pypi_json(f'{name}/{ver}')
rel = [o for o in pypi['urls'] if o['packagetype']=='sdist'][0]
return ver,rel['url'],rel['digests']['sha256']
# %% ../nbs/api/18_release.ipynb #431f625b
import shlex
from subprocess import Popen, PIPE, CalledProcessError
def _run(cmd):
res = ""
with Popen(shlex.split(cmd), stdout=PIPE, bufsize=1, text=True, encoding="utf-8") as p:
for line in p.stdout:
print(line, end='')
res += line
if p.returncode != 0: raise CalledProcessError(p.returncode, p.args)
return res
# %% ../nbs/api/18_release.ipynb #6a9d34ab
def conda_output_path(name, build='build'):
"Output path for conda build"
return run(f'conda {build} --output {name}').strip().replace('\\', '/')
# %% ../nbs/api/18_release.ipynb #99fb71c2
def _write_yaml(path, name, d1, d2):
path = Path(path)
p = path/name
p.mkdir(exist_ok=True, parents=True)
yaml.SafeDumper.ignore_aliases = lambda *args : True
with (p/'meta.yaml').open('w', encoding="utf-8") as f:
yaml.safe_dump(d1, f)
yaml.safe_dump(d2, f)
# %% ../nbs/api/18_release.ipynb #5c78f115
def _get_conda_meta():
cfg = get_config()
name,ver = cfg.lib_name,cfg.version
url = cfg.doc_host or cfg.git_url
doc_url = (cfg.doc_host + cfg.doc_baseurl) if (cfg.doc_host and cfg.doc_baseurl) else url
dev_url = cfg.git_url if cfg.git_url else url
hostreqs = ['packaging', f'python >={cfg.min_python}']
reqs = hostreqs+[]
if cfg.get('requirements'): reqs += cfg.requirements
if cfg.get('conda_requirements'): reqs += cfg.conda_requirements
pypi = pypi_json(f'{name}/{ver}')
rel = [o for o in pypi['urls'] if o['packagetype']=='sdist'][0]
# Work around conda build bug - 'package' and 'source' must be first
d1 = {
'package': {'name': name, 'version': ver},
'source': {'url':rel['url'], 'sha256':rel['digests']['sha256']}
}
_dir = cfg.lib_path.parent
readme = _dir/'README.md'
descr = readme.read_text() if readme.exists() else ''
d2 = {
'build': {'number': '0', 'noarch': 'python',
'script': '{{ PYTHON }} -m pip install . -vv'},
'requirements': {'host':hostreqs, 'run':reqs},
'test': {'imports': [cfg.lib_path.name]},
'about': {
'license': 'Apache Software',
'license_family': 'APACHE',
'home': dev_url, 'doc_url': doc_url, 'dev_url': dev_url,
'summary': cfg.get('description'),
'description': descr
},
'extra': {'recipe-maintainers': [cfg.get('user')]}
}
return name,d1,d2
# %% ../nbs/api/18_release.ipynb #b911bd4d
def write_conda_meta(path='conda'):
"Writes a `meta.yaml` file to the `conda` directory of the current directory"
_write_yaml(path, *_get_conda_meta())
# %% ../nbs/api/18_release.ipynb #7550557f
@call_parse
def write_requirements(path:str=''):
"Writes a `requirements.txt` file to `directory` based on pyproject.toml."
cfg = get_config()
d = Path(path) if path else cfg.config_path
req = '\n'.join(['\n'.join(cfg.get(k) or []) for k in ['requirements', 'pip_requirements']])
(d/'requirements.txt').mk_write(req)
# %% ../nbs/api/18_release.ipynb #715ae3ac
CONDA_WARNING='Conda support for nbdev is deprecated and scheduled for removal in a future version.'
def anaconda_upload(name, loc=None, user=None, token=None, env_token=None):
"Upload `name` to anaconda"
warn(CONDA_WARNING)
user = f'-u {user} ' if user else ''
if env_token: token = os.getenv(env_token)
token = f'-t {token} ' if token else ''
if not loc: loc = conda_output_path(name)
if not loc: raise Exception("Failed to find output")
return _run(f'anaconda {token} upload {user} {loc} --skip-existing')
# %% ../nbs/api/18_release.ipynb #952bc33d
@call_parse
def release_conda(
path:str='conda', # Path where package will be created
do_build:bool_arg=True, # Run `conda build` step
build_args:str='', # Additional args (as str) to send to `conda build`
skip_upload:store_true=False, # Skip `anaconda upload` step
mambabuild:store_true=False, # Use `mambabuild` (requires `boa`)
upload_user:str=None # Optional user to upload package to
):
"Create a `meta.yaml` file ready to be built into a package, and optionally build and upload it"
warn(CONDA_WARNING)
name = get_config().lib_name
write_conda_meta(path)
out = f"Done. Next steps:\n```\ncd {path}\n"""
os.chdir(path)
build = 'mambabuild' if mambabuild else 'build'
if not do_build: return print(f"{out}conda {build} {name}")
for f in globtastic('out', file_glob='*.tar.bz2'): os.remove(f)
cmd = f"conda {build} --output-folder out --no-anaconda-upload {build_args} {name}"
print(cmd)
res = _run(cmd)
outs = globtastic('out', file_glob='*.tar.bz2')
assert len(outs)==1
loc = outs[0]
if skip_upload: return print(loc)
if not upload_user: upload_user = get_config().conda_user
if not upload_user: return print("`conda_user` not in pyproject.toml and no `upload_user` passed. Cannot upload")
if 'anaconda upload' not in res: return print(f"{res}\n\nFailed. Check auto-upload not set in .condarc. Try `--do_build False`.")
return anaconda_upload(name, loc)
# %% ../nbs/api/18_release.ipynb #0500d972
def chk_conda_rel(
nm:str, # Package name on pypi
apkg:str=None, # Anaconda Package (defaults to {nm})
channel:str='fastai', # Anaconda Channel
force:store_true=False # Always return github tag
):
"Prints GitHub tag only if a newer release exists on Pypi compared to an Anaconda Repo."
if not apkg: apkg=nm
condavs = L(loads(run(f'mamba repoquery search {apkg} -c {channel} --json'))['result']['pkgs'])
condatag = condavs.attrgot('version').map(parse)
pypitag = latest_pypi(nm)
if force or not condatag or pypitag > max(condatag): return f'{pypitag}'
# %% ../nbs/api/18_release.ipynb #bf55df9b
@call_parse
def release_pypi(
repository:str="pypi", # Respository to upload to (defined in ~/.pypirc)
quiet:bool=False # Reduce output verbosity
):
"Create and upload Python package to PyPI"
_dir = get_config().lib_path.parent
q = ' --quiet' if quiet else ''
p = ' --disable-progress-bar' if quiet else ''
system(f'cd {_dir} && rm -rf dist build && python -m build{q}')
system(f'twine upload --repository {repository}{p} {_dir}/dist/*')
# %% ../nbs/api/18_release.ipynb #06edfcb0
@call_parse
def release_both(
path:str='conda', # Path where package will be created
do_build:bool_arg=True, # Run `conda build` step
build_args:str='', # Additional args (as str) to send to `conda build`
skip_upload:store_true=False, # Skip `anaconda upload` step
mambabuild:store_true=False, # Use `mambabuild` (requires `boa`)
upload_user:str=None, # Optional user to upload package to
repository:str="pypi" # Pypi respository to upload to (defined in ~/.pypirc)
):
"Release both conda and PyPI packages"
release_pypi.__wrapped__(repository)
release_conda.__wrapped__(path, do_build=do_build, build_args=build_args, skip_upload=skip_upload, mambabuild=mambabuild, upload_user=upload_user)
nbdev_bump_version.__wrapped__()
# %% ../nbs/api/18_release.ipynb #c0f64b2c
@call_parse
def nbdev_bump_version(
part:int=2, # Part of version to bump
unbump:bool=False): # Reduce version instead of increasing it
"Increment version in __init__.py by one"
cfg = get_config()
old_ver = read_version(cfg.lib_path)
print(f'Old version: {old_ver}')
new_ver = bump_version(old_ver, part, unbump=unbump)
set_version(cfg.lib_path, new_ver)
nbdev_export.__wrapped__()
print(f'New version: {new_ver}')