Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Development tools #14

Merged
merged 1 commit into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}
Loading