Просмотр исходного кода

Closes #22603: Add experimental Python packaging support for NetBox (#22605)

Add initial Python package support for NetBox, including wheel and sdist
builds, generated package metadata, and Test PyPI publishing for maintainer
validation.

Add package-aware CLI support, `netbox setup` scaffolding for instance-local
files, and centralized wheel-vs-checkout path handling while preserving the
existing source/archive install layout.

Bundle pre-rendered embedded documentation in the wheel, and extend CI to
verify dependency pins, wheel metadata, artifact contents, CLI behavior, sdist
rebuilds, and smoke-test upgrades.
Martin Hauser 1 неделя назад
Родитель
Сommit
48ecc712bc

+ 328 - 0
.github/workflows/release.yml

@@ -0,0 +1,328 @@
+name: Build and publish Python package
+
+# Least-privilege default for every job; the publish job grants itself id-token below.
+permissions:
+  contents: read
+
+on:
+  pull_request:
+    paths:
+      - '.github/workflows/release.yml'
+      - 'pyproject.toml'
+      - 'README.md'
+      - 'LICENSE.txt'
+      - 'base_requirements.txt'
+      - 'requirements.txt'
+      - 'upgrade.sh'
+      - 'contrib/**'
+      - 'docs/**'
+      - 'mkdocs.yml'
+      - 'netbox/**'
+      - 'scripts/packaging/**'
+      - 'scripts/verify_*.py'
+      - 'scripts/smoketest_configuration.py'
+  push:
+    tags:
+      - 'v*'
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build package artifacts
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install build tooling
+        run: python -m pip install --upgrade build twine
+
+      - name: Install documentation toolchain
+        run: python -m pip install -r requirements.txt
+
+      - name: Render the documentation
+        # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
+        # additionally guards against a partial render reaching the wheel.
+        run: zensical build -c -s
+
+      - name: Build sdist and wheel
+        run: python -m build
+
+      - name: Check package metadata
+        run: twine check dist/*
+
+      - name: Upload package artifacts
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+          if-no-files-found: error
+
+  verify-dependencies:
+    name: Verify dependency pins are in sync
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install packaging
+        run: python -m pip install packaging
+
+      - name: Verify requirements.txt is consistent with base_requirements.txt
+        run: python scripts/verify_dependencies.py
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify wheel Requires-Dist matches requirements.txt
+        run: python scripts/verify_wheel_metadata.py dist/*.whl
+
+      - name: Verify wheel excludes live configuration files
+        run: python scripts/verify_wheel_contents.py dist/*.whl
+
+  verify-sdist:
+    name: Verify the sdist builds a wheel
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the sdist contents
+        run: |
+          python scripts/verify_sdist_contents.py dist/*.tar.gz
+
+      - name: Build a wheel from the sdist
+        run: |
+          python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
+
+      - name: Verify the sdist-built wheel
+        run: |
+          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 "$RUNNER_TEMP/netbox-cli-venv"
+          "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m pip install --no-deps dist/*.whl
+
+      - name: Exercise the pre-configuration CLI
+        run: |
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" --version
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" version
+          "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m netbox --version
+          "$RUNNER_TEMP/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: |
+          "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
+          for f in "$RUNNER_TEMP/nbroot/conf/__init__.py" "$RUNNER_TEMP/nbroot/conf/configuration.py" "$RUNNER_TEMP/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 "$RUNNER_TEMP/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:
+      postgres:
+        image: postgres:17
+        env:
+          POSTGRES_DB: netbox
+          POSTGRES_USER: netbox
+          POSTGRES_PASSWORD: netbox
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U netbox -d netbox"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+      redis:
+        image: redis:7
+        ports:
+          - 6379:6379
+        options: >-
+          --health-cmd "redis-cli ping"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    env:
+      NETBOX_CONFIGURATION: smoketest_configuration
+      POSTGRES_DB: netbox
+      POSTGRES_USER: netbox
+      POSTGRES_PASSWORD: netbox
+      POSTGRES_HOST: 127.0.0.1
+      POSTGRES_PORT: 5432
+      REDIS_HOST: 127.0.0.1
+      REDIS_PORT: 6379
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Install system build dependencies for psycopg
+        run: sudo apt-get update && sudo apt-get install -y libpq-dev
+
+      - name: Install wheel into a clean virtual environment
+        run: |
+          python -m venv "$RUNNER_TEMP/netbox-wheel-venv"
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install --upgrade pip
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install dist/*.whl
+
+      - name: Run NetBox smoke checks
+        env:
+          # STATIC_ROOT is not a configuration parameter; NETBOX_ROOT places it under the scratch base.
+          NETBOX_ROOT: ${{ runner.temp }}/netbox-smoketest
+          NETBOX_SMOKETEST_BASE: ${{ runner.temp }}/netbox-smoketest
+          PYTHONPATH: ${{ github.workspace }}/scripts
+        run: |
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" check
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" upgrade --no-input
+          test -f "$NETBOX_SMOKETEST_BASE/static/docs/index.html" || { echo "bundled documentation was not collected to STATIC_ROOT"; exit 1; }
+          test -f "$NETBOX_SMOKETEST_BASE/static/docs/models/dcim/device/index.html" || { echo "model documentation page was not collected"; exit 1; }
+
+      - name: Smoke-test netbox setup from the wheel
+        run: |
+          "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
+          diff -q "$RUNNER_TEMP/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 "$RUNNER_TEMP/nbroot/contrib/$f" "contrib/$f"
+          done
+
+  publish-testpypi:
+    name: Publish package to Test PyPI
+    runs-on: ubuntu-latest
+    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
+      url: https://test.pypi.org/p/netbox
+    permissions:
+      contents: read
+      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:
+          persist-credentials: false
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the git tag matches the built version
+        run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
+
+      - name: Publish package distributions to Test PyPI
+        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+        with:
+          repository-url: https://test.pypi.org/legacy/

+ 5 - 0
.gitignore

@@ -64,3 +64,8 @@ yarn-error.log*
 .idea/
 .vscode/
 .python-version
+
+# Python package build artifacts
+/dist/
+/build/
+*.egg-info/

+ 3 - 0
contrib/netbox.env

@@ -0,0 +1,3 @@
+# Optional overrides for a pip-installed NetBox. Do not put secrets here.
+# NetBox loads conf/configuration.py from NETBOX_ROOT automatically.
+NETBOX_ROOT=/opt/netbox

+ 126 - 0
docs/development/building-the-package.md

@@ -0,0 +1,126 @@
+# 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 for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. 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.
+
+## Prerequisites
+
+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
+```
+
+Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
+
+## Building
+
+Render the documentation site at the repository root before building; both the wheel and the sdist bundle the rendered output, and the release workflow's `build` job renders in the same way:
+
+```no-highlight
+python -m pip install -r requirements.txt
+zensical build -c -s
+```
+
+Always render with `-c` (clean cache) and `-s` (strict mode, abort on warnings) so a stale cache or a degraded build cannot slip into the artifacts. This writes `netbox/project-static/docs/` (gitignored). Building without a prior render fails because the rendered docs directory is a required Hatch force-include: Hatchling raises `FileNotFoundError: Forced include not found` for the missing directory. A render that exits successfully but produces a partial site is caught by `scripts/verify_wheel_contents.py`, which requires both the site root (`index.html`) and a model documentation page (`models/dcim/device/index.html`) in the wheel.
+
+Build both the source distribution (sdist) and the wheel into `dist/`:
+
+```no-highlight
+python -m build
+```
+
+To build only the wheel (faster, and the form most useful for a quick local install test):
+
+```no-highlight
+python -m build --wheel
+```
+
+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.
+
+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
+
+Check the built artifacts for valid package metadata and README rendering:
+
+```no-highlight
+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:
+
+```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 pre-rendered documentation site under `_data/docs/`. These are 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):
+
+```no-highlight
+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:
+
+```no-highlight
+python -m venv /tmp/netbox-build-test
+/tmp/netbox-build-test/bin/python -m pip install --upgrade pip
+/tmp/netbox-build-test/bin/python -m pip install dist/*.whl
+PYTHONPATH=$PWD/scripts \
+NETBOX_CONFIGURATION=smoketest_configuration \
+NETBOX_ROOT=/tmp/netbox-build-test-root \
+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 used by the release workflow's smoke-test job (`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, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. 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` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
+
+## 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.
+
+### 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.
+
+### sdist and the sdist-to-wheel guard
+
+`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate 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. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
+
+### 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`, the pre-rendered documentation site (rendered by `zensical build` into `netbox/project-static/docs/` before packaging; see [Building](#building) above), the bundled deployment examples (`contrib/`, seven files, unmodified), and the two tracked configuration templates.
+
+The wheel bundles the rendered site itself, not the documentation sources. The documentation build is not run from the installed wheel, and there is nothing to build on the instance. In wheel mode, the default `DOCS_ROOT` and the STATICFILES `docs` prefix source both resolve to the same bundled `_data/docs` directory (see `resolve_install_paths()` in `netbox/netbox/settings_utils.py`), which `collectstatic` then picks up the same way it does for a checkout build. The sdist force-includes the same rendered site (`netbox/project-static/docs/`, kept alongside the markdown sources it was rendered from), so a wheel built from the sdist (the `verify-sdist` job, or `pip install <sdist>`) is identical in this respect.
+
+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, defaults the writable paths (`MEDIA_ROOT`, `REPORTS_ROOT`, `SCRIPTS_ROOT`) beneath it, and fixes `STATIC_ROOT` to `$NETBOX_ROOT/static`; `STATIC_ROOT` is intentionally not a `configuration.py` parameter, so the collected static path cannot drift from the instance layout the bundled deployment examples expect. 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 by default. This keeps the active LDAP configuration beside the active NetBox configuration, regardless of install method. One compatibility exception remains: 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:
+
+* `netbox version` / `netbox --version` print the installed package version.
+* `netbox setup` creates the local configuration files for the instance: `conf/__init__.py`, `conf/configuration.py` copied verbatim from the bundled `configuration_example.py` template, and an empty `local_requirements.txt`. It also copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi, `netbox.env`) unmodified into `<target>/contrib/`. The examples are copied as-is, 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.

+ 38 - 8
docs/development/release-checklist.md

@@ -97,14 +97,23 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
 
 ### Update Python Dependencies
 
-Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
+Before each release, update each of NetBox's Python dependencies to its most recent stable version. Loose runtime constraints (and per-package descriptions) live in `base_requirements.txt`; `requirements.txt` is the pinned, top-level dependency file consumed by the release archive, the git install flow (`upgrade.sh`), and the published wheel's dependency metadata. Optional dependency groups (for example `ldap`, `saml2`) are declared in `pyproject.toml`.
 
-1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
-2. Run all tests and check that the UI and API function as expected.
-3. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
-4. Update the package versions in `requirements.txt` as appropriate.
+To update the pinned requirements:
 
-In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+1. Review each constraint in `base_requirements.txt`.
+2. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
+3. Run all tests and check that the UI and API function as expected.
+4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
+5. If upgrading a dependency is breaking, constrain it in `base_requirements.txt` with an explanatory comment and revisit it for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+6. Update the pinned versions in `requirements.txt` to the versions you just tested. Keep `requirements.txt` in the existing bare `package==version` format (one top-level package per line, the same package set as `base_requirements.txt`).
+7. Verify there is no drift between the policy file and the pins:
+
+    ```no-highlight
+    python3 scripts/verify_dependencies.py
+    ```
+
+The published wheel's `Requires-Dist` is generated from `requirements.txt` at build time, so the package installs the same tested pins as the archive and git flows.
 
 ### Update UI Dependencies
 
@@ -143,7 +152,7 @@ Then, compile these portable (`.po`) files for use in the application:
 ### Update Version and Changelog
 
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
-* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
+* No manual `pyproject.toml` version edit is needed: the package version is derived automatically from `release.yaml` (`version` plus any `designation`) by the build backend.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 
 !!! tip
@@ -196,4 +205,25 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
 * **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
 * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
 
-Once created, the release will become available for users to install.
+Once created, the release will become available for users to install from GitHub.
+
+### Publish to Test PyPI
+
+Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
+
+After a publish run completes:
+
+* Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
+* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
+* Confirm that the new version is visible on Test PyPI.
+* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again:
+
+    ```no-highlight
+    pip install -r requirements.txt
+    pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
+    ```
+
+!!! note "Trusted publishing prerequisites"
+    Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
+
+The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.

+ 1 - 0
mkdocs.yml

@@ -331,6 +331,7 @@ nav:
         - Internationalization: 'development/internationalization.md'
         - Translations: 'development/translations.md'
         - Release Checklist: 'development/release-checklist.md'
+        - Building the Package: 'development/building-the-package.md'
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'

+ 107 - 0
netbox/core/management/commands/upgrade.py

@@ -0,0 +1,107 @@
+"""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
+
+
+def _docs_source_root():
+    # mkdocs.yml sits beside the application root in a checkout. Wheels ship the
+    # pre-rendered site instead of the sources: no mkdocs.yml, the build is skipped.
+    candidate = os.path.dirname(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):
+        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):
+        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; the documentation source tree is not available "
+                    "in this installation."
+                ))
+            else:
+                out.write("Building documentation...")
+                # -c cleans the cache; -s (strict) is deliberately omitted so a docs
+                # warning cannot abort an instance upgrade.
+                subprocess.run(['zensical', 'build', '-c'], 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."))

+ 76 - 1
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,7 +9,7 @@ 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
 
 
@@ -315,3 +317,76 @@ class SyncDataSourceTestCase(TestCase):
         self.assertIn('[1] Syncing source-a', out.getvalue())
         self.assertIn('[2] Syncing source-b', out.getvalue())
         self.assertIn('Finished.', out.getvalue())
+
+
+class UpgradeCommandTest(TestCase):
+    """The upgrade command orchestrates the application task sequence for installs and upgrades."""
+
+    def _run(self, **kwargs):
+        out = StringIO()
+        with (
+            patch('core.management.commands.upgrade.call_command') as cc,
+            patch('core.management.commands.upgrade.subprocess.run') as 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()
+        self.assertEqual(seq, [
+            'migrate', 'trace_paths',
+            'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
+        ])
+        sub.assert_not_called()  # docs not built by default
+
+    def test_readonly_skips_all_tasks_including_static(self):
+        """--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)
+        self.assertEqual(
+            seq,
+            ['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
+        )
+
+    def test_build_docs_invokes_zensical_when_sources_present(self):
+        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', '-c'])
+
+    def test_build_docs_skipped_when_sources_absent(self):
+        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('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_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_none_when_absent(self):
+        """No mkdocs.yml beside the application root 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())

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import secrets
+from utilities.secret_key import generate_secret_key
 
-charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
-print(''.join(secrets.choice(charset) for _ in range(50)))
+print(generate_secret_key())

+ 8 - 0
netbox/netbox/__main__.py

@@ -0,0 +1,8 @@
+"""Allow `python -m netbox` to behave like the `netbox` console script."""
+
+import sys
+
+from .cli import main
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 9 - 13
netbox/netbox/authentication/__init__.py

@@ -9,6 +9,7 @@ from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 
+from netbox.settings_utils import load_ldap_config
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import Group, ObjectPermission, User
 from utilities.permissions import (
@@ -344,15 +345,10 @@ class LDAPBackend:
                 )
             raise e
 
-        try:
-            from netbox import ldap_config
-        except ModuleNotFoundError as e:
-            if getattr(e, 'name') == 'ldap_config':
-                raise ImproperlyConfigured(
-                    "LDAP configuration file not found: Check that ldap_config.py has been created alongside "
-                    "configuration.py."
-                )
-            raise e
+        ldap_config = load_ldap_config(
+            settings.CONFIGURATION_DIR,
+            allow_legacy_fallback=settings.NETBOX_INSTALL_MODE == 'checkout',
+        )
 
         try:
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@@ -364,11 +360,11 @@ class LDAPBackend:
         obj = NBLDAPBackend()
 
         # Read LDAP configuration parameters from ldap_config.py instead of settings.py
-        settings = LDAPSettings()
+        ldap_settings = LDAPSettings()
         for param in dir(ldap_config):
-            if param.startswith(settings._prefix):
-                setattr(settings, param[10:], getattr(ldap_config, param))
-        obj.settings = settings
+            if param.startswith(ldap_settings._prefix):
+                setattr(ldap_settings, param[10:], getattr(ldap_config, param))
+        obj.settings = ldap_settings
 
         # Optionally disable strict certificate checking
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):

+ 98 - 0
netbox/netbox/cli.py

@@ -0,0 +1,98 @@
+"""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.
+# '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')
+
+_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} help" (once configured) for the full management command listing."""
+
+
+def _prog():
+    if sys.argv and sys.argv[0]:
+        name = os.path.basename(sys.argv[0])
+        # `python -m netbox` executes __main__.py; show the user-facing name instead.
+        if name != '__main__.py':
+            return name
+    return 'netbox'
+
+
+def _print_version():
+    try:
+        print(version('netbox'))
+    except PackageNotFoundError:  # pragma: no cover - only in a non-installed checkout
+        print('unknown')
+
+
+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)
+
+    # `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.
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line([prog, *args])
+    return 0

+ 3 - 3
netbox/netbox/configuration_example.py

@@ -179,7 +179,7 @@ LOGOUT_REDIRECT_URL = 'home'
 
 # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
 # the default value of this setting is derived from the installed location.
-# MEDIA_ROOT = '/opt/netbox/netbox/media'
+# MEDIA_ROOT = '/path/to/netbox/media'
 
 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
 METRICS_ENABLED = False
@@ -214,14 +214,14 @@ RELEASE_CHECK_URL = None
 
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
-# REPORTS_ROOT = '/opt/netbox/netbox/reports'
+# REPORTS_ROOT = '/path/to/netbox/reports'
 
 # Maximum execution time for background tasks, in seconds.
 RQ_DEFAULT_TIMEOUT = 300
 
 # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
-# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
+# SCRIPTS_ROOT = '/path/to/netbox/scripts'
 
 # The name to use for the session cookie.
 SESSION_COOKIE_NAME = 'sessionid'

+ 88 - 0
netbox/netbox/scaffold.py

@@ -0,0 +1,88 @@
+"""`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
+import sys
+from importlib.resources import files
+from pathlib import Path
+
+
+def _bundled_data_dir():
+    return files('netbox') / '_data'
+
+
+def _config_template():
+    return files('netbox') / 'configuration_example.py'
+
+
+def _contrib_dir():
+    return files('netbox') / '_data' / 'contrib'
+
+
+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)
+    print(f'Wrote {destination}')
+    return True
+
+
+def scaffold_instance(target):
+    target = Path(target)
+    conf = target / 'conf'
+    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=(
+            '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).')
+    args = parser.parse_args(argv)
+
+    if not _bundled_data_dir().is_dir():
+        print(
+            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}')")
+
+    scaffold_instance(args.target)
+    return 0

+ 32 - 20
netbox/netbox/settings.py

@@ -18,6 +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 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
@@ -30,8 +31,18 @@ from .monkey import get_unique_validators
 
 RELEASE = load_release_data()
 VERSION = RELEASE.full_version  # Retained for backward compatibility
-# Set the base directory two levels up
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# Settings package directory (settings.py lives here in both checkout & wheel).
+_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
+
+# 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
@@ -43,17 +54,15 @@ if sys.version_info < (3, 12):  # noqa: UP036
 # Configuration import
 #
 
-# Import the configuration module
-config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration')
-try:
-    configuration = importlib.import_module(config_path)
-except ModuleNotFoundError as e:
-    if getattr(e, 'name') == config_path:
-        raise ImproperlyConfigured(
-            f"Specified configuration module ({config_path}) not found. Please define netbox/netbox/configuration.py "
-            f"per the documentation, or specify an alternate module in the NETBOX_CONFIGURATION environment variable."
-        )
-    raise
+# Import the configuration module (wheel mode prefers NETBOX_ROOT/conf/configuration.py).
+configuration = load_configuration(
+    install_mode=NETBOX_INSTALL_MODE,
+    install_root=NETBOX_ROOT,
+    environ=os.environ,
+)
+
+# The directory holding the active configuration.py; ldap_config.py lives beside it.
+CONFIGURATION_DIR = get_configuration_dir(configuration)
 
 # Check for missing/conflicting required configuration parameters
 for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@@ -119,7 +128,7 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'users.delete_token': ({'user': '$user'},),
 })
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
-DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
+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', [
@@ -150,7 +159,7 @@ LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
-MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
+MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media')).rstrip('/')
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
@@ -175,7 +184,7 @@ REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_
 REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
 REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
 # Required by extras/migrations/0109_script_models.py
-REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
+REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(NETBOX_ROOT, 'reports')).rstrip('/')
 RQ = getattr(configuration, 'RQ', {})
 if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
     warnings.warn(
@@ -187,7 +196,7 @@ else:
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
-SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts')).rstrip('/')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')  # Required
 SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
@@ -232,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
@@ -594,13 +603,16 @@ USE_X_FORWARDED_HOST = True
 X_FRAME_OPTIONS = 'SAMEORIGIN'
 
 # Static files (CSS, JavaScript, Images)
-STATIC_ROOT = BASE_DIR + '/static'
+# STATIC_ROOT is deliberately not a configuration parameter; static files are collected to <NETBOX_ROOT>/static.
+STATIC_ROOT = os.path.join(NETBOX_ROOT, 'static')
 STATIC_URL = f'/{BASE_PATH}static/'
 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'),
-    ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')),  # Prefix with /docs
+    # May not exist on a checkout until `manage.py upgrade --build-docs` runs (wheels bundle
+    # the pre-rendered site); collectstatic tolerates that.
+    ('docs', _PATHS.static_docs_root),  # Prefix with /docs
 )
 
 # Media URL

+ 204 - 0
netbox/netbox/settings_utils.py

@@ -0,0 +1,204 @@
+"""Startup helpers for settings.py. Import-safe: no Django settings access at import time."""
+
+import importlib
+import importlib.util
+import os
+import sys
+import warnings
+from typing import NamedTuple
+
+from django.core.exceptions import ImproperlyConfigured
+
+__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 on a checkout, the pre-rendered site in a wheel (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 pre-rendered documentation site)
+    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')
+        # The wheel bundles the pre-rendered documentation site at _data/docs; it serves as
+        # both the DOCS_ROOT default and the STATICFILES 'docs' prefix source.
+        static_docs_root = docs_root
+    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):
+    """Import a configuration module by dotted path.
+
+    Preserve NetBox's historical behavior: a friendly ImproperlyConfigured when the module
+    itself is absent, but re-raise the original error when the module exists yet imports
+    something else that is missing.
+    """
+    try:
+        return importlib.import_module(name)
+    except ModuleNotFoundError as e:
+        if e.name == name:
+            raise ImproperlyConfigured(
+                f"Specified configuration module ({name}) not found. Please define "
+                f"netbox/netbox/configuration.py per the documentation, or specify an alternate "
+                f"module in the NETBOX_CONFIGURATION environment variable."
+            )
+        raise
+
+
+def _import_from_path(module_name, path):
+    """Load a configuration module from an explicit file path.
+
+    The module is registered in sys.modules (and removed again if execution fails), and the
+    file's directory is placed on sys.path for the duration of execution so the module can
+    import siblings, matching normal import semantics closely enough for configuration files.
+    """
+    path = os.path.abspath(path)
+    module_dir = os.path.dirname(path)
+    spec = importlib.util.spec_from_file_location(module_name, path)
+    if spec is None or spec.loader is None:
+        raise ImproperlyConfigured(f"Unable to load configuration file {path}")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[module_name] = module
+    sys.path.insert(0, module_dir)
+    try:
+        spec.loader.exec_module(module)
+    except Exception:
+        if sys.modules.get(module_name) is module:
+            del sys.modules[module_name]
+        raise
+    finally:
+        # 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 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
+
+
+def load_configuration(*, install_mode, install_root, environ):
+    """Import and return NetBox's configuration module.
+
+    An explicit NETBOX_CONFIGURATION module always wins. In wheel mode, prefer
+    <install_root>/conf/configuration.py, loaded by file path (so a stale source tree at
+    <install_root>/netbox cannot shadow it and no generic 'configuration' module is left in
+    sys.modules), then
+    fall back to the legacy <install_root>/netbox/netbox/configuration.py with a migration
+    warning. In checkout mode, keep the historical default module.
+    """
+    explicit = environ.get('NETBOX_CONFIGURATION')
+    if explicit:
+        return _import_module(explicit)
+
+    if install_mode == 'wheel':
+        conf_dir = os.path.join(install_root, 'conf')
+        preferred = os.path.join(conf_dir, 'configuration.py')
+        legacy = os.path.join(install_root, 'netbox', 'netbox', 'configuration.py')
+        if os.path.isfile(preferred):
+            if os.path.isfile(legacy):
+                warnings.warn(
+                    f"Both {preferred} and the legacy {legacy} exist; using {preferred} and "
+                    f"ignoring the legacy file.",
+                    RuntimeWarning,
+                )
+            return _import_from_path('netbox_local_configuration', preferred)
+        if os.path.isfile(legacy):
+            warnings.warn(
+                f"Loaded NetBox configuration from the legacy source-tree path {legacy}. For a "
+                f"pip-installed NetBox, move it to {preferred}.",
+                RuntimeWarning,
+            )
+            return _import_from_path('netbox_legacy_configuration', legacy)
+        raise ImproperlyConfigured(
+            f"No NetBox configuration found. For a pip-installed NetBox, create {preferred}, "
+            f"or set NETBOX_CONFIGURATION to an importable module."
+        )
+
+    return _import_module('netbox.configuration')
+
+
+def load_ldap_config(config_dir, *, allow_legacy_fallback=False):
+    """Load ldap_config.py from the active configuration directory (settings.CONFIGURATION_DIR).
+
+    One rule for every install method: the active ldap_config.py is the one next to the
+    active configuration.py. Checkout installs may additionally allow a legacy fallback to
+    the historical netbox/netbox/ldap_config.py module, because a custom NETBOX_CONFIGURATION
+    can live outside the source tree while LDAP config stayed inside it; the fallback warns
+    so those installs can migrate to the sibling rule.
+    """
+    path = os.path.join(config_dir, 'ldap_config.py') if config_dir else None
+    if path and os.path.isfile(path):
+        return _import_from_path('netbox.ldap_config', path)
+    if allow_legacy_fallback:
+        try:
+            module = importlib.import_module('netbox.ldap_config')
+        except ModuleNotFoundError as e:
+            if e.name != 'netbox.ldap_config':
+                raise
+        else:
+            warnings.warn(
+                "Loaded LDAP configuration from the legacy netbox/netbox/ldap_config.py module. "
+                "Move ldap_config.py into the directory containing the active configuration.py; "
+                "this fallback may be removed in a future release.",
+                RuntimeWarning,
+            )
+            return module
+    if not config_dir:
+        raise ImproperlyConfigured(
+            "LDAP configuration file not found: unable to determine the directory containing "
+            "configuration.py."
+        )
+    raise ImproperlyConfigured(
+        "LDAP configuration file not found: Check that ldap_config.py has been created "
+        "alongside configuration.py. For a pip-installed NetBox, this is "
+        "NETBOX_ROOT/conf/ldap_config.py."
+    )

+ 41 - 1
netbox/netbox/tests/test_authentication.py

@@ -1,5 +1,7 @@
 import datetime
-from unittest.mock import MagicMock
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
 
 from django.conf import settings
 from django.contrib.messages.storage.fallback import FallbackStorage
@@ -13,6 +15,7 @@ from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile, ObjectType
 from dcim.models import Rack, Site
 from extras.models import ScriptModule
+from netbox.authentication import LDAPBackend
 from netbox.authentication.misc import _mirror_groups
 from netbox.middleware import SocialAuthExceptionMiddleware
 from users.constants import TOKEN_PREFIX
@@ -562,6 +565,43 @@ class LDAPMirrorGroupsTestCase(TestCase):
         )
 
 
+class LDAPBackendTest(SimpleTestCase):
+    """The LDAP backend reads ldap_config.py from the active configuration directory."""
+
+    def test_backend_loads_ldap_config_from_configuration_dir(self):
+        with override_settings(CONFIGURATION_DIR='/srv/netbox/conf', NETBOX_INSTALL_MODE='checkout'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/srv/netbox/conf', allow_legacy_fallback=True)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def test_backend_disables_legacy_fallback_for_wheel_installs(self):
+        with override_settings(CONFIGURATION_DIR='/opt/netbox/conf', NETBOX_INSTALL_MODE='wheel'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/opt/netbox/conf', allow_legacy_fallback=False)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def _build_backend(self):
+        fake_ldap = ModuleType('ldap')
+        fake_ldap.set_option = MagicMock()
+        backend_module = ModuleType('django_auth_ldap.backend')
+        backend_module.LDAPSettings = type('LDAPSettings', (), {'_prefix': 'AUTH_LDAP_'})
+        package = ModuleType('django_auth_ldap')
+        package.backend = backend_module
+        ldap_config = ModuleType('netbox.ldap_config')
+        ldap_config.AUTH_LDAP_SERVER_URI = 'ldaps://example'
+        with (
+            patch.dict(sys.modules, {
+                'ldap': fake_ldap,
+                'django_auth_ldap': package,
+                'django_auth_ldap.backend': backend_module,
+            }),
+            patch('netbox.authentication.NBLDAPBackend', MagicMock(), create=True),
+            patch('netbox.authentication.load_ldap_config', return_value=ldap_config) as loader,
+        ):
+            backend = LDAPBackend()
+        return backend, loader
+
+
 class ObjectPermissionAPIViewTestCase(TestCase):
     client_class = APIClient
 

+ 131 - 0
netbox/netbox/tests/test_cli.py

@@ -0,0 +1,131 @@
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import cli
+
+
+class CliDispatchTest(SimpleTestCase):
+    """The console script resolves pre-configuration commands before importing Django."""
+
+    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, stdout.getvalue(), stderr.getvalue()
+
+    def test_version_flag_prints_version_without_django(self):
+        with patch('netbox.cli.version', return_value='4.7.0b1'):
+            rc, execute, out, _ = self._main(['--version'])
+        execute.assert_not_called()
+        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'):
+            rc, execute, out, _ = self._main(['version'])
+        execute.assert_not_called()
+        self.assertEqual(out, '4.7.0b1\n')
+        self.assertEqual(rc, 0)
+
+    def test_version_help(self):
+        rc, execute, out, _ = self._main(['version', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox version', out)
+
+    def test_version_rejects_unexpected_arguments(self):
+        rc, execute, _, err = self._main(['version', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        self.assertIn('unrecognized arguments: bogus', err)
+
+    def test_no_arguments_prints_help_without_django(self):
+        rc, execute, out, _ = self._main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 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):
+                rc, execute, out, _ = self._main([flag])
+                execute.assert_not_called()
+                self.assertEqual(rc, 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):
+        rc, execute, out, _ = self._main(['secret-key'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertEqual(len(out.strip()), 50)
+
+    def test_secret_key_help(self):
+        rc, execute, out, _ = self._main(['secret-key', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox secret-key', out)
+
+    def test_secret_key_rejects_unexpected_arguments(self):
+        rc, execute, _, err = self._main(['secret-key', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        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'])
+        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'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 3)
+
+    def test_other_commands_dispatch_to_django(self):
+        with (
+            patch.object(cli.sys, 'argv', ['/opt/netbox/venv/bin/netbox', 'check', '--deploy']),
+            patch.dict(cli.os.environ),
+            patch('django.core.management.execute_from_command_line') as execute,
+        ):
+            cli.os.environ.pop('DJANGO_SETTINGS_MODULE', None)
+            rc = cli.main(['check', '--deploy'])
+            self.assertEqual(cli.os.environ['DJANGO_SETTINGS_MODULE'], 'netbox.settings')
+        execute.assert_called_once_with(['netbox', 'check', '--deploy'])
+        self.assertEqual(rc, 0)
+
+    def test_subcommand_help_falls_through_to_django(self):
+        rc, execute, _, _ = self._main(['migrate', '--help'])
+        execute.assert_called_once_with(['netbox', 'migrate', '--help'])
+        self.assertEqual(rc, 0)
+
+    def test_prog_falls_back_for_python_m_invocation(self):
+        with patch('netbox.scaffold.main', return_value=0) as setup_main:
+            self._main(['setup'], argv0='/x/netbox/__main__.py')
+        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,
+            redirect_stdout(out),
+        ):
+            rc = cli.main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox', out.getvalue())

+ 150 - 0
netbox/netbox/tests/test_scaffold.py

@@ -0,0 +1,150 @@
+import contextlib
+import tempfile
+from io import StringIO
+from pathlib import Path
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import scaffold
+
+_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 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'),
+        )
+        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):
+        """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):
+        """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'),
+        )
+
+    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_fails_friendly_without_bundled_data(self):
+        """Without bundled package data, main() reports rc 1 and a friendly stderr message."""
+        err = StringIO()
+        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'))

+ 281 - 0
netbox/netbox/tests/test_settings_utils.py

@@ -0,0 +1,281 @@
+import os
+import sys
+import tempfile
+from types import ModuleType
+from unittest.mock import patch
+
+from django.conf import settings as django_settings
+from django.core.exceptions import ImproperlyConfigured
+from django.test import SimpleTestCase
+
+from netbox import settings_utils
+
+
+class LoadConfigurationTest(SimpleTestCase):
+    def test_explicit_module_wins(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='wheel', install_root='/opt/netbox',
+                environ={'NETBOX_CONFIGURATION': 'my.config'},
+            )
+        import_module.assert_called_once_with('my.config')
+
+    def test_checkout_uses_default_module(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='checkout', install_root='/repo', environ={},
+            )
+        import_module.assert_called_once_with('netbox.configuration')
+
+    def test_checkout_missing_module_raises_improperly_configured(self):
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'netbox.configuration'", name='netbox.configuration'),
+        ):
+            with self.assertRaises(ImproperlyConfigured):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo', environ={},
+                )
+
+    def test_wheel_prefers_conf_dir(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            saved = list(sys.path)
+            try:
+                with patch('netbox.settings_utils._import_from_path') as import_from_path:
+                    settings_utils.load_configuration(
+                        install_mode='wheel', install_root=root, environ={},
+                    )
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+                self.assertEqual(sys.path, saved)
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_falls_back_to_legacy_with_warning(self):
+        with tempfile.TemporaryDirectory() as root:
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            legacy = os.path.join(legacy_dir, 'configuration.py')
+            open(legacy, 'w').close()
+            with (
+                patch('netbox.settings_utils._import_from_path') as importer,
+                self.assertWarns(RuntimeWarning),
+            ):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+            self.assertEqual(importer.call_args.args[1], legacy)
+
+    def test_wheel_missing_configuration_raises(self):
+        with tempfile.TemporaryDirectory() as root:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'conf/configuration.py'):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+
+    def test_explicit_module_reraises_other_import_error(self):
+        # A missing dependency of the config module must propagate, not become a friendly error.
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+        ):
+            with self.assertRaises(ModuleNotFoundError):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo',
+                    environ={'NETBOX_CONFIGURATION': 'my.config'},
+                )
+
+    def test_import_from_path_loads_module_and_restores_sys_path(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'legacy_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_legacy_cfg', None)
+            saved = list(sys.path)
+            module = settings_utils._import_from_path('netbox_test_legacy_cfg', path)
+            self.assertEqual(module.ALLOWED_HOSTS, ['example'])
+            self.assertEqual(sys.path, saved)
+            self.assertIs(sys.modules['netbox_test_legacy_cfg'], module)
+
+    def test_import_from_path_removes_module_on_failure(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'broken_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('raise RuntimeError("Simulated configuration error")\n')
+            with self.assertRaisesMessage(RuntimeError, 'Simulated configuration error'):
+                settings_utils._import_from_path('netbox_test_broken_cfg', path)
+            self.assertNotIn('netbox_test_broken_cfg', sys.modules)
+
+    def test_import_from_path_rejects_unloadable_path(self):
+        # A suffix-less file yields no loader; the helper must fail cleanly.
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'noext')
+            open(path, 'w').close()
+            with self.assertRaisesMessage(ImproperlyConfigured, 'Unable to load'):
+                settings_utils._import_from_path('netbox_test_noext_cfg', path)
+
+    def test_import_from_path_preserves_preexisting_sys_path_entry(self):
+        # 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:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_preexisting_cfg', None)
+            saved = list(sys.path)
+            sys.path.append(root)
+            try:
+                settings_utils._import_from_path('netbox_test_preexisting_cfg', path)
+                self.assertEqual(sys.path, saved + [root])
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_both_configs_present_warns_and_prefers_conf(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            open(os.path.join(legacy_dir, 'configuration.py'), 'w').close()
+            saved = list(sys.path)
+            try:
+                with (
+                    patch('netbox.settings_utils._import_from_path') as import_from_path,
+                    self.assertWarns(RuntimeWarning),
+                ):
+                    settings_utils.load_configuration(install_mode='wheel', install_root=root, environ={})
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+            finally:
+                sys.path[:] = saved
+
+
+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.get_configuration_dir(module), '/srv/netbox/conf')
+
+    def test_returns_none_without_file(self):
+        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'))
+            self.assertEqual(paths.static_docs_root, os.path.join(base_dir, '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):
+    def test_loads_sibling_ldap_config(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://example"\n')
+            self.addCleanup(sys.modules.pop, 'netbox.ldap_config', None)
+            module = settings_utils.load_ldap_config(conf_dir)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://example')
+            self.assertIs(sys.modules['netbox.ldap_config'], module)
+
+    def test_legacy_fallback_loads_historical_module_with_warning(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}), self.assertWarns(RuntimeWarning):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+        self.assertIs(module, legacy)
+
+    def test_legacy_fallback_prefers_sibling_file(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://sibling"\n')
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://sibling')
+
+    def test_legacy_fallback_disabled_raises(self):
+        legacy = ModuleType('netbox.ldap_config')
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir)
+
+    def test_legacy_fallback_missing_module_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'netbox.ldap_config'", name='netbox.ldap_config'),
+            ):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_legacy_fallback_reraises_broken_dependency(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+            ):
+                with self.assertRaises(ModuleNotFoundError):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_none_config_dir_raises(self):
+        with self.assertRaisesMessage(ImproperlyConfigured, 'unable to determine'):
+            settings_utils.load_ldap_config(None)
+
+    def test_missing_file_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'ldap_config.py'):
+                settings_utils.load_ldap_config(conf_dir)
+
+    def test_configuration_dir_setting_matches_active_configuration(self):
+        from netbox import configuration_testing
+        self.assertEqual(
+            django_settings.CONFIGURATION_DIR,
+            os.path.dirname(os.path.abspath(configuration_testing.__file__)),
+        )

+ 24 - 1
netbox/utilities/release.py

@@ -1,4 +1,5 @@
 import datetime
+import importlib.util
 import os
 from dataclasses import asdict, dataclass, field
 
@@ -11,6 +12,28 @@ RELEASE_PATH = 'release.yaml'
 LOCAL_RELEASE_PATH = 'local/release.yaml'
 
 
+def _find_release_base_path():
+    """
+    Return the directory containing release.yaml.
+
+    In a source checkout, release.yaml lives under the NetBox application root
+    beside manage.py. In a wheel install, release.yaml is bundled under the
+    installed netbox package's _data directory.
+    """
+    checkout_base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    if os.path.isfile(os.path.join(checkout_base_path, RELEASE_PATH)):
+        return checkout_base_path
+
+    spec = importlib.util.find_spec('netbox')  # pragma: no cover
+    locations = spec.submodule_search_locations if spec is not None else ()  # pragma: no cover
+    for location in locations or ():  # pragma: no cover
+        bundled_base_path = os.path.join(location, '_data')
+        if os.path.isfile(os.path.join(bundled_base_path, RELEASE_PATH)):
+            return bundled_base_path
+
+    raise ImproperlyConfigured(f"Unable to locate {RELEASE_PATH} for this NetBox installation.")  # pragma: no cover
+
+
 @dataclass
 class FeatureSet:
     """
@@ -53,7 +76,7 @@ def load_release_data():
     """
     Load any locally-defined release attributes and return a ReleaseInfo instance.
     """
-    base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    base_path = _find_release_base_path()
 
     # Load canonical release attributes
     with open(os.path.join(base_path, RELEASE_PATH)) as release_file:

+ 14 - 0
netbox/utilities/secret_key.py

@@ -0,0 +1,14 @@
+"""Generate SECRET_KEY values. Import-safe (stdlib only), so it works before Django or a NetBox
+configuration exists: backs `netbox secret-key` and the netbox/generate_secret_key.py script."""
+
+import secrets
+
+__all__ = ('SECRET_KEY_CHARSET', 'SECRET_KEY_LENGTH', 'generate_secret_key')
+
+SECRET_KEY_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
+SECRET_KEY_LENGTH = 50
+
+
+def generate_secret_key():
+    """Return a random 50-character string suitable for SECRET_KEY or an API token pepper."""
+    return ''.join(secrets.choice(SECRET_KEY_CHARSET) for _ in range(SECRET_KEY_LENGTH))

+ 29 - 0
netbox/utilities/tests/test_release.py

@@ -0,0 +1,29 @@
+import os
+from unittest.mock import patch
+
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase
+
+from utilities.release import RELEASE_PATH, _find_release_base_path, load_release_data
+
+
+class ReleaseDataTestCase(TestCase):
+    def test_find_release_base_path_locates_release_yaml(self):
+        """The resolved base path contains release.yaml in a source checkout."""
+        base_path = _find_release_base_path()
+        self.assertTrue(os.path.isfile(os.path.join(base_path, RELEASE_PATH)))
+
+    def test_find_release_base_path_raises_when_release_yaml_missing(self):
+        """Neither a checkout release.yaml nor a bundled _data copy resolves."""
+        with (
+            patch('utilities.release.os.path.isfile', return_value=False),
+            patch('utilities.release.importlib.util.find_spec', return_value=None),
+        ):
+            with self.assertRaisesMessage(ImproperlyConfigured, RELEASE_PATH):
+                _find_release_base_path()
+
+    def test_load_release_data_returns_version(self):
+        """Release data loads and exposes a non-empty version string."""
+        release = load_release_data()
+        self.assertTrue(release.version)
+        self.assertTrue(release.full_version)

+ 19 - 0
netbox/utilities/tests/test_secret_key.py

@@ -0,0 +1,19 @@
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from utilities.secret_key import SECRET_KEY_CHARSET, SECRET_KEY_LENGTH, generate_secret_key
+
+
+class GenerateSecretKeyTest(SimpleTestCase):
+    def test_generate_secret_key_length(self):
+        self.assertEqual(SECRET_KEY_LENGTH, 50)
+        self.assertEqual(len(generate_secret_key()), SECRET_KEY_LENGTH)
+
+    def test_generate_secret_key_uses_only_charset_characters(self):
+        self.assertTrue(set(generate_secret_key()) <= set(SECRET_KEY_CHARSET))
+
+    def test_generate_secret_key_draws_every_character_via_secrets_choice(self):
+        with patch('utilities.secret_key.secrets.choice', side_effect=lambda charset: charset[0]) as choice:
+            self.assertEqual(generate_secret_key(), SECRET_KEY_CHARSET[0] * SECRET_KEY_LENGTH)
+        self.assertEqual(choice.call_count, SECRET_KEY_LENGTH)

+ 132 - 1
pyproject.toml

@@ -1,9 +1,13 @@
 # See PEP 518 for the spec of this file
 # https://www.python.org/dev/peps/pep-0518/
 
+[build-system]
+requires = ["hatchling>=1.27", "packaging"]
+build-backend = "hatchling.build"
+
 [project]
 name = "netbox"
-version = "4.6.4"
+dynamic = ["version", "dependencies"]
 requires-python = ">=3.12"
 description = "The premier source of truth powering network automation."
 readme = "README.md"
@@ -20,6 +24,39 @@ classifiers = [
     "Programming Language :: Python :: 3.14",
 ]
 
+[project.optional-dependencies]
+ldap = ["django-auth-ldap"]
+saml2 = ["python3-saml"]
+remote-auth = ["django-auth-ldap", "python3-saml"]
+sentry = ["sentry-sdk"]
+swift = ["django-storage-swift"]
+s3 = ["boto3"]
+git = ["dulwich"]
+# NetBox Labs plugins (proprietary, opt-in). Minor-bounded to the tested series.
+branching = ["netboxlabs-netbox-branching>=1.1.0,<1.2.0"]
+custom-objects = ["netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0"]
+# Convenience aggregate of the recommended NetBox Labs plugins (NOT a catch-all of every extra).
+# The component pins are duplicated literally, as remote-auth duplicates ldap and saml2;
+# scripts/verify_wheel_metadata.py verifies in CI that this aggregate equals the union of the
+# branching and custom-objects extras, guarding the duplication against drift.
+recommended-plugins = [
+    "netboxlabs-netbox-branching>=1.1.0,<1.2.0",
+    "netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0",
+]
+dev = [
+    "build",
+    "coverage",
+    "packaging",
+    "pre-commit",
+    "ruff==0.15.20",
+    "tblib",
+    "twine",
+    "uv",
+]
+
+[project.scripts]
+netbox = "netbox.cli:main"
+
 [project.urls]
 Homepage = "https://netboxlabs.com/products/netbox/"
 Documentation = "https://netboxlabs.com/docs/netbox/"
@@ -42,11 +79,105 @@ omit = [
     "netbox/scripts/*",  # SCRIPTS_ROOT: user/generated scripts
     "*/netbox/configuration*.py",  # settings/config files (template, testing, local)
     "*/netbox/wsgi.py",  # WSGI entrypoint
