"""Django `nox` sessions.
Description:
Helpers and defaults for Django `nox` sessions. Includes sessions like generate-django-secret, which
creates a secure string that can be used for your Django apps' `SECRET_KEY`.
"""
from __future__ import annotations
from contextlib import contextmanager
import logging
import os
import platform
import secrets
import socket
from pathlib import Path
log: logging.Logger = logging.getLogger(__name__)
import nox_extra.nox_utils as nox_utils
import nox
__all__: list[str] = [
"_find_free_port",
"_django_makemigrations",
"_django_migrate",
"generate_secure_secret",
"generate_django_secret",
"run_django_devserver",
"django_make_migrations",
"django_migrate",
"django_do_migrations",
]
## Path to output requirements.txt file(s)
REQUIREMENTS_OUTPUT_DIR: Path = nox_utils.REQUIREMENTS_OUTPUT_DIR
## Define versions to test
PY_VERSIONS: list[str] = nox_utils.PY_VERSIONS
## Get tuple of Python ver ('maj', 'min', 'mic')
PY_VER_TUPLE: tuple[str, str, str] = nox_utils.PY_VER_TUPLE
## Dynamically set Python version
DEFAULT_PYTHON: str = nox_utils.DEFAULT_PYTHON
## UPDATE THIS VALUE FOR EACH NEW DJANGO PROJECT
DJANGO_PROJECT_DIR = "./weatherdaily/"
def _find_free_port(host_addr: str = "0.0.0.0", start_port=8000) -> int:
"""Find a free port starting from a specific port number.
Params:
host_addr (str): The host address to bind to. Default is '0.0.0.0', which can be dangerous.
start_port (int): Attempt to bind to this port, and increment 1 each time port binding fails.
Returns:
(int): An open port number, i.e. 8000 if 8001 is in use.
"""
port: int = start_port
## Loop open port check until one is bound
while True:
## Create a socked
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
## Try binding port to host address
sock.bind((host_addr, port))
return port
except socket.error:
## Port in use, increment & retry
log.info(f"Port {port} is in use, trying the next port.")
port += 1
def _django_makemigrations(session: nox.Session) -> None:
"""Run Django's manage.py makemigrations.
Description:
Creates migrations for all Django apps in your project. Use with caution,
this will create migrations for all models.
"""
## Set script path to path with Django's manage.py
with nox_utils.cd(newdir=DJANGO_PROJECT_DIR):
try:
session.run("python", "manage.py", "makemigrations")
except Exception as exc:
msg = f"({type(exc)}) Unhandled exception running manage.py makemigrations. Details: {exc}"
log.error(msg)
raise exc
def _django_migrate(session: nox.Session) -> None:
"""Do all database migrations.
Description:
Performs migrations for all Django apps in your project. Use with caution,
this will migrate models for all apps.
"""
## Set script path to path with Django's manage.py
with nox_utils.cd(newdir=DJANGO_PROJECT_DIR):
try:
session.run("python", "manage.py", "migrate")
except Exception as exc:
msg = f"({type(exc)}) Unhandled exception running manage.py migrate. Details: {exc}"
log.error(msg)
raise exc
def generate_secure_secret(
random_char_seed: str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
print_secret: bool = False,
) -> str:
"""Generate a secure string.
Description:
Combines the functionality of some code lovingly lifted from
django.core.management.utils.get_random_secret_key(). In their words,
"return a securely generated random string."
This can be used to generate secrets, like Django's `SECRET_KEY` setting.
"""
def get_random_string(length, allowed_chars=random_char_seed):
"""Return a securely generated random string.
The bit length of the returned value can be calculated with the formula:
log_2(len(allowed_chars)^length)
For example, with default `allowed_chars` (26+26+10), this gives:
* length: 12, bit length =~ 71 bits
* length: 22, bit length =~ 131 bits
"""
return "".join(secrets.choice(allowed_chars) for i in range(length))
## Generate a secret
_secret: str = get_random_string(50, random_char_seed)
if print_secret:
## Print (instead of log, which could leak), the secret.
print(f"Generated secure string:\n\n{_secret}\n")
return _secret
@nox.session(
python=DEFAULT_PYTHON, name="generate-django-secret", tags=["django", "init"]
)
def generate_django_secret(session: nox.Session) -> None:
"""Securely generate a Django secret key.
Description:
Calls the generate_secure_secret() function, which returns an encrypted key made from randomized input.
Optionally prints the secret. This uses the print() function, instead of a logger, to prevent secrets from
leaking.
"""
## Generate & print a Django secret
generate_secure_secret(print_secret=True)
@nox.session(python=DEFAULT_PYTHON, name="run-django-devserver", tags=["django"])
def run_django_devserver(session: nox.Session) -> None:
"""Call Django's manage.py runserver with args.
Description:
Uses the _find_free_port() function to test binding ports to a socket,
returning an open/available port if the desired port is in use.
Uses the cd() context manager to set the script's path to the Django project
path for the duration of the script; while inside the Django project directory,
calls python manage.py runserver 0.0.0.0:$free_port.
"""
session.install("-r", f"{REQUIREMENTS_OUTPUT_DIR}/requirements.txt")
## Find an open port
free_port: int = _find_free_port(start_port=8000)
log.info(f"Running local Django development server on port {free_port}")
## Temporarily set script path to Django's project path
with nox_utils.cd(newdir=DJANGO_PROJECT_DIR):
try:
session.run("python", "manage.py", "runserver", f"0.0.0.0:{free_port}")
except Exception as exc:
msg = f"({type(exc)}) Unhandled exception running Django development server. Details: {exc}"
log.error(msg)
raise exc
@nox.session(python=DEFAULT_PYTHON, name="django-makemigrations", tags=["django", "db"])
def django_make_migrations(session: nox.Session) -> None:
"""Run Django's manage.py makemigrations on all app models.
Description:
Creates migrations for all Django apps in your project. Use with caution,
this will create migrations for all models.
"""
session.install("-r", f"{REQUIREMENTS_OUTPUT_DIR}/requirements.txt")
log.info("Running manage.py makemigrations")
try:
_django_makemigrations(session=session)
except Exception as exc:
msg = f"({type(exc)}) Error running manage.py makemigrations. Details: {exc}"
log.error(msg)
@nox.session(python=DEFAULT_PYTHON, name="django-migrate", tags=["django", "db"])
def django_migrate(session: nox.Session) -> None:
"""Run Django's manage.py migrate for all app models.
Description:
Performs migrations for all Django apps in your project. Use with caution,
this will migrate models for all apps.
"""
session.install("-r", f"{REQUIREMENTS_OUTPUT_DIR}/requirements.txt")
log.info("Running manage.py migrate")
_django_migrate(session=session)
@nox.session(
python=DEFAULT_PYTHON,
name="django-migrate-all",
tags=["django", "db", "migrations"],
)
def django_do_migrations(session: nox.Session) -> None:
"""Create & perform Django database migrations.
Description:
Calls Django's manage.py makemigrations to create migration files for
all app models, then do the migrations with manage.py migrate. Use with
caution, combining both of these steps can cause problems if there are
errors in your migrations.
"""
session.install("-r", f"{REQUIREMENTS_OUTPUT_DIR}/requirements.txt")
log.info("Running manage.py makemigrations and manage.py migrate")
## Determine whether migrations should be performed.
# Only flips to True if makemigrations is successful
DO_MIGRATION: bool = False
try:
_django_makemigrations(session=session)
## Enable migration if makemigrations is successful
DO_MIGRATION = True
except Exception as exc:
msg: str = f"({type(exc)}) Error create database migrations. Details: {exc}"
log.error(msg)
DO_MIGRATION = False
if DO_MIGRATION:
log.info("Doing all Django database migrations.")
_django_migrate(session=session)
else:
log.warning("Error during makemigrations. Skipping manage.py migrate.")