[project] name = "steam-backlog-enforcer" version = "1.0.0" description = "Enforce your Steam backlog: one game at a time, tracked with HowLongToBeat data" # 3.11 floor: websockets 17 (and its successors) drop 3.10, and this repo # tracks newest stable rather than the oldest interpreter still alive. requires-python = ">=3.14" dependencies = [ "requests==2.34.2", "howlongtobeatpy==1.0.24", # Directly imported by the package (aiohttp: _hltb_* async lookups; # websockets: library_hider). aiohttp was previously satisfied only # transitively via howlongtobeatpy and websockets was undeclared — both # are declared here so a clean install imports the package. "aiohttp==3.14.4", "websockets==17.2", # The earner registry (gaming budget terms); stdlib-only, so the root # daemon imports it from the desktop user's site-packages. "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time", ] # Optional extras. The `mcp` group is ONLY needed by the MCP server # (steam_backlog_enforcer._mcp), which Claude Code spawns via a dedicated # venv (see scripts/setup_mcp.sh / .mcp.json). It is deliberately NOT a core # dependency so the systemd/CLI system-python path stays dependency-light and # never has to import the MCP SDK. [project.optional-dependencies] mcp = ["mcp==2.3.0"] # Explicit package discovery. Without this, setuptools' flat-layout # auto-discovery aborts because the repo root holds two top-level dirs # (`steam_backlog_enforcer/` and the React `web/` frontend) and it refuses # to guess. Scoping discovery to the Python package fixes `pip install -e .` # for the CLI, systemd, AND the dedicated MCP venv. [tool.setuptools.packages.find] include = ["steam_backlog_enforcer*"] # ============================================================================ # RUFF - Fast Python linter and formatter # ============================================================================ [tool.ruff] target-version = "py314" include = ["*.py", "**/*.py"] # scripts/file_length is a VENDORED copy of ~/src/utils/file_length and must stay # byte-identical to upstream; ruff --unsafe-fixes otherwise strips its print() # calls (T201) and silently guts the gate's reporting. exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs", "scripts/file_length"] [tool.ruff.lint] select = ["ALL"] ignore = [ "D203", # conflicts with D211 "D213", # conflicts with D212 "COM812", # formatter handles this "ISC001", # formatter may create these "S603", # prone to false positives "CPY001", # personal repo, no copyright policy (ruff 0.17 made CPY001 stable under select=ALL) # Conflicts with SIM117. A `pytest.raises` that needs patches in scope has # no legal form: combine them into one `with` and PT012 objects to the # extra context managers; nest `raises` inside and SIM117 objects to the # nested `with`. SIM117's combined form is the style used across this repo, # so PT012 is the one that loses. "PT012", ] fixable = ["ALL"] unfixable = [] [tool.ruff.lint.per-file-ignores] "**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] "**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.isort] force-single-line = false force-sort-within-sections = true known-first-party = ["steam_backlog_enforcer"] [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" inline-quotes = "double" [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" [tool.ruff.format] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" docstring-code-format = true # ============================================================================ # MYPY - Static type checker # ============================================================================ [tool.mypy] python_version = "3.14" strict = true warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true warn_unreachable = true disallow_any_unimported = true disallow_any_explicit = false disallow_any_generics = true disallow_subclassing_any = true strict_equality = true extra_checks = true ignore_missing_imports = true show_error_codes = true color_output = true exclude = [".venv/"] # ============================================================================ # PYLINT # ============================================================================ [tool.pylint.main] # The repo root on the path, so pylint resolves the package's own imports the # same way pytest does instead of reporting import-error for them. init-hook = "import sys; sys.path.insert(0, '.')" analyse-fallback-blocks = true persistent = true jobs = 0 py-version = "3.14" ignore = [".venv", "__pycache__"] # Test suites intentionally use patterns (protected-access, fixture-arg # shadowing) that don't apply to source code; ruff's per-file-ignores carry # the equivalent exemptions, so tests stay linted -- by ruff. This is the same # source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has # to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the # basename of what pylint is *given*, and pre-commit hands it explicit file # paths, so a directory name in `ignore` never fires under the hook. ignore-paths = ["^(.*/)?tests/.*$"] ignore-patterns = [".*\\.pyi$"] [tool.pylint.basic] # snake_case, plus the ``do_`` names http.server dispatches on: the # stdlib looks handlers up as ``"do_" + command``, so ``do_GET`` is the only # spelling that works and cannot be renamed to satisfy the style. method-rgx = "([^\\W\\dA-Z][^\\WA-Z]{2,}|_[^\\WA-Z]*|__[^\\W\\dA-Z][^\\WA-Z]+__|do_[A-Z]+)$" [tool.pylint.messages_control] enable = "all" # Globally disabled checks, matching wake-alarm / diet-guard: each is a # stylistic preference that conflicts with deliberate, clearer code. # Everything else stays at max strictness. disable = [ # use-implicit-booleaness family (C1803/C1804/C1805): pylint wants # `not x` / `not s` instead of `x == 0` / `s == ""`. Explicit comparisons # against 0 and "" state numeric/string intent more clearly than truthiness # (and are not equivalent when the value may be None), so we keep them. "use-implicit-booleaness-not-comparison", "use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-zero", ] [tool.pylint.design] min-public-methods = 0 max-module-lines = 250 max-attributes = 10 [tool.pylint.typecheck] generated-members = [ ".*\\.assert_called_once_with", ".*\\.assert_called_once", ".*\\.assert_called", ".*\\.assert_not_called", ".*\\.assert_any_call", ".*\\.call_args", ".*\\.call_args_list", ".*\\.call_count", ] # ============================================================================ # BANDIT - Security linter # ============================================================================ [tool.bandit] exclude_dirs = ["tests", ".venv"] # ============================================================================ # PYTEST # ============================================================================ [tool.pytest.ini_options] testpaths = ["steam_backlog_enforcer/tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = [ "-v", "--strict-markers", "--strict-config", "-ra", "--cov=steam_backlog_enforcer", "--cov-branch", "--cov-report=term-missing", "--cov-report=lcov", ] filterwarnings = [ "error", "ignore::DeprecationWarning", "default::pytest.PytestUnraisableExceptionWarning", ] # ============================================================================ # COVERAGE # ============================================================================ [tool.coverage.run] source = ["steam_backlog_enforcer"] branch = true omit = ["*/__pycache__/*", "*/tests/*", "*/.venv/*"] [tool.coverage.report] fail_under = 100 show_missing = true skip_covered = false exclude_lines = [ "pragma: no cover", "raise NotImplementedError", "raise AssertionError", "if TYPE_CHECKING:", 'if __name__ == "__main__":', ] partial_branches = ["pragma: no branch"]