Explorar o código

chore(packaging): Rework setup, docs shipping, and layout after review

Address the first round of maintainer review on the pip install
support, together with a follow-up simplification pass over the whole
branch.

The wheel now ships the documentation sources (docs/ and mkdocs.yml),
so `netbox upgrade --build-docs` works on pip installs with the
already pinned toolchain. DOCS_ROOT always resolves, and the docs
static entry is unconditional again in both install modes. All
remaining wheel-vs-checkout path decisions live in a single tested
resolve_install_paths() helper that settings.py merely consumes.

`netbox setup` is scaled back to honest bootstrapping: it scaffolds
the conf/ package and local_requirements.txt, and copies the bundled
contrib deployment examples byte-verbatim into <target>/contrib/. The
unit rendering and adaptation logic is gone, along with --force and
--systemd-dir; adapting and installing services remains the
administrator's responsibility.

The console script is now argparse-driven, the upgrade helpers moved
into the management command itself, configuration_dir() became
get_configuration_dir(), and the configuration loader removes only
the sys.path entry it inserted.

A few genuine bugs surfaced while reworking this: `netbox setup`
crashed on wheel installs once pip byte-compiled the bundled
gunicorn.py example (a stray __pycache__ broke the copy loop), the
SECRET_KEY error hint pointed wheel users at a script that is not
packaged (it now recommends `netbox secret-key` in wheel mode), and
read-only upgrades silently omitted the skipped steps that have no
dedicated flag.

On the CI side, the publish job enforces the release tag format up
front, a new cli-smoke-test job exercises the wheel CLI on every pull
request, the full smoke test builds and collects the documentation,
and the wheel content verifier cross-checks its expectations against
pyproject's force-include table so the two cannot drift apart
silently.
Martin Hauser hai 9 horas
pai
achega
5bdf10193f

+ 67 - 13
.github/workflows/release.yml

@@ -13,6 +13,8 @@ on:
       - 'requirements.txt'
       - 'upgrade.sh'
       - 'contrib/**'
+      - 'docs/**'
+      - 'mkdocs.yml'
       - 'netbox/**'
       - 'scripts/packaging/**'
       - 'scripts/verify_*.py'
@@ -129,12 +131,53 @@ jobs:
           python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
           python scripts/verify_wheel_contents.py sdist-wheel/*.whl
 
+  cli-smoke-test:
+    name: Smoke test wheel CLI (no dependencies)
+    runs-on: ubuntu-latest
+    needs: build
+    # The pre-configuration CLI paths are stdlib-only, so a --no-deps install suffices.
+    # Unlike smoke-test, this job also runs on pull requests.
+
+    steps:
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Install wheel without dependencies
+        run: |
+          python -m venv /tmp/netbox-cli-venv
+          /tmp/netbox-cli-venv/bin/python -m pip install --no-deps dist/*.whl
+
+      - name: Exercise the pre-configuration CLI
+        run: |
+          /tmp/netbox-cli-venv/bin/netbox --version
+          /tmp/netbox-cli-venv/bin/netbox version
+          /tmp/netbox-cli-venv/bin/netbox secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
+
+      - name: Smoke-test netbox setup from the wheel
+        run: |
+          /tmp/netbox-cli-venv/bin/netbox setup --target /tmp/nbroot
+          for f in /tmp/nbroot/conf/__init__.py /tmp/nbroot/conf/configuration.py /tmp/nbroot/local_requirements.txt; do
+            test -f "$f" || { echo "missing $f"; exit 1; }
+          done
+          for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
+            test -s "/tmp/nbroot/contrib/$f" || { echo "missing or empty contrib/$f"; exit 1; }
+          done
+
   smoke-test:
     name: Smoke test wheel install
     runs-on: ubuntu-latest
     needs: build
     # The wheel install + database migration is expensive; only run it for tag
     # pushes and manual dispatch, not on every packaging-related pull request.
+    # cli-smoke-test provides lightweight, dependency-free CLI coverage on every PR instead.
     if: github.event_name != 'pull_request'
 
     services:
@@ -203,33 +246,35 @@ jobs:
         env:
           PYTHONPATH: ${{ github.workspace }}/scripts
         run: |
+          # The docs build runs `zensical` as a subprocess, which resolves via PATH;
+          # prepending the venv bin is equivalent to activating it (as upgrade.sh does).
+          export PATH="/tmp/netbox-wheel-venv/bin:$PATH"
           /tmp/netbox-wheel-venv/bin/netbox check
-          /tmp/netbox-wheel-venv/bin/netbox upgrade --no-input
+          upgrade_output=$(/tmp/netbox-wheel-venv/bin/netbox upgrade --no-input --build-docs)
+          echo "$upgrade_output"
+          ! grep -q 'Skipping documentation build' <<< "$upgrade_output" || { echo "documentation build was skipped"; exit 1; }
+          test -f /tmp/netbox-smoketest/static/docs/index.html || { echo "documentation was not collected to STATIC_ROOT"; exit 1; }
 
       - name: Smoke-test netbox setup from the wheel
         run: |
-          /tmp/netbox-wheel-venv/bin/netbox setup --target /tmp/nbroot --systemd-dir /tmp/systemd
-          for f in /tmp/nbroot/conf/configuration.py /tmp/nbroot/gunicorn.py /tmp/nbroot/netbox.env \
-                   /tmp/nbroot/local_requirements.txt /tmp/systemd/netbox.service /tmp/systemd/netbox-rq.service; do
-            test -f "$f" || { echo "missing $f"; exit 1; }
+          /tmp/netbox-wheel-venv/bin/netbox setup --target /tmp/nbroot
+          diff -q /tmp/nbroot/conf/configuration.py netbox/netbox/configuration_example.py
+          for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
+            diff -q "/tmp/nbroot/contrib/$f" "contrib/$f"
           done
-          grep -qx 'NETBOX_ROOT=/tmp/nbroot' /tmp/nbroot/netbox.env || { echo "netbox.env missing active NETBOX_ROOT=/tmp/nbroot"; exit 1; }
-          grep -q '/tmp/nbroot' /tmp/systemd/netbox.service || { echo "service not rendered to target"; exit 1; }
-          ! grep -q '/opt/netbox' /tmp/systemd/netbox.service || { echo "service still references /opt/netbox"; exit 1; }
-          ! grep -q 'pythonpath' /tmp/systemd/netbox.service || { echo "pip unit still passes --pythonpath"; exit 1; }
-          grep -q 'ExecStart=/tmp/nbroot/venv/bin/netbox rqworker high default low' /tmp/systemd/netbox-rq.service || { echo "rq unit not adapted to the console script"; exit 1; }
-          grep -q 'EnvironmentFile=-/tmp/nbroot/netbox.env' /tmp/systemd/netbox.service || { echo "unit missing EnvironmentFile"; exit 1; }
-          /tmp/netbox-wheel-venv/bin/netbox secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
 
   publish-testpypi:
     name: Publish package to Test PyPI
     runs-on: ubuntu-latest
-    needs: [smoke-test, verify-dependencies, verify-sdist]
+    needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
     # Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
     # automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
     # Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
     # but the publish job is skipped. Production PyPI publishing is intentionally absent
     # during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
+    # startsWith() only routes to this job (workflow `if:` expressions cannot regex-match);
+    # the exact tag format (v<release.yaml version>) is enforced below by the "Enforce
+    # release tag format" step and scripts/verify_release_tag.py before any upload.
     if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
     environment:
       name: testpypi
@@ -239,6 +284,15 @@ jobs:
       id-token: write
 
     steps:
+      - name: Enforce release tag format
+        env:
+          TAG: ${{ github.ref_name }}
+        run: |
+          [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
+            echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
+            exit 1
+          }
+
       - name: Check out repository
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:

+ 25 - 109
docs/development/building-the-package.md

@@ -1,20 +1,12 @@
 # Building the Package
 
-NetBox package artifacts (a wheel and a source distribution) can be built and verified locally.
-During the v4.6.x preview period, published artifacts are intended for maintainer validation
-only, and installing NetBox via pip is not a supported installation path; experimental support
-for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for
-maintainers and contributors working on the packaging itself; routine development does not
-require building a package.
+NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are intended for maintainer validation only, and installing NetBox via pip is not a supported installation path; experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package.
 
-The artifacts are always built by CI from a clean checkout (see
-`.github/workflows/release.yml`). A local build is useful for testing packaging changes before
-they are merged.
+The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
 
 ## Prerequisites
 
-Install the minimum local build tooling (all three are also included in the `dev`
-optional dependency group):
+Install the minimum local build tooling (all three are also included in the `dev` optional dependency group):
 
 ```no-highlight
 python -m pip install --upgrade build packaging twine
@@ -34,24 +26,13 @@ To build only the wheel (faster, and the form most useful for a quick local inst
 python -m build --wheel
 ```
 
-The package version is derived from `netbox/release.yaml` by the build hook in
-`scripts/packaging/hatch_metadata.py`; you do not set it in `pyproject.toml`. The wheel's runtime dependency
-metadata (`Requires-Dist`) is generated from the pinned `requirements.txt` by the same hook,
-so the wheel ships the exact dependency versions NetBox is tested against.
+The package version and the wheel's runtime dependency metadata are both computed at build time by a Hatchling hook; see [Dynamic metadata](#dynamic-metadata) below.
 
 ## Clean-tree caveat
 
-Always build release artifacts from a clean checkout. The build configuration keeps
-deployment-local files out of the artifacts: the Hatch excludes drop every
-`configuration*.py` and `ldap_config*.py` except the two tracked configuration templates
-(`configuration_example.py` and `configuration_testing.py`, which are force-included
-explicitly), and CI verifies the contents of both the wheel and the sdist before anything
-is published.
+Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published.
 
-These checks are defense in depth, not a license to build from a dirty tree: other untracked
-files under `netbox/` can still be picked up by a local build. CI builds from a clean
-checkout, so the published artifacts are unaffected. For a comparable local build, use a
-fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
+These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
 
 ## Verifying
 
@@ -61,25 +42,20 @@ Check the built artifacts for valid metadata and rendering:
 twine check dist/*
 ```
 
-Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the
-pinned `requirements.txt`, and the declared optional-dependency groups:
+Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups:
 
 ```no-highlight
 python scripts/verify_wheel_metadata.py dist/*.whl
 ```
 
-Confirm the artifacts ship only the two tracked configuration templates, and that the wheel
-carries the runtime-critical bundled data (`_data/release.yaml`, templates, translations,
-static assets, and the deployment examples; the same content checks CI runs before
-publishing):
+Confirm the artifacts ship only the two tracked configuration templates, and that the wheel carries the runtime-critical bundled data (`_data/release.yaml`, `_data/mkdocs.yml`, templates, translations, static assets, and the documentation sources; the same content checks CI runs before publishing):
 
 ```no-highlight
 python scripts/verify_wheel_contents.py dist/*.whl
 python scripts/verify_sdist_contents.py dist/*.tar.gz
 ```
 
-Confirm `requirements.txt` is still consistent with the maintainer policy in
-`base_requirements.txt` (the same drift guard CI runs before publishing):
+Confirm `requirements.txt` is still consistent with the maintainer policy in `base_requirements.txt` (the same drift guard CI runs before publishing):
 
 ```no-highlight
 python scripts/verify_dependencies.py
@@ -87,8 +63,7 @@ python scripts/verify_dependencies.py
 
 ## Test-installing the wheel
 
-Install the wheel into a throwaway virtual environment and run the system checks to confirm
-the package is importable and runnable:
+Install the wheel into a throwaway virtual environment and run the system checks to confirm the package is importable and runnable:
 
 ```no-highlight
 python -m venv /tmp/netbox-build-test
@@ -100,99 +75,40 @@ NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
 /tmp/netbox-build-test/bin/netbox check
 ```
 
-Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py`
-(default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development
-workstation. The environment variables above point `netbox check` at the same minimal
-configuration module the release workflow's smoke-test job uses
-(`scripts/smoketest_configuration.py`); run the command from the repository root so
-`PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under
-which the module creates its media, reports, scripts, and static roots. Any other importable
-configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the
-configuration lives outside the package). To exercise the full post-install task sequence from
-the wheel, run `netbox upgrade --no-input` against a throwaway database; this is what the
-release workflow's smoke-test job does.
+Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module the release workflow's smoke-test job uses (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, scripts, and static roots. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` (add `--build-docs` to also build the local documentation) against a throwaway database; this is what the release workflow's smoke-test job does.
 
 ## Packaging architecture
 
-This section is a developer-facing overview of how the package is assembled and how a
-pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip
-install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0);
-this page does not cover end-user installation steps.
+This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps.
 
 ### Dynamic metadata
 
-`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via
-`[tool.hatch.metadata.hooks.custom]`). It computes the package version from
-`netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the
-published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both
-fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
+`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via `[tool.hatch.metadata.hooks.custom]`). It computes the package version from `netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
 
 ### sdist and the sdist-to-wheel guard
 
-`python -m build` produces both an sdist and a wheel (the wheel is built from the sdist). The
-release workflow's `verify-sdist` job rebuilds a wheel from the published sdist and runs
-`scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a
-missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress
-unnoticed.
+`python -m build` produces both an sdist and a wheel (the wheel is built from the sdist). The release workflow's `verify-sdist` job rebuilds a wheel from the published sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed.
 
 ### Wheel data layout
 
-Source assets that are not Python modules are force-included with a `netbox/netbox/_data/`
-target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's
-`sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`:
-templates,
-translations, the compiled `project-static` bundles, `release.yaml`, and the bundled
-deployment examples under `_data/examples/` (`gunicorn.py`, the systemd units, `nginx.conf`,
-`apache.conf`, `netbox.env`).
-The documentation is deliberately not part of the wheel: neither the `docs/` sources nor
-`mkdocs.yml` ship (the sdist carries both), so `netbox upgrade --build-docs` skips with a
-notice and `DOCS_ROOT` resolves to `None`, disabling the embedded docs.
-The service and web server examples are the canonical `contrib/` files, bundled unmodified;
-`netbox setup` adapts the systemd units for a pip install at render time (an optional
-`EnvironmentFile`, no `--pythonpath`, console-script `rqworker`), and the path renderer
-already collapses the source-layout static path for nginx/apache, so both install methods
-share a single source. A unit test pins the transform anchors against `contrib/`, so an
-upstream contrib change fails CI rather than `netbox setup`.
-At runtime `settings.py` detects the bundled `_data` directory
-and sets `BASE_DIR` to it (install mode `wheel`); a source checkout has no `_data`, so
-`BASE_DIR` stays the project root (install mode `checkout`).
+Source assets that are not Python modules are force-included with a `netbox/netbox/_data/` target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's `sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`: templates, translations, the compiled `project-static` bundles, `release.yaml`, the documentation sources (`docs/` and `mkdocs.yml`), the bundled deployment examples (`contrib/`, seven files, unmodified), and the two tracked configuration templates.
+
+The documentation sources ship in the wheel, so a pip-installed instance builds its own copy the same way a checkout does: `netbox upgrade --build-docs` runs the pinned toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`; see `requirements.txt`) against the bundled `_data/mkdocs.yml`. The configured `site_dir` is relative to the config file, so the build output lands under `_data/netbox/project-static/docs`, which `collectstatic` then picks up the same way it does for a checkout build. `DOCS_ROOT` always resolves to the installed documentation sources; there is no unconfigured fallback.
+
+At runtime `settings.py` detects the bundled `_data` directory and resolves the install mode, `BASE_DIR`, `NETBOX_ROOT`, and the documentation roots through `resolve_install_paths()` in `netbox/netbox/settings_utils.py`: a wheel install (`_data` present) keeps package data under `_data` and mutable instance files under `NETBOX_ROOT`; a source checkout (no `_data`) keeps the historical layout, where both roots are the project directory.
 
 ### Wheel-mode runtime
 
-A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual
-environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the
-environment) as the instance root and defaults the writable paths (`MEDIA_ROOT`,
-`REPORTS_ROOT`, `SCRIPTS_ROOT`, `STATIC_ROOT`) beneath it. In a checkout `NETBOX_ROOT` equals
-`BASE_DIR`, so archive and Git installs are unaffected.
-
-Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`.
-An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers
-`NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy
-`NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration
-directory is added to `sys.path` only while the configuration file executes, so sibling
-imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree
-shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP
-deployments, `settings.py` exposes the active configuration file's directory as the
-`CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same
-directory: the active `ldap_config.py` is always the one beside the active `configuration.py`,
-regardless of install method.
-One compatibility exception: in checkout mode only, when no sibling file exists, the
-historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so
-existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
+A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the environment) as the instance root and defaults the writable paths (`MEDIA_ROOT`, `REPORTS_ROOT`, `SCRIPTS_ROOT`, `STATIC_ROOT`) beneath it. In a checkout `NETBOX_ROOT` equals `BASE_DIR`, so archive and Git installs are unaffected.
+
+Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`. An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers `NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy `NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration directory is added to `sys.path` only while the configuration file executes, so sibling imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP deployments, `settings.py` exposes the active configuration file's directory as the `CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same directory: the active `ldap_config.py` is always the one beside the active `configuration.py`, regardless of install method. One compatibility exception: in checkout mode only, when no sibling file exists, the historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
 
 ### Console script
 
-`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper
-resolves a few commands itself before importing Django, so they work without a
-configuration present:
+`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper resolves a few commands itself before importing Django, so they work without a configuration present:
 
 * `netbox version` / `netbox --version` print the installed package version.
-* `netbox setup` copies the bundled `_data/examples/*` into the instance root and
-  `--systemd-dir`, and scaffolds the `conf/` package from the bundled
-  `configuration_example.py`. It never overwrites an existing `configuration.py`,
-  `conf/__init__.py`, `local_requirements.txt`, or `netbox.env`, even with `--force`.
+* `netbox setup` creates the local configuration files for the instance: `conf/configuration.py` copied verbatim from the bundled `configuration_example.py` template, plus an empty `local_requirements.txt`, and copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi, `netbox.env`) unmodified into `<target>/contrib/`. Nothing is generated or rewritten, and existing files are never overwritten; adapting and installing the examples (paths, systemd, the web server) remains the administrator's responsibility.
 * `netbox secret-key` prints a new 50-character `SECRET_KEY` value.
 
-These names are reserved by the wrapper. Every other command falls through to the
-Django management commands (`netbox upgrade`, `netbox check`, and so on), which
-require a valid configuration.
+These names are reserved by the wrapper. Every other command falls through to the Django management commands (`netbox upgrade`, `netbox check`, and so on), which require a valid configuration.

+ 92 - 11
netbox/core/management/commands/upgrade.py

@@ -1,21 +1,102 @@
+"""Run the NetBox application tasks required after installing or upgrading NetBox.
+
+The command runs the NetBox application-level tasks that prepare the database and
+static assets after the package and configuration are already in place - for both a
+fresh installation and an upgrade. It does not perform host or bootstrap work
+(creating the virtual environment, installing packages, configuring services); that
+stays in upgrade.sh and the documented pip steps.
+"""
+
+import os
+import subprocess
+
+from django.conf import settings
+from django.core.management import call_command
 from django.core.management.base import BaseCommand
 
-from utilities.upgrade_tasks import add_upgrade_arguments, run_upgrade_tasks
+
+def _docs_source_root():
+    # mkdocs.yml sits beside the application root in a checkout, and inside the bundled
+    # package data (netbox/_data) in a wheel. No sources: return None (build is skipped).
+    for candidate in (os.path.dirname(settings.BASE_DIR), settings.BASE_DIR):
+        if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')):
+            return candidate
+    return None
 
 
 class Command(BaseCommand):
     help = "Run the NetBox application tasks required after installing or upgrading NetBox."
 
     def add_arguments(self, parser):
-        add_upgrade_arguments(parser)
+        parser.add_argument('--no-input', action='store_true', dest='no_input',
+                            help="Do not prompt for user input.")
+        parser.add_argument('--readonly', action='store_true', dest='readonly',
+                            help="Skip all tasks that modify the database or filesystem "
+                                 "(no migrations, no static collection).")
+        parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
+                            help="Skip applying database migrations.")
+        parser.add_argument('--skip-static', action='store_true', dest='skip_static',
+                            help="Skip collecting static files.")
+        parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
+                            help="Skip rebuilding the search index.")
+        parser.add_argument('--build-docs', action='store_true', dest='build_docs',
+                            help="Build the local documentation (requires the documentation source tree).")
 
     def handle(self, *args, **options):
-        run_upgrade_tasks(
-            self,
-            no_input=options['no_input'],
-            readonly=options['readonly'],
-            skip_migrations=options['skip_migrations'],
-            skip_static=options['skip_static'],
-            skip_reindex=options['skip_reindex'],
-            build_docs=options['build_docs'],
-        )
+        out, style = self.stdout, self.style
+        out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
+
+        # Database migrations (writes to the database)
+        if options['skip_migrations'] or options['readonly']:
+            out.write("Skipping database migrations.")
+        else:
+            out.write("Applying database migrations...")
+            call_command('migrate', interactive=not options['no_input'], stdout=out)
+
+        # Missing cable paths (writes to the database)
+        if options['readonly']:
+            out.write("Skipping cable path check.")
+        else:
+            out.write("Checking for missing cable paths...")
+            call_command('trace_paths', no_input=options['no_input'], stdout=out)
+
+        # Documentation (filesystem; needs the documentation source tree)
+        if options['readonly'] and options['build_docs']:
+            out.write("Skipping documentation build.")
+        elif options['build_docs']:
+            docs_root = _docs_source_root()
+            if docs_root is None:
+                out.write(style.WARNING("Skipping documentation build (documentation source tree not found)."))
+            else:
+                out.write("Building documentation...")
+                subprocess.run(['zensical', 'build'], cwd=docs_root, check=True)
+
+        # Static files (filesystem)
+        if options['skip_static'] or options['readonly']:
+            out.write("Skipping static file collection.")
+        else:
+            out.write("Collecting static files...")
+            call_command('collectstatic', interactive=not options['no_input'], stdout=out)
+
+        # Stale content types (writes to the database)
+        if options['readonly']:
+            out.write("Skipping stale content type removal.")
+        else:
+            out.write("Removing stale content types...")
+            call_command('remove_stale_contenttypes', interactive=not options['no_input'], stdout=out)
+
+        # Search index (writes to the database)
+        if options['skip_reindex'] or options['readonly']:
+            out.write("Skipping search index rebuild.")
+        else:
+            out.write("Rebuilding the search index (lazily)...")
+            call_command('reindex', lazy=True, stdout=out)
+
+        # Expired sessions (writes to the database)
+        if options['readonly']:
+            out.write("Skipping expired session cleanup.")
+        else:
+            out.write("Clearing expired sessions...")
+            call_command('clearsessions', stdout=out)
+
+        out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

+ 49 - 22
netbox/core/tests/test_management_commands.py

@@ -1,3 +1,5 @@
+import os
+import tempfile
 from io import StringIO
 from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
@@ -7,9 +9,8 @@ from django.core.management.base import CommandError
 from django.test import TestCase, override_settings
 
 from core.choices import DataSourceStatusChoices
-from core.management.commands import nbshell
+from core.management.commands import nbshell, upgrade
 from core.management.commands.rqworker import DEFAULT_QUEUES
-from utilities.upgrade_tasks import _docs_source_root
 
 
 class MakeMigrationsTestCase(TestCase):
@@ -322,15 +323,16 @@ class UpgradeCommandTest(TestCase):
     """The upgrade command orchestrates the application task sequence for installs and upgrades."""
 
     def _run(self, **kwargs):
+        out = StringIO()
         with (
-            patch('utilities.upgrade_tasks.call_command') as cc,
-            patch('utilities.upgrade_tasks.subprocess.run') as sub,
+            patch('core.management.commands.upgrade.call_command') as cc,
+            patch('core.management.commands.upgrade.subprocess.run') as sub,
         ):
-            call_command('upgrade', stdout=StringIO(), **kwargs)
-        return [c.args[0] for c in cc.call_args_list], cc, sub
+            call_command('upgrade', stdout=out, **kwargs)
+        return [c.args[0] for c in cc.call_args_list], cc, sub, out.getvalue()
 
     def test_full_sequence_order(self):
-        seq, _, sub = self._run()
+        seq, _, sub, _ = self._run()
         self.assertEqual(seq, [
             'migrate', 'trace_paths',
             'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
@@ -338,37 +340,62 @@ class UpgradeCommandTest(TestCase):
         sub.assert_not_called()  # docs not built by default
 
     def test_readonly_skips_all_tasks_including_static(self):
-        seq, _, sub = self._run(readonly=True)
+        """--readonly prints a skip message for every task, including the three without dedicated flags."""
+        seq, _, sub, out = self._run(readonly=True)
         self.assertEqual(seq, [])
         sub.assert_not_called()
+        self.assertIn('Skipping database migrations.', out)
+        self.assertIn('Skipping cable path check.', out)
+        self.assertIn('Skipping static file collection.', out)
+        self.assertIn('Skipping stale content type removal.', out)
+        self.assertIn('Skipping search index rebuild.', out)
+        self.assertIn('Skipping expired session cleanup.', out)
 
     def test_skip_flags(self):
-        seq, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
+        seq, _, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
         self.assertEqual(
             seq,
             ['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
         )
 
     def test_build_docs_invokes_zensical_when_sources_present(self):
-        with patch('utilities.upgrade_tasks._docs_source_root', return_value='/repo'):
-            _, _, sub = self._run(build_docs=True)
+        with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
+            _, _, sub, _ = self._run(build_docs=True)
         sub.assert_called_once()
         self.assertEqual(sub.call_args.args[0], ['zensical', 'build'])
 
     def test_build_docs_skipped_when_sources_absent(self):
-        with patch('utilities.upgrade_tasks._docs_source_root', return_value=None):
-            _, _, sub = self._run(build_docs=True)
+        with patch('core.management.commands.upgrade._docs_source_root', return_value=None):
+            _, _, sub, _ = self._run(build_docs=True)
         sub.assert_not_called()
 
     def test_readonly_with_build_docs_skips_docs(self):
-        with patch('utilities.upgrade_tasks._docs_source_root', return_value='/repo'):
-            _, _, sub = self._run(readonly=True, build_docs=True)
+        with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
+            _, _, sub, _ = self._run(readonly=True, build_docs=True)
         sub.assert_not_called()
 
-    def test_docs_source_root_found_when_mkdocs_present(self):
-        with patch('utilities.upgrade_tasks.os.path.isfile', return_value=True):
-            self.assertIsNotNone(_docs_source_root())
-
-    def test_docs_source_root_none_when_mkdocs_absent(self):
-        with patch('utilities.upgrade_tasks.os.path.isfile', return_value=False):
-            self.assertIsNone(_docs_source_root())
+    def test_docs_source_root_checkout_shaped(self):
+        """mkdocs.yml beside the application root (checkout layout) is found."""
+        with tempfile.TemporaryDirectory() as root:
+            base_dir = os.path.join(root, 'netbox')
+            os.mkdir(base_dir)
+            open(os.path.join(root, 'mkdocs.yml'), 'w').close()
+            with override_settings(BASE_DIR=base_dir):
+                self.assertEqual(upgrade._docs_source_root(), root)
+
+    def test_docs_source_root_wheel_shaped(self):
+        """mkdocs.yml inside BASE_DIR (bundled package data layout) is found."""
+        with tempfile.TemporaryDirectory() as root:
+            base_dir = os.path.join(root, '_data')
+            os.mkdir(base_dir)
+            open(os.path.join(base_dir, 'mkdocs.yml'), 'w').close()
+            with override_settings(BASE_DIR=base_dir):
+                self.assertEqual(upgrade._docs_source_root(), base_dir)
+
+    def test_docs_source_root_none_when_absent(self):
+        """No mkdocs.yml in either candidate location returns None."""
+        with tempfile.TemporaryDirectory() as root:
+            base_dir = os.path.join(root, 'netbox')
+            os.mkdir(base_dir)
+            with override_settings(BASE_DIR=base_dir):
+                self.assertIsNone(upgrade._docs_source_root())

+ 50 - 44
netbox/netbox/cli.py

@@ -1,26 +1,23 @@
 """Console entry point for pip-installed NetBox."""
 
+import argparse
 import os
 import sys
 from importlib.metadata import PackageNotFoundError, version
 
 # Commands handled here must not require Django settings. These names are intentionally
 # reserved by the console wrapper and are never dispatched to Django management commands.
-_HELP = """usage: {prog} <command> [options]
+# 'setup' is not listed: the early-dispatch branch in main() owns it and always returns
+# before this tuple is consulted.
+_RESERVED_COMMANDS = ('secret-key', 'version')
 
-Pre-configuration commands (no NetBox configuration required):
-  {prog} version         Print the installed NetBox package version.
-  {prog} setup           Scaffold local deployment files for a pip-installed instance.
-  {prog} secret-key      Generate a new 50-character SECRET_KEY value.
-
-Any other command is dispatched to the Django management commands, which require a
-valid NetBox configuration, e.g.:
+_EPILOG = """Any other command is dispatched to the Django management commands, which
+require a valid NetBox configuration, e.g.:
   {prog} upgrade
   {prog} check
   {prog} createsuperuser
 
-Run "{prog} setup --help" for scaffolding options, and "{prog} help" (once configured)
-for the full management command listing."""
+Run "{prog} help" (once configured) for the full management command listing."""
 
 
 def _prog():
@@ -39,50 +36,59 @@ def _print_version():
         print('unknown')
 
 
-def _version(args, prog):
-    if args in (['-h'], ['--help']):
-        print(f'usage: {prog} version\n\nPrint the installed NetBox package version.')
-        return 0
-    if args:
-        print(f'{prog} version: unexpected arguments: {" ".join(args)}', file=sys.stderr)
-        return 2
-    # Resolved before importing Django, so it works without configuration.
-    _print_version()
-    return 0
-
-
-def _secret_key(args, prog):
-    if args in (['-h'], ['--help']):
-        print(f'usage: {prog} secret-key\n\nGenerate a new 50-character SECRET_KEY value.')
-        return 0
-    if args:
-        print(f'{prog} secret-key: unexpected arguments: {" ".join(args)}', file=sys.stderr)
-        return 2
-    # Deferred so the command works before Django or a configuration exists.
-    from utilities.secret_key import generate_secret_key
-    print(generate_secret_key())
-    return 0
+def _build_parser(prog):
+    parser = argparse.ArgumentParser(
+        prog=prog,
+        description='NetBox command line interface.',
+        epilog=_EPILOG.format(prog=prog),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    subparsers = parser.add_subparsers(
+        dest='command',
+        title='pre-configuration commands (no NetBox configuration required)',
+    )
+    subparsers.add_parser(
+        'version', help='Print the installed NetBox package version.',
+        description='Print the installed NetBox package version.')
+    subparsers.add_parser(
+        'setup', add_help=False,
+        help='Create the local configuration files for a pip-installed instance.')
+    subparsers.add_parser(
+        'secret-key', help='Generate a new 50-character SECRET_KEY value.',
+        description='Generate a new 50-character SECRET_KEY value.')
+    return parser
 
 
 def main(argv=None):
     prog = _prog()
     args = list(sys.argv[1:] if argv is None else argv)
 
-    if not args or args[0] in ('-h', '--help'):
-        print(_HELP.format(prog=prog))
-        return 0
-
-    if args[0] in ('version', '--version'):
-        return _version(args[1:], prog)
-
-    if args[0] == 'secret-key':
-        return _secret_key(args[1:], prog)
-
-    if args[0] == 'setup':
+    # `setup` owns its own parser (netbox.scaffold); dispatch before the wrapper parser.
+    if args and args[0] == 'setup':
         # Deferred so the command works before Django or a configuration exists.
         from netbox.scaffold import main as setup_main
         return setup_main(args[1:], prog=f'{prog} setup')
 
+    if not args:
+        _build_parser(prog).print_help()
+        return 0
+
+    if args[0] in _RESERVED_COMMANDS or args[0] in ('-h', '--help', '--version'):
+        parser = _build_parser(prog)
+        if args[0] == '--version':
+            args = ['version', *args[1:]]
+        try:
+            options = parser.parse_args(args)
+        except SystemExit as e:  # argparse already printed help (0) or an error (2)
+            return int(e.code or 0)
+        if options.command == 'version':
+            _print_version()
+        elif options.command == 'secret-key':
+            # Deferred so the command works before Django or a configuration exists.
+            from utilities.secret_key import generate_secret_key
+            print(generate_secret_key())
+        return 0
+
     os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
 
     # Deferred on purpose: Django must not import until DJANGO_SETTINGS_MODULE is set.

+ 50 - 124
netbox/netbox/scaffold.py

@@ -1,12 +1,12 @@
-"""`netbox setup`: scaffold a pip-installed NetBox instance root.
-
-This helper intentionally avoids importing Django or NetBox settings so it can run before a
-local configuration exists. Dispatched from the `netbox` console script (netbox.cli), it copies
-bundled example deployment files into place - adapting the canonical systemd units for a pip
-install (EnvironmentFile, no --pythonpath, console-script rqworker) and rewriting the
-instance-root path to the chosen target - scaffolds the conf/ package, and creates an empty
-local_requirements.txt. conf/configuration.py, conf/__init__.py, local_requirements.txt, and
-netbox.env are never overwritten once they exist.
+"""`netbox setup`: create the local configuration files for a pip-installed NetBox instance.
+
+Runs before Django or a local configuration exists (dispatched from the `netbox` console
+script, netbox.cli). Scaffolds conf/__init__.py and conf/configuration.py (copied verbatim
+from the bundled configuration_example.py template) and an empty local_requirements.txt,
+then copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi,
+netbox.env) unmodified into <target>/contrib/. Nothing is generated or rewritten; adapting
+and installing the examples (paths, systemd, the web server) remains the administrator's
+job. Existing files are never overwritten.
 """
 
 import argparse
@@ -14,149 +14,75 @@ import sys
 from importlib.resources import files
 from pathlib import Path
 
-# bundled example name -> placement ('target' = instance root, 'systemd' = systemd dir)
-_PLACEMENT = {
-    'gunicorn.py': 'target',
-    'nginx.conf': 'target',
-    'apache.conf': 'target',
-    'netbox.env': 'target',
-    'netbox.service': 'systemd',
-    'netbox-rq.service': 'systemd',
-}
-
-# Functional adaptations applied to the bundled CANONICAL contrib files for a pip install,
-# before the instance-root rendering below. nginx.conf and apache.conf need no rules because
-# _render() already collapses the source-layout static path. test_scaffold pins every
-# anchor against contrib/, so an upstream contrib edit fails CI rather than `netbox setup`.
-_PIP_TRANSFORMS = {
-    'netbox.service': (
-        # Optional per-instance overrides (NETBOX_ROOT) for pip installs.
-        (
-            'PIDFile=/var/tmp/netbox.pid\n',
-            'PIDFile=/var/tmp/netbox.pid\nEnvironmentFile=-/opt/netbox/netbox.env\n',
-        ),
-        # The venv provides the import path; a stale source tree must not shadow the package.
-        (' --pythonpath /opt/netbox/netbox', ''),
-    ),
-    'netbox-rq.service': (
-        (
-            'Group=netbox\n',
-            'Group=netbox\nEnvironmentFile=-/opt/netbox/netbox.env\n',
-        ),
-        # The console script replaces manage.py under pip.
-        (
-            'ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker high default low\n',
-            'ExecStart=/opt/netbox/venv/bin/netbox rqworker high default low\n',
-        ),
-    ),
-}
-
 
-def _examples_dir():
-    return files('netbox') / '_data' / 'examples'
+def _bundled_data_dir():
+    return files('netbox') / '_data'
 
 
 def _config_template():
     return files('netbox') / 'configuration_example.py'
 
 
-def _render(text, target):
-    """Rewrite the canonical /opt/netbox instance-root paths in a bundled template to target.
+def _contrib_dir():
+    return files('netbox') / '_data' / 'contrib'
 
-    Templates use /opt/netbox as the instance root, and /opt/netbox/netbox/... for the
-    source-layout paths in configuration_example.py. Rewrite both, longest prefix first, so the
-    config example collapses to the pip defaults (<target>/<name>). For the default target this
-    is a no-op for the service/web files.
-    """
-    # Rewrite to a unique placeholder first, then to target, so a target that itself contains
-    # /opt/netbox (e.g. /opt/netbox-prod) is not double-rewritten.
-    marker = '\x00NETBOX_ROOT\x00'
-    text = text.replace('/opt/netbox/netbox/', f'{marker}/').replace('/opt/netbox', marker)
-    return text.replace(marker, str(target))
 
-
-def _write(destination, data, *, force, written):
-    if destination.exists() and not force:
-        print(f"Skipping existing {destination}")
-        return
+def _write(destination, data):
+    if destination.exists():
+        print(f'Skipping existing {destination}')
+        return False
     destination.parent.mkdir(parents=True, exist_ok=True)
     destination.write_bytes(data)
-    written.append(str(destination))
-    print(f"Wrote {destination}")
-
-
-def _apply_pip_transforms(name, text):
-    """Adapt a bundled canonical contrib file for a pip install; fail loudly on a stale rule."""
-    for old, new in _PIP_TRANSFORMS.get(name, ()):
-        if old not in text:
-            raise RuntimeError(f'{name}: expected contrib template anchor not found: {old!r}')
-        text = text.replace(old, new)
-    return text
+    print(f'Wrote {destination}')
+    return True
 
 
-def _write_rendered(destination, source, target, *, force, written):
-    text = _apply_pip_transforms(destination.name, source.read_bytes().decode('utf-8'))
-    rendered = _render(text, target).encode('utf-8')
-    _write(destination, rendered, force=force, written=written)
-
-
-def scaffold_instance(target, systemd_dir, *, force=False):
+def scaffold_instance(target):
     target = Path(target)
-    systemd_dir = Path(systemd_dir)
-    root = str(target)
-    examples = _examples_dir()
-    written = []
-
-    for name, placement in _PLACEMENT.items():
-        destination = (target if placement == 'target' else systemd_dir) / name
-        # netbox.env is user-owned once created (like local_requirements.txt); --force skips it
-        file_force = force and name != 'netbox.env'
-        _write_rendered(destination, examples / name, root, force=file_force, written=written)
-
-    # Scaffold the conf/ package. configuration.py is never clobbered, and __init__.py is
-    # create-if-missing: once it exists it is user-owned configuration package state.
     conf = target / 'conf'
-    _write(conf / '__init__.py', b'', force=False, written=written)
-    config = conf / 'configuration.py'
-    if config.exists():
-        print(f"Skipping existing {config}")
-    else:
-        _write_rendered(config, _config_template(), root, force=force, written=written)
-
-    # An empty local_requirements.txt makes the optional plugin/package step discoverable and
-    # the documented "pip install -r local_requirements.txt" safe. force=False even under --force
-    # so a re-run never erases a user's plugin requirements.
-    _write(target / 'local_requirements.txt', b'', force=False, written=written)
-
-    return written
+    items = [
+        (conf / '__init__.py', b''),
+        (conf / 'configuration.py', _config_template().read_bytes()),
+        # An empty local_requirements.txt makes the optional plugin step discoverable and
+        # the documented "pip install -r local_requirements.txt" safe.
+        (target / 'local_requirements.txt', b''),
+        # Bundled deployment examples, copied byte-verbatim; adapting them is the admin's job.
+        # is_file() skips __pycache__: pip's post-install bytecode compilation of gunicorn.py
+        # (the only .py file among the examples) leaves one alongside the real files.
+        *(
+            (target / 'contrib' / example.name, example.read_bytes())
+            for example in sorted(_contrib_dir().iterdir(), key=lambda entry: entry.name)
+            if example.is_file()
+        ),
+    ]
+    return [str(destination) for destination, data in items if _write(destination, data)]
 
 
 def main(argv=None, *, prog='netbox setup'):
     parser = argparse.ArgumentParser(
         prog=prog,
-        description="Scaffold local deployment files for a pip-installed NetBox instance "
-                    "(conf/, gunicorn.py, netbox.env, service and web server examples, "
-                    "local_requirements.txt).",
+        description=(
+            'Create the local configuration files for a pip-installed NetBox instance '
+            '(conf/configuration.py copied verbatim from the bundled template, plus an empty '
+            'local_requirements.txt), and copy the bundled deployment examples (gunicorn, '
+            'systemd units, nginx, apache, uwsgi, netbox.env) unmodified into '
+            "<target>/contrib/. Nothing is generated or rewritten; adapting and installing "
+            "the examples is the administrator's job. Existing files are never overwritten."
+        ),
     )
-    parser.add_argument('--target', default='/opt/netbox', help="NetBox instance root (NETBOX_ROOT).")
-    parser.add_argument('--systemd-dir', default='/etc/systemd/system', help="Directory for the systemd unit files.")
-    parser.add_argument('--force', action='store_true',
-                        help="Overwrite generated deployment files. Never overwrite conf/__init__.py, "
-                             "conf/configuration.py, local_requirements.txt, or netbox.env once they exist.")
+    parser.add_argument('--target', default='/opt/netbox', help='NetBox instance root (NETBOX_ROOT).')
     args = parser.parse_args(argv)
 
-    if not _examples_dir().is_dir():
+    if not _bundled_data_dir().is_dir():
         print(
-            f'{prog}: the bundled deployment examples are not present in this installation. '
+            f'{prog}: this NetBox installation does not include the bundled package data. '
             'This command is available only from the installed netbox package (pip/wheel); '
             'for an archive or Git installation, follow the standard installation guide instead.',
             file=sys.stderr,
         )
         return 1
+    if not Path(args.target).is_absolute():
+        parser.error(f"--target must be an absolute path (got '{args.target}')")
 
-    for argument, value in (('--target', args.target), ('--systemd-dir', args.systemd_dir)):
-        if not Path(value).is_absolute():
-            parser.error(f"{argument} must be an absolute path (got '{value}')")
-
-    scaffold_instance(args.target, args.systemd_dir, force=args.force)
+    scaffold_instance(args.target)
     return 0

+ 17 - 32
netbox/netbox/settings.py

@@ -18,7 +18,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
-from netbox.settings_utils import configuration_dir, load_configuration
+from netbox.settings_utils import get_configuration_dir, load_configuration, resolve_install_paths, secret_key_hint
 from utilities.release import load_release_data
 from utilities.security import validate_peppers
 from utilities.string import trailing_slash
@@ -34,22 +34,15 @@ VERSION = RELEASE.full_version  # Retained for backward compatibility
 # Settings package directory (settings.py lives here in both checkout & wheel).
 _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
 
-# In a wheel install, package data is bundled under netbox/_data. In a checkout,
-# the data directories remain siblings of the settings package's parent.
-_BUNDLED_DATA = os.path.join(_SETTINGS_DIR, '_data')
-if os.path.isdir(_BUNDLED_DATA):
-    BASE_DIR = _BUNDLED_DATA  # pragma: no cover
-    NETBOX_INSTALL_MODE = 'wheel'  # pragma: no cover
-else:
-    BASE_DIR = os.path.dirname(_SETTINGS_DIR)
-    NETBOX_INSTALL_MODE = 'checkout'
-
-# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units);
-# equals BASE_DIR in a checkout so archive/git behavior is unchanged.
-if NETBOX_INSTALL_MODE == 'wheel':
-    NETBOX_ROOT = os.path.abspath(os.environ.get('NETBOX_ROOT', '/opt/netbox'))  # pragma: no cover
-else:
-    NETBOX_ROOT = BASE_DIR
+# All wheel-vs-checkout path branching is centralized in resolve_install_paths(): a wheel
+# bundles package data under netbox/_data and keeps mutable instance files under an external
+# instance root (NETBOX_ROOT, default /opt/netbox); a checkout keeps both roots as the project
+# directory, so archive/git behavior is unchanged.
+_PATHS = resolve_install_paths(_SETTINGS_DIR, os.environ)
+NETBOX_INSTALL_MODE = _PATHS.install_mode
+BASE_DIR = _PATHS.base_dir
+# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units).
+NETBOX_ROOT = _PATHS.netbox_root
 
 # Validate the Python version
 if sys.version_info < (3, 12):  # noqa: UP036
@@ -69,7 +62,7 @@ configuration = load_configuration(
 )
 
 # The directory holding the active configuration.py; ldap_config.py lives beside it.
-CONFIGURATION_DIR = configuration_dir(configuration)
+CONFIGURATION_DIR = get_configuration_dir(configuration)
 
 # Check for missing/conflicting required configuration parameters
 for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@@ -135,10 +128,7 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'users.delete_token': ({'user': '$user'},),
 })
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
-_DEFAULT_DOCS_ROOT = os.path.join(os.path.dirname(BASE_DIR), 'docs')
-if not os.path.isdir(_DEFAULT_DOCS_ROOT):
-    _DEFAULT_DOCS_ROOT = None  # pragma: no cover
-DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _DEFAULT_DOCS_ROOT)
+DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _PATHS.docs_root)
 EMAIL = getattr(configuration, 'EMAIL', {})
 STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
 EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
@@ -251,7 +241,7 @@ if type(SECRET_KEY) is not str:
 if len(SECRET_KEY) < 50:
     raise ImproperlyConfigured(
         f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n"
-        f"  python {BASE_DIR}/generate_secret_key.py"
+        f"  {secret_key_hint(NETBOX_INSTALL_MODE, BASE_DIR)}"
     )
 
 # Validate API token peppers
@@ -615,18 +605,13 @@ X_FRAME_OPTIONS = 'SAMEORIGIN'
 # Static files (CSS, JavaScript, Images)
 STATIC_ROOT = getattr(configuration, 'STATIC_ROOT', os.path.join(NETBOX_ROOT, 'static')).rstrip('/')
 STATIC_URL = f'/{BASE_PATH}static/'
-_STATIC_DOCS_ROOT = os.path.join(BASE_DIR, 'project-static', 'docs')
-STATICFILES_DIRS = [
+STATICFILES_DIRS = (
     os.path.join(BASE_DIR, 'project-static', 'dist'),
     os.path.join(BASE_DIR, 'project-static', 'img'),
     os.path.join(BASE_DIR, 'project-static', 'js'),
-]
-# Checkout installs keep the docs entry even when the directory does not exist yet:
-# `manage.py upgrade --build-docs` builds the docs AFTER settings are imported, and the
-# in-process collectstatic must still pick them up. Wheels never ship the docs sources.
-if NETBOX_INSTALL_MODE == 'checkout' or os.path.isdir(_STATIC_DOCS_ROOT):
-    STATICFILES_DIRS.append(('docs', _STATIC_DOCS_ROOT))  # Prefix with /docs
-STATICFILES_DIRS = tuple(STATICFILES_DIRS)
+    # May not exist until `manage.py upgrade --build-docs` runs; collectstatic tolerates that.
+    ('docs', _PATHS.static_docs_root),  # Prefix with /docs
+)
 
 # Media URL
 MEDIA_URL = f'/{BASE_PATH}media/'

+ 66 - 4
netbox/netbox/settings_utils.py

@@ -5,10 +5,71 @@ import importlib.util
 import os
 import sys
 import warnings
+from typing import NamedTuple
 
 from django.core.exceptions import ImproperlyConfigured
 
-__all__ = ('configuration_dir', 'load_configuration', 'load_ldap_config')
+__all__ = (
+    'InstallPaths',
+    'get_configuration_dir',
+    'load_configuration',
+    'load_ldap_config',
+    'resolve_install_paths',
+    'secret_key_hint',
+)
+
+
+class InstallPaths(NamedTuple):
+    """Filesystem layout resolved from the install mode (wheel vs. source checkout)."""
+    install_mode: str      # 'wheel' or 'checkout'
+    base_dir: str          # package data root (BASE_DIR)
+    netbox_root: str       # instance root for mutable files (NETBOX_ROOT)
+    docs_root: str         # documentation sources (DOCS_ROOT default)
+    static_docs_root: str  # built documentation, source of the STATICFILES 'docs' prefix
+
+
+def resolve_install_paths(settings_dir, environ):
+    """Resolve the install mode and filesystem roots for this NetBox installation.
+
+    A wheel bundles package data (including the documentation sources and mkdocs.yml)
+    under netbox/_data and keeps mutable instance files under an external instance root
+    (NETBOX_ROOT, default /opt/netbox); a source checkout keeps the historical layout,
+    where both roots are the project directory. All wheel-vs-checkout branching lives
+    here so settings.py stays declarative.
+    """
+    bundled_data = os.path.join(settings_dir, '_data')
+    if os.path.isdir(bundled_data):
+        install_mode = 'wheel'
+        base_dir = bundled_data
+        netbox_root = os.path.abspath(environ.get('NETBOX_ROOT', '/opt/netbox'))
+        docs_root = os.path.join(base_dir, 'docs')
+        # site_dir in mkdocs.yml is netbox/project-static/docs relative to the config
+        # file, which is bundled at _data/mkdocs.yml; the built docs land accordingly.
+        static_docs_root = os.path.join(base_dir, 'netbox', 'project-static', 'docs')
+    else:
+        install_mode = 'checkout'
+        base_dir = os.path.dirname(settings_dir)
+        netbox_root = base_dir
+        docs_root = os.path.join(os.path.dirname(base_dir), 'docs')
+        static_docs_root = os.path.join(base_dir, 'project-static', 'docs')
+    return InstallPaths(
+        install_mode=install_mode,
+        base_dir=base_dir,
+        netbox_root=netbox_root,
+        docs_root=docs_root,
+        static_docs_root=static_docs_root,
+    )
+
+
+def secret_key_hint(install_mode, base_dir):
+    """Return the command to suggest in the SECRET_KEY-too-short error, based on install mode.
+
+    generate_secret_key.py is not packaged in a wheel, so a wheel install points at the
+    `netbox secret-key` console command instead of the (nonexistent) script path.
+    """
+    if install_mode == 'wheel':
+        return 'netbox secret-key'
+    return f'python {base_dir}/generate_secret_key.py'
 
 
 def _import_module(name):
@@ -52,12 +113,13 @@ def _import_from_path(module_name, path):
             del sys.modules[module_name]
         raise
     finally:
-        if module_dir in sys.path:
-            sys.path.remove(module_dir)
+        # Remove only the entry this helper inserted at index 0.
+        if sys.path and sys.path[0] == module_dir:
+            sys.path.pop(0)
     return module
 
 
-def configuration_dir(module):
+def get_configuration_dir(module):
     """Return the directory containing a loaded configuration module (None if unknown)."""
     source = getattr(module, '__file__', None)
     return os.path.dirname(os.path.abspath(source)) if source else None

+ 36 - 35
netbox/netbox/tests/test_cli.py

@@ -1,3 +1,5 @@
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
 from unittest.mock import patch
 
 from django.test import SimpleTestCase
@@ -10,89 +12,87 @@ class CliDispatchTest(SimpleTestCase):
 
     def _main(self, args, argv0='netbox'):
         # prog derives from sys.argv[0] even when argv is passed explicitly, so pin both.
+        stdout, stderr = StringIO(), StringIO()
         with (
             patch.object(cli.sys, 'argv', [argv0, *args]),
             patch('django.core.management.execute_from_command_line') as execute,
+            redirect_stdout(stdout), redirect_stderr(stderr),
         ):
             rc = cli.main(args)
-        return rc, execute
+        return rc, execute, stdout.getvalue(), stderr.getvalue()
 
     def test_version_flag_prints_version_without_django(self):
-        with patch('netbox.cli.version', return_value='4.7.0b1'), patch('builtins.print') as printed:
-            rc, execute = self._main(['--version'])
+        with patch('netbox.cli.version', return_value='4.7.0b1'):
+            rc, execute, out, err = self._main(['--version'])
         execute.assert_not_called()
-        printed.assert_called_once_with('4.7.0b1')
+        self.assertEqual(out, '4.7.0b1\n')
         self.assertEqual(rc, 0)
 
     def test_version_command_prints_version_without_django(self):
-        with patch('netbox.cli.version', return_value='4.7.0b1'), patch('builtins.print') as printed:
-            rc, execute = self._main(['version'])
+        with patch('netbox.cli.version', return_value='4.7.0b1'):
+            rc, execute, out, err = self._main(['version'])
         execute.assert_not_called()
-        printed.assert_called_once_with('4.7.0b1')
+        self.assertEqual(out, '4.7.0b1\n')
         self.assertEqual(rc, 0)
 
     def test_version_help(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main(['version', '--help'])
+        rc, execute, out, err = self._main(['version', '--help'])
         execute.assert_not_called()
         self.assertEqual(rc, 0)
-        self.assertIn('usage: netbox version', printed.call_args_list[0].args[0])
+        self.assertIn('usage: netbox version', out)
 
     def test_version_rejects_unexpected_arguments(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main(['version', 'bogus'])
+        rc, execute, out, err = self._main(['version', 'bogus'])
         execute.assert_not_called()
         self.assertEqual(rc, 2)
-        self.assertIn('unexpected arguments: bogus', printed.call_args.args[0])
-        self.assertIs(printed.call_args.kwargs.get('file'), cli.sys.stderr)
+        self.assertIn('unrecognized arguments: bogus', err)
 
     def test_no_arguments_prints_help_without_django(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main([])
+        rc, execute, out, err = self._main([])
         execute.assert_not_called()
         self.assertEqual(rc, 0)
-        self.assertIn('Pre-configuration commands', printed.call_args.args[0])
+        self.assertIn('usage: netbox', out)
+        for command in ('version', 'setup', 'secret-key'):
+            self.assertIn(command, out)
 
     def test_help_flags_print_help_without_django(self):
         for flag in ('-h', '--help'):
-            with self.subTest(flag=flag), patch('builtins.print') as printed:
-                rc, execute = self._main([flag])
+            with self.subTest(flag=flag):
+                rc, execute, out, err = self._main([flag])
                 execute.assert_not_called()
                 self.assertEqual(rc, 0)
-                self.assertIn('usage: netbox', printed.call_args.args[0])
+                self.assertIn('usage: netbox', out)
+                for command in ('version', 'setup', 'secret-key'):
+                    self.assertIn(command, out)
 
     def test_secret_key_prints_50_char_key_without_django(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main(['secret-key'])
+        rc, execute, out, err = self._main(['secret-key'])
         execute.assert_not_called()
         self.assertEqual(rc, 0)
-        self.assertEqual(len(printed.call_args.args[0]), 50)
+        self.assertEqual(len(out.strip()), 50)
 
     def test_secret_key_help(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main(['secret-key', '--help'])
+        rc, execute, out, err = self._main(['secret-key', '--help'])
         execute.assert_not_called()
         self.assertEqual(rc, 0)
-        self.assertIn('usage: netbox secret-key', printed.call_args_list[0].args[0])
+        self.assertIn('usage: netbox secret-key', out)
 
     def test_secret_key_rejects_unexpected_arguments(self):
-        with patch('builtins.print') as printed:
-            rc, execute = self._main(['secret-key', 'bogus'])
+        rc, execute, out, err = self._main(['secret-key', 'bogus'])
         execute.assert_not_called()
         self.assertEqual(rc, 2)
-        self.assertIn('unexpected arguments: bogus', printed.call_args.args[0])
-        self.assertIs(printed.call_args.kwargs.get('file'), cli.sys.stderr)
+        self.assertIn('unrecognized arguments: bogus', err)
 
     def test_setup_dispatches_to_scaffold_with_prog(self):
         with patch('netbox.scaffold.main', return_value=0) as setup_main:
-            rc, execute = self._main(['setup', '--target', '/srv/netbox'])
+            rc, execute, out, err = self._main(['setup', '--target', '/srv/netbox'])
         execute.assert_not_called()
         setup_main.assert_called_once_with(['--target', '/srv/netbox'], prog='netbox setup')
         self.assertEqual(rc, 0)
 
     def test_setup_return_code_propagates(self):
         with patch('netbox.scaffold.main', return_value=3):
-            rc, execute = self._main(['setup'])
+            rc, execute, out, err = self._main(['setup'])
         execute.assert_not_called()
         self.assertEqual(rc, 3)
 
@@ -109,7 +109,7 @@ class CliDispatchTest(SimpleTestCase):
         self.assertEqual(rc, 0)
 
     def test_subcommand_help_falls_through_to_django(self):
-        rc, execute = self._main(['migrate', '--help'])
+        rc, execute, out, err = self._main(['migrate', '--help'])
         execute.assert_called_once_with(['netbox', 'migrate', '--help'])
         self.assertEqual(rc, 0)
 
@@ -119,12 +119,13 @@ class CliDispatchTest(SimpleTestCase):
         setup_main.assert_called_once_with([], prog='netbox setup')
 
     def test_prog_falls_back_when_argv_is_empty(self):
+        out = StringIO()
         with (
             patch.object(cli.sys, 'argv', []),
             patch('django.core.management.execute_from_command_line') as execute,
-            patch('builtins.print') as printed,
+            redirect_stdout(out),
         ):
             rc = cli.main([])
         execute.assert_not_called()
         self.assertEqual(rc, 0)
-        self.assertIn('usage: netbox', printed.call_args.args[0])
+        self.assertIn('usage: netbox', out.getvalue())

+ 119 - 279
netbox/netbox/tests/test_scaffold.py

@@ -2,309 +2,149 @@ import contextlib
 import tempfile
 from io import StringIO
 from pathlib import Path
-from unittest import skipUnless
 from unittest.mock import patch
 
 from django.test import SimpleTestCase
 
 from netbox import scaffold
 
-_CONTRIB_DIR = Path(__file__).resolve().parents[3] / 'contrib'
+_CONFIG_TEMPLATE_TEXT = (
+    "# example configuration\n"
+    "STORAGE_ROOT = '/opt/netbox/netbox/media'\n"
+    "NETBOX_ROOT = '/opt/netbox'\n"
+)
+_CONTRIB_FILENAMES = (
+    'apache.conf', 'gunicorn.py', 'netbox-rq.service', 'netbox.env', 'netbox.service', 'nginx.conf', 'uwsgi.ini',
+)
 
 
 class ScaffoldInstanceTest(SimpleTestCase):
-    def _fake_examples(self, tmp):
-        src = Path(tmp) / 'examples'
-        src.mkdir()
-        for name in ('gunicorn.py', 'nginx.conf', 'apache.conf', 'netbox.env'):
-            (src / name).write_text(f'# {name}')
-        (src / 'netbox.service').write_text(
-            'PIDFile=/var/tmp/netbox.pid\n'
-            'ExecStart=/opt/netbox/venv/bin/gunicorn --pythonpath /opt/netbox/netbox netbox.wsgi\n'
+    def setUp(self):
+        tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(tmp.cleanup)
+        root = Path(tmp.name)
+
+        bundled = root / '_data'
+        contrib_src = bundled / 'contrib'
+        contrib_src.mkdir(parents=True)
+        for name in _CONTRIB_FILENAMES:
+            (contrib_src / name).write_text(f'# {name} contents\n')
+        # A real install leaves __pycache__ here too: pip's post-install bytecode compilation
+        # runs on gunicorn.py, the only .py file among the examples.
+        (contrib_src / '__pycache__').mkdir()
+        self.contrib_src = contrib_src
+
+        template = root / 'configuration_example.py'
+        template.write_text(_CONFIG_TEMPLATE_TEXT)
+
+        self.target = root / 'opt'
+        self.target.mkdir()
+
+        self.enterContext(patch('netbox.scaffold._bundled_data_dir', return_value=bundled))
+        self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
+        self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
+
+    def test_scaffolds_configuration_and_contrib_examples(self):
+        """A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / '__init__.py').read_bytes(), b'')
+        self.assertEqual(
+            (self.target / 'conf' / 'configuration.py').read_bytes(),
+            _CONFIG_TEMPLATE_TEXT.encode('utf-8'),
         )
-        (src / 'netbox-rq.service').write_text(
-            'Group=netbox\n'
-            'ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker high default low\n'
-        )
-        return src
-
-    def test_scaffolds_instance_root_and_conf(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                written = scaffold.scaffold_instance(target, systemd)
-            self.assertTrue((target / 'gunicorn.py').exists())
-            self.assertTrue((target / 'nginx.conf').exists())
-            self.assertTrue((target / 'netbox.env').exists())
-            self.assertTrue((systemd / 'netbox.service').exists())
-            self.assertTrue((systemd / 'netbox-rq.service').exists())
-            self.assertTrue((target / 'conf' / '__init__.py').exists())
-            self.assertTrue((target / 'conf' / 'configuration.py').exists())
-            self.assertTrue(written)
+        self.assertEqual((self.target / 'local_requirements.txt').read_bytes(), b'')
+        for name in _CONTRIB_FILENAMES:
+            self.assertEqual(
+                (self.target / 'contrib' / name).read_bytes(),
+                (self.contrib_src / name).read_bytes(),
+            )
+        expected = [
+            str(self.target / 'conf' / '__init__.py'),
+            str(self.target / 'conf' / 'configuration.py'),
+            str(self.target / 'local_requirements.txt'),
+            *(str(self.target / 'contrib' / name) for name in _CONTRIB_FILENAMES),
+        ]
+        self.assertEqual(written, expected)
+        self.assertFalse((self.target / 'contrib' / '__pycache__').exists())
 
     def test_never_clobbers_existing_configuration(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            (target / 'conf').mkdir(parents=True)
-            (target / 'conf' / 'configuration.py').write_text('SECRET = 1')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd)
-            self.assertEqual((target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
-
-    def test_existing_file_is_skipped_without_force(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            (target / 'gunicorn.py').write_text('# keep me')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                written = scaffold.scaffold_instance(target, systemd)
-            self.assertEqual((target / 'gunicorn.py').read_text(), '# keep me')
-            self.assertNotIn(str(target / 'gunicorn.py'), written)
+        """An existing conf/configuration.py is left untouched."""
+        (self.target / 'conf').mkdir(parents=True)
+        (self.target / 'conf' / 'configuration.py').write_text('SECRET = 1')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
+        self.assertNotIn(str(self.target / 'conf' / 'configuration.py'), written)
+
+    def test_never_clobbers_existing_conf_init(self):
+        """An existing conf/__init__.py is left untouched."""
+        (self.target / 'conf').mkdir(parents=True)
+        (self.target / 'conf' / '__init__.py').write_text('# user-owned\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
+        self.assertNotIn(str(self.target / 'conf' / '__init__.py'), written)
+
+    def test_never_clobbers_existing_local_requirements(self):
+        """An existing local_requirements.txt is left untouched."""
+        (self.target / 'local_requirements.txt').write_text('django-auth-ldap\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
+        self.assertNotIn(str(self.target / 'local_requirements.txt'), written)
+
+    def test_never_clobbers_existing_contrib_file(self):
+        """An existing <target>/contrib/<name> file is left untouched; siblings are still written."""
+        (self.target / 'contrib').mkdir(parents=True)
+        (self.target / 'contrib' / 'gunicorn.py').write_text('# edited\n')
+        written = scaffold.scaffold_instance(self.target)
+        self.assertEqual((self.target / 'contrib' / 'gunicorn.py').read_text(), '# edited\n')
+        self.assertNotIn(str(self.target / 'contrib' / 'gunicorn.py'), written)
+        self.assertIn(str(self.target / 'contrib' / 'nginx.conf'), written)
 
     def test_main_uses_arguments(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                result = scaffold.main(['--target', str(target), '--systemd-dir', str(systemd)])
-            self.assertEqual(result, 0)
-            self.assertTrue((target / 'gunicorn.py').exists())
-            self.assertTrue((target / 'conf' / 'configuration.py').exists())
-
-    def test_main_rejects_relative_target(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            with patch('netbox.scaffold._examples_dir', return_value=Path(tmp)):
-                with self.assertRaises(SystemExit):
-                    scaffold.main(['--target', 'relative/path'])
-
-    def test_resource_helpers_resolve_under_package(self):
-        self.assertTrue(str(scaffold._examples_dir()).endswith('_data/examples'))
-        self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
-
-    def test_render_rewrites_both_layouts(self):
-        text = 'WorkingDirectory=/opt/netbox\nMEDIA=/opt/netbox/netbox/media\nvenv=/opt/netbox/venv/bin'
-        out = scaffold._render(text, '/srv/netbox')
-        self.assertIn('WorkingDirectory=/srv/netbox', out)
-        self.assertIn('MEDIA=/srv/netbox/media', out)
-        self.assertIn('venv=/srv/netbox/venv/bin', out)
-        self.assertNotIn('/opt/netbox', out)
-
-    def test_render_does_not_double_rewrite_target_containing_opt_netbox(self):
-        out = scaffold._render('cfg=/opt/netbox/netbox/media\nvenv=/opt/netbox/venv', '/opt/netbox-prod')
-        self.assertIn('cfg=/opt/netbox-prod/media', out)
-        self.assertIn('venv=/opt/netbox-prod/venv', out)
-        self.assertNotIn('/opt/netbox-prod-prod', out)
-
-    def test_scaffold_renders_target_into_files(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'srv'
-            target.mkdir()
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd)
-            svc = (systemd / 'netbox.service').read_text()
-            self.assertIn(str(target), svc)
-            self.assertNotIn('/opt/netbox', svc)
-
-    def test_scaffold_adapts_service_units_for_pip(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'srv'
-            target.mkdir()
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd)
-            wsgi = (systemd / 'netbox.service').read_text()
-            rq = (systemd / 'netbox-rq.service').read_text()
-            self.assertIn(f'EnvironmentFile=-{target}/netbox.env', wsgi)
-            self.assertNotIn('--pythonpath', wsgi)
-            self.assertIn(f'EnvironmentFile=-{target}/netbox.env', rq)
-            self.assertIn(f'ExecStart={target}/venv/bin/netbox rqworker high default low', rq)
-            self.assertNotIn('manage.py', rq)
-
-    def test_apply_pip_transforms_raises_on_missing_anchor(self):
-        with self.assertRaisesRegex(RuntimeError, 'anchor not found'):
-            scaffold._apply_pip_transforms('netbox.service', '# no anchors here')
-
-    def test_apply_pip_transforms_ignores_files_without_rules(self):
-        self.assertEqual(scaffold._apply_pip_transforms('nginx.conf', '# unchanged'), '# unchanged')
-
-    @skipUnless(_CONTRIB_DIR.is_dir(), 'contrib sources not present')
-    def test_pip_transforms_match_canonical_contrib_files(self):
-        for name, substitutions in scaffold._PIP_TRANSFORMS.items():
-            text = (_CONTRIB_DIR / name).read_text()
-            for old, _new in substitutions:
-                self.assertIn(old, text, f'{name}: stale transform anchor {old!r}')
-
-    @skipUnless(_CONTRIB_DIR.is_dir(), 'contrib sources not present')
-    def test_canonical_contrib_files_render_for_pip(self):
-        rendered = {
-            name: scaffold._render(
-                scaffold._apply_pip_transforms(name, (_CONTRIB_DIR / name).read_text()), '/srv/netbox'
-            )
-            for name in ('netbox.service', 'netbox-rq.service', 'nginx.conf', 'apache.conf')
-        }
-        self.assertNotIn('--pythonpath', rendered['netbox.service'])
-        self.assertIn('EnvironmentFile=-/srv/netbox/netbox.env', rendered['netbox.service'])
-        self.assertIn('PIDFile=/var/tmp/netbox.pid', rendered['netbox.service'])
-        self.assertIn('RestartSec=30', rendered['netbox.service'])
-        self.assertIn(
-            'ExecStart=/srv/netbox/venv/bin/netbox rqworker high default low\n', rendered['netbox-rq.service']
+        """main() dispatches --target through to scaffold_instance."""
+        result = scaffold.main(['--target', str(self.target)])
+        self.assertEqual(result, 0)
+        self.assertEqual(
+            (self.target / 'conf' / 'configuration.py').read_bytes(),
+            _CONFIG_TEMPLATE_TEXT.encode('utf-8'),
         )
-        self.assertNotIn('manage.py', rendered['netbox-rq.service'])
-        self.assertIn('EnvironmentFile=-/srv/netbox/netbox.env', rendered['netbox-rq.service'])
-        self.assertIn('alias /srv/netbox/static/;', rendered['nginx.conf'])
-        self.assertIn('/srv/netbox/static', rendered['apache.conf'])
-        for name, text in rendered.items():
-            self.assertNotIn('/opt/netbox', text, name)
-            self.assertNotIn('/srv/netbox/netbox/', text, name)
 
-    def test_creates_empty_local_requirements(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd)
-            self.assertTrue((target / 'local_requirements.txt').exists())
-            self.assertEqual((target / 'local_requirements.txt').read_text(), '')
-
-    def test_does_not_clobber_existing_local_requirements(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            (target / 'local_requirements.txt').write_text('django-auth-ldap\n')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd)
-            self.assertEqual((target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
-
-    def test_force_does_not_clobber_existing_configuration(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            (target / 'conf').mkdir(parents=True)
-            (target / 'conf' / 'configuration.py').write_text('SECRET = 1')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd, force=True)
-            self.assertEqual((target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
-
-    def test_force_does_not_clobber_existing_local_requirements(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            (target / 'local_requirements.txt').write_text('django-auth-ldap\n')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd, force=True)
-            self.assertEqual((target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
-
-    def test_force_does_not_clobber_existing_conf_init(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            (target / 'conf').mkdir(parents=True)
-            (target / 'conf' / '__init__.py').write_text('# user-owned\n')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                scaffold.scaffold_instance(target, systemd, force=True)
-            self.assertEqual((target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
-
-    def test_force_does_not_clobber_existing_netbox_env(self):
-        with tempfile.TemporaryDirectory() as tmp:
-            src = self._fake_examples(tmp)
-            target = Path(tmp) / 'opt'
-            target.mkdir()
-            (target / 'netbox.env').write_text('NETBOX_ROOT=/srv/custom\n')
-            systemd = Path(tmp) / 'systemd'
-            systemd.mkdir()
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=src),
-                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
-            ):
-                written = scaffold.scaffold_instance(target, systemd, force=True)
-            self.assertEqual((target / 'netbox.env').read_text(), 'NETBOX_ROOT=/srv/custom\n')
-            self.assertNotIn(str(target / 'netbox.env'), written)
+    def test_main_rejects_relative_target(self):
+        """A relative --target is rejected with rc 2."""
+        with (
+            contextlib.redirect_stderr(StringIO()),
+            self.assertRaises(SystemExit) as cm,
+        ):
+            scaffold.main(['--target', 'relative/path'])
+        self.assertEqual(cm.exception.code, 2)
 
     def test_main_help_uses_netbox_setup_prog(self):
+        """--help shows the netbox setup prog name."""
         out = StringIO()
         with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as cm:
             scaffold.main(['--help'])
         self.assertEqual(cm.exception.code, 0)
         self.assertIn('usage: netbox setup', out.getvalue())
 
-    def test_main_rejects_stale_secret_key_subcommand(self):
-        err = StringIO()
-        with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as cm:
-            scaffold.main(['secret-key'])
-        self.assertEqual(cm.exception.code, 2)
-        self.assertIn('unrecognized arguments', err.getvalue())
-
-    def test_main_fails_friendly_without_bundled_examples(self):
+    def test_main_fails_friendly_without_bundled_data(self):
+        """Without bundled package data, main() reports rc 1 and a friendly stderr message."""
         err = StringIO()
-        with tempfile.TemporaryDirectory() as tmp:
-            with (
-                patch('netbox.scaffold._examples_dir', return_value=Path(tmp) / 'missing'),
-                contextlib.redirect_stderr(err),
-            ):
-                rc = scaffold.main(['--target', '/tmp/x'])
+        with (
+            patch('netbox.scaffold._bundled_data_dir', return_value=self.target / 'missing'),
+            contextlib.redirect_stderr(err),
+        ):
+            rc = scaffold.main(['--target', '/tmp/x'])
         self.assertEqual(rc, 1)
         self.assertIn('installed netbox package', err.getvalue())
+
+
+class ScaffoldResourceHelpersTest(SimpleTestCase):
+    """The unpatched resource helpers resolve real paths under the installed netbox package."""
+
+    def test_bundled_data_dir_resolves_under_package(self):
+        self.assertTrue(str(scaffold._bundled_data_dir()).endswith('_data'))
+
+    def test_config_template_resolves_under_package(self):
+        self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
+
+    def test_contrib_dir_resolves_under_bundled_data(self):
+        self.assertTrue(str(scaffold._contrib_dir()).endswith('_data/contrib'))

+ 54 - 3
netbox/netbox/tests/test_settings_utils.py

@@ -118,7 +118,7 @@ class LoadConfigurationTest(SimpleTestCase):
                 settings_utils._import_from_path('netbox_test_noext_cfg', path)
 
     def test_import_from_path_preserves_preexisting_sys_path_entry(self):
-        # remove() drops the first match, i.e. the inserted index-0 copy; a pre-existing entry survives.
+        # Only the index-0 entry this helper inserted is popped; a pre-existing entry survives.
         with tempfile.TemporaryDirectory() as root:
             path = os.path.join(root, 'preexisting_cfg.py')
             with open(path, 'w') as handle:
@@ -157,10 +157,61 @@ class ConfigurationDirTest(SimpleTestCase):
     def test_returns_directory_of_module_file(self):
         module = ModuleType('cfg')
         module.__file__ = '/srv/netbox/conf/configuration.py'
-        self.assertEqual(settings_utils.configuration_dir(module), '/srv/netbox/conf')
+        self.assertEqual(settings_utils.get_configuration_dir(module), '/srv/netbox/conf')
 
     def test_returns_none_without_file(self):
-        self.assertIsNone(settings_utils.configuration_dir(ModuleType('cfg')))
+        self.assertIsNone(settings_utils.get_configuration_dir(ModuleType('cfg')))
+
+
+class ResolveInstallPathsTest(SimpleTestCase):
+    """resolve_install_paths() centralizes wheel-vs-checkout filesystem layout decisions."""
+
+    def test_checkout_roots(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(settings_dir)
+            base_dir = os.path.join(root, 'netbox')
+            paths = settings_utils.resolve_install_paths(settings_dir, {})
+            self.assertEqual(paths.install_mode, 'checkout')
+            self.assertEqual(paths.base_dir, base_dir)
+            self.assertEqual(paths.netbox_root, base_dir)
+            self.assertEqual(paths.docs_root, os.path.join(root, 'docs'))
+            self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'project-static', 'docs'))
+
+    def test_wheel_roots_default_netbox_root(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'site-packages', 'netbox')
+            base_dir = os.path.join(settings_dir, '_data')
+            os.makedirs(base_dir)
+            paths = settings_utils.resolve_install_paths(settings_dir, {})
+            self.assertEqual(paths.install_mode, 'wheel')
+            self.assertEqual(paths.base_dir, base_dir)
+            self.assertEqual(paths.netbox_root, '/opt/netbox')
+            self.assertEqual(paths.docs_root, os.path.join(base_dir, 'docs'))
+            # zensical's site_dir (netbox/project-static/docs) is relative to the bundled
+            # mkdocs.yml at _data/mkdocs.yml, so the built docs land under _data/netbox/...,
+            # unlike the checkout layout where base_dir already is the netbox/ directory.
+            self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'netbox', 'project-static', 'docs'))
+
+    def test_netbox_root_env_override_is_abspathed(self):
+        with tempfile.TemporaryDirectory() as root:
+            settings_dir = os.path.join(root, 'site-packages', 'netbox')
+            os.makedirs(os.path.join(settings_dir, '_data'))
+            paths = settings_utils.resolve_install_paths(settings_dir, {'NETBOX_ROOT': 'relative/root'})
+            self.assertEqual(paths.netbox_root, os.path.abspath('relative/root'))
+
+
+class SecretKeyHintTest(SimpleTestCase):
+    """secret_key_hint() picks the SECRET_KEY-too-short hint by install mode."""
+
+    def test_wheel_mode_suggests_console_command(self):
+        self.assertEqual(settings_utils.secret_key_hint('wheel', '/opt/netbox/lib/netbox'), 'netbox secret-key')
+
+    def test_checkout_mode_suggests_generate_secret_key_script(self):
+        self.assertEqual(
+            settings_utils.secret_key_hint('checkout', '/repo/netbox'),
+            'python /repo/netbox/generate_secret_key.py',
+        )
 
 
 class LoadLdapConfigTest(SimpleTestCase):

+ 0 - 95
netbox/utilities/upgrade_tasks.py

@@ -1,95 +0,0 @@
-"""Implementation helpers for the `upgrade` management command.
-
-The command runs the NetBox application-level tasks that prepare the database and
-static assets after the package and configuration are already in place - for both a
-fresh installation and an upgrade. It does not perform host or bootstrap work
-(creating the virtual environment, installing packages, configuring services); that
-stays in upgrade.sh and the documented pip steps.
-"""
-
-import os
-import subprocess
-
-from django.conf import settings
-from django.core.management import call_command
-
-__all__ = ('add_upgrade_arguments', 'run_upgrade_tasks')
-
-
-def add_upgrade_arguments(parser):
-    parser.add_argument('--no-input', action='store_true', dest='no_input',
-                        help="Do not prompt for user input.")
-    parser.add_argument('--readonly', action='store_true', dest='readonly',
-                        help="Skip all tasks that modify the database or filesystem "
-                             "(no migrations, no static collection).")
-    parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
-                        help="Skip applying database migrations.")
-    parser.add_argument('--skip-static', action='store_true', dest='skip_static',
-                        help="Skip collecting static files.")
-    parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
-                        help="Skip rebuilding the search index.")
-    parser.add_argument('--build-docs', action='store_true', dest='build_docs',
-                        help="Build the local documentation (requires the documentation source tree).")
-
-
-def _docs_source_root():
-    # mkdocs.yml sits beside the application root (one level above BASE_DIR in a checkout);
-    # a wheel install has no documentation sources, so this returns None there.
-    candidate = os.path.dirname(settings.BASE_DIR)
-    return candidate if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')) else None
-
-
-def run_upgrade_tasks(command, *, no_input=False, readonly=False,
-                      skip_migrations=False, skip_static=False, skip_reindex=False,
-                      build_docs=False):
-    out, style = command.stdout, command.style
-    out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
-
-    # Database migrations (writes to the database)
-    if skip_migrations or readonly:
-        out.write("Skipping database migrations.")
-    else:
-        out.write("Applying database migrations...")
-        call_command('migrate', interactive=not no_input, stdout=out)
-
-    # Missing cable paths (writes to the database)
-    if not readonly:
-        out.write("Checking for missing cable paths...")
-        call_command('trace_paths', no_input=no_input, stdout=out)
-
-    # Documentation (filesystem; needs the documentation source tree)
-    if readonly and build_docs:
-        out.write("Skipping documentation build.")
-    elif build_docs:
-        docs_root = _docs_source_root()
-        if docs_root is None:
-            out.write(style.WARNING("Skipping documentation build (documentation source tree not found)."))
-        else:
-            out.write("Building documentation...")
-            subprocess.run(['zensical', 'build'], cwd=docs_root, check=True)
-
-    # Static files (filesystem)
-    if skip_static or readonly:
-        out.write("Skipping static file collection.")
-    else:
-        out.write("Collecting static files...")
-        call_command('collectstatic', interactive=not no_input, stdout=out)
-
-    # Stale content types (writes to the database)
-    if not readonly:
-        out.write("Removing stale content types...")
-        call_command('remove_stale_contenttypes', interactive=not no_input, stdout=out)
-
-    # Search index (writes to the database)
-    if skip_reindex or readonly:
-        out.write("Skipping search index rebuild.")
-    else:
-        out.write("Rebuilding the search index (lazily)...")
-        call_command('reindex', lazy=True, stdout=out)
-
-    # Expired sessions (writes to the database)
-    if not readonly:
-        out.write("Clearing expired sessions...")
-        call_command('clearsessions', stdout=out)
-
-    out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

+ 16 - 11
pyproject.toml

@@ -49,7 +49,7 @@ dev = [
     "coverage",
     "packaging",
     "pre-commit",
-    "ruff==0.15.10",
+    "ruff==0.15.20",
     "tblib",
     "twine",
     "uv",
@@ -123,20 +123,25 @@ exclude = [
 # setting above strips one "netbox/" prefix from every path (including these
 # force-include targets); after stripping they resolve to netbox/_data/...
 [tool.hatch.build.targets.wheel.force-include]
-"netbox/templates" = "netbox/netbox/_data/templates"
-"netbox/translations" = "netbox/netbox/_data/translations"
 "netbox/project-static/dist" = "netbox/netbox/_data/project-static/dist"
 "netbox/project-static/img" = "netbox/netbox/_data/project-static/img"
 "netbox/project-static/js" = "netbox/netbox/_data/project-static/js"
 "netbox/release.yaml" = "netbox/netbox/_data/release.yaml"
-"contrib/gunicorn.py" = "netbox/netbox/_data/examples/gunicorn.py"
-# The canonical contrib files are bundled as-is; `netbox setup` adapts the systemd units for
-# a pip install at render time (see _PIP_TRANSFORMS in netbox/netbox/scaffold.py).
-"contrib/netbox.service" = "netbox/netbox/_data/examples/netbox.service"
-"contrib/netbox-rq.service" = "netbox/netbox/_data/examples/netbox-rq.service"
-"contrib/nginx.conf" = "netbox/netbox/_data/examples/nginx.conf"
-"contrib/apache.conf" = "netbox/netbox/_data/examples/apache.conf"
-"contrib/netbox.env" = "netbox/netbox/_data/examples/netbox.env"
+"netbox/templates" = "netbox/netbox/_data/templates"
+"netbox/translations" = "netbox/netbox/_data/translations"
+# Documentation sources ship so an instance can build its own docs (`netbox upgrade
+# --build-docs`, using the pinned zensical/mkdocs toolchain); see docs/development/
+# building-the-package.md.
+"docs" = "netbox/netbox/_data/docs"
+"mkdocs.yml" = "netbox/netbox/_data/mkdocs.yml"
+# Deployment examples, bundled verbatim for `netbox setup` (no adaptation).
+"contrib/apache.conf" = "netbox/netbox/_data/contrib/apache.conf"
+"contrib/gunicorn.py" = "netbox/netbox/_data/contrib/gunicorn.py"
+"contrib/netbox-rq.service" = "netbox/netbox/_data/contrib/netbox-rq.service"
+"contrib/netbox.env" = "netbox/netbox/_data/contrib/netbox.env"
+"contrib/netbox.service" = "netbox/netbox/_data/contrib/netbox.service"
+"contrib/nginx.conf" = "netbox/netbox/_data/contrib/nginx.conf"
+"contrib/uwsgi.ini" = "netbox/netbox/_data/contrib/uwsgi.ini"
 # Force the two tracked config templates in over the configuration*.py exclude above.
 "netbox/netbox/configuration_example.py" = "netbox/netbox/configuration_example.py"
 "netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py"

+ 21 - 6
scripts/verify_dependencies.py

@@ -7,18 +7,29 @@ policy in base_requirements.txt (same package set, and every pin satisfies its
 declared constraint).
 """
 
+import importlib.util
 import sys
 from pathlib import Path
 
 from packaging.requirements import Requirement
 
 
-def parse(path):
+def load_hatch_metadata():
+    """Load scripts/packaging/hatch_metadata.py by path.
+
+    scripts/packaging is not a package (no __init__.py), and importing it by name would
+    collide with the third-party packaging distribution, so load it from its file path.
+    """
+    path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
+    spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def parse(path, hatch_metadata):
     reqs = {}
-    for raw_line in Path(path).read_text().splitlines():
-        line = raw_line.split('#', 1)[0].strip()
-        if not line or line.startswith('-'):
-            continue
+    for line in hatch_metadata.read_requirements(Path(path).read_text()):
         req = Requirement(line)
         reqs[req.name.lower().replace('_', '-')] = req
     return reqs
@@ -50,7 +61,11 @@ def check(base, pinned):
 
 def main():
     root = Path(__file__).resolve().parent.parent
-    errors = check(parse(root / 'base_requirements.txt'), parse(root / 'requirements.txt'))
+    hatch_metadata = load_hatch_metadata()
+    errors = check(
+        parse(root / 'base_requirements.txt', hatch_metadata),
+        parse(root / 'requirements.txt', hatch_metadata),
+    )
     if errors:
         print("Dependency drift detected:")
         for error in errors:

+ 49 - 12
scripts/verify_wheel_contents.py

@@ -6,13 +6,17 @@ live configuration.py (which holds SECRET_KEY and database credentials), any oth
 local configuration*.py variant, or any ldap_config*.py (which holds LDAP bind
 credentials). This guards against a dirty or manual build leaking secrets into a
 published artifact. The wheel must also ship the runtime-critical bundled data
-(release metadata, templates, translations, static assets, deployment examples), so
-a broken build fails here with a precise message instead of at smoke-test time.
+(release metadata, templates, translations, static assets, documentation sources,
+deployment examples), so a broken build fails here with a precise message instead of
+at smoke-test time. main() also cross-checks pyproject.toml's wheel force-include
+table against REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED, so an addition there without
+matching verifier coverage fails too.
 """
 
 import sys
+import tomllib
 import zipfile
-from pathlib import PurePosixPath
+from pathlib import Path, PurePosixPath
 
 # The scan covers the entire wheel; only these two tracked templates (at netbox/<name> after the
 # wheel `sources = ["netbox"]` strip) are allowed to ship.
@@ -22,21 +26,27 @@ ALLOWED = {
 }
 
 # Runtime-critical bundled data; mirrors the force-include table in pyproject.toml.
+# Hand-maintained, not derived: main() cross-checks pyproject.toml's wheel
+# force-include table against these sets (plus ALLOWED) in the other direction, so a
+# force-include added there without matching coverage here also fails.
 REQUIRED_FILES = {
+    'netbox/_data/contrib/apache.conf',
+    'netbox/_data/contrib/gunicorn.py',
+    'netbox/_data/contrib/netbox-rq.service',
+    'netbox/_data/contrib/netbox.env',
+    'netbox/_data/contrib/netbox.service',
+    'netbox/_data/contrib/nginx.conf',
+    'netbox/_data/contrib/uwsgi.ini',
+    'netbox/_data/mkdocs.yml',
     'netbox/_data/release.yaml',
-    'netbox/_data/examples/gunicorn.py',
-    'netbox/_data/examples/netbox.service',
-    'netbox/_data/examples/netbox-rq.service',
-    'netbox/_data/examples/nginx.conf',
-    'netbox/_data/examples/apache.conf',
-    'netbox/_data/examples/netbox.env',
 }
 REQUIRED_PREFIXES = (
-    'netbox/_data/templates/',
-    'netbox/_data/translations/',
+    'netbox/_data/docs/',
     'netbox/_data/project-static/dist/',
     'netbox/_data/project-static/img/',
     'netbox/_data/project-static/js/',
+    'netbox/_data/templates/',
+    'netbox/_data/translations/',
 )
 
 
@@ -59,6 +69,29 @@ def missing_runtime_data(names):
     return missing
 
 
+def uncovered_force_includes(pyproject_path):
+    """Return wheel force-include destinations in pyproject.toml not covered by this verifier.
+
+    REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED stay hand-owned and independent of pyproject.toml
+    (a full derivation would let a deleted force-include line shrink the expectations with it
+    and go silently green), so this only catches drift in one direction: a force-include added
+    to pyproject.toml without matching verifier coverage. A deleted force-include line still
+    fails the REQUIRED_FILES/REQUIRED_PREFIXES checks in missing_runtime_data().
+    """
+    with open(pyproject_path, 'rb') as handle:
+        pyproject = tomllib.load(handle)
+    force_include = pyproject['tool']['hatch']['build']['targets']['wheel']['force-include']
+    uncovered = []
+    for destination in force_include.values():
+        # The wheel's `sources = ["netbox"]` setting strips one leading "netbox/" segment from
+        # every path, including these force-include destinations.
+        stripped = destination.removeprefix('netbox/')
+        if stripped in REQUIRED_FILES or stripped in ALLOWED or f'{stripped}/' in REQUIRED_PREFIXES:
+            continue
+        uncovered.append(destination)
+    return sorted(uncovered)
+
+
 def main(argv):
     if len(argv) != 2:
         print('usage: verify_wheel_contents.py <wheel>')
@@ -69,7 +102,9 @@ def main(argv):
     missing = sorted(ALLOWED - found)
     unexpected = sorted(found - ALLOWED)
     missing_data = missing_runtime_data(names)
-    if missing or unexpected or missing_data:
+    pyproject_path = Path(__file__).resolve().parents[1] / 'pyproject.toml'
+    uncovered = uncovered_force_includes(pyproject_path)
+    if missing or unexpected or missing_data or uncovered:
         print('Wheel contents are not as expected:')
         if missing:
             print(f'  - missing templates: {missing}')
@@ -77,6 +112,8 @@ def main(argv):
             print(f'  - unexpected (possible secret leak): {unexpected}')
         if missing_data:
             print(f'  - missing runtime data: {missing_data}')
+        if uncovered:
+            print(f'  - pyproject.toml force-includes not covered by this verifier: {uncovered}')
         return 1
     print(f'OK: wheel ships the required bundled data and only the {len(ALLOWED)} configuration templates')
     return 0

+ 12 - 13
scripts/verify_wheel_metadata.py

@@ -99,26 +99,25 @@ def check_version(metadata, root, hatch_metadata):
     return []
 
 
+def _diff_errors(expected, actual, label):
+    """Build 'missing'/'unexpected' error messages for the set difference of expected vs actual."""
+    errors = []
+    if missing := sorted(expected - actual):
+        errors.append(f'{label} missing from wheel: {missing}')
+    if unexpected := sorted(actual - expected):
+        errors.append(f'unexpected {label} in wheel: {unexpected}')
+    return errors
+
+
 def check_core_requires(core, root, hatch_metadata):
     # Parse with the hook's own parser so the verifier cannot drift from the build.
     pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
-    errors = []
-    missing = sorted(pin for pin in pins if normalize(pin) not in core)
-    unexpected = sorted(core - {normalize(pin) for pin in pins})
-    if missing:
-        errors.append(f'requirements.txt pins missing from wheel: {missing}')
-    if unexpected:
-        errors.append(f'unexpected core requirements in wheel: {unexpected}')
-    return errors
+    return _diff_errors({normalize(pin) for pin in pins}, core, 'core requirements')
 
 
 def check_extras(metadata, by_extra):
-    errors = []
     provided = frozenset(metadata.get_all('Provides-Extra') or [])
-    if missing := sorted(EXPECTED_EXTRAS - provided):
-        errors.append(f'extras missing from wheel: {missing}')
-    if unexpected := sorted(provided - EXPECTED_EXTRAS):
-        errors.append(f'unexpected extras in wheel: {unexpected}')
+    errors = _diff_errors(EXPECTED_EXTRAS, provided, 'extras')
     for aggregate, components in AGGREGATE_EXTRAS.items():
         expected = set().union(*(by_extra[component] for component in components))
         actual = by_extra[aggregate]