diff --git a/AGENTS.md b/AGENTS.md index 91a1d28..10dd1f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,6 +307,8 @@ The user typically prefers **incremental, directly actionable code guidance**. ## 13. If You Add New Code +No new functional top-level directory may be added without a dedicated architecture Issue. New Python capabilities must first be placed under `src/cacheroute//` or an existing component hierarchy. Cross-component shared code must use a dependency-light, side-effect-free package under the `cacheroute` namespace. + When introducing a new helper, field, or branch: - keep naming consistent with existing repository terms, - add comments for non-obvious logic, diff --git a/client/client.py b/client/client.py index 54f218c..ff7904e 100644 --- a/client/client.py +++ b/client/client.py @@ -17,10 +17,23 @@ import json import logging import shlex +import sys from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import urlparse +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + repo_root = Path(__file__).resolve().parents[1] + for source_root in (repo_root, repo_root / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() + from core.config import REQUIRED_FIELDS, ALLOWED_OPTION_FIELDS import requests @@ -447,4 +460,4 @@ def run_repl() -> None: pretty_print_response(resp, parsed.body) if __name__ == "__main__": - run_repl() \ No newline at end of file + run_repl() diff --git a/client/kv_timing_sender.py b/client/kv_timing_sender.py index 3e3ef4d..0d6de52 100644 --- a/client/kv_timing_sender.py +++ b/client/kv_timing_sender.py @@ -21,10 +21,16 @@ import httpx -# Allow repository modules to be imported when running `python client/kv_timing_sender.py`. -_REPO_ROOT = Path(__file__).resolve().parents[1] -if str(_REPO_ROOT) not in sys.path: - sys.path.insert(0, str(_REPO_ROOT)) +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + repo_root = Path(__file__).resolve().parents[1] + for source_root in (repo_root, repo_root / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() from proxy.metrics.queue_predictor import queue_predictor from core.tokenizer_registry import estimate_tokens, TokenizerRegistry @@ -650,4 +656,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/conftest.py b/conftest.py index f1e3ee2..ae97e63 100644 --- a/conftest.py +++ b/conftest.py @@ -6,5 +6,6 @@ ROOT = Path(__file__).resolve().parent -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) +for source_root in (ROOT, ROOT / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) diff --git a/core/README.md b/core/README.md index 9ea184d..e0ae530 100644 --- a/core/README.md +++ b/core/README.md @@ -28,9 +28,9 @@ Main responsibilities: ## Runtime compatibility boundary -`core/runtime_compat.py` is the compatibility-facing import path used by existing CacheRoute modules. The dependency-light implementation lives in `cacheroute_compat/runtime.py`, which keeps Runtime Profile normalization available without importing the full application graph. +`core/runtime_compat.py` is the compatibility-facing import path used by existing CacheRoute modules. The dependency-light implementation lives in `src/cacheroute/compat/runtime.py`, which keeps Runtime Profile normalization available without importing the full application graph. The deprecated `cacheroute_compat` import path is a forwarding shim scheduled for removal in CacheRoute 0.3.0. -Persisted and wire-level objects must use a resolved Runtime Profile (`legacy`, `v1`, or `test/mock`). The `auto` value is limited to startup resolution and must not become an operation-time fallback. The storage-neutral KDN representation of these profiles is documented in the [KDN domain vocabulary](../kdn_server/domain/README.md); serving-stack migration and startup details remain in [runtime compatibility documentation](../docs/runtime_compatibility_v1.md). +Persisted and wire-level objects must use a resolved Runtime Profile (`legacy`, `v1`, or `test/mock`). The `auto` value is limited to startup resolution and must not become an operation-time fallback. The storage-neutral KDN representation of these profiles is documented in the [KDN domain vocabulary](../kdn_server/domain/README.md); serving-stack migration and startup details remain in [runtime compatibility documentation](../doc/runtime_compatibility_v1.md). ## Configuration diff --git a/core/config.py b/core/config.py index 6f2138e..7251846 100644 --- a/core/config.py +++ b/core/config.py @@ -5,12 +5,9 @@ - Centralizes all default values - Provides a clear configuration reference for deployment """ -import sys from pathlib import Path ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.append(str(ROOT_DIR)) # Default model settings DEFAULT_MODEL = "/workspace/llm-stack/models/LLM-Research/Meta-Llama-3-70B-Instruct" # Default main model path, used when Scheduler/Client does not specify one diff --git a/core/runtime_compat.py b/core/runtime_compat.py index d9f9ef3..4be5a56 100644 --- a/core/runtime_compat.py +++ b/core/runtime_compat.py @@ -1,5 +1,5 @@ """Compatibility re-export for the dependency-light runtime profile helpers.""" -from cacheroute_compat.runtime import ( +from cacheroute.compat.runtime import ( RUNTIME_PROFILE_AUTO, RUNTIME_PROFILE_LEGACY, RUNTIME_PROFILE_TEST_MOCK, diff --git a/doc/package_migration_phase_a.md b/doc/package_migration_phase_a.md new file mode 100644 index 0000000..ffcaf16 --- /dev/null +++ b/doc/package_migration_phase_a.md @@ -0,0 +1,247 @@ +# Phase A package migration audit + +This audit records the repository state reviewed for Issue #157 Phase A and +separates that complete inventory from the two dependency-light packages moved +by PR #158. It is maintained alongside later namespace migration phases. + +## Scope and status + +PR #158 migrates only `cacheroute_compat` to `cacheroute.compat`, establishes +the lightweight `cacheroute` namespace, and reserves +`cacheroute.observability`. It does **not** migrate application packages or the +actual observability implementation being developed in PR #156. Consequently, +the observability acceptance criterion is not complete and neither #157 nor +#141 is closed by this phase. + +PR #156 must rebase onto PR #158 and move its `cacheroute_observability` +implementation, tests, demo, README, imports, and links into +`src/cacheroute/observability`. A permanent root `cacheroute_observability` +package must not be retained. + +## Complete top-level directory inventory + +Classification reflects directories tracked at the Phase A baseline. Generated +`.git`, `.pytest_cache`, and `__pycache__` directories are not architecture. + +| Directory | Classification | Packaged before Phase A? | Phase A action | +|---|---|---:|---| +| `.assets` | Documentation assets | No | Unchanged | +| `.github` | Repository/CI metadata | No | Unchanged | +| `UI` | Runtime UI package | Yes | Preserved at root | +| `cacheroute_compat` | Dependency-light compatibility implementation | Yes | Moved to `src/cacheroute/compat` | +| `client` | Runtime client package | Yes | Preserved at root | +| `core` | Shared application runtime package | Yes | Preserved at root | +| `data` | Packaged data helper package | Yes | Preserved at root | +| `doc` | Documentation | No | This audit added here | +| `env` | Environment, Docker, and deployment tooling | No | Unchanged | +| `instance` | Runtime Instance package | Yes | Preserved at root | +| `kdn_server` | Runtime KDN package | Yes | Preserved at root | +| `log` | Experiment log artifacts | No | Unchanged | +| `model` | Runtime model package | Yes | Preserved at root | +| `proxy` | Runtime Proxy package | Yes | Preserved at root | +| `scheduler` | Runtime Scheduler package | Yes | Preserved at root | +| `scripts` | Repository scripts | No | Unchanged | +| `src` | Canonical Python source root | No (new) | Hosts migrated packages | +| `store` | Runtime storage package | Yes | Preserved at root | +| `test` | Tests and demo launchers | Excluded | Updated validation only | +| `util` | Runtime utility package | Yes | Preserved at root | + +There are no baseline top-level `component` or `storage` Python directories; +the corresponding current concepts live in component packages and `store`. + +## Complete packaged runtime inventory + +The pre-PR namespace-aware setuptools discovery (`where = ["."]`) discovered the following +packages. The transitional explicit package list preserves every entry and adds +the four entries marked **new**: + +```text +UI, UI.client_ui, UI.client_ui.static, UI.client_ui.templates, UI.kdn_ui, +UI.proxy_ui, UI.proxy_ui.static +client, client.taskset +core +data, data.CacheRoute_dataset, data.CacheRoute_dataset.knowledge_document +doc, doc.blog, doc.integrations +env, env.config, env.docker, env.docker.cu130, env.docker.cu130.scripts +instance, instance.TPOT_predictor, instance.TTFT_predictor, +instance.TTFT_predictor.data, instance.pclient, instance.resource_agent, +instance.resource_agent.src, instance.resource_dashboard, +instance.resource_dashboard.static +kdn_server, kdn_server.KV_database, kdn_server.contracts, kdn_server.domain, +kdn_server.gateway, kdn_server.sclient, kdn_server.text_database, +kdn_server.text_database.blocks, kdn_server.util +log, log.scheduler +model +proxy, proxy.metrics, proxy.metrics.data, proxy.queue, proxy.resource, +proxy.sclient, proxy.strategy +scheduler, scheduler.knowledge, scheduler.resource, scheduler.strategy +scripts +store +test, test.kdn +util +cacheroute (new) +cacheroute.compat (new canonical implementation) +cacheroute.observability (new namespace only) +cacheroute_compat (moved deprecated shim) +``` + +Archive inspection (ignoring the `.dist-info` directory) produced these exact +top-level Python package directories: + +| Wheel | Complete top-level package list | +|---|---| +| Before PR #158 | `UI`, `cacheroute_compat`, `client`, `core`, `data`, `env`, `instance`, `kdn_server`, `model`, `proxy`, `scheduler`, `scripts`, `store`, `test`, `util` | +| After corrected PR #158 | `UI`, `cacheroute`, `cacheroute_compat`, `client`, `core`, `data`, `env`, `instance`, `kdn_server`, `model`, `proxy`, `scheduler`, `scripts`, `store`, `test`, `util` | + +The baseline wheel required the unrelated invalid placeholder author email to +be removed in its temporary archived copy before current setuptools could build +it; no package-discovery setting was changed for that inspection. + +### Normalized wheel member and package-data audit + +After excluding `.dist-info` metadata and normalizing the intentional +`cacheroute_compat/runtime.py` relocation to `cacheroute/compat/runtime.py`, the +baseline has 155 members and the corrected Phase A wheel has 197. No normalized +member was removed. The 42 added members are: + +```text +UI/client_ui/static/app.js +UI/client_ui/static/style.css +UI/client_ui/templates/index.html +UI/proxy_ui/static/app.js +UI/proxy_ui/static/index.html +UI/proxy_ui/static/style.css +cacheroute/__init__.py +cacheroute/observability/__init__.py +cacheroute_compat/__init__.py +cacheroute_compat/runtime.py +instance/TTFT_predictor/data/README.md +instance/TTFT_predictor/data/log-bs1-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs2-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs3-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs4-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs5-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs6-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs7-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/log-bs8-rtx5090-8-llama3-70b.txt +instance/TTFT_predictor/data/补录数据.txt +instance/TTFT_predictor/data/补录数据(小长度).txt +instance/resource_dashboard/static/app.js +instance/resource_dashboard/static/index.html +instance/resource_dashboard/static/style.css +model/model_configs.yaml +proxy/metrics/data/log-bs1-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs2-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs3-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs4-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs5-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs6-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs7-rtx5090-8-llama3-70b.txt +proxy/metrics/data/log-bs8-rtx5090-8-llama3-70b.txt +proxy/metrics/data/redis_pull_table_from_image.json +proxy/metrics/redis_pull_coefficients.json +proxy/metrics/tpot_benchmark_table.json +proxy/metrics/tpot_coefficients.json +proxy/metrics/ttft_benchmark_table.json +proxy/metrics/ttft_coefficients.json +test/test_namespace_layout.py +test/test_repository_governance.py +test/test_source_checkout_imports.py +``` + +The baseline wheel contained zero non-Python, non-metadata members. The +corrected wheel contains 35: the UI/dashboard assets, predictor data, model +configuration, and metric tables listed above. These additions make required +runtime package data explicit; they do not replace or remove baseline data. +The generated `instance/TTFT_predictor/prompt_length_validation.log` is +explicitly excluded. At the same source head, its removal reduced the wheel +from 586,283 bytes to 585,892 bytes. + +Package-data groups are limited to runtime inputs: + +- `UI.client_ui` and `UI.proxy_ui`: browser JavaScript, CSS, and HTML assets; +- `instance.resource_dashboard`: dashboard JavaScript, CSS, and HTML assets; +- `instance.TTFT_predictor`: checked-in calibration tables and their data note; +- `proxy.metrics`: checked-in prediction coefficients, benchmark tables, and + calibration inputs; +- `model`: the runtime model configuration YAML. + +Generated logs, test output, temporary files, and standalone documentation are +not selected by the package-data patterns. + +## Reference audit + +The inventory searched all tracked repository files, including Python, TOML, +YAML, JSON, Dockerfiles, shell files, CI configuration, and Markdown. The +following categories are repository-wide; only the compatibility references +listed under “changed mapping” were modified. + +| Reference class | Inventory result | +|---|---| +| Normal compatibility imports | `core/runtime_compat.py`, `kdn_server/domain/models.py`, `test/test_runtime_compat.py`, and `test/test_wheel_install.py` | +| Dynamic/importlib references | `instance/capability_builder.py` uses distribution metadata; no dynamic string names target either migrated package | +| `python -m` commands | Operational/docs commands occur in `README.md`, `doc/`, `env/`, component READMEs, and `test/README.md`; application commands follow documented installation, while pytest uses the centralized root `conftest.py` source bootstrap | +| Uvicorn/FastAPI module strings | `instance/TTFT_predictor/prefill_prediction_server.py`, its README/workflow, and `UI/proxy_ui/README.md`; none target the migrated package | +| Subprocess module invocations | `client/perf_client.py`, Instance dashboards, test demo launchers, resource-monitor tests, and wheel tests; only the wheel test exercises the migrated imports | +| Monkeypatch/mock target strings | Tests under `test/` and `test/kdn/`; none target the migrated implementation path | +| Docker/Compose/shell/CI | `Dockerfile`, `env/docker/cu130/`, `env/README.md`, `scripts/`, and `.github/`; no Compose file or migrated-package reference was found | +| Markdown references | `core/README.md` and `test/kdn/README.md` referenced the old implementation path and were updated; compatibility history remains explicitly marked deprecated | +| Source checkout path bootstraps | Root `conftest.py` and dedicated demos in `test/` bootstrap their explicit test/entrypoint boundary. Dual-purpose `client/client.py`, `client/kv_timing_sender.py`, `kdn_server/kdn_register_cli.py`, `scheduler/scheduler_cli.py`, `scripts/validate_v1_kdn_roundtrip.py`, `store/knowledge_build.py`, and `util/kdn_build_kv.py` call a local bootstrap only when `__package__ in (None, "")`; normal package imports do not mutate `sys.path`. `test/test_demo_instance_ui.py` has a test-only root bootstrap, while `test/demo_resource_monitor_e2e.py` uses its root path for subprocess working directories. Ordinary library modules, including `core/config.py`, remain side-effect free. Isolated wheel tests run elsewhere and do not use any source bootstrap. | + +The reproducible inventory commands are: + +```bash +find . -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort +python3 -c "from setuptools import find_namespace_packages; print(*find_namespace_packages('.', exclude=['tests*','docs*','examples*']), sep='\n')" +rg -n --hidden -g '!.git/**' 'cacheroute_compat|cacheroute_observability' +rg -n --hidden -g '!.git/**' 'importlib|python(3)?[[:space:]]+-m|uvicorn|subprocess|monkeypatch|mock[.]patch' +``` + +## Changed path and entry-point mapping + +| Before | After | Policy | +|---|---|---| +| `cacheroute_compat` | `cacheroute.compat` | Canonical implementation moved | +| `cacheroute_compat.runtime` | `cacheroute.compat.runtime` | Canonical import for new code | +| None | `cacheroute` | Lightweight canonical namespace | +| None | `cacheroute.observability` | Reserved namespace, implementation pending PR #156 | + +No CLI, Uvicorn, FastAPI, dynamic-import, or `python -m` entry point changed. +The `cacheroute_compat` forwarding shim is scheduled for removal in CacheRoute +0.3.0. It contains imports only and is narrowly covered by compatibility tests. + +## Transitional packaging design + +Setuptools cannot safely discover unrelated packages from two source roots with +one declarative `find` rule. Phase A therefore uses an explicit, reviewable +package list plus targeted `package-dir` mappings for `cacheroute` and +`cacheroute_compat`. This prevents the src-layout migration from silently +dropping root packages. The final package-migration phase removes the explicit +transition and enables src-only discovery after every listed root runtime +package is migrated or intentionally deprecated through review. + +## Later phases and risks + +1. PR #156 performs the observability implementation migration described above. +2. Later #157 phases move each root runtime package without changing its public + import path, then replace the transitional explicit package list. +3. CacheRoute 0.3.0 removes `cacheroute_compat` after downstream imports migrate. +4. Root runtime package initializers currently import optional application + dependencies. The wheel tests use separate clean virtual environments: a + no-dependency environment for lightweight namespaces and a second environment + that installs declared dependencies before exercising the full public surface. +5. Packaging data files used by runtime modules requires review during each + component migration; Phase A deliberately preserves the prior package list + and runtime behavior rather than broadening that scope. + +Run the network-dependent full clean-wheel validation explicitly with: + +```bash +CACHEROUTE_RUN_NETWORK_TESTS=1 \ + python3 -m pytest -q test/test_wheel_install.py -m network +``` + +For an offline complete wheelhouse, also set +`CACHEROUTE_TEST_WHEELHOUSE=/path/to/wheelhouse`; pip then uses `--no-index` and +`--find-links`. A default skip is not release evidence: the network-marked test +must pass once against a package index or complete wheelhouse before merge. diff --git a/instance/TTFT_predictor/WORKFLOW.md b/instance/TTFT_predictor/WORKFLOW.md index 6193410..c426495 100644 --- a/instance/TTFT_predictor/WORKFLOW.md +++ b/instance/TTFT_predictor/WORKFLOW.md @@ -15,7 +15,7 @@ python prefill_prediction_server.py ``` -入口位于 `[prefill_prediction_server.py](/d:/研/代码/PD分离推理调度代码/BurstGPT/example/scheduler/TTFT_predictor/prefill_prediction_server.py)`。 +入口位于 [`prefill_prediction_server.py`](prefill_prediction_server.py)。 启动后主流程如下: @@ -31,7 +31,7 @@ await predict_ttft(batch_size=1, prompt_length=1) ## 2. 首次初始化会触发哪些函数 -`predict_ttft(...)` 定义在 `[prefill_predictor.py](/d:/研/代码/PD分离推理调度代码/BurstGPT/example/scheduler/TTFT_predictor/prefill_predictor.py)`。 +`predict_ttft(...)` 定义在 [`prefill_predictor.py`](prefill_predictor.py)。 调用链如下: @@ -80,7 +80,7 @@ asyncio.create_task(run_warmup_in_background()) ## 4. 真实 warmup 会触发哪些函数 -`perform_detailed_warmup(...)` 位于 `[prefill_predictor.py](/d:/研/代码/PD分离推理调度代码/BurstGPT/example/scheduler/TTFT_predictor/prefill_predictor.py)`。 +`perform_detailed_warmup(...)` 位于 [`prefill_predictor.py`](prefill_predictor.py)。 它的主要流程如下: diff --git a/kdn_server/domain/models.py b/kdn_server/domain/models.py index b0a57fe..95bc135 100644 --- a/kdn_server/domain/models.py +++ b/kdn_server/domain/models.py @@ -9,7 +9,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator -from cacheroute_compat.runtime import normalize_runtime_profile +from cacheroute.compat.runtime import normalize_runtime_profile def utc_now() -> datetime: diff --git a/kdn_server/kdn_register_cli.py b/kdn_server/kdn_register_cli.py index ae61d9d..11da1b1 100644 --- a/kdn_server/kdn_register_cli.py +++ b/kdn_server/kdn_register_cli.py @@ -2,7 +2,21 @@ import argparse import os import shlex +import sys +from pathlib import Path import requests + +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + repo_root = Path(__file__).resolve().parents[1] + for source_root in (repo_root, repo_root / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() + from core import config """ Unified KDN registration/build tool for Text + KV. @@ -543,4 +557,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/pyproject.toml b/pyproject.toml index 592276c..cc7d7dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "cacheroute" version = "0.1.9" description = "Compute-network-aware LLM scheduling for flexible KV cache reuse across vLLM and LMCache systems." authors = [ - { name = "xxx", email = "xxx" } + { name = "xxx" } ] readme = "README.md" requires-python = ">=3.10" @@ -37,7 +37,41 @@ dev = [ ] [tool.setuptools] +# Transitional Phase A package map. Root packages remain installed until their +# later reviewed migrations; the final migration phase replaces this explicit +# list with src-only discovery. +package-dir = {cacheroute = "src/cacheroute", cacheroute_compat = "src/cacheroute_compat"} +packages = [ + "cacheroute", "cacheroute.compat", "cacheroute.observability", + "cacheroute_compat", + "UI", "UI.client_ui", "UI.client_ui.static", "UI.client_ui.templates", + "UI.kdn_ui", "UI.proxy_ui", "UI.proxy_ui.static", + "client", "client.taskset", "core", "data", "data.CacheRoute_dataset", + "data.CacheRoute_dataset.knowledge_document", "doc", "doc.blog", + "doc.integrations", "env", "env.config", "env.docker", "env.docker.cu130", + "env.docker.cu130.scripts", "instance", "instance.TPOT_predictor", + "instance.TTFT_predictor", "instance.TTFT_predictor.data", "instance.pclient", + "instance.resource_agent", "instance.resource_agent.src", + "instance.resource_dashboard", "instance.resource_dashboard.static", + "kdn_server", "kdn_server.KV_database", "kdn_server.contracts", + "kdn_server.domain", "kdn_server.gateway", "kdn_server.sclient", + "kdn_server.text_database", "kdn_server.text_database.blocks", + "kdn_server.util", "log", "log.scheduler", "model", "proxy", + "proxy.metrics", "proxy.metrics.data", "proxy.queue", "proxy.resource", + "proxy.sclient", "proxy.strategy", "scheduler", "scheduler.knowledge", + "scheduler.resource", "scheduler.strategy", "scripts", "store", "test", + "test.kdn", "util", +] + +[tool.setuptools.package-data] +"UI.client_ui" = ["static/*", "templates/*"] +"UI.proxy_ui" = ["static/*"] +"instance.resource_dashboard" = ["static/*"] +"instance.TTFT_predictor" = ["data/*.md", "data/*.txt"] +"proxy.metrics" = ["*.json", "data/*.json", "data/*.txt"] +model = ["*.yaml"] -[tool.setuptools.packages.find] -where = ["."] -exclude = ["tests*", "docs*", "examples*"] +[tool.pytest.ini_options] +markers = [ + "network: requires external package indexes or a complete local wheelhouse", +] diff --git a/scheduler/scheduler_cli.py b/scheduler/scheduler_cli.py index 88d4880..b10e7f8 100644 --- a/scheduler/scheduler_cli.py +++ b/scheduler/scheduler_cli.py @@ -2,11 +2,24 @@ import argparse import os import shlex +import sys import time import requests +from pathlib import Path from urllib.parse import urlparse, urlunparse +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + repo_root = Path(__file__).resolve().parents[1] + for source_root in (repo_root, repo_root / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() + from core import config """ diff --git a/scripts/validate_v1_kdn_roundtrip.py b/scripts/validate_v1_kdn_roundtrip.py index 40688af..224735f 100644 --- a/scripts/validate_v1_kdn_roundtrip.py +++ b/scripts/validate_v1_kdn_roundtrip.py @@ -20,8 +20,17 @@ from urllib.request import Request, urlopen REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) + + +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + for source_root in (REPO_ROOT, REPO_ROOT / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() import redis # noqa: E402 diff --git a/src/cacheroute/__init__.py b/src/cacheroute/__init__.py new file mode 100644 index 0000000..744bc3e --- /dev/null +++ b/src/cacheroute/__init__.py @@ -0,0 +1,3 @@ +"""CacheRoute's dependency-light public Python namespace.""" + +__all__: list[str] = [] diff --git a/cacheroute_compat/__init__.py b/src/cacheroute/compat/__init__.py similarity index 100% rename from cacheroute_compat/__init__.py rename to src/cacheroute/compat/__init__.py diff --git a/cacheroute_compat/runtime.py b/src/cacheroute/compat/runtime.py similarity index 93% rename from cacheroute_compat/runtime.py rename to src/cacheroute/compat/runtime.py index 0b424b3..f80de14 100644 --- a/cacheroute_compat/runtime.py +++ b/src/cacheroute/compat/runtime.py @@ -123,3 +123,16 @@ def filter_supported_keys( key for key in key_set if classify_lmcache_redis_key(key) in allowed } + + +__all__ = [ + "RUNTIME_PROFILE_AUTO", + "RUNTIME_PROFILE_LEGACY", + "RUNTIME_PROFILE_TEST_MOCK", + "RUNTIME_PROFILE_V1", + "SUPPORTED_RUNTIME_PROFILES", + "classify_lmcache_redis_key", + "filter_supported_keys", + "normalize_runtime_profile", + "resolve_scan_match", +] diff --git a/src/cacheroute/observability/__init__.py b/src/cacheroute/observability/__init__.py new file mode 100644 index 0000000..ac8bf0a --- /dev/null +++ b/src/cacheroute/observability/__init__.py @@ -0,0 +1,3 @@ +"""Dependency-light namespace for CacheRoute observability helpers.""" + +__all__: list[str] = [] diff --git a/src/cacheroute_compat/__init__.py b/src/cacheroute_compat/__init__.py new file mode 100644 index 0000000..0d5e0b6 --- /dev/null +++ b/src/cacheroute_compat/__init__.py @@ -0,0 +1,8 @@ +"""Deprecated forwarding package for :mod:`cacheroute.compat`. + +This import path is retained for Phase A compatibility and is scheduled for +removal in CacheRoute 0.3.0. New code should import :mod:`cacheroute.compat`. +""" + +from cacheroute.compat import * # noqa: F401,F403 +from cacheroute.compat import __all__ diff --git a/src/cacheroute_compat/runtime.py b/src/cacheroute_compat/runtime.py new file mode 100644 index 0000000..f524551 --- /dev/null +++ b/src/cacheroute_compat/runtime.py @@ -0,0 +1,4 @@ +"""Deprecated forwarding module for :mod:`cacheroute.compat.runtime`.""" + +from cacheroute.compat.runtime import * # noqa: F401,F403 +from cacheroute.compat.runtime import __all__ diff --git a/store/knowledge_build.py b/store/knowledge_build.py index 4488ba7..3cba1d0 100644 --- a/store/knowledge_build.py +++ b/store/knowledge_build.py @@ -4,14 +4,25 @@ import sys from pathlib import Path from typing import List, Dict, Any, Optional -from core.config import DEFAULT_EMBED_MODEL import yaml ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.append(str(ROOT_DIR)) + + +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + for source_root in (ROOT_DIR, ROOT_DIR / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() + + KNOWLEDGE_YAML_PATH = ROOT_DIR / "data" +from core.config import DEFAULT_EMBED_MODEL from model import EmbeddingEngine def build_knowledge_yaml( @@ -108,4 +119,4 @@ def build_knowledge_yaml( source_path= str(KNOWLEDGE_YAML_PATH / "raw_data.yaml"), output_path=str(KNOWLEDGE_YAML_PATH / "knowledge_base.yaml"), model_name="intfloat/multilingual-e5-large-instruct", - ) \ No newline at end of file + ) diff --git a/test/demo_client.py b/test/demo_client.py index 9fb3113..d80d221 100644 --- a/test/demo_client.py +++ b/test/demo_client.py @@ -23,8 +23,9 @@ def _ensure_project_root_on_syspath() -> None: while still importing packages such as client/ and UI/ from the project root. """ root = Path(__file__).resolve().parents[1] # test/ -> project root - if str(root) not in sys.path: - sys.path.insert(0, str(root)) + for source_root in (root, root / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) def run_cli() -> None: diff --git a/test/demo_instance.py b/test/demo_instance.py index 91fdad0..de00191 100644 --- a/test/demo_instance.py +++ b/test/demo_instance.py @@ -13,8 +13,9 @@ from pathlib import Path ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.append(str(ROOT_DIR)) +for source_root in (ROOT_DIR, ROOT_DIR / "src"): + if str(source_root) not in sys.path: + sys.path.append(str(source_root)) import importlib.util diff --git a/test/demo_kdn.py b/test/demo_kdn.py index eec591f..ac70310 100644 --- a/test/demo_kdn.py +++ b/test/demo_kdn.py @@ -4,8 +4,9 @@ from pathlib import Path ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.append(str(ROOT_DIR)) +for source_root in (ROOT_DIR, ROOT_DIR / "src"): + if str(source_root) not in sys.path: + sys.path.append(str(source_root)) from kdn_server.kdn_api import kdn from core import config diff --git a/test/demo_proxy.py b/test/demo_proxy.py index d652b5d..6afdd23 100644 --- a/test/demo_proxy.py +++ b/test/demo_proxy.py @@ -18,8 +18,9 @@ from pathlib import Path ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.append(str(ROOT_DIR)) +for source_root in (ROOT_DIR, ROOT_DIR / "src"): + if str(source_root) not in sys.path: + sys.path.append(str(source_root)) _CONFIG_SPEC = importlib.util.spec_from_file_location("cacheroute_core_config", ROOT_DIR / "core" / "config.py") if _CONFIG_SPEC is None or _CONFIG_SPEC.loader is None: diff --git a/test/demo_scheduler.py b/test/demo_scheduler.py index 414e8f0..ea9fc0f 100644 --- a/test/demo_scheduler.py +++ b/test/demo_scheduler.py @@ -15,8 +15,9 @@ # Allow direct execution from test/ while importing project packages from the # repository root, consistent with the other demo entrypoints. ROOT_DIR = Path(__file__).resolve().parents[1] -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) +for source_root in (ROOT_DIR, ROOT_DIR / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) from scheduler import scheduler from core import config diff --git a/test/kdn/README.md b/test/kdn/README.md index 71e95e6..1d770b3 100644 --- a/test/kdn/README.md +++ b/test/kdn/README.md @@ -86,7 +86,7 @@ python3 -m pytest -q \ test/test_instance_capability_registration.py python3 -m compileall -q \ - cacheroute_compat \ + src/cacheroute \ kdn_server/contracts \ kdn_server/domain \ kdn_server/gateway \ diff --git a/test/test_namespace_layout.py b/test/test_namespace_layout.py new file mode 100644 index 0000000..5ce448a --- /dev/null +++ b/test/test_namespace_layout.py @@ -0,0 +1,87 @@ +"""Tests for the dependency-light canonical package namespace.""" + +import subprocess +import sys +from pathlib import Path + + +PROHIBITED_MODULES = ( + "core", "proxy", "instance", "scheduler", "kdn_server", + "fastapi", "redis", "numpy", "torch", "vllm", "lmcache", +) + + +def _run_isolated(import_statement, exercise=""): + repo = Path(__file__).resolve().parents[1] + script = f""" +import sys +import runpy +runpy.run_path({str(repo / 'conftest.py')!r}) +{import_statement} +{exercise} + +for name in {PROHIBITED_MODULES!r}: + assert name not in sys.modules, name +""" + subprocess.run([sys.executable, "-I", "-c", script], check=True) + + +def test_cacheroute_import_is_dependency_light(): + _run_isolated("import cacheroute") + + +def test_compat_import_is_dependency_light_and_functional(): + _run_isolated( + "import cacheroute.compat as compatibility", + "assert compatibility.normalize_runtime_profile('modern') == 'v1'", + ) + + +def test_observability_import_is_dependency_light(): + _run_isolated("import cacheroute.observability") + + +def test_demo_client_bootstrap_resolves_source_namespace(tmp_path): + repo = Path(__file__).resolve().parents[1] + script = f""" +from pathlib import Path +import runpy +namespace = runpy.run_path({str(repo / 'test' / 'demo_client.py')!r}) +namespace['_ensure_project_root_on_syspath']() +import cacheroute.compat +path = Path(cacheroute.compat.__file__).resolve() +print(f'cacheroute.compat={{path}}') +assert path.is_relative_to({str(repo / 'src')!r}) +""" + result = subprocess.run( + [sys.executable, "-I", "-S", "-c", script], + cwd=tmp_path, + check=True, + text=True, + capture_output=True, + ) + assert "src/cacheroute/compat/__init__.py" in result.stdout + + +def test_normal_package_imports_do_not_mutate_sys_path(tmp_path): + repo = Path(__file__).resolve().parents[1] + script = f""" +import sys +import runpy +runpy.run_path({str(repo / 'conftest.py')!r}) +before = tuple(sys.path) +import client.client +assert tuple(sys.path) == before +import store.knowledge_build +assert tuple(sys.path) == before +assert not any(path.endswith('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/site-packages/src') for path in sys.path) +print('normal import sys.path unchanged') +""" + result = subprocess.run( + [sys.executable, "-I", "-c", script], + cwd=tmp_path, + check=True, + text=True, + capture_output=True, + ) + assert result.stdout.strip() == "normal import sys.path unchanged" diff --git a/test/test_repository_governance.py b/test/test_repository_governance.py new file mode 100644 index 0000000..c5dfd90 --- /dev/null +++ b/test/test_repository_governance.py @@ -0,0 +1,220 @@ +"""Repository checks that keep the Phase A migration reviewable.""" + +import ast +from fnmatch import fnmatchcase +import os +from pathlib import Path +import re +import subprocess +import tomllib + +ROOT = Path(__file__).resolve().parents[1] +ALLOWED_TOP_LEVEL_DIRECTORIES = { + ".assets", ".git", ".github", ".pytest_cache", "UI", "__pycache__", + "client", "core", "data", "doc", "env", "instance", "kdn_server", + "log", "model", "proxy", "scheduler", "scripts", "src", "store", + "test", "util", +} +LEGACY_REFERENCE_ALLOWLIST = { + Path("core/README.md"), + Path("doc/package_migration_phase_a.md"), + Path("pyproject.toml"), + Path("src/cacheroute_compat/__init__.py"), + Path("src/cacheroute_compat/runtime.py"), + Path("test/test_repository_governance.py"), + Path("test/test_runtime_compat.py"), + Path("test/test_wheel_install.py"), +} +OBSERVABILITY_REFERENCE_ALLOWLIST = { + Path("doc/package_migration_phase_a.md"), + Path("test/test_repository_governance.py"), +} +CANONICAL_PACKAGES = { + "cacheroute", "cacheroute.compat", "cacheroute.observability", + "cacheroute_compat", +} +SOURCE_BOOTSTRAP_ENTRYPOINTS = { + Path("client/client.py"), + Path("client/kv_timing_sender.py"), + Path("kdn_server/kdn_register_cli.py"), + Path("scheduler/scheduler_cli.py"), + Path("scripts/validate_v1_kdn_roundtrip.py"), + Path("store/knowledge_build.py"), + Path("test/demo_client.py"), + Path("test/demo_instance.py"), + Path("test/demo_kdn.py"), + Path("test/demo_proxy.py"), + Path("test/demo_scheduler.py"), + Path("util/kdn_build_kv.py"), +} +GUARDED_SOURCE_BOOTSTRAP_MODULES = { + Path("client/client.py"), + Path("client/kv_timing_sender.py"), + Path("kdn_server/kdn_register_cli.py"), + Path("scheduler/scheduler_cli.py"), + Path("scripts/validate_v1_kdn_roundtrip.py"), + Path("store/knowledge_build.py"), + Path("util/kdn_build_kv.py"), +} +SYS_PATH_ALLOWLIST = SOURCE_BOOTSTRAP_ENTRYPOINTS | { + Path("conftest.py"), + Path("test/test_demo_instance_ui.py"), + Path("test/test_namespace_layout.py"), + Path("test/test_repository_governance.py"), + Path("test/test_wheel_install.py"), +} +GENERATED_PACKAGE_EXCLUDES = [ + "src", "src.*", "build", "build.*", "dist", "dist.*", "wheelhouse", + "wheelhouse.*", ".venv", ".venv.*", "*.egg-info", "*.egg-info.*", + ".pytest_cache", ".pytest_cache.*", "__pycache__", "__pycache__.*", + "*.__pycache__", "*.__pycache__.*", + ".mypy_cache", ".mypy_cache.*", ".ruff_cache", ".ruff_cache.*", + "tests", "tests.*", "docs", "docs.*", +] + + +def test_no_unreviewed_functional_root_directories(): + tracked = { + path.relative_to(ROOT).parts[0] + for path in _tracked_files() if len(path.relative_to(ROOT).parts) > 1 + } + assert tracked <= ALLOWED_TOP_LEVEL_DIRECTORIES + + +def test_transitional_explicit_packages_match_root_discovery(): + configuration = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + configured = set(configuration["tool"]["setuptools"]["packages"]) + discovered_root = _discover_root_namespace_packages() + assert CANONICAL_PACKAGES <= configured + assert configured - CANONICAL_PACKAGES == discovered_root + + +def _discover_root_namespace_packages(): + packages = set() + for directory, child_directories, _files in os.walk(ROOT): + relative = Path(directory).relative_to(ROOT) + if relative == Path("."): + child_directories[:] = [name for name in child_directories if not name.startswith(".")] + continue + package = ".".join(relative.parts) + if any(fnmatchcase(package, pattern) for pattern in GENERATED_PACKAGE_EXCLUDES): + child_directories[:] = [] + continue + if not any(part.startswith(".") for part in relative.parts): + packages.add(package) + return packages + + +def test_legacy_compatibility_references_are_narrowly_allowlisted(): + references = _files_containing("cacheroute_compat") + assert references <= LEGACY_REFERENCE_ALLOWLIST + + +def test_observability_legacy_references_are_narrowly_allowlisted(): + assert not (ROOT / "cacheroute_observability").exists() + references = _files_containing("cacheroute_observability") + assert references <= OBSERVABILITY_REFERENCE_ALLOWLIST + + +def _files_containing(needle): + references = set() + for path in _tracked_files(): + try: + if needle in path.read_text(encoding="utf-8"): + references.add(path.relative_to(ROOT)) + except UnicodeDecodeError: + continue + return references + + +def _tracked_files(): + result = subprocess.run( + ["git", "ls-files", "-z"], cwd=ROOT, check=True, capture_output=True, + ) + return tuple( + ROOT / raw.decode() for raw in result.stdout.split(b"\0") if raw + ) + + +def test_legacy_shim_contains_imports_only(): + for relative in ("src/cacheroute_compat/__init__.py", "src/cacheroute_compat/runtime.py"): + module = ast.parse((ROOT / relative).read_text(encoding="utf-8")) + functional = [ + node for node in module.body + if not isinstance(node, ast.ImportFrom) + and not ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ) + ] + assert functional == [] + + +def test_source_bootstraps_stay_at_test_and_entrypoint_boundaries(): + config_tree = ast.parse((ROOT / "core/config.py").read_text(encoding="utf-8")) + assert not any(isinstance(node, (ast.Import, ast.ImportFrom)) and any( + alias.name == "sys" for alias in node.names + ) for node in config_tree.body) + assert "sys.path" not in (ROOT / "core/config.py").read_text(encoding="utf-8") + + files_with_sys_path = { + path.relative_to(ROOT) for path in _tracked_files() + if path.suffix == ".py" and "sys.path" in path.read_text(encoding="utf-8") + } + assert files_with_sys_path <= SYS_PATH_ALLOWLIST + + project_roots = {"core", "kdn_server", "proxy", "instance", "scheduler", "cacheroute"} + for relative in GUARDED_SOURCE_BOOTSTRAP_MODULES: + tree = ast.parse((ROOT / relative).read_text(encoding="utf-8")) + bootstrap_index = next( + index for index, node in enumerate(tree.body) + if isinstance(node, ast.FunctionDef) and node.name == "_bootstrap_source_checkout" + ) + guard_index = next( + index for index, node in enumerate(tree.body) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "__package__" + and any( + isinstance(child, ast.Call) + and isinstance(child.func, ast.Name) + and child.func.id == "_bootstrap_source_checkout" + for child in ast.walk(node) + ) + ) + project_import_indexes = [ + index for index, node in enumerate(tree.body) + if isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] in project_roots + or isinstance(node, ast.Import) and any( + alias.name.split(".")[0] in project_roots for alias in node.names + ) + ] + assert bootstrap_index < guard_index < min(project_import_indexes) + function = tree.body[bootstrap_index] + assert any( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "sys" + and node.attr == "path" + for node in ast.walk(function) + ) + + +def test_local_markdown_links_resolve(): + missing = [] + inline_pattern = re.compile(r"!?\[[^]]*]\(([^)]+)\)") + reference_pattern = re.compile(r"^\s*\[[^]]+]\s*:\s*(\S+)", re.MULTILINE) + fence_pattern = re.compile(r"^\s*```.*?^\s*```\s*$", re.MULTILINE | re.DOTALL) + for markdown in (path for path in _tracked_files() if path.suffix == ".md"): + text = fence_pattern.sub("", markdown.read_text(encoding="utf-8")) + targets = inline_pattern.findall(text) + reference_pattern.findall(text) + for raw_target in targets: + target = raw_target.strip().split(maxsplit=1)[0].strip("<>") + if not target or target.startswith(("#", "http://", "https://", "mailto:")): + continue + local = target.split("#", 1)[0] + if local and not (markdown.parent / local).resolve().exists(): + missing.append(f"{markdown.relative_to(ROOT)} -> {target}") + assert missing == [] diff --git a/test/test_runtime_compat.py b/test/test_runtime_compat.py index 2d7779c..a2470ca 100644 --- a/test/test_runtime_compat.py +++ b/test/test_runtime_compat.py @@ -1,6 +1,21 @@ import pytest -from core.runtime_compat import filter_supported_keys, normalize_runtime_profile, resolve_scan_match +import cacheroute.compat.runtime as canonical_runtime +import cacheroute_compat.runtime as legacy_runtime +import core.runtime_compat as core_runtime + +filter_supported_keys = canonical_runtime.filter_supported_keys +normalize_runtime_profile = canonical_runtime.normalize_runtime_profile +resolve_scan_match = canonical_runtime.resolve_scan_match + + +def test_legacy_import_forwards_to_canonical_objects(): + assert legacy_runtime.__all__ == canonical_runtime.__all__ + assert core_runtime.__all__ == canonical_runtime.__all__ + for name in canonical_runtime.__all__: + canonical = getattr(canonical_runtime, name) + assert getattr(legacy_runtime, name) is canonical + assert getattr(core_runtime, name) is canonical @pytest.mark.parametrize("value, expected", [ diff --git a/test/test_source_checkout_imports.py b/test/test_source_checkout_imports.py new file mode 100644 index 0000000..205c7f5 --- /dev/null +++ b/test/test_source_checkout_imports.py @@ -0,0 +1,60 @@ +"""Exercise application imports from outside an uninstalled source checkout.""" + +import os +from pathlib import Path +import subprocess +import sys + +import cacheroute.compat +from core.runtime_compat import normalize_runtime_profile +from kdn_server.domain import RuntimeProfile +import kdn_server.kv_builder + + +def _assert_source_imports(): + repo = Path(__file__).resolve().parents[1] + assert Path(cacheroute.compat.__file__).resolve().is_relative_to(repo / "src") + assert normalize_runtime_profile("modern") == "v1" + assert RuntimeProfile.normalize("old") is RuntimeProfile.LEGACY + assert Path(kdn_server.kv_builder.__file__).resolve().is_relative_to(repo) + + +def test_application_imports_from_outside_source_checkout(tmp_path): + if os.getenv("CACHEROUTE_SOURCE_CHECKOUT_CHILD") == "1": + _assert_source_imports() + print(f"cacheroute.compat={Path(cacheroute.compat.__file__).resolve()}") + print(f"kdn_server.kv_builder={Path(kdn_server.kv_builder.__file__).resolve()}") + return + + repo = Path(__file__).resolve().parents[1] + environment = os.environ.copy() + environment["CACHEROUTE_SOURCE_CHECKOUT_CHILD"] = "1" + environment.pop("PYTHONPATH", None) + result = subprocess.run( + [sys.executable, "-I", "-m", "pytest", "-q", "-s", str(Path(__file__).resolve())], + cwd=tmp_path, + env=environment, + check=True, + text=True, + capture_output=True, + ) + assert "2 passed" in result.stdout + assert "src/cacheroute/compat/__init__.py" in result.stdout + assert "kdn_server/kv_builder.py" in result.stdout + print(result.stdout, end="") + _assert_source_imports() + + +def test_direct_kdn_builder_entrypoint_bootstraps_source_checkout(tmp_path): + repo = Path(__file__).resolve().parents[1] + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + result = subprocess.run( + [sys.executable, "-I", str(repo / "util" / "kdn_build_kv.py"), "--help"], + cwd=tmp_path, + env=environment, + check=True, + text=True, + capture_output=True, + ) + assert "--kv-root" in result.stdout diff --git a/test/test_wheel_install.py b/test/test_wheel_install.py index 6e2eea7..2f161e4 100644 --- a/test/test_wheel_install.py +++ b/test/test_wheel_install.py @@ -1,34 +1,164 @@ -"""Verify compatibility imports from an installed wheel, not the source tree.""" +"""Verify wheel contents and imports without using the source checkout.""" from pathlib import Path +import os +import shutil import subprocess import sys +import venv +import zipfile +import pytest -def test_wheel_contains_dependency_light_compatibility_package(tmp_path): - repo = Path(__file__).resolve().parents[1] - wheelhouse = tmp_path / "wheelhouse" - installed = tmp_path / "installed" - outside = tmp_path / "outside" - wheelhouse.mkdir() - outside.mkdir() +EXPECTED_TOP_LEVEL_PACKAGES = { + "UI", "cacheroute", "cacheroute_compat", "client", "core", "data", + "env", "instance", "kdn_server", "model", "proxy", "scheduler", + "scripts", "store", "test", "util", +} +REQUIRED_PACKAGE_DATA = { + "UI/client_ui/static/app.js", + "UI/client_ui/static/style.css", + "UI/client_ui/templates/index.html", + "UI/proxy_ui/static/app.js", + "UI/proxy_ui/static/index.html", + "UI/proxy_ui/static/style.css", + "instance/resource_dashboard/static/app.js", + "instance/resource_dashboard/static/index.html", + "instance/resource_dashboard/static/style.css", + "instance/TTFT_predictor/data/README.md", + "instance/TTFT_predictor/data/log-bs1-rtx5090-8-llama3-70b.txt", + "instance/TTFT_predictor/data/补录数据.txt", + "model/model_configs.yaml", + "proxy/metrics/ttft_benchmark_table.json", + "proxy/metrics/data/redis_pull_table_from_image.json", +} + + +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory): + repo = Path(__file__).resolve().parents[1] + wheelhouse = tmp_path_factory.mktemp("wheelhouse") + source = tmp_path_factory.mktemp("wheel-source") / "CacheRoute" + shutil.copytree( + repo, + source, + ignore=shutil.ignore_patterns( + ".git", "build", "dist", "*.egg-info", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", ".venv", "wheelhouse", + ), + ) subprocess.run( - [sys.executable, "-m", "pip", "wheel", str(repo), "--no-deps", "--no-build-isolation", "-w", str(wheelhouse)], + [sys.executable, "-m", "pip", "wheel", str(source), "--no-deps", "--no-build-isolation", "-w", str(wheelhouse)], check=True, ) - wheel = next(wheelhouse.glob("cacheroute-*.whl")) - subprocess.run( - [sys.executable, "-m", "pip", "install", "--no-deps", "--target", str(installed), str(wheel)], + return next(wheelhouse.glob("cacheroute-*.whl")) + + +def _create_isolated_environment(path: Path) -> Path: + venv.EnvBuilder(with_pip=True, system_site_packages=False).create(path) + return path / "bin" / "python" + + +def _run_outside_repo(python: Path, outside: Path, script: str) -> subprocess.CompletedProcess[str]: + outside.mkdir() + return subprocess.run( + [python, "-I", "-c", script], + cwd=outside, check=True, + text=True, + capture_output=True, ) - script = f""" + + +def test_wheel_preserves_packages_and_runtime_data(built_wheel): + with zipfile.ZipFile(built_wheel) as archive: + members = set(archive.namelist()) + packaged = { + name.partition("/")[0] + for name in members + if "/" in name and not name.partition("/")[0].endswith(".dist-info") + } + assert packaged == EXPECTED_TOP_LEVEL_PACKAGES + assert REQUIRED_PACKAGE_DATA <= members + assert "instance/TTFT_predictor/prompt_length_validation.log" not in members + + +def test_dependency_light_clean_wheel_imports(built_wheel, tmp_path): + python = _create_isolated_environment(tmp_path / "light-venv") + subprocess.run([python, "-m", "pip", "install", "--no-deps", str(built_wheel)], check=True) + result = _run_outside_repo(python, tmp_path / "outside-light", """ +from pathlib import Path import sys -sys.path.insert(0, {str(installed)!r}) +import cacheroute +import cacheroute.compat +import cacheroute.compat.runtime as canonical_runtime +import cacheroute.observability +import cacheroute_compat.runtime as legacy_runtime + +modules = (cacheroute, cacheroute.compat, canonical_runtime, + cacheroute.observability, legacy_runtime) +for module in modules: + path = Path(module.__file__).resolve() + print(f"{module.__name__}={path}") + assert path.is_relative_to(Path(sys.prefix).resolve()) +assert canonical_runtime.normalize_runtime_profile("modern") == "v1" +assert canonical_runtime.__all__ == legacy_runtime.__all__ +for name in canonical_runtime.__all__: + assert getattr(legacy_runtime, name) is getattr(canonical_runtime, name) +print("dependency-light clean-wheel imports: passed") +""") + assert "dependency-light clean-wheel imports: passed" in result.stdout + print(result.stdout, end="") + + +@pytest.mark.network +@pytest.mark.skipif( + os.getenv("CACHEROUTE_RUN_NETWORK_TESTS") != "1", + reason="set CACHEROUTE_RUN_NETWORK_TESTS=1 to install declared dependencies in a clean venv", +) +def test_full_public_imports_from_clean_wheel(built_wheel, tmp_path): + python = _create_isolated_environment(tmp_path / "full-venv") + # This deliberately installs declared dependencies. An unavailable package + # index must fail the test rather than leaking host packages into the venv. + install = [python, "-m", "pip", "install"] + wheelhouse = os.getenv("CACHEROUTE_TEST_WHEELHOUSE") + if wheelhouse: + install.extend(["--no-index", "--find-links", wheelhouse]) + subprocess.run([*install, str(built_wheel)], check=True) + result = _run_outside_repo(python, tmp_path / "outside-full", """ +from pathlib import Path +import sys +import core +import core.runtime_compat as core_runtime +import proxy +import instance +import kdn_server +import kdn_server.domain +import cacheroute.compat.runtime as canonical_runtime +import cacheroute_compat.runtime as legacy_runtime from core.runtime_compat import normalize_runtime_profile from kdn_server.domain import RuntimeProfile -assert normalize_runtime_profile('modern') == 'v1' -assert RuntimeProfile.normalize('old') is RuntimeProfile.LEGACY -print('installed domain compatibility: passed') -""" - subprocess.run([sys.executable, "-I", "-c", script], cwd=outside, check=True) + +modules = (core, core_runtime, proxy, instance, kdn_server, kdn_server.domain, + canonical_runtime, legacy_runtime) +for module in modules: + path = Path(module.__file__).resolve() + print(f"{module.__name__}={path}") + assert path.is_relative_to(Path(sys.prefix).resolve()) +before_imports = tuple(sys.path) +import client.client +assert tuple(sys.path) == before_imports +import store.knowledge_build +assert tuple(sys.path) == before_imports +assert normalize_runtime_profile("modern") == "v1" +assert RuntimeProfile.normalize("old") is RuntimeProfile.LEGACY +assert canonical_runtime.__all__ == legacy_runtime.__all__ == core_runtime.__all__ +for name in canonical_runtime.__all__: + canonical = getattr(canonical_runtime, name) + assert getattr(legacy_runtime, name) is canonical + assert getattr(core_runtime, name) is canonical +print("full clean-wheel public imports: passed") +""") + assert "full clean-wheel public imports: passed" in result.stdout + print(result.stdout, end="") diff --git a/util/kdn_build_kv.py b/util/kdn_build_kv.py index f83d138..1c82591 100644 --- a/util/kdn_build_kv.py +++ b/util/kdn_build_kv.py @@ -1,7 +1,21 @@ #!/usr/bin/env python3 import argparse +import sys from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _bootstrap_source_checkout() -> None: + """Expose repository packages only for direct source-file execution.""" + for source_root in (REPO_ROOT, REPO_ROOT / "src"): + if str(source_root) not in sys.path: + sys.path.insert(0, str(source_root)) + + +if __package__ in (None, ""): + _bootstrap_source_checkout() + from kdn_server.kv_builder import KVBuildConfig, KVCacheBuilder