#hide
# default_exp core
API for auto-generated tagged releases, and release notes (from GitHub issues)
#export
from fastcore.imports import *
from fastcore.utils import *
from fastcore.foundation import *
from datetime import datetime
from configparser import ConfigParser
import json,subprocess,shutil
from urllib.request import HTTPError
from fastcore.script import *
#hide
from nbdev.showdoc import show_doc
#export
GH_HOST = "https://api.github.com"
#export
def find_config(cfg_name="settings.ini"):
cfg_path = Path().absolute()
while cfg_path != cfg_path.parent and not (cfg_path/cfg_name).exists(): cfg_path = cfg_path.parent
config_file = cfg_path/cfg_name
assert config_file.exists(), f"Couldn't find {cfg_name}"
config = ConfigParser()
config.read(config_file)
return config['DEFAULT'],cfg_path
#export
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)
res += '\n'
if not issue.body: return res
return res + f" - {issue.body.strip()}\n"
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}")
#export
class FastRelease:
def __init__(self, owner=None, repo=None, token=None, **groups):
"Create CHANGELOG.md from GitHub issues"
self.cfg,cfg_path = find_config()
self.changefile = cfg_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(cfg_path)
if not owner: owner = self.cfg['user']
if not repo: repo = self.cfg['lib_name']
token = ifnone(token, os.getenv('FASTRELEASE_TOKEN',None))
if not token and Path('token').exists(): token = Path('token').read_text().strip()
if not token: raise Exception('Failed to find token')
self.headers = { 'Authorization' : 'token ' + token }
self.owner,self.repo,self.groups = owner,repo,groups
self.repo_url = f"{GH_HOST}/repos/{owner}/{repo}"
def gh(self, path, complete=False, post=False, **data):
"Call GitHub API `path`"
if not complete: path = f"{self.repo_url}/{path}"
return dict2obj(do_request(path, headers=self.headers, post=post, **data))
def _tag_date(self, tag):
try: tag_d = self.gh(f"git/ref/tags/{tag}")
except HTTPError: raise Exception(f"Failed to find tag {tag}")
commit_d = self.gh(tag_d.object.url, complete=True)
self.commit_date = commit_d.committer.date
return self.commit_date
def _issues(self, label):
return self.gh("issues", state='closed', sort='created', filter='all',
since=self.commit_date, labels=label)
def _issue_groups(self): return parallel(self._issues, self.groups.keys(), progress=False)
def _latest_release(self): return self.gh("releases/latest").tag_name
def changelog(self, debug=False):
"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:
latest = self._latest_release()
self._tag_date(latest)
except HTTPError: # no prior releases
self.commit_date = '2000-01-01T00:00:004Z'
res = f"\n## {self.cfg['version']}\n"
issues = self._issue_groups()
res += '\n'.join(_issues_txt(*o) for o in zip(issues, self.groups.values()))
if debug: return res
res = self.changefile.read_text().replace(marker, marker+res+"\n")
shutil.copy(self.changefile, self.changefile.with_suffix(".bak"))
self.changefile.write_text(res)
def release(self):
"Tag and create a release in GitHub for the current version"
ver = self.cfg['version']
run(f'git tag {ver}')
run('git push --tags')
run('git pull --tags')
notes = self.latest_notes()
if not notes.startswith(ver): notes = ''
self.gh("releases", post=True, tag_name=ver, name=ver, body=notes)
return ver
def latest_notes(self):
"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 its[1].strip()
To create a markdown changelog, first create a FastRelease
object, passing a mapping from GitHub labels to markdown titles. Put your github token in a file named token
at the root of your repo. FastRelease
attempts to fetch values for arguments from the following locations if not supplied:
owner: fetched from the field user
in settings.ini
. This is the owner name of the repository on GitHub. For example for the repo fastai/fastcore
the owner would be fastai
.
repo: fetched from the field lib_name
in settings.ini
. This is the name of the repository on GitHub. For example for the repo fastai/fastcore
the owner would be fastcore
.
token: fetched from a file named token
at the root of your repo. Creating a token is discussed in the setup section.
groups: (optional) fetched from the field label_groups
in settings.ini
, which is a JSON string. This is a mapping from label names to titles in your release notes. For example, this is a valid way of setting your label_groups:
label_gr{"breaking": "Breaking Changes",
"enhancement":"New Features",
"bug":"Bugs Squashed"}
If not specified, this defaults to:
{"breaking": "Breaking Changes",
"enhancement":"New Features",
"bug":"Bugs Squashed"}
show_doc(FastRelease.changelog)
FastRelease.changelog
[source]
FastRelease.changelog
(debug
=False
)
Create the CHANGELOG.md file, or return the proposed text if debug
is True
Fetches relevant pull requests and issues from the GitHub API, and categorizes these entities according to a user-supplied mapping from labels to markdown headings. The default mapping from labels to markdown headings is:
# rel = FastRelease()
# print(rel.changelog(debug=True))
# rel.changelog()
show_doc(FastRelease.release)
FastRelease.release
[source]
FastRelease.release
()
Tag and create a release in GitHub for the current version
This uses the version information from your settings.ini
.
show_doc(FastRelease.gh)
FastRelease.gh
[source]
FastRelease.gh
(path
,complete
=False
,post
=False
, ****data
**)
Call GitHub API path
#export
@call_parse
def fastrelease_changelog(debug:Param("Print info to be added to CHANGELOG, instead of updating file", store_true)=False):
"Create a CHANGELOG.md file from closed and labeled GitHub issues"
FastRelease().changelog(debug=debug)
#export
@call_parse
def fastrelease_release(token:Param("Optional GitHub token (otherwise `token` file is used)", str)=None):
"Tag and create a release in GitHub for the current version"
ver = FastRelease(token=token).release()
print(f"Released {ver}")
#export
@call_parse
def fastrelease(debug:Param("Print info to be added to CHANGELOG, instead of updating file", store_true)=False,
token:Param("Optional GitHub token (otherwise `token` file is used)", str)=None):
"Calls `fastrelease_changelog`, lets you edit the result, then pushes to git and calls `fastrelease_release`"
cfg,cfg_path = find_config()
FastRelease().changelog()
if debug: return
subprocess.run([os.environ.get('EDITOR','nano'), cfg_path/'CHANGELOG.md'])
if not input("Make release now? (y/n) ").lower().startswith('y'): sys.exit(1)
run('git commit -am release')
run('git push')
ver = FastRelease(token=token).release()
print(f"Released {ver}")
#export
def bump_version(version, part=2):
version = version.split('.')
version[part] = str(int(version[part]) + 1)
for i in range(part+1, 3): version[i] = '0'
return '.'.join(version)
test_eq(bump_version('0.1.1' ), '0.1.2')
test_eq(bump_version('0.1.1', 1), '0.2.0')
#export
@call_parse
def fastrelease_bump_version(part:Param("Part of version to bump", int)=2):
"Increment version in `settings.py` by one"
cfg = Config()
print(f'Old version: {cfg.version}')
cfg.d['version'] = bump_version(Config().version, part)
cfg.save()
print(f'New version: {cfg.version}')
#hide
from nbdev.export import notebook2script
notebook2script()
Converted 00_core.ipynb. Converted 01_conda.ipynb. Converted index.ipynb.