Skip to content

Commit

Permalink
Update development tools to a common baseline
Browse files Browse the repository at this point in the history
Common baseline provides tox, justfile, and check scripts.
  • Loading branch information
alexrudy committed May 21, 2024
1 parent 42ecc74 commit 6e766d5
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 66 deletions.
8 changes: 4 additions & 4 deletions .flake8
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[flake8]
select = B, E, F, W, B9, ISC
ignore =
B902
E203
E402
E501
E704
E711
E712
E722
W503
W504
E712,E711
B902
max-line-length = 120
min_python_version = 3.11
exclude = src/dominate-stubs
min_python_version = 3.11.0
40 changes: 32 additions & 8 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
# Common Flask project tasks

# Set up the virtual environment based on the direnv convention
# https://direnv.net/docs/legacy.html#virtualenv
virtual_env := justfile_directory() / ".direnv/python-3.11/bin"
python_version := env('PYTHON_VERSION', "3.12")
virtual_env := justfile_directory() / ".direnv/python-$python_version/bin"
export PATH := virtual_env + ":" + env('PATH')
export REQUIREMENTS_TXT := env('REQUIREMENTS', '')

[private]
prepare:
pip install --quiet --upgrade pip
pip install --quiet pip-tools pip-compile-multi
pip install --quiet -r requirements/pip-tools.txt

# lock the requirements files
compile: prepare
pip-compile-multi --use-cache --backtracking

# Install dependencies
sync: prepare
pip-compile-multi --use-cache
pip-sync requirements/dev.txt
pip install -e .
tox --notest
[[ -f requirements/local.txt ]] && pip install -r requirements/local.txt
tox -p auto --notest

alias install := sync
alias develop := sync

# Sort imports
isort:
-pre-commit run reorder-python-imports --all-files

# Run tests
test:
pytest
pytest -q -n 4 --cov-report=html

# Run all tests
test-all:
tox
tox -p auto

alias tox := test-all
alias t := test-all

# Run lints
lint:
Expand All @@ -31,6 +48,13 @@ lint:
mypy:
mypy

# run the flask application
serve:
flask run

alias s := serve
alias run := serve

# Build docs
docs:
cd docs && make html
Expand All @@ -46,7 +70,7 @@ clean-docs:
rm -rf docs/api

# Clean aggressively
clean-all: clean
clean-all: clean clean-docs
rm -rf .direnv
rm -rf .venv
rm -rf .tox
Expand Down
126 changes: 78 additions & 48 deletions scripts/check-dist.py
Original file line number Diff line number Diff line change
@@ -1,99 +1,129 @@
#!/usr/bin/env python3
import contextlib
import dataclasses
import os
import subprocess
import sys
import tempfile
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import wait
from pathlib import Path

import click


def run(*args: str) -> None:
cmd = " ".join(args)
click.echo("{} {}".format(click.style(">", fg="blue", bold=True), cmd))
@dataclasses.dataclass
class VirtualEnv:

path: Path

def run(self, *args: object) -> None:
python = self.path / "bin" / "python"
run(python, *args)


verbose = click.get_current_context().meta["verbose"]
process = subprocess.run(args, capture_output=(not verbose))
def run(*args: object) -> None:
args = [str(arg) for arg in args]
cmd = " ".join(args)
ctx = click.get_current_context()

if not ctx.meta.get("quiet", False):
click.echo("{} {}".format(click.style(">", fg="blue", bold=True), cmd))

verbose = ctx.meta.get("verbose", False)
process = subprocess.run(
args,
stdout=subprocess.PIPE if not verbose else None,
stderr=subprocess.STDOUT if not verbose else None,
check=False,
)
if process.returncode != 0:
click.echo(
"{} {} failed with returncode {}".format(click.style("!", fg="red", bold=True), cmd, process.returncode),
err=True,
)

if process.stderr or process.stdout:
click.echo(process.stdout.decode())
click.echo(process.stderr.decode(), err=True)
if process.stdout:
click.echo(process.stdout.decode(), err=True)
raise click.ClickException(f"Command failed with return code {process.returncode}")


def python(venv: Path, *args: str) -> None:
pybinary = venv / "bin" / "python"
run(str(pybinary), *args)
BUILD_COMMAND_ARG = {
"sdist": "-s",
"wheel": "-w",
}

BUILD_ARTIFACT_PATTERN = {
"sdist": "*.tar.gz",
"wheel": "*.whl",
}


def dist(location: Path, pattern: str) -> Path:
def find_dist(location: Path, pattern: str) -> Path:
candidates = sorted(location.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
if not candidates:
raise click.ClickException("No sdist found")
raise click.ClickException(f"No {pattern} found")
return candidates[0]


def clean(package: str) -> None:
run("rm", "-rf", f"src/{package}/assets/*")
run("rm", "-rf", "dist")


@contextlib.contextmanager
def virtualenv(root: Path, name: str) -> Iterator[Path]:
def virtualenv(root: Path, name: str) -> Iterator[VirtualEnv]:
"""Create a virtualenv and yield the path to it."""
run("python", "-m", "venv", str(root / name))
yield root / name
yield VirtualEnv(root / name)
run("rm", "-rf", str(root / name))


def check(venv: Path, package: str, assets: bool = False) -> None:
python(venv, "-c", f"import {package}; print({package}.__version__)")

if assets:
python(venv, "-c", f"import {package}.assets; {package}.assets.check_dist()")
def check_dist(ctx: click.Context, package: str, dist: str, assets: bool = False) -> None:

python(venv, "-m", "pip", "install", "twine")
python(venv, "-m", "twine", "check", f"dist/{package}-*")
with ctx.scope(), tempfile.TemporaryDirectory() as tmp_directory:
tmpdir = Path(tmp_directory)
distdir = tmpdir / "dist"
run(sys.executable, "-m", "build", BUILD_COMMAND_ARG[dist], ".", "--outdir", distdir)

with virtualenv(tmpdir, "venv-dist") as venv:
venv.run("-m", "pip", "install", "--upgrade", "pip")
sdist = find_dist(distdir, BUILD_ARTIFACT_PATTERN[dist])
venv.run("-m", "pip", "install", str(sdist))

def sdist(package: str, assets: bool = False) -> None:
clean(package=package)
run("python", "-m", "build", "-s", ".")
with virtualenv(Path("dist"), "venv-sdist") as venv:
python(venv, "-m", "pip", "install", "--upgrade", "pip")
sdist = dist(Path("dist/"), "*.tar.gz")
python(venv, "-m", "pip", "install", str(sdist))
check(venv, package, assets=assets)
click.secho("sdist built and installed successfully", fg="green", bold=True)
venv.run("-c", f"import {package}; print({package}.__version__)")
if assets:
venv.run("-c", f"import {package}.assets; {package}.assets.check_dist()")

with virtualenv(tmpdir, "venv-twine") as venv:
venv.run("-m", "pip", "install", "twine")
venv.run("-m", "twine", "check", sdist)

def wheel(package: str, assets: bool = False) -> None:
clean(package=package)
run("python", "-m", "build", "-w", ".")
with virtualenv(Path("dist"), "venv-wheel") as venv:
wheel = dist(Path("dist/"), "*.whl")
python(venv, "-m", "pip", "install", str(wheel))
check(venv, package, assets=assets)
click.secho("wheel built and installed successfully", fg="green", bold=True)
click.secho(f"{dist} built and installed successfully", fg="green", bold=True)


@click.command()
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.option("-q", "--quiet", is_flag=True, help="Enable quiet output")
@click.option("-a", "--assets", is_flag=True, help="Check assets")
@click.argument("package", type=str)
@click.option("-t", "--timeout", default=60.0, help="Timeout for checking distribution")
@click.argument("toxinidir", type=str, required=True)
@click.pass_context
def main(ctx: click.Context, package: str, verbose: bool, assets: bool) -> None:
def main(ctx: click.Context, toxinidir: str, verbose: bool, quiet: bool, assets: bool, timeout: float) -> None:
"""Check distribution for package"""
if os.environ.get("CI") == "true":
verbose = True

ctx.meta["quiet"] = quiet
ctx.meta["verbose"] = verbose
sdist(package, assets=assets)
wheel(package, assets=assets)

package = Path(toxinidir).name
click.secho(f"Checking distribution for {package}", bold=True)

with ThreadPoolExecutor() as executor:
sdist = executor.submit(check_dist, ctx, package, "sdist", assets=assets)
wheel = executor.submit(check_dist, ctx, package, "wheel", assets=assets)

done, _ = wait([sdist, wheel], return_when="ALL_COMPLETED", timeout=timeout)

for future in done:
future.result()


if __name__ == "__main__":
Expand Down
16 changes: 16 additions & 0 deletions scripts/check-minimal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
import pathlib
import subprocess
import sys


def check_minimal():
"""Check if the package can be imported."""
project = pathlib.Path(__file__).parent.name
print(f"Checking minimal for project: {project}")

subprocess.run([sys.executable, "-c", f"import {project}"], check=True)


if __name__ == "__main__":
check_minimal()
2 changes: 1 addition & 1 deletion tests/nav/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def test_nav_alignment(alignment: core.NavAlignment, cls: str) -> None:

source = render(nav)
expected = f"""
<ul class='nav {cls if cls else str()}'>
<ul class='nav {cls if cls else ''}'>
<li class='nav-item'><span class='nav-link disabled' aria-disabled='true'>Text</span></li>
</ul>"""

Expand Down
17 changes: 12 additions & 5 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[tox]
envlist =
py311
py312
py3{11,12}
coverage
style
typing
docs
Expand All @@ -11,7 +11,14 @@ skip_missing_interpreters = true

[testenv]
deps = -r requirements/tests.txt
commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs}
commands =
pytest -v --tb=short --basetemp={envtmpdir} {posargs}

[testenv:coverage]
depends = py3{11,12}
deps = -r requirements/tests.txt
commands =
coverage report --fail-under=90 --skip-covered

[testenv:style]
deps = pre-commit
Expand All @@ -28,11 +35,11 @@ commands = sphinx-build -W -b html -d {envtmpdir}/doctrees {toxinidir}/docs {env

[testenv:minimal]
deps =
commands = python -c 'import bootlace'
commands = python {toxinidir}/scripts/check-minimal.py

[testenv:dist]
deps =
hatch
build
skip_install = true
commands = python {toxinidir}/scripts/check-dist.py bootlace
commands = python {toxinidir}/scripts/check-dist.py {toxinidir} {posargs:-q}

0 comments on commit 6e766d5

Please sign in to comment.