From b5b59bcd77d57098eef93833f512edc4ec881b69 Mon Sep 17 00:00:00 2001 From: JD Davis Date: Sat, 11 Jul 2026 15:26:20 +0000 Subject: [PATCH] Extract project name policy (#1974) ## Description Extracts project-name normalization for proxy attribution from `savings_tracker.py` into `headroom.proxy.project_name_policy`. `savings_tracker.sanitize_project_name` and `PROJECT_NAME_MAX_LENGTH` remain compatibility aliases for existing callers. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `project_name_policy.py` for project-name decoding, printable-character filtering, trimming, and length capping. - Kept `savings_tracker` compatibility aliases for existing imports and project-context callers. - Added focused policy tests plus re-export coverage. - Carried forward the LiteLLM callback compatibility shim needed for current mypy on `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests\test_project_name_policy.py tests\test_proxy_project_savings.py 20 passed in 17.05s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports Success: no issues found in 409 source files gitleaks protect --staged --no-banner --redact no leaks found ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, branch `jd/architecture-slice-26`. - Exact command / steps: ran new project-name policy tests, existing project savings tests, ruff, ruff format check, mypy, and staged gitleaks scan. - Observed result: project attribution/savings behavior remains covered and local lint/type/security checks pass. - Not tested: full proxy runtime; this slice preserves existing `savings_tracker` imports. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are N/A for this internal architecture-only refactor. The push reported existing default-branch Dependabot alerts; no staged secret leaks were found for this PR. --- headroom/proxy/project_name_policy.py | 25 +++++++++++++++++++++++++ headroom/proxy/savings_tracker.py | 23 ++++------------------- tests/test_project_name_policy.py | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 headroom/proxy/project_name_policy.py create mode 100644 tests/test_project_name_policy.py diff --git a/headroom/proxy/project_name_policy.py b/headroom/proxy/project_name_policy.py new file mode 100644 index 000000000..e5677530c --- /dev/null +++ b/headroom/proxy/project_name_policy.py @@ -0,0 +1,25 @@ +"""Project-name normalization policy for proxy attribution.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import unquote + +PROJECT_NAME_MAX_LENGTH = 128 + + +def sanitize_project_name(value: Any) -> str | None: + """Normalize a client-supplied project name; ``None`` when unusable. + + Strips control characters, trims whitespace, and caps length so a + misbehaving client cannot bloat persisted state or dashboard payloads. + Percent-encoded values are decoded first so stored names match the original + directory name. + """ + if not isinstance(value, str): + return None + decoded = unquote(value) + cleaned = "".join(ch for ch in decoded if ch.isprintable()).strip() + if not cleaned: + return None + return cleaned[:PROJECT_NAME_MAX_LENGTH] diff --git a/headroom/proxy/savings_tracker.py b/headroom/proxy/savings_tracker.py index 32bda91dc..560cfad36 100644 --- a/headroom/proxy/savings_tracker.py +++ b/headroom/proxy/savings_tracker.py @@ -14,7 +14,6 @@ import math import os import tempfile import threading -import urllib.parse from csv import DictWriter from datetime import datetime, timedelta, timezone from io import StringIO @@ -22,6 +21,10 @@ from pathlib import Path from typing import Any from headroom import paths as _paths +from headroom.proxy import project_name_policy + +PROJECT_NAME_MAX_LENGTH = project_name_policy.PROJECT_NAME_MAX_LENGTH +sanitize_project_name = project_name_policy.sanitize_project_name logger = logging.getLogger(__name__) @@ -31,7 +34,6 @@ DEFAULT_SAVINGS_FILE = "proxy_savings.json" SCHEMA_VERSION = 4 DEFAULT_MAX_HISTORY_POINTS = 5000 DEFAULT_MAX_PROJECTS = 50 -PROJECT_NAME_MAX_LENGTH = 128 DEFAULT_MAX_HISTORY_AGE_DAYS = 365 DEFAULT_MAX_RESPONSE_HISTORY_POINTS = 500 DEFAULT_DISPLAY_SESSION_INACTIVITY_MINUTES = 60 @@ -370,23 +372,6 @@ def _empty_display_session() -> dict[str, Any]: } -def sanitize_project_name(value: Any) -> str | None: - """Normalize a client-supplied project name; ``None`` when unusable. - - Strips control characters, trims whitespace, and caps length so a - misbehaving client cannot bloat the persisted state or the dashboard. - Percent-encoded values (from non-ASCII cwd names) are decoded first so - the stored project name matches the original directory name. - """ - if not isinstance(value, str): - return None - value = urllib.parse.unquote(value) - cleaned = "".join(ch for ch in value if ch.isprintable()).strip() - if not cleaned: - return None - return cleaned[:PROJECT_NAME_MAX_LENGTH] - - def _empty_project_entry() -> dict[str, Any]: return { "requests": 0, diff --git a/tests/test_project_name_policy.py b/tests/test_project_name_policy.py new file mode 100644 index 000000000..12e94d7cb --- /dev/null +++ b/tests/test_project_name_policy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from headroom.proxy.project_name_policy import PROJECT_NAME_MAX_LENGTH, sanitize_project_name +from headroom.proxy.savings_tracker import sanitize_project_name as savings_sanitize_project_name + + +def test_project_name_policy_normalizes_and_caps() -> None: + assert sanitize_project_name(" api-server ") == "api-server" + assert sanitize_project_name("a" * 300) == "a" * PROJECT_NAME_MAX_LENGTH + assert sanitize_project_name("x\x00\x1by") == "xy" + + +def test_project_name_policy_decodes_percent_encoded_unicode() -> None: + assert sanitize_project_name("%E9%A1%B9%E7%9B%AE") == "\u9879\u76ee" + assert sanitize_project_name("my%20repo") == "my repo" + + +def test_project_name_policy_rejects_unusable_values() -> None: + assert sanitize_project_name("") is None + assert sanitize_project_name(" ") is None + assert sanitize_project_name(None) is None + assert sanitize_project_name(42) is None + + +def test_savings_tracker_reexports_project_name_policy() -> None: + assert savings_sanitize_project_name is sanitize_project_name