# # pyproject.toml # Copyright (C) 2026 Gill-Bates http://github.com/Gill-Bates # # Single source of truth for the project version, its dependencies and the # lint/test tooling. Replaces the former VERSION, requirements.txt and # docs/requirements-docs.txt files. # [build-system] requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "wirebuddy" # The one place the release version lives. app/utils/version.py reads it back # at runtime, the release workflow validates the git tag against it, and # docker/Dockerfile copies this file into the image so the About page reports # the same number the tag was cut from. version = "1.7.0" description = "Lightweight WireGuard Management WebUI" readme = "README.md" requires-python = ">=3.13" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Gill-Bates", email = "contact@wirebuddy.example.com" }] keywords = ["wireguard", "vpn", "fastapi", "dns", "self-hosted"] classifiers = [ "Environment :: Web Environment", "Framework :: FastAPI", "Intended Audience :: End Users/Desktop", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.13", "Topic :: System :: Networking", ] # Everything the app imports directly. Every entry carries an exact pin, so # the published image and a local install always resolve to the same # versions. dependencies = [ "aiosqlite==0.22.1", "cbor2==6.1.5", "cryptography==50.0.2", "email-validator==2.3.0", "fastapi==0.162.0", "geoip2==5.3.0", "httpx[http2]==0.28.1", "jinja2==3.1.6", "markdown==3.11.2", "nh3==0.3.7", "Pillow==13.0.0", "pydantic==2.14.1", "pydantic-settings==2.16.1", "pyotp==2.10.0", "python-multipart==0.0.32", "qrcode[pil]==8.2", "uvicorn[standard]==0.54.0", "webauthn==3.0.1", ] [project.optional-dependencies] dev = [ "pytest", "pytest-asyncio", "pytest-cov", "ruff==0.17.3", ] docs = [ "markdown", "mkdocs", "mkdocs-git-revision-date-localized-plugin", "mkdocs-material", "mkdocs-minify-plugin", "mkdocs-redirects", "pymdown-extensions", ] [project.urls] Homepage = "https://github.com/Gill-Bates/wirebuddy" Documentation = "https://gill-bates.github.io/wirebuddy/" Source = "https://github.com/Gill-Bates/wirebuddy" Changelog = "https://github.com/Gill-Bates/wirebuddy/blob/main/CHANGELOG.md" # The repo is a flat-layout application, not a library, and it declares no # console script: `pip install -e .` is a convenience for local dev that pulls # in the dependencies and puts the source tree on sys.path. The Docker image # installs the dependencies alone and runs the copied source tree. # # Discovery rather than an explicit list, because none of these directories # carries an __init__.py at every level - they are namespace packages, and an # explicit `packages = ["app"]` risks silently dropping a subpackage the # discovery would otherwise find. The excludes keep the two asset trees out: # they hold no Python, and discovery would otherwise declare them as empty # namespace packages. [tool.setuptools] include-package-data = false [tool.setuptools.packages.find] include = ["app*"] exclude = ["app.static*", "app.templates*"] # --------------------------------------------------------------------------- # # Ruff - lint + format for every Python file in the repo # --------------------------------------------------------------------------- # [tool.ruff] line-length = 160 target-version = "py313" # A different local ruff must not silently produce other results than the CI gate. required-version = "==0.17.3" extend-exclude = ["data", "dev-data", "site", ".mkdocs-cache"] [tool.ruff.lint] select = [ "A", # flake8-builtins - shadowing open/id/type hides real bugs "ARG", # unused arguments "ASYNC", # blocking calls inside async def - this app is ASGI end to end "B", # bugbear "C4", # comprehensions "D", # pydocstyle - Google-style docstrings (see [tool.ruff.lint.pydocstyle]) "DTZ", # naive datetimes; timestamps are UTC-aware on purpose "E", # pycodestyle errors "EM", # exception messages assigned to a name first "F", # pyflakes "FAST001", # redundant response_model in the route decorator "FAST003", # path parameter missing from the handler signature "G", # logging-format: %s args, never f-strings (lazy formatting) "I", # import sorting "ICN", # import conventions "ISC", # implicit string concat "LOG", # logging misuse "N", # pep8-naming "PERF", # obvious performance traps "PIE", # misc lints "PL", # pylint subset "PTH", # prefer pathlib "RET", # return simplifications "RSE", # raise cleanups "RUF", # ruff-specific "S", # bandit - this app parses untrusted input and shells out to wg/unbound "SIM", # simplifications "SLF", # private member access across modules "T20", # stray print() "TRY", # exception antipatterns "UP", # pyupgrade "W", # pycodestyle warnings ] ignore = [ # Formatter-owned; keeping this on fights `ruff format`. "ISC001", # Further rules Ruff documents as conflicting with `ruff format`. "E111", "E114", "E117", "D300", # The codebase is indented with tabs on purpose (see AGENTS.md); this is a # style choice, not a defect. "W191", # Tab indentation makes E101 (mixed spaces/tabs at line start) fire on # otherwise-consistent code; the formatter is the source of truth here. "E101", # Same tab decision one layer down: D206 wants docstring continuation lines # indented with spaces, which `ruff format` with indent-style = "tab" undoes # on the next run. Ruff itself lists it as formatter-incompatible. "D206", # The *presence* of docstrings is not gated. What this codebase documents is # the non-obvious: the modules carry a header block, and the functions whose # behaviour needs explaining carry a Google-style docstring, while the # single-statement DB accessors and route handlers whose name already says # everything do not. Turning these on would demand ~270 new docstrings whose # only content would be a restatement of the signature, which is how a # docstring becomes something readers learn to skip. The D2xx/D4xx rules stay # enabled, so every docstring that *is* written has to be well-formed. "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", # Exception messages are inlined rather than assigned to a name first; # matches the existing codebase style throughout app/. "EM101", "EM102", "TRY003", # try/except -> job/task status transitions read better inline than # split across helper functions for this branchy pipeline code. "TRY300", "TRY301", "B904", # Deliberate lazy imports (heavy or optional dependencies, import cycles) # across app/ (main, server, tasks, api, utils, node). "PLC0415", # Package-level singletons (db connections, schedulers, caches) are # rebound by lifespan hooks and module-level setup on purpose. "PLW0603", # Thresholds and HTTP status codes read fine inline; naming every 200/404 # would add indirection without adding meaning. "PLR2004", # The DNS/WireGuard/multi-node orchestration code is genuinely branchy; # splitting it to satisfy a counter would hide the sequence it documents. "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR0917", # Every occurrence is an isinstance() check on *deserialised external input* - # enrollment token payloads, WebAuthn clientDataJSON, a node's persisted state # file, a master's HTTP response. A wrong type there is a data-validation # failure, not a caller passing the wrong argument, so ValueError (or # RuntimeError for corrupted state) is the right class and the callers catch # exactly that. app/api/passkeys.py even converts TypeError *into* ValueError # two lines above one of these checks; app/utils/node_token.py raises # ValueError for all three ways one token field can be malformed. TRY004 # would split those into two exception classes for no gain. "TRY004", # A `timeout` parameter on an async function is deliberate throughout: these # are wrappers that hand the value to the underlying primitive, and for # app/utils/subprocess.py it also selects the SIGTERM-then-kill escalation # that `asyncio.timeout()` at the call site cannot express (see # tests/test_subprocess_limits.py). Pushing it to callers would be a wide API # change that loses behaviour. "ASYNC109", ] # The prose uses real typography: EN DASH in docstrings, log messages and UI # strings, MULTIPLICATION SIGN in comments. Every other homoglyph is still flagged. allowed-confusables = ["–", "×"] [tool.ruff.lint.flake8-bugbear] # `Depends(...)` / `File(...)` in defaults *is* the FastAPI DI contract; B008 stays # active for every other call in a default argument. extend-immutable-calls = [ "fastapi.Depends", "fastapi.File", "fastapi.Form", "fastapi.Query", "fastapi.Body", "fastapi.Header", "fastapi.Cookie", "fastapi.Path", "fastapi.Security", ] [tool.ruff.lint.per-file-ignores] # The one module that is deliberately *not* UTC: it schedules the speedtest for a # wall-clock hour in the operator's own timezone ("run at 2am local"), and # time.mktime() on a naive datetime is what applies the system's DST rules. Making # these aware would move the window whenever DST shifts, which is the opposite of # the intent. DTZ stays enabled everywhere else, where timestamps really are # UTC-aware (see app/utils/time.py and tests/test_time.py). "app/utils/speedtest_window.py" = ["DTZ001", "DTZ005", "DTZ006"] "tests/*" = [ "S603", "S608", # Stub signatures have to match what the real callable is invoked with, # so monkeypatch lambdas routinely take arguments they ignore. "ARG001", "ARG002", "ARG005", "PLR2004", "PTH101", "RUF012", "S101", "S105", "S106", "S107", "S108", "SIM117", "SLF001", "T20", ] "tools/*.py" = ["T20"] # This application's job is driving `wg`, `ip`, `iptables` and `conntrack`. S603 # fires on every direct subprocess call and cannot be satisfied while that remains # true, so it is exempt only in the modules that make such calls. The properties it # stands in for are enforced instead: there is no `shell=True` anywhere in app/, # every call passes an argument *list*, and the privileged binaries are resolved to # absolute paths from fixed allowlists rather than PATH. S607 stays enabled # everywhere, which is what catches a binary invoked by bare name. A new module # calling subprocess directly is flagged and has to justify itself. "app/utils/conntrack.py" = ["S603"] "app/node/firewall.py" = ["S603"] "app/node/wg_manager.py" = ["S603"] "app/api/frontend_pages.py" = ["S603"] # Dynamic SQL here only ever interpolates *identifiers* the code itself owns - # column lists built from literals like `"username = ?"`, a generated `?,?,?` # placeholder string, table names from module-level allowlists. Every value is # bound, never formatted in, and SQL parameters cannot express a dynamic column # list. Exempt only in the DB layer and the backup restore; S608 stays on # everywhere else, where a formatted query would be a real injection risk. "app/db/*.py" = ["S608"] "app/api/backup.py" = ["S608"] # Fullwidth digits are the input under test (NFKC normalisation of MFA codes). "tests/test_mfa_code_normalization.py" = ["RUF001"] [tool.ruff.lint.pydocstyle] # Google style is what the tree already uses: a one-line summary, a blank line, # then `Args:` / `Returns:` / `Raises:` sections. Setting the convention also # switches off the D rules that contradict it (D203, D213, D215, D400, D401, # D404, D406-D409, D413), so only one docstring shape is ever demanded. convention = "google" [tool.ruff.format] quote-style = "double" indent-style = "tab" line-ending = "lf" # --------------------------------------------------------------------------- # # pytest # --------------------------------------------------------------------------- # [tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] pythonpath = ["."] python_files = ["test_*.py"] addopts = "-q --strict-markers --strict-config" [tool.coverage.run] source = ["app"] branch = true omit = ["*/__pycache__/*"] [tool.coverage.report] show_missing = true skip_covered = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", ]