forked from FollowTheProcess/pytoil
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathnoxfile.py
571 lines (455 loc) · 15.8 KB
/
noxfile.py
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
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
"""
Nox configuration file for the project.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import tempfile
import webbrowser
from pathlib import Path
from typing import Any
import nox
# Nox config
nox.needs_version = ">=2022.1.7"
nox.options.error_on_external_run = True
# GitHub Actions
ON_CI = bool(os.getenv("CI"))
ON_WINDOWS = platform.system().lower() == "windows"
# Global project stuff
PROJECT_ROOT = Path(__file__).parent.resolve()
PROJECT_SRC = PROJECT_ROOT / "pytoil"
PROJECT_TESTS = PROJECT_ROOT / "tests"
PROJECT_ENTRY_POINT = PROJECT_SRC / "__main__.py"
PROJECT_PROFILE = PROJECT_ROOT / "profile.html"
# Artifacts
ARTIFACTS = [
PROJECT_PROFILE,
PROJECT_ROOT.joinpath(".coverage"),
PROJECT_ROOT.joinpath("dist"),
PROJECT_ROOT.joinpath("site"),
]
# Git info
DEFAULT_BRANCH = "main"
# VSCode
VSCODE_DIR = PROJECT_ROOT / ".vscode"
SETTINGS_JSON = VSCODE_DIR / "settings.json"
# Poetry virtual environment stuff
VENV_DIR = PROJECT_ROOT / ".venv"
PYTHON = os.fsdecode(VENV_DIR / "bin" / "python")
# Python to use for non-test sessions
DEFAULT_PYTHON: str = "3.10"
# All supported python versions for pytoil
PYTHON_VERSIONS: list[str] = [
"3.9",
"3.10",
]
# List of seed packages to upgrade to their most
# recent versions in every nox environment
# these aren't strictly required but I've found including them
# solves most installation problems
SEEDS: list[str] = [
"pip",
"setuptools",
"wheel",
]
# Dependencies for each of the nox session names
# these names must be identical to the names of the defined nox sessions
# the list of dependencies here are installed against the constraints
# file generated by poetry_install
SESSION_REQUIREMENTS: dict[str, list[str]] = {
"test": [
"pytest",
"pytest-asyncio",
"pytest-httpx",
"pytest-cov",
"pytest-mock",
"pytest-clarity",
"freezegun",
"coverage[toml]",
],
"lint": [
"pre-commit",
],
"docs": [
"mkdocs",
"mkdocs-material",
],
"coverage": [
"coverage[toml]",
],
"codecov": [
"pytest",
"pytest-asyncio",
"pytest-httpx",
"pytest-cov",
"pytest-mock",
"pytest-clarity",
"freezegun",
"coverage[toml]",
],
}
# "dev" should only be run if no virtual environment found and we're not on CI
# i.e. someone is using nox to set up their local dev environment
# to work on pytoil
if not VENV_DIR.exists() and not ON_CI:
nox.options.sessions = ["dev"]
else:
nox.options.sessions = ["test", "coverage", "lint", "docs", "audit"]
@nox.session(python=False)
def dev(session: nox.Session) -> None:
"""
Sets up a python dev environment for the project if one doesn't already exist.
"""
# Check if dev has been run before
# this prevents manual running nox -s dev more than once
# thus potentially corrupting an environment
if VENV_DIR.exists():
session.error(
"There is already a virtual environment deactivate and remove it "
"before running 'dev' again"
)
# Error out if user does not have poetry installed
session_requires(session, "poetry")
session.run("poetry", "install", external=True)
# Poetry doesn't always install latest pip
# Here we use the absolute path to the poetry venv's python interpreter
session.run(PYTHON, "-m", "pip", "install", "--upgrade", *SEEDS, silent=True)
if bool(shutil.which("code")) or bool(shutil.which("code-insiders")):
# Only do this is user has VSCode installed
set_up_vscode(session)
@nox.session(python=False)
def update(session: nox.Session) -> None:
"""
Updates the dependencies in the poetry.lock file.
"""
# Error out if user does not have poetry installed
session_requires(session, "poetry")
session.run("poetry", "update")
@nox.session(python=PYTHON_VERSIONS)
def test(session: nox.Session) -> None:
"""
Runs the test suite against all supported python versions.
"""
# Error out if user does not have poetry installed
session_requires(session, "poetry")
# We can't use get_session_requirements here because the session
# is parametrized against different python versions meaning the
# session name is 'test-{version}'
requirements = SESSION_REQUIREMENTS.get("test", [])
if not requirements:
session.error("Requirements for nox session: 'test', not found in noxfile.py.")
update_seeds(session)
# Tests require the package to be installed
session.run("poetry", "install", "--no-dev", external=True, silent=True)
poetry_install(session, *requirements)
if "verbose" in session.posargs:
session.run("pytest", "-vv", f"--cov={PROJECT_SRC}", f"{PROJECT_TESTS}")
else:
session.run("pytest", f"--cov={PROJECT_SRC}", f"{PROJECT_TESTS}")
# Notify queues up 'coverage' to run next
# so 'nox -s test' will run coverage afterwards
session.notify("coverage")
@nox.session(python=DEFAULT_PYTHON)
def coverage(session: nox.Session) -> None:
"""
Test coverage analysis.
"""
# Error out if user does not have poetry installed
session_requires(session, "poetry")
requirements = get_session_requirements(session)
update_seeds(session)
poetry_install(session, *requirements)
session.run("coverage", "report", "--show-missing")
@nox.session(python=DEFAULT_PYTHON)
def codecov(session: nox.Session) -> None:
"""
Generate a codecov xml report for CI.
"""
session_requires(session, "poetry")
requirements = get_session_requirements(session)
update_seeds(session)
session.run("poetry", "install", "--no-dev", external=True, silent=True)
poetry_install(session, *requirements)
session.run("pytest", f"--cov={PROJECT_SRC}", f"{PROJECT_TESTS}")
session.run("coverage", "xml")
@nox.session(python=DEFAULT_PYTHON)
def lint(session: nox.Session) -> None:
"""
Run pre-commit linting.
"""
session_requires(session, "poetry")
requirements = get_session_requirements(session)
update_seeds(session)
poetry_install(session, *requirements)
session.run("pre-commit", "run", "--all-files")
@nox.session(python=DEFAULT_PYTHON)
def docs(session: nox.Session) -> None:
"""
Builds the project documentation. Use '-- serve' to see changes live.
"""
# Error out if user does not have poetry installed
session_requires(session, "poetry")
requirements = get_session_requirements(session)
update_seeds(session)
poetry_install(session, *requirements)
if "serve" in session.posargs:
webbrowser.open(url="http://127.0.0.1:8000/pytoil/")
session.run("mkdocs", "serve")
else:
session.run("mkdocs", "build", "--clean")
@nox.session(python=DEFAULT_PYTHON)
def audit(session: nox.Session) -> None:
"""
Audit dependencies for security vulnerabilities.
"""
session_requires(session, "poetry")
update_seeds(session)
session.run(
"poetry", "install", external=True, silent=True
) # Install everything, dev deps included
session.run("pip-audit")
@nox.session(python=DEFAULT_PYTHON)
def profile(session: nox.Session) -> None:
"""
Profile the profile passing session posargs to the CLI.
E.g. `nox -s profile -- config show`
"""
session.install(".")
session.install("scalene")
session.run(
"scalene",
"--html",
"--outfile",
str(PROJECT_PROFILE),
str(PROJECT_ENTRY_POINT),
*session.posargs,
)
@nox.session
def deploy_docs(session: nox.Session) -> None:
"""
Used by GitHub actions to deploy docs to GitHub Pages.
"""
session_requires(session, "poetry")
requirements = SESSION_REQUIREMENTS.get("docs")
if not requirements:
session.error("Could not find requirements for 'docs' in SESSION_REQUIREMENTS")
update_seeds(session)
poetry_install(session, *requirements) # type: ignore
if ON_CI:
session.run(
"git",
"remote",
"add",
"gh-token",
"https://${GITHUB_TOKEN}@github.com/FollowTheProcess/pytoil.git",
external=True,
)
session.run("git", "fetch", "gh-token", external=True)
session.run("git", "fetch", "gh-token", "gh-pages:gh-pages", external=True)
session.run("mkdocs", "gh-deploy", "-v", "--clean", "--remote-name", "gh-token")
else:
session.run("mkdocs", "gh-deploy")
@nox.session(python=DEFAULT_PYTHON)
def build(session: nox.Session) -> None:
"""
Builds the package sdist and wheel.
"""
# Error out if user does not have poetry installed
session_requires(session, "poetry")
update_seeds(session)
session.run("poetry", "install", "--no-dev", external=True, silent=True)
session.run("poetry", "build", external=True)
@nox.session(python=False)
def clean(session: nox.Session) -> None:
"""
Clean up artifacts from other nox sessions.
"""
for artifact in ARTIFACTS:
if artifact.exists():
if artifact.is_dir():
shutil.rmtree(artifact, ignore_errors=True)
else:
os.remove(artifact)
@nox.session(python=DEFAULT_PYTHON)
def release(session: nox.Session) -> None:
"""
Kicks off the automated release process by creating and pushing a new tag.
Invokes bump2version with the posarg setting the version.
Usage:
$ nox -s release -- [major|minor|patch]
"""
enforce_branch_no_changes(session)
parser = argparse.ArgumentParser(description="Release a new semantic version.")
parser.add_argument(
"version",
type=str,
nargs=1,
help="The type of semver release to make.",
choices={"major", "minor", "patch"},
)
args: argparse.Namespace = parser.parse_args(args=session.posargs)
version: str = args.version.pop()
# If we get here, we should be good to go
# Let's do a final check for safety
confirm = input(
f"You are about to bump the {version!r} version. Are you sure? [y/n]: "
)
# Abort on anything other than 'y'
if confirm.lower().strip() != "y":
session.error(f"You said no when prompted to bump the {version!r} version.")
# Error out if user does not have poetry installed
session_requires(session, "poetry")
update_seeds(session)
poetry_install(session, "bump2version")
session.log(f"Bumping the {version!r} version")
session.run("bump2version", version)
session.log("Pushing the new tag")
session.run("git", "push", external=True)
session.run("git", "push", "--tags", external=True)
def poetry_install(session: nox.Session, *args: str, **kwargs: Any) -> None:
"""
Install packages constrained by Poetry's lock file.
This function is a wrapper for nox.Session.install. It
invokes pip to install packages inside of the session's virtualenv.
Additionally, pip is passed a constraints file generated from
Poetry's lock file, to ensure that the packages are pinned to the
versions specified in poetry.lock.
This allows you to manage the
packages as Poetry development dependencies.
Args:
session (nox.Session): The enclosing nox Session.
args (str): List of packages to install.
kwargs: Keyword arguments passed to session.install.
"""
# NamedTemporaryFile has known issue where exiting the context manager throws
# a PermissionError, this is solved on CI by simply not deleting the file as it
# will be cleaned up when the runner is shut down anyway
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", delete=False if ON_CI and ON_WINDOWS else True
) as requirements:
session.run(
"poetry",
"export",
"--dev",
"--format=requirements.txt",
f"--output={requirements.name}",
"--without-hashes",
external=True,
silent=True,
)
session.install(f"--constraint={requirements.name}", *args, **kwargs)
def set_up_vscode(session: nox.Session) -> None:
"""
Helper function that will set VSCode's workspace settings
to use the auto-created virtual environment and enable
pytest support.
If called, this function will only do anything if
there aren't already VSCode workspace settings defined.
Args:
session (nox.Session): The enclosing nox session.
"""
if not VSCODE_DIR.exists():
session.log("Setting up VSCode Workspace.")
VSCODE_DIR.mkdir(parents=True)
SETTINGS_JSON.touch()
settings = {
"python.defaultInterpreterPath": PYTHON,
"python.testing.pytestEnabled": True,
"python.testing.pytestArgs": [PROJECT_TESTS.name],
}
with open(SETTINGS_JSON, mode="w", encoding="utf-8") as f:
json.dump(settings, f, sort_keys=True, indent=4)
def update_seeds(session: nox.Session) -> None:
"""
Helper function to update the core installation seed packages
to their latest versions in each session.
Args:
session (nox.Session): The enclosing nox session.
"""
session.install("--upgrade", *SEEDS)
def session_requires(session: nox.Session, tool: str) -> None:
"""
Helper function that 'tells' the session that it requires
a particular external `tool`.
When the function is called from within a session it checks
if `tool` is installed and available on $PATH.
If the tool is installed, the function does nothing and the
session is allowed to continue as normal.
If the tool is not installed, the session is exited immediately
and an error is thrown.
Args:
session (nox.Session): The enclosing nox session.
tool (str): Name of the external tool to check for.
"""
if not bool(shutil.which(tool)):
session.error(
f"{tool!r} not installed. Session: {session.name!r} requires {tool!r}."
)
def get_session_requirements(session: nox.Session) -> list[str]:
"""
Gets the session requirements from the global
SESSION_REQUIREMENTS dict based on the session name.
If it cannot find the requirements, will exit the session
and log an error.
Args:
session (nox.Session): The enclosing nox session.
Returns:
List[str]: List of requirements for the session.
"""
requirements = SESSION_REQUIREMENTS.get(f"{session.name}", [])
if not requirements:
session.error(
f"Requirements for nox session: {session.name!r}, not found in noxfile.py."
)
return requirements
def has_changes() -> bool:
"""
Invoke git in a subprocess to check if we have
any uncommitted changes in the local repo.
Returns:
bool: True if uncommitted changes, else False.
"""
status = (
subprocess.run(
"git status --porcelain",
shell=True,
check=True,
stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
)
return len(status) > 0
def get_branch() -> str:
"""
Invoke git in a subprocess to get the name of
the current branch.
Returns:
str: Name of current branch.
"""
return (
subprocess.run(
"git rev-parse --abbrev-ref HEAD",
shell=True,
check=True,
stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
)
def enforce_branch_no_changes(session: nox.Session) -> None:
"""
Errors out the current session if we're not on
default branch or if there are uncommitted changes.
"""
if has_changes():
session.error("All changes must be committed or removed before release")
branch = get_branch()
if branch != DEFAULT_BRANCH:
session.error(
f"Must be on {DEFAULT_BRANCH!r} branch. Currently on {branch!r} branch"
)