+    "*/netbox/__main__.py",  # `python -m netbox` entry point
     "*/generate_secret_key.py",  # standalone CLI helper
     "*/utilities/debug.py",  # debug-toolbar hook, active only when DEBUG=True
     "*/extras/management/commands/housekeeping.py",  # deprecated; will not be tested
 ]
 
+[tool.hatch.metadata.hooks.custom]
+# Implemented in scripts/packaging/hatch_metadata.py; computes a PEP 440 version from
+# netbox/release.yaml (version + optional designation) and runtime deps from requirements.txt.
+path = "scripts/packaging/hatch_metadata.py"
+
+[tool.hatch.build.targets.wheel]
+sources = ["netbox"]
+packages = [
+    "netbox/account",
+    "netbox/circuits",
+    "netbox/core",
+    "netbox/dcim",
+    "netbox/extras",
+    "netbox/ipam",
+    "netbox/netbox",
+    "netbox/tenancy",
+    "netbox/users",
+    "netbox/utilities",
+    "netbox/virtualization",
+    "netbox/vpn",
+    "netbox/wireless",
+]
+
+# Never ship a live or local configuration file: configuration.py holds SECRET_KEY
+# and database credentials, ldap_config.py holds LDAP bind credentials, and developers
+# often keep ad-hoc configuration*.py copies in this directory. Exclude both sets, then
+# re-include only the two tracked templates. (verify_wheel_contents.py enforces this in CI.)
+exclude = [
+    "netbox/netbox/configuration*.py",
+    "netbox/netbox/ldap_config*.py",
+]
+
+# Bundle runtime data inside the installed netbox package at netbox/_data/.
+# Destinations carry an extra leading "netbox/" because the wheel `sources`
+# 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/project-static/dist" = "netbox/netbox/_data/project-static/dist"
+# The pre-rendered documentation site ships in the wheel; render it with `zensical build`
+# before `python -m build` (the release workflow's build job does; see
+# docs/development/building-the-package.md for local builds).
+"netbox/project-static/docs" = "netbox/netbox/_data/docs"
+"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"
+"netbox/templates" = "netbox/netbox/_data/templates"
+"netbox/translations" = "netbox/netbox/_data/translations"
+# 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"
+
+[tool.hatch.build.targets.sdist]
+include = [
+    "/.github/workflows/release.yml",
+    "/CHANGELOG.md",
+    "/LICENSE.txt",
+    "/README.md",
+    "/base_requirements.txt",
+    "/contrib",
+    "/docs",
+    "/mkdocs.yml",
+    "/netbox",
+    "/pyproject.toml",
+    "/requirements.txt",
+    "/scripts",
+    "/upgrade.sh",
+]
+# Keep live/local configuration*.py and ldap_config*.py out of the sdist (same rule as the
+# wheel, keeping only the two tracked templates), and drop the local netbox/configuration.py
+# symlink that otherwise breaks `python -m build --sdist` with an AbsoluteLinkError.
+exclude = [
+    "netbox/netbox/configuration*.py",
+    "netbox/netbox/ldap_config*.py",
+    "netbox/configuration.py",
+    "netbox/ldap_config.py",
+]
+
+[tool.hatch.build.targets.sdist.force-include]
+# 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"
+# The rendered site is gitignored, so the plain /netbox include skips it; force it in so a
+# wheel rebuilt from the sdist (verify-sdist job, `pip install <sdist>`) ships the docs.
+"netbox/project-static/docs" = "netbox/project-static/docs"
+
 [tool.pyright]
 include = ["netbox"]
 exclude = [

+ 63 - 0
scripts/packaging/hatch_metadata.py

@@ -0,0 +1,63 @@
+"""Hatchling metadata hook: derive a PEP 440 version from netbox/release.yaml.
+
+NetBox stores the release version and any pre-release designation (for example
+"beta1") separately in netbox/release.yaml. This hook combines them into a
+canonical PEP 440 version so wheels publish correctly, including pre-releases.
+"""
+
+import re
+from pathlib import Path
+
+from packaging.version import Version
+
+try:
+    from hatchling.metadata.plugin.interface import MetadataHookInterface
+except ModuleNotFoundError:
+    # hatchling is only installed inside the isolated build environment. Fall back
+    # so this module (and compute_version) remains importable for unit testing.
+    MetadataHookInterface = object
+
+
+def compute_version(version, designation):
+    """Return a canonical PEP 440 version from a version and optional designation."""
+    raw = f"{version}{designation}" if designation else version
+    return str(Version(raw))
+
+
+def _read_release_field(text, field):
+    match = re.search(rf'^{field}:\s*"?([^"\n]+?)"?\s*$', text, re.MULTILINE)
+    return match.group(1).strip() if match else None
+
+
+def read_requirements(text):
+    """Parse a pinned requirements.txt body into PEP 508 dependency specifiers.
+
+    Assumes NetBox's flat "package==version" format (one top-level pin per line).
+    Blank lines, comments, and pip option lines (starting with "-") are skipped.
+    """
+    dependencies = []
+    for raw_line in text.splitlines():
+        line = raw_line.split('#', 1)[0].strip()
+        if not line or line.startswith('-'):
+            continue
+        dependencies.append(line)
+    return dependencies
+
+
+class NetBoxMetadataHook(MetadataHookInterface):
+    def update(self, metadata):
+        root = Path(self.root)
+
+        # Version: derived from release.yaml (version + optional designation).
+        release_path = root / 'netbox' / 'release.yaml'
+        text = release_path.read_text()
+        version = _read_release_field(text, 'version')
+        if not version:
+            raise ValueError(f"Unable to read 'version' from {release_path}")
+        designation = _read_release_field(text, 'designation')
+        metadata['version'] = compute_version(version, designation)
+
+        # Dependencies: requirements.txt is the single pinned source of truth, so the
+        # published wheel's Requires-Dist matches the tested pins, not loose ranges.
+        requirements_path = root / 'requirements.txt'
+        metadata['dependencies'] = read_requirements(requirements_path.read_text())

+ 51 - 0
scripts/smoketest_configuration.py

@@ -0,0 +1,51 @@
+"""Minimal NetBox configuration used by the wheel-install smoke test."""
+
+import os
+from pathlib import Path
+
+BASE_DIR = Path(os.getenv('NETBOX_SMOKETEST_BASE', '/tmp/netbox-smoketest'))
+BASE_DIR.mkdir(parents=True, exist_ok=True)
+
+ALLOWED_HOSTS = ['*']
+API_TOKEN_PEPPERS = {
+    1: os.getenv('NETBOX_SMOKETEST_API_TOKEN_PEPPER', 'a' * 50),
+}
+SECRET_KEY = os.getenv('NETBOX_SMOKETEST_SECRET_KEY', 'b' * 50)
+
+DATABASES = {
+    'default': {
+        'NAME': os.getenv('POSTGRES_DB', 'netbox'),
+        'USER': os.getenv('POSTGRES_USER', 'netbox'),
+        'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'netbox'),
+        'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
+        'PORT': os.getenv('POSTGRES_PORT', '5432'),
+        'CONN_MAX_AGE': 0,
+    }
+}
+
+REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
+REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
+REDIS = {
+    'tasks': {
+        'HOST': REDIS_HOST,
+        'PORT': REDIS_PORT,
+        'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
+        'DATABASE': int(os.getenv('REDIS_TASKS_DATABASE', '0')),
+        'SSL': False,
+    },
+    'caching': {
+        'HOST': REDIS_HOST,
+        'PORT': REDIS_PORT,
+        'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
+        'DATABASE': int(os.getenv('REDIS_CACHING_DATABASE', '1')),
+        'SSL': False,
+    },
+}
+
+# STATIC_ROOT is fixed to <NETBOX_ROOT>/static; the workflow sets NETBOX_ROOT to the scratch base.
+MEDIA_ROOT = str(BASE_DIR / 'media')
+REPORTS_ROOT = str(BASE_DIR / 'reports')
+SCRIPTS_ROOT = str(BASE_DIR / 'scripts')
+
+for path in (MEDIA_ROOT, REPORTS_ROOT, SCRIPTS_ROOT):
+    Path(path).mkdir(parents=True, exist_ok=True)

+ 79 - 0
scripts/verify_dependencies.py

@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""Verify requirements.txt is consistent with base_requirements.txt.
+
+Guards against dependency drift before publishing. The wheel sources its pinned
+dependencies from requirements.txt, which must stay in sync with the maintainer
+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 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 line in hatch_metadata.read_requirements(Path(path).read_text()):
+        req = Requirement(line)
+        reqs[req.name.lower().replace('_', '-')] = req
+    return reqs
+
+
+def check(base, pinned):
+    errors = []
+    only_base = sorted(set(base) - set(pinned))
+    only_pinned = sorted(set(pinned) - set(base))
+    if only_base:
+        errors.append(f"In base_requirements.txt but not requirements.txt: {only_base}")
+    if only_pinned:
+        errors.append(f"In requirements.txt but not base_requirements.txt: {only_pinned}")
+    for name in sorted(set(base) & set(pinned)):
+        if base[name].extras != pinned[name].extras:
+            errors.append(
+                f"{name}: extras differ (base {sorted(base[name].extras)} vs "
+                f"requirements.txt {sorted(pinned[name].extras)})"
+            )
+        spec = pinned[name].specifier
+        if not spec or not all(s.operator == '==' for s in spec):
+            errors.append(f"{name}: requirements.txt must pin exactly (got '{pinned[name]}')")
+            continue
+        version = next(iter(spec)).version
+        if not base[name].specifier.contains(version, prereleases=True):
+            errors.append(f"{name}: pinned {version} violates base constraint '{base[name].specifier}'")
+    return errors
+
+
+def main():
+    root = Path(__file__).resolve().parent.parent
+    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:
+            print(f"  - {error}")
+        return 1
+    print("OK: requirements.txt is consistent with base_requirements.txt")
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 45 - 0
scripts/verify_release_tag.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Verify a git tag matches the built wheel's version before publishing.
+
+Usage: verify_release_tag.py <git-ref-or-tag> <wheel>
+
+Normalizes the tag (strips a leading 'v' and PEP 440-normalizes it, so v4.7.0-beta1
+becomes 4.7.0b1) and asserts it equals the wheel's Version metadata.
+"""
+
+import sys
+import zipfile
+from email.parser import Parser
+
+from packaging.version import Version
+
+
+def wheel_version(wheel_path):
+    with zipfile.ZipFile(wheel_path) as archive:
+        meta_name = next(name for name in archive.namelist() if name.endswith('.dist-info/METADATA'))
+        metadata = Parser().parsestr(archive.read(meta_name).decode())
+    return metadata['Version']
+
+
+def normalize_tag(ref):
+    tag = ref.rsplit('/', 1)[-1]
+    if tag.startswith('v'):
+        tag = tag[1:]
+    return str(Version(tag))
+
+
+def main(argv):
+    if len(argv) != 3:
+        print('usage: verify_release_tag.py <git-ref-or-tag> <wheel>')
+        return 2
+    tag_version = normalize_tag(argv[1])
+    built_version = str(Version(wheel_version(argv[2])))
+    if tag_version != built_version:
+        print(f'Tag/version mismatch: tag -> {tag_version}, wheel -> {built_version}')
+        return 1
+    print(f'OK: tag matches wheel version ({built_version})')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 56 - 0
scripts/verify_sdist_contents.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Verify a built sdist ships only the intended configuration templates.
+
+The sdist is a published artifact in its own right. It must contain the two tracked
+configuration templates and must NOT contain a live configuration.py (which holds
+SECRET_KEY and database credentials), any other local configuration*.py variant, or
+any ldap_config*.py (which holds LDAP bind credentials). The wheel guard alone is not
+enough: a wheel rebuilt from the sdist re-applies the wheel excludes, so it can come
+out clean even when the sdist itself leaks a file.
+"""
+
+import sys
+import tarfile
+from pathlib import PurePosixPath
+
+# Allowed members, relative to the sdist's netbox-<version>/ root directory. The sdist
+# keeps the full repository layout (no `sources` strip), unlike the wheel.
+ALLOWED = {
+    'netbox/netbox/configuration_example.py',
+    'netbox/netbox/configuration_testing.py',
+}
+
+
+def configuration_members(sdist_path):
+    """Return the set of configuration*.py members anywhere inside the sdist."""
+    with tarfile.open(sdist_path) as archive:
+        names = archive.getnames()
+    members = set()
+    for name in names:
+        path = PurePosixPath(name)
+        if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
+            # Strip the leading netbox-<version>/ directory for a stable comparison.
+            members.add(str(PurePosixPath(*path.parts[1:])))
+    return members
+
+
+def main(argv):
+    if len(argv) != 2:
+        print('usage: verify_sdist_contents.py <sdist>')
+        return 2
+    found = configuration_members(argv[1])
+    missing = sorted(ALLOWED - found)
+    unexpected = sorted(found - ALLOWED)
+    if missing or unexpected:
+        print('Sdist configuration files are not as expected:')
+        if missing:
+            print(f'  - missing templates: {missing}')
+        if unexpected:
+            print(f'  - unexpected (possible secret leak): {unexpected}')
+        return 1
+    print(f'OK: sdist ships only the {len(ALLOWED)} configuration templates')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 125 - 0
scripts/verify_wheel_contents.py

@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Verify a built wheel ships the required bundled data and only the intended configuration templates.
+
+The wheel must contain the tracked configuration templates and must NOT contain a
+live configuration.py (which holds SECRET_KEY and database credentials), any other
+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, the pre-rendered
+documentation site, 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 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.
+ALLOWED = {
+    'netbox/configuration_example.py',
+    'netbox/configuration_testing.py',
+}
+
+# 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/docs/index.html',
+    'netbox/_data/docs/models/dcim/device/index.html',
+    'netbox/_data/release.yaml',
+}
+REQUIRED_PREFIXES = (
+    'netbox/_data/docs/',
+    'netbox/_data/project-static/dist/',
+    'netbox/_data/project-static/img/',
+    'netbox/_data/project-static/js/',
+    'netbox/_data/templates/',
+    'netbox/_data/translations/',
+)
+
+
+def configuration_members(names):
+    """Return the set of configuration*.py members anywhere inside the wheel."""
+    members = set()
+    for name in names:
+        path = PurePosixPath(name)
+        # Scan the whole wheel: any configuration*.py outside the two tracked templates, or any
+        # ldap_config*.py at all, is a leak, wherever it sits in the archive.
+        if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
+            members.add(name)
+    return members
+
+
+def missing_runtime_data(names):
+    """Return the sorted list of required bundled files and prefixes absent from the wheel."""
+    missing = sorted(REQUIRED_FILES - names)
+    missing += [prefix for prefix in REQUIRED_PREFIXES if not any(name.startswith(prefix) for name in 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>')
+        return 2
+    with zipfile.ZipFile(argv[1]) as archive:
+        names = set(archive.namelist())
+    found = configuration_members(names)
+    missing = sorted(ALLOWED - found)
+    unexpected = sorted(found - ALLOWED)
+    missing_data = missing_runtime_data(names)
+    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}')
+        if unexpected:
+            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
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 157 - 0
scripts/verify_wheel_metadata.py

@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Verify a built wheel's metadata matches the repository's declared inputs.
+
+Checks:
+  1. Version equals the PEP 440 version computed from netbox/release.yaml, reusing the
+     same compute_version the hatchling metadata hook uses at build time.
+  2. Core Requires-Dist entries (those without an "extra ==" marker) match
+     requirements.txt exactly, so the published wheel pins the tested dependency set.
+  3. Provides-Extra equals the expected set of optional-dependency groups.
+  4. Each aggregate extra equals the union of its component extras, comparing the wheel
+     metadata against itself (immune to backend specifier normalization). pyproject.toml
+     duplicates these requirement strings literally; this catches drift, for example a
+     plugin pin bumped in one place only. Aggregates must not reference netbox itself,
+     which would defeat this guard.
+"""
+
+import importlib.util
+import re
+import sys
+import zipfile
+from collections import defaultdict
+from email.parser import Parser
+from pathlib import Path
+
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+
+# Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
+EXPECTED_EXTRAS = frozenset({
+    'branching',
+    'custom-objects',
+    'dev',
+    'git',
+    'ldap',
+    'recommended-plugins',
+    'remote-auth',
+    's3',
+    'saml2',
+    'sentry',
+    'swift',
+})
+
+# Aggregate extra -> the component extras whose entries it must equal the union of.
+AGGREGATE_EXTRAS = {
+    'remote-auth': ('ldap', 'saml2'),
+    'recommended-plugins': ('branching', 'custom-objects'),
+}
+
+# hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
+EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
+
+
+def read_metadata(wheel_path):
+    with zipfile.ZipFile(wheel_path) as archive:
+        name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
+        return Parser().parsestr(archive.read(name).decode())
+
+
+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 normalize(requirement):
+    return requirement.strip().lower().replace(' ', '')
+
+
+def split_requires(metadata):
+    """Split Requires-Dist entries into core requirements and a per-extra mapping."""
+    core = set()
+    by_extra = defaultdict(set)
+    for entry in metadata.get_all('Requires-Dist') or []:
+        requirement, _, marker = entry.partition(';')
+        match = EXTRA_MARKER.search(marker)
+        if match:
+            by_extra[match.group(1)].add(normalize(requirement))
+        else:
+            core.add(normalize(entry))
+    return core, by_extra
+
+
+def check_version(metadata, root, hatch_metadata):
+    release_text = (root / 'netbox' / 'release.yaml').read_text()
+    version = hatch_metadata._read_release_field(release_text, 'version')
+    if not version:
+        return ['unable to read version from netbox/release.yaml']
+    designation = hatch_metadata._read_release_field(release_text, 'designation')
+    expected = hatch_metadata.compute_version(version, designation)
+    if metadata['Version'] != expected:
+        return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
+    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())
+    return _diff_errors({normalize(pin) for pin in pins}, core, 'core requirements')
+
+
+def check_extras(metadata, by_extra):
+    provided = frozenset(metadata.get_all('Provides-Extra') or [])
+    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]
+        if actual != expected:
+            errors.append(
+                f'extra [{aggregate}] must equal the union of {list(components)}: '
+                f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
+            )
+        if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
+            errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
+    return errors
+
+
+def main(argv):
+    if len(argv) != 2:
+        print('usage: verify_wheel_metadata.py <wheel>')
+        return 2
+    root = Path(__file__).resolve().parent.parent
+    hatch_metadata = load_hatch_metadata()
+    metadata = read_metadata(argv[1])
+    core, by_extra = split_requires(metadata)
+    errors = [
+        *check_version(metadata, root, hatch_metadata),
+        *check_core_requires(core, root, hatch_metadata),
+        *check_extras(metadata, by_extra),
+    ]
+    if errors:
+        print('Wheel metadata does not match the repository:')
+        for error in errors:
+            print(f'  - {error}')
+        return 1
+    print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))