deptime 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
deptime/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DepTime: an API time machine for Python libraries, served to coding agents over MCP and HTTP."""
2
+
3
+ __version__ = "0.0.1"
deptime/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """`deptime` command line.
2
+
3
+ Everyday use (local, private):
4
+ deptime mcp run the MCP server over stdio for Claude Code / Cursor / Codex / VS Code
5
+ deptime explain|history|check|diff|coverage
6
+ deptime index pull|status manage the prebuilt index (downloaded from GitHub Releases)
7
+ Building the index yourself / CI:
8
+ deptime ingest <pkg>|--all analyse PyPI releases into a locally built index
9
+ deptime index pack --out DIR produce index.sqlite.gz + manifest.json for publishing
10
+ Hosting (optional):
11
+ deptime serve MCP over HTTP at /mcp plus a JSON API at /v1/*
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from deptime.config import Settings, load_coverage
22
+ from deptime.index.store import IndexStore
23
+ from deptime.models import SymbolQuery
24
+ from deptime.query import DepTime
25
+
26
+
27
+ def _err(message: str) -> None:
28
+ print(message, file=sys.stderr, flush=True)
29
+
30
+
31
+ def _dt(settings: Settings) -> DepTime:
32
+ """Query-command setup: download the index on first use (synchronously), then open it read-only."""
33
+ if not settings.index_path.exists():
34
+ from deptime.index.distribution import ensure_index
35
+
36
+ if not settings.offline:
37
+ _err(f"deptime: no index at {settings.index_path}; downloading it once…")
38
+ _err(f"deptime: {ensure_index(settings).message}")
39
+ store = IndexStore.open(settings.index_path) if settings.index_path.exists() else None
40
+ return DepTime(store, load_coverage(settings.coverage_file), settings.index_path)
41
+
42
+
43
+ def _print(model) -> None:
44
+ print(model.model_dump_json(indent=2, exclude_none=True))
45
+
46
+
47
+ def cmd_ingest(args: argparse.Namespace, settings: Settings) -> int:
48
+ from deptime.index.distribution import mark_locally_built
49
+ from deptime.ingest.pipeline import ingest_package
50
+
51
+ coverage = load_coverage(settings.coverage_file)
52
+ names = [p.name for p in coverage.packages] if args.all else args.packages
53
+ if not names:
54
+ _err("name at least one package, or pass --all")
55
+ return 2
56
+ store = IndexStore.open(settings.index_path, readonly=False)
57
+ mark_locally_built(settings.index_path) # from now on this index is yours; it is never auto-replaced
58
+ failures = 0
59
+ for name in names:
60
+ spec = coverage.get(name)
61
+ if spec is None:
62
+ _err(f"{name}: not in the coverage list (src/deptime/coverage.toml or $DEPTIME_COVERAGE_FILE)")
63
+ failures += 1
64
+ continue
65
+ try:
66
+ report = ingest_package(
67
+ spec, coverage.policy, store, settings.cache_dir, versions=args.versions or None, log=_err
68
+ )
69
+ except Exception as exc: # one broken package must not stop the rest of the run
70
+ _err(f"[{name}] FAILED: {type(exc).__name__}: {exc}")
71
+ failures += 1
72
+ continue
73
+ failures += bool(report.failed)
74
+ if report.failed:
75
+ _err(json.dumps({"package": name, "failed": report.failed}, indent=2))
76
+ store.close()
77
+ return 1 if failures else 0
78
+
79
+
80
+ def cmd_explain(args: argparse.Namespace, settings: Settings) -> int:
81
+ text = Path(args.file).read_text() if args.file else sys.stdin.read()
82
+ installed = dict(item.split("==", 1) for item in args.installed)
83
+ freeze = Path(args.freeze).read_text() if args.freeze else None
84
+ _print(_dt(settings).explain_error(text, installed or None, freeze))
85
+ return 0
86
+
87
+
88
+ def cmd_history(args: argparse.Namespace, settings: Settings) -> int:
89
+ _print(_dt(settings).symbol_history(args.package, args.path))
90
+ return 0
91
+
92
+
93
+ def cmd_check(args: argparse.Namespace, settings: Settings) -> int:
94
+ queries = []
95
+ for raw in args.symbols:
96
+ path, _, kw = raw.partition(":")
97
+ queries.append(SymbolQuery(path=path, kwargs=[k for k in kw.split(",") if k]))
98
+ _print(_dt(settings).check_symbols(args.package, args.version, queries))
99
+ return 0
100
+
101
+
102
+ def cmd_diff(args: argparse.Namespace, settings: Settings) -> int:
103
+ _print(
104
+ _dt(settings).diff_versions(
105
+ args.package,
106
+ args.from_version,
107
+ args.to_version,
108
+ args.used or None,
109
+ args.include_added,
110
+ args.limit,
111
+ )
112
+ )
113
+ return 0
114
+
115
+
116
+ def cmd_coverage(args: argparse.Namespace, settings: Settings) -> int:
117
+ _print(_dt(settings).coverage_report())
118
+ return 0
119
+
120
+
121
+ def cmd_index(args: argparse.Namespace, settings: Settings) -> int:
122
+ from deptime.index import distribution as dist
123
+
124
+ if args.action == "pull":
125
+ local = dist.read_local_meta(settings.index_path)
126
+ if settings.index_path.exists() and local is None and not args.force:
127
+ _err(
128
+ f"{settings.index_path} was built locally; pass --force to replace it with the published index"
129
+ )
130
+ return 1
131
+ with dist.http_client() as client:
132
+ manifest = dist.fetch_manifest(settings.index_url, client)
133
+ if local and local.get("manifest", {}).get("sha256") == manifest.sha256 and not args.force:
134
+ _err(f"deptime: index already up to date (built {manifest.built_at})")
135
+ return 0
136
+ dist.download(settings.index_url, settings.index_path, client, manifest=manifest)
137
+ _err(f"deptime: installed index with {manifest.packages} packages, built {manifest.built_at}")
138
+ return 0
139
+
140
+ if args.action == "pack":
141
+ if not settings.index_path.exists():
142
+ _err(f"no index at {settings.index_path}; run `deptime ingest` first")
143
+ return 1
144
+ manifest = dist.pack(settings.index_path, Path(args.out))
145
+ print(json.dumps(manifest.__dict__, indent=2))
146
+ return 0
147
+
148
+ # status
149
+ local = dist.read_local_meta(settings.index_path)
150
+ status: dict[str, object] = {
151
+ "index_path": str(settings.index_path),
152
+ "exists": settings.index_path.exists(),
153
+ "source": "downloaded" if local else ("locally built" if settings.index_path.exists() else None),
154
+ "index_url": settings.index_url,
155
+ "offline": settings.offline,
156
+ }
157
+ if settings.index_path.exists():
158
+ store = IndexStore.open(settings.index_path)
159
+ status.update(
160
+ built_at=store.built_at(),
161
+ packages=len(store.package_rows()),
162
+ size_bytes=settings.index_path.stat().st_size,
163
+ )
164
+ store.close()
165
+ if local:
166
+ status.update(downloaded_at=local.get("downloaded_at"), checked_at=local.get("checked_at"))
167
+ print(json.dumps(status, indent=2))
168
+ return 0
169
+
170
+
171
+ def cmd_mcp(args: argparse.Namespace, settings: Settings) -> int:
172
+ """Local stdio MCP server. stdout is the protocol channel: everything else goes to stderr."""
173
+ from deptime.index.distribution import start_background_refresh
174
+ from deptime.server.mcp_app import build_mcp
175
+
176
+ store = IndexStore.open(settings.index_path) if settings.index_path.exists() else None
177
+ dt = DepTime(store, load_coverage(settings.coverage_file), settings.index_path)
178
+ start_background_refresh(dt, settings)
179
+ build_mcp(dt).run(transport="stdio")
180
+ return 0
181
+
182
+
183
+ def cmd_serve(args: argparse.Namespace, settings: Settings) -> int:
184
+ import uvicorn
185
+
186
+ from deptime.server.http_app import create_app
187
+
188
+ uvicorn.run(create_app(settings), host=args.host or settings.host, port=args.port or settings.port)
189
+ return 0
190
+
191
+
192
+ def main(argv: list[str] | None = None) -> int:
193
+ parser = argparse.ArgumentParser(
194
+ prog="deptime", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
195
+ )
196
+ sub = parser.add_subparsers(dest="cmd", required=True)
197
+
198
+ p = sub.add_parser("mcp", help="run the MCP server over stdio (local mode)")
199
+ p.set_defaults(func=cmd_mcp)
200
+
201
+ p = sub.add_parser("explain", help="explain a traceback (stdin or --file)")
202
+ p.add_argument("--file")
203
+ p.add_argument("--installed", nargs="*", default=[], metavar="PKG==VER")
204
+ p.add_argument("--freeze", help="path to `pip freeze` output")
205
+ p.set_defaults(func=cmd_explain)
206
+
207
+ p = sub.add_parser("history", help="version history of one symbol")
208
+ p.add_argument("package")
209
+ p.add_argument("path")
210
+ p.set_defaults(func=cmd_history)
211
+
212
+ p = sub.add_parser("check", help="check symbols (path or path:kw1,kw2) at a version")
213
+ p.add_argument("package")
214
+ p.add_argument("version")
215
+ p.add_argument("symbols", nargs="+")
216
+ p.set_defaults(func=cmd_check)
217
+
218
+ p = sub.add_parser("diff", help="breaking changes between versions (from may be YYYY-MM-DD)")
219
+ p.add_argument("package")
220
+ p.add_argument("from_version")
221
+ p.add_argument("to_version", nargs="?")
222
+ p.add_argument("--used", nargs="*")
223
+ p.add_argument("--include-added", action="store_true")
224
+ p.add_argument("--limit", type=int, default=200)
225
+ p.set_defaults(func=cmd_diff)
226
+
227
+ p = sub.add_parser("coverage", help="what is indexed")
228
+ p.set_defaults(func=cmd_coverage)
229
+
230
+ p = sub.add_parser("index", help="manage the index: pull | status | pack")
231
+ p.add_argument("action", choices=["pull", "status", "pack"])
232
+ p.add_argument("--force", action="store_true", help="pull: replace a locally built or current index")
233
+ p.add_argument("--out", default="dist", help="pack: output directory")
234
+ p.set_defaults(func=cmd_index)
235
+
236
+ p = sub.add_parser("ingest", help="build or extend a local index from PyPI releases")
237
+ p.add_argument("packages", nargs="*")
238
+ p.add_argument("--all", action="store_true", help="every package in the coverage list")
239
+ p.add_argument("--versions", nargs="*", help="explicit versions instead of the coverage policy")
240
+ p.set_defaults(func=cmd_ingest)
241
+
242
+ p = sub.add_parser("serve", help="host MCP over HTTP (/mcp) plus the JSON API (/v1/*)")
243
+ p.add_argument("--host")
244
+ p.add_argument("--port", type=int)
245
+ p.set_defaults(func=cmd_serve)
246
+
247
+ args = parser.parse_args(argv)
248
+ return args.func(args, Settings.from_env())
249
+
250
+
251
+ if __name__ == "__main__":
252
+ raise SystemExit(main())
deptime/config.py ADDED
@@ -0,0 +1,160 @@
1
+ """Runtime settings (from environment) and the coverage list (packaged `deptime/coverage.toml`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ from packaging.utils import canonicalize_name
13
+
14
+ # Rolling GitHub Release that CI refreshes nightly (.github/workflows/build-index.yml).
15
+ DEFAULT_INDEX_URL = "https://github.com/cschmertz/deptime/releases/download/index"
16
+
17
+
18
+ def _env(name: str, default: str) -> str:
19
+ value = os.environ.get(name, "").strip()
20
+ return value or default
21
+
22
+
23
+ def _env_int(name: str, default: int) -> int:
24
+ return int(_env(name, str(default)))
25
+
26
+
27
+ def _env_bool(name: str, default: bool) -> bool:
28
+ return _env(name, "true" if default else "false").lower() in {"1", "true", "yes", "on"}
29
+
30
+
31
+ def default_home() -> Path:
32
+ """Where DepTime keeps its index and build cache: $DEPTIME_HOME, else $XDG_CACHE_HOME/deptime, else ~/.cache/deptime."""
33
+ explicit = os.environ.get("DEPTIME_HOME", "").strip()
34
+ if explicit:
35
+ return Path(explicit).expanduser()
36
+ xdg = os.environ.get("XDG_CACHE_HOME", "").strip()
37
+ return (Path(xdg).expanduser() if xdg else Path.home() / ".cache") / "deptime"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Settings:
42
+ index_path: Path # the SQLite index the tools read
43
+ cache_dir: Path # ingestion build cache: artifacts, extracted wheels, snapshots
44
+ coverage_file: Path | None # None = the coverage list packaged with deptime
45
+ index_url: str # base URL serving manifest.json + index.sqlite.gz
46
+ index_max_age_hours: int # how stale a *downloaded* index may get before a refresh check
47
+ offline: bool # never touch the network (no index download/refresh)
48
+ # --- hosted mode only (local stdio mode never meters) ---
49
+ host: str
50
+ port: int
51
+ public_url: str
52
+ trust_proxy_headers: bool
53
+ anon_daily_limit: int
54
+ free_key_monthly_limit: int
55
+ pro_monthly_limit: int
56
+ dev_api_keys: str # "key:plan,key2:plan"; a real key store is parked with the paid tiers
57
+ # With trust_proxy_headers: proxies *append* to X-Forwarded-For, so the client is this many hops from the right.
58
+ trusted_proxy_hops: int = 1
59
+ client_ip_header: str = "" # e.g. "Fly-Client-IP": a header your platform's proxy sets (wins over XFF)
60
+
61
+ @classmethod
62
+ def from_env(cls) -> Settings:
63
+ home = default_home()
64
+ coverage = os.environ.get("DEPTIME_COVERAGE_FILE", "").strip()
65
+ return cls(
66
+ index_path=Path(_env("DEPTIME_INDEX_PATH", str(home / "index.sqlite"))).expanduser(),
67
+ cache_dir=Path(_env("DEPTIME_CACHE_DIR", str(home / "build"))).expanduser(),
68
+ coverage_file=Path(coverage).expanduser() if coverage else None,
69
+ index_url=_env("DEPTIME_INDEX_URL", DEFAULT_INDEX_URL).rstrip("/"),
70
+ index_max_age_hours=_env_int("DEPTIME_INDEX_MAX_AGE_HOURS", 24),
71
+ offline=_env_bool("DEPTIME_OFFLINE", False),
72
+ host=_env("DEPTIME_HOST", "127.0.0.1"),
73
+ port=_env_int("DEPTIME_PORT", 8000),
74
+ public_url=_env("DEPTIME_PUBLIC_URL", "http://127.0.0.1:8000"),
75
+ trust_proxy_headers=_env_bool("DEPTIME_TRUST_PROXY_HEADERS", False),
76
+ anon_daily_limit=_env_int("DEPTIME_ANON_DAILY_LIMIT", 25),
77
+ free_key_monthly_limit=_env_int("DEPTIME_FREE_KEY_MONTHLY_LIMIT", 1500),
78
+ pro_monthly_limit=_env_int("DEPTIME_PRO_MONTHLY_LIMIT", 20000),
79
+ dev_api_keys=_env("DEPTIME_DEV_API_KEYS", ""),
80
+ trusted_proxy_hops=max(1, _env_int("DEPTIME_TRUSTED_PROXY_HOPS", 1)),
81
+ client_ip_header=_env("DEPTIME_CLIENT_IP_HEADER", ""),
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class VersionPolicy:
87
+ since: date
88
+ recent_months: int
89
+ include_prereleases: bool
90
+ include_yanked: bool
91
+
92
+ def describe(self) -> str:
93
+ return (
94
+ f"first+last release of every minor series active since {self.since.isoformat()}, "
95
+ f"plus every release from the last {self.recent_months} months; "
96
+ f"pre-releases {'included' if self.include_prereleases else 'excluded'}, "
97
+ f"yanked {'included' if self.include_yanked else 'excluded'}"
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class PackageSpec:
103
+ name: str # canonical PEP 503 name
104
+ import_names: tuple[str, ...]
105
+ tier: str
106
+ notes: str = ""
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Coverage:
111
+ policy: VersionPolicy
112
+ packages: tuple[PackageSpec, ...]
113
+
114
+ def get(self, name: str) -> PackageSpec | None:
115
+ wanted = canonicalize_name(name)
116
+ return next((p for p in self.packages if p.name == wanted), None)
117
+
118
+ def packages_for_module(self, module: str) -> list[PackageSpec]:
119
+ """Packages whose import names are the longest dotted prefix of `module`.
120
+
121
+ `google.genai.types` -> google-genai; `agents.run` -> openai-agents. Several packages can tie
122
+ (import-name collisions); callers disambiguate with installed_versions.
123
+ """
124
+ best_len, best = 0, []
125
+ for spec in self.packages:
126
+ for import_name in spec.import_names:
127
+ if module == import_name or module.startswith(import_name + "."):
128
+ n = import_name.count(".") + 1
129
+ if n > best_len:
130
+ best_len, best = n, [spec]
131
+ elif n == best_len and spec not in best:
132
+ best.append(spec)
133
+ return best
134
+
135
+
136
+ def load_coverage(path: Path | None = None) -> Coverage:
137
+ """Load a coverage file, or the list packaged with deptime when `path` is None."""
138
+ text = (
139
+ path.read_text(encoding="utf-8")
140
+ if path is not None
141
+ else resources.files("deptime").joinpath("coverage.toml").read_text(encoding="utf-8")
142
+ )
143
+ raw = tomllib.loads(text)
144
+ pol = raw.get("policy", {})
145
+ policy = VersionPolicy(
146
+ since=date.fromisoformat(pol.get("since", "2023-01-01")),
147
+ recent_months=int(pol.get("recent_months", 12)),
148
+ include_prereleases=bool(pol.get("include_prereleases", False)),
149
+ include_yanked=bool(pol.get("include_yanked", False)),
150
+ )
151
+ packages = tuple(
152
+ PackageSpec(
153
+ name=canonicalize_name(p["name"]),
154
+ import_names=tuple(p.get("import_names") or [canonicalize_name(p["name"]).replace("-", "_")]),
155
+ tier=p.get("tier", "B"),
156
+ notes=p.get("notes", ""),
157
+ )
158
+ for p in raw.get("package", [])
159
+ )
160
+ return Coverage(policy=policy, packages=packages)