diff --git a/noxfile.py b/noxfile.py index 44afb33..e083aa7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,9 +1,24 @@ -"""Configure nox as the task runner.""" +"""Configure nox as the task runner. + +Nox provides the following tasks: + +- "fix-branch-references": adjusts links with git branch references in + various files (e.g., Mardown or notebooks) + +""" + +import contextlib +import glob +import os +import re +import shutil +import subprocess +import tempfile import nox -PYTHON = "3.8" +REPOSITORY = "webartifex/intro-to-python" # Use a unified .cache/ folder for all develop tools. nox.options.envdir = ".cache/nox" @@ -11,3 +26,103 @@ nox.options.envdir = ".cache/nox" # All tools except git and poetry are project dependencies. # Avoid accidental successes if the environment is not set up properly. nox.options.error_on_external_run = True + + +@nox.session(name="fix-branch-references", venv_backend="none") +def fix_branch_references(_session): + """Change git branch references. + + Intended to be run as a pre-commit hook. + + Many files in the project (e.g., README.md) contain links to resources on + github.com, nbviewer.jupyter.org, or mybinder.org that contain git branch + labels. + + This task rewrites branch labels into either "main" or "develop". + """ + # Glob patterns that expand into the files whose links are re-written. + paths = ["*.md", "**/*.ipynb"] + + branch = ( + subprocess.check_output( + ("git", "rev-parse", "--abbrev-ref", "HEAD"), + ) + .decode() + .strip() + ) + # If the current branch is only temporary and will be merged into "main", ... + if branch.startswith("release-") or branch.startswith("hotfix-"): + branch = "main" + # If the branch is not "main", we assume it is a feature branch. + elif branch != "main": + branch = "develop" + + rewrites = [ + { + "name": "github", + "pattern": re.compile( + fr"((((http)|(https))://github\.com/{REPOSITORY}/((blob)|(tree))/)([\w-]+)/)" + ), + "replacement": fr"\2{branch}/", + }, + { + "name": "nbviewer", + "pattern": re.compile( + fr"((((http)|(https))://nbviewer\.jupyter\.org/github/{REPOSITORY}/((blob)|(tree))/)([\w-]+)/)", + ), + "replacement": fr"\2{branch}/", + }, + { + "name": "mybinder", + "pattern": re.compile( + fr"((((http)|(https))://mybinder\.org/v2/gh/{REPOSITORY}/)([\w-]+)\?)", + ), + "replacement": fr"\2{branch}?", + }, + ] + + for expanded in _expand(*paths): + with _line_by_line_replace(expanded) as (old_file, new_file): + for line in old_file: + for rewrite in rewrites: + line = re.sub(rewrite["pattern"], rewrite["replacement"], line) + new_file.write(line) + + +def _expand(*patterns): + """Expand glob patterns into paths. + + Args: + *patterns: the patterns to be expanded + + Yields: + path: a single expanded path + """ + for pattern in patterns: + yield from glob.glob(pattern.strip()) + + +@contextlib.contextmanager +def _line_by_line_replace(path): + """Replace/change the lines in a file one by one. + + This generator function yields two file handles, one to the current file + (i.e., `old_file`) and one to its replacement (i.e., `new_file`). + + Usage: loop over the lines in `old_file` and write the files to be kept + to `new_file`. Files not written to `new_file` are removed! + + Args: + path: the file whose lines are to be replaced + + Yields: + old_file, new_file: handles to a file and its replacement + """ + file_handle, new_file_path = tempfile.mkstemp() + with os.fdopen(file_handle, "w") as new_file: + with open(path) as old_file: + yield old_file, new_file + + shutil.copymode(path, new_file_path) + os.remove(path) + shutil.move(new_file_path, path)