python-docs-mcp-server 0.1.2__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.
Files changed (36) hide show
  1. mcp_server_python_docs/__init__.py +17 -0
  2. mcp_server_python_docs/__main__.py +574 -0
  3. mcp_server_python_docs/app_context.py +33 -0
  4. mcp_server_python_docs/data/synonyms.yaml +194 -0
  5. mcp_server_python_docs/detection.py +82 -0
  6. mcp_server_python_docs/diagnostics.py +74 -0
  7. mcp_server_python_docs/errors.py +33 -0
  8. mcp_server_python_docs/ingestion/__init__.py +1 -0
  9. mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
  10. mcp_server_python_docs/ingestion/inventory.py +178 -0
  11. mcp_server_python_docs/ingestion/publish.py +504 -0
  12. mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
  13. mcp_server_python_docs/models.py +216 -0
  14. mcp_server_python_docs/py.typed +0 -0
  15. mcp_server_python_docs/retrieval/__init__.py +31 -0
  16. mcp_server_python_docs/retrieval/budget.py +63 -0
  17. mcp_server_python_docs/retrieval/query.py +183 -0
  18. mcp_server_python_docs/retrieval/ranker.py +369 -0
  19. mcp_server_python_docs/server.py +329 -0
  20. mcp_server_python_docs/services/__init__.py +7 -0
  21. mcp_server_python_docs/services/cache.py +111 -0
  22. mcp_server_python_docs/services/content.py +155 -0
  23. mcp_server_python_docs/services/observability.py +130 -0
  24. mcp_server_python_docs/services/package_docs.py +161 -0
  25. mcp_server_python_docs/services/persistent_cache.py +147 -0
  26. mcp_server_python_docs/services/search.py +111 -0
  27. mcp_server_python_docs/services/version.py +47 -0
  28. mcp_server_python_docs/services/version_resolution.py +79 -0
  29. mcp_server_python_docs/storage/__init__.py +1 -0
  30. mcp_server_python_docs/storage/db.py +146 -0
  31. mcp_server_python_docs/storage/schema.sql +136 -0
  32. python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
  33. python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
  34. python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
  35. python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
  36. python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,194 @@
1
+ # Python stdlib concept synonym/expansion table
2
+ # Format: concept: [expansion terms]
3
+ # Used for query expansion before FTS5 search
4
+ # ~150 curated entries covering major Python stdlib concepts
5
+
6
+ # Concurrency & Parallelism
7
+ parallel: [concurrent, multiprocessing, threading, asyncio, concurrent.futures, parallel execution]
8
+ async: [asyncio, await, coroutine, async for, async with, event loop, asynchronous]
9
+ threading: [thread, threading, Thread, Lock, RLock, Semaphore, Barrier, Event, Condition]
10
+ multiprocessing: [multiprocessing, Process, Pool, Queue, Pipe, shared memory]
11
+ concurrent: [concurrent.futures, ThreadPoolExecutor, ProcessPoolExecutor, Future, Executor]
12
+ subprocess: [subprocess, subprocess.run, subprocess.Popen, shlex, shell command]
13
+ event loop: [asyncio, asyncio.run, asyncio.get_event_loop, AbstractEventLoop]
14
+ coroutine: [asyncio, async def, await, coroutine, Task, gather]
15
+ task group: [asyncio.TaskGroup, asyncio.gather, asyncio.wait, task management]
16
+ synchronization: [Lock, RLock, Semaphore, Event, Condition, Barrier, threading]
17
+ future: [concurrent.futures, Future, asyncio.Future, result, exception]
18
+
19
+ # Data Serialization
20
+ json: [json, json.loads, json.dumps, json.load, json.dump, JSON parsing]
21
+ parse json: [json, json.loads, json.load, JSON decoding]
22
+ serialize json: [json, json.dumps, json.dump, JSON encoding]
23
+ csv: [csv, csv.reader, csv.writer, csv.DictReader, csv.DictWriter, delimiter]
24
+ xml: [xml, xml.etree.ElementTree, xml.dom, xml.sax, XML parsing]
25
+ html parsing: [html.parser, HTMLParser, html, html.entities]
26
+ serialization: [json, marshal, object serialization, data format]
27
+ struct: [struct, struct.pack, struct.unpack, binary data, byte packing]
28
+ base64: [base64, base64.b64encode, base64.b64decode, encoding]
29
+ configparser: [configparser, ConfigParser, ini file, configuration]
30
+ toml: [tomllib, tomllib.load, TOML parsing]
31
+
32
+ # File System & I/O
33
+ file io: [open, pathlib, os.path, io, shutil, fileinput, file reading, file writing]
34
+ path: [pathlib, pathlib.Path, os.path, path manipulation, file path]
35
+ pathlib: [pathlib, Path, PurePath, PosixPath, WindowsPath]
36
+ temp files: [tempfile, TemporaryFile, NamedTemporaryFile, mkdtemp, temporary]
37
+ glob: [glob, glob.glob, fnmatch, pattern matching, file search]
38
+ shutil: [shutil, shutil.copy, shutil.move, shutil.rmtree, file operations]
39
+ directory: [os, os.listdir, os.scandir, os.walk, pathlib, directory listing]
40
+ file copy: [shutil.copy, shutil.copy2, shutil.copytree, file duplication]
41
+ file delete: [os.remove, os.unlink, shutil.rmtree, pathlib.Path.unlink]
42
+ read file: [open, read, readline, readlines, pathlib.Path.read_text]
43
+ write file: [open, write, writelines, pathlib.Path.write_text]
44
+ binary io: [io, io.BytesIO, io.BufferedReader, io.BufferedWriter, binary]
45
+ text io: [io, io.StringIO, io.TextIOWrapper, text stream]
46
+
47
+ # Networking & Web
48
+ http: [http, http.client, http.server, urllib, urllib.request, HTTP]
49
+ http requests: [urllib, http.client, urllib.request, urllib.parse, URL fetch]
50
+ socket: [socket, socket.socket, TCP, UDP, network programming]
51
+ ssl: [ssl, ssl.create_default_context, TLS, certificate, HTTPS]
52
+ email: [email, email.message, smtplib, imaplib, email parsing]
53
+ url: [urllib, urllib.parse, urlparse, urlencode, URL manipulation]
54
+ ftp: [ftplib, FTP, file transfer]
55
+ http server: [http.server, HTTPServer, SimpleHTTPRequestHandler]
56
+ url parsing: [urllib.parse, urlparse, urljoin, quote, unquote]
57
+ ip address: [ipaddress, IPv4Address, IPv6Address, ip_network]
58
+
59
+ # Text Processing
60
+ string: [str, string, string formatting, string methods, text]
61
+ string formatting: [str.format, f-string, format, printf, string interpolation]
62
+ regex: [re, regular expression, pattern, re.match, re.search, re.findall, re.sub]
63
+ text processing: [string, textwrap, difflib, unicodedata, text manipulation]
64
+ unicode: [unicodedata, codecs, encoding, utf-8, character, codepoint]
65
+ encoding: [codecs, encode, decode, utf-8, ascii, latin-1, chardet]
66
+ template: [string.Template, string formatting, placeholder]
67
+ textwrap: [textwrap, textwrap.wrap, textwrap.dedent, text wrapping]
68
+ difflib: [difflib, SequenceMatcher, unified_diff, context_diff]
69
+ translate: [str.translate, str.maketrans, character mapping]
70
+
71
+ # Data Structures
72
+ list: [list, append, extend, insert, sort, sorted, sequence]
73
+ dict: [dict, dictionary, mapping, keys, values, items, defaultdict]
74
+ set: [set, frozenset, intersection, union, difference, symmetric_difference]
75
+ tuple: [tuple, namedtuple, immutable sequence]
76
+ collections: [collections, deque, defaultdict, OrderedDict, Counter, ChainMap, namedtuple]
77
+ deque: [collections.deque, double-ended queue, appendleft, popleft]
78
+ counter: [collections.Counter, counting, most_common, frequency]
79
+ defaultdict: [collections.defaultdict, default factory, missing key]
80
+ ordered dict: [collections.OrderedDict, ordered dictionary, insertion order]
81
+ named tuple: [collections.namedtuple, typing.NamedTuple, named fields]
82
+ array: [array, array.array, typed array, numeric array]
83
+ queue: [queue, Queue, LifoQueue, PriorityQueue, SimpleQueue]
84
+ heap: [heapq, heappush, heappop, priority queue, heap]
85
+ bisect: [bisect, bisect_left, bisect_right, insort, sorted insertion]
86
+ chain map: [collections.ChainMap, nested scopes, mapping chain]
87
+
88
+ # Date & Time
89
+ date time: [datetime, time, calendar, zoneinfo, timedelta, timezone]
90
+ datetime: [datetime, datetime.datetime, datetime.date, datetime.time, datetime.timedelta]
91
+ time: [time, time.time, time.sleep, time.monotonic, time.perf_counter]
92
+ calendar: [calendar, calendar.month, weekday, leap year]
93
+ timezone: [zoneinfo, ZoneInfo, timezone, datetime.timezone, UTC]
94
+ timedelta: [datetime.timedelta, time difference, duration]
95
+
96
+ # Type System
97
+ type hint: [typing, annotation, generic, TypeVar, Protocol, Union, Optional]
98
+ typing: [typing, List, Dict, Set, Tuple, Optional, Union, Any, Generic]
99
+ protocol: [typing.Protocol, structural subtyping, duck typing, ABC]
100
+ generic: [typing.Generic, TypeVar, ParamSpec, type variable]
101
+ dataclass: [dataclasses, dataclass, field, asdict, astuple]
102
+ enum: [enum, Enum, IntEnum, Flag, auto, enumeration]
103
+ abstract: [abc, ABC, abstractmethod, abstract base class]
104
+ type checking: [typing, TYPE_CHECKING, runtime type, mypy, pyright]
105
+ annotated: [typing.Annotated, type annotation, metadata]
106
+ type alias: [typing.TypeAlias, type, NewType]
107
+
108
+ # Testing
109
+ testing: [unittest, pytest, mock, doctest, unittest.mock, test]
110
+ unittest: [unittest, TestCase, setUp, tearDown, assert, test runner]
111
+ mock: [unittest.mock, Mock, MagicMock, patch, side_effect]
112
+ doctest: [doctest, doctest.testmod, interactive example, docstring test]
113
+ assert: [assert, assertEqual, assertTrue, assertRaises, assertion]
114
+
115
+ # Math & Numbers
116
+ math: [math, math.sqrt, math.ceil, math.floor, math.log, mathematics]
117
+ decimal: [decimal, Decimal, precision, rounding, financial]
118
+ fractions: [fractions, Fraction, rational number, numerator, denominator]
119
+ random: [random, random.choice, random.randint, random.shuffle, random.sample]
120
+ statistics: [statistics, mean, median, stdev, variance, statistical]
121
+ complex: [complex, cmath, complex number, imaginary]
122
+ number: [int, float, complex, decimal, fractions, numeric]
123
+ infinity: [math.inf, float inf, math.isinf, infinite]
124
+
125
+ # Functional Programming
126
+ itertools: [itertools, chain, product, permutations, combinations, cycle, repeat]
127
+ functools: [functools, lru_cache, partial, reduce, wraps, total_ordering]
128
+ operator: [operator, itemgetter, attrgetter, methodcaller, operator overloading]
129
+ contextlib: [contextlib, contextmanager, suppress, redirect_stdout, ExitStack]
130
+ context manager: [contextlib, with statement, __enter__, __exit__, contextmanager]
131
+ decorator: [functools.wraps, decorator pattern, wrapper function]
132
+ lambda: [lambda, anonymous function, inline function]
133
+ map filter: [map, filter, reduce, list comprehension, generator expression]
134
+ partial: [functools.partial, partial application, currying]
135
+ cache: [functools.lru_cache, functools.cache, memoization, caching]
136
+
137
+ # OS & System
138
+ os: [os, os.path, os.environ, os.getcwd, operating system]
139
+ platform: [platform, sys.platform, os.name, system information]
140
+ signal: [signal, signal.signal, SIGINT, SIGTERM, signal handler]
141
+ environment: [os.environ, os.getenv, environment variable, env]
142
+ process: [os, os.getpid, os.fork, multiprocessing, process management]
143
+ permissions: [os.chmod, os.chown, stat, file permissions, access]
144
+ system info: [platform, sys, os.uname, machine, processor]
145
+ working directory: [os.getcwd, os.chdir, pathlib.Path.cwd]
146
+ symlink: [os.symlink, os.readlink, symbolic link, pathlib.Path.symlink_to]
147
+
148
+ # Debugging & Inspection
149
+ logging: [logging, logger, getLogger, basicConfig, log level, handler]
150
+ warnings: [warnings, warn, DeprecationWarning, filter, simplefilter]
151
+ traceback: [traceback, traceback.format_exc, traceback.print_exc, stack trace]
152
+ pdb: [pdb, breakpoint, debugger, pdb.set_trace, step, continue]
153
+ inspect: [inspect, inspect.signature, inspect.getmembers, introspection]
154
+ ast: [ast, abstract syntax tree, ast.parse, ast.dump, code analysis]
155
+ dis: [dis, disassemble, bytecode, opcode, instruction]
156
+ gc: [gc, garbage collector, gc.collect, gc.get_objects, memory]
157
+ tracemalloc: [tracemalloc, memory tracking, memory leak, allocation]
158
+ profiling: [cProfile, profile, pstats, timeit, performance measurement]
159
+ weakref: [weakref, WeakRef, weak reference, reference counting]
160
+
161
+ # Security & Cryptography
162
+ hashlib: [hashlib, md5, sha256, sha512, hash, digest, checksum]
163
+ secrets: [secrets, token_hex, token_urlsafe, secure random, password]
164
+ hmac: [hmac, message authentication, HMAC, digest, signature]
165
+ password: [hashlib, secrets, password hashing, salt]
166
+
167
+ # CLI & Arguments
168
+ argparse: [argparse, ArgumentParser, add_argument, parse_args, command line]
169
+ command line: [argparse, sys.argv, click, CLI, command line interface]
170
+ getopt: [getopt, option parsing, command line options]
171
+ shlex: [shlex, shlex.split, shell parsing, command splitting]
172
+
173
+ # Compression & Archives
174
+ zip: [zipfile, ZipFile, compression, archive, extract]
175
+ tar: [tarfile, TarFile, tar archive, gzip, bzip2]
176
+ gzip: [gzip, GzipFile, compression, decompress]
177
+ compression: [zlib, gzip, bz2, lzma, compress, decompress]
178
+
179
+ # Copy & Comparison
180
+ copy: [copy, copy.copy, copy.deepcopy, shallow copy, deep copy]
181
+ comparison: [operator, functools.total_ordering, __eq__, __lt__, rich comparison]
182
+ pprint: [pprint, pprint.pprint, pretty print, formatted output]
183
+ repr: [repr, __repr__, __str__, string representation]
184
+
185
+ # Internationalization
186
+ locale: [locale, localization, number format, currency format]
187
+ gettext: [gettext, translation, internationalization, i18n]
188
+
189
+ # Networking Protocols
190
+ xmlrpc: [xmlrpc, xmlrpc.client, xmlrpc.server, RPC, remote procedure call]
191
+
192
+ # Database
193
+ sqlite: [sqlite3, sqlite3.connect, SQL, database, cursor, execute]
194
+ dbm: [dbm, dbm.open, key-value store, persistent dictionary]
@@ -0,0 +1,82 @@
1
+ """Python version detection from the user's environment.
2
+
3
+ Probes multiple sources to determine which Python version the user
4
+ is working with, so the server can default to the right documentation.
5
+
6
+ Detection order:
7
+ 1. .python-version file in cwd (pyenv, mise, rtx convention)
8
+ 2. ``python3 --version`` in PATH (user's active interpreter)
9
+ 3. ``sys.version_info`` (server's own runtime — last resort)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _VERSION_RE = re.compile(r"(\d+\.\d+)")
22
+
23
+
24
+ def _parse_major_minor(raw: str) -> str | None:
25
+ """Extract 'X.Y' from a version string like '3.13.2', 'Python 3.13.2', 'cpython-3.13'."""
26
+ m = _VERSION_RE.search(raw)
27
+ return m.group(1) if m else None
28
+
29
+
30
+ def detect_python_version() -> tuple[str, str]:
31
+ """Detect the user's Python version.
32
+
33
+ Returns:
34
+ Tuple of (major_minor, source) where major_minor is like '3.13'
35
+ and source describes how it was detected.
36
+ """
37
+ # 1. .python-version file (pyenv / mise / rtx)
38
+ pv_file = Path.cwd() / ".python-version"
39
+ if pv_file.is_file():
40
+ try:
41
+ first_line = pv_file.read_text().strip().splitlines()[0].strip()
42
+ version = _parse_major_minor(first_line)
43
+ if version:
44
+ logger.info("Detected Python %s from .python-version", version)
45
+ return version, ".python-version file"
46
+ except Exception:
47
+ pass
48
+
49
+ # 2. python3 --version in PATH
50
+ try:
51
+ result = subprocess.run(
52
+ ["python3", "--version"],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=5,
56
+ )
57
+ if result.returncode == 0:
58
+ version = _parse_major_minor(result.stdout.strip())
59
+ if version:
60
+ logger.info("Detected Python %s from python3 in PATH", version)
61
+ return version, "python3 in PATH"
62
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
63
+ pass
64
+
65
+ # 3. Server's own runtime
66
+ version = f"{sys.version_info.major}.{sys.version_info.minor}"
67
+ logger.info("Using server runtime Python %s as fallback", version)
68
+ return version, "server runtime"
69
+
70
+
71
+ def match_to_indexed(
72
+ detected: str, indexed_versions: list[str]
73
+ ) -> str | None:
74
+ """Match a detected version to the closest indexed version.
75
+
76
+ Returns the detected version if it's in the index, otherwise None.
77
+ We don't guess -- if a detected version is not indexed, return None and
78
+ let the normal default resolution handle it.
79
+ """
80
+ if detected in indexed_versions:
81
+ return detected
82
+ return None
@@ -0,0 +1,74 @@
1
+ """Environment diagnostics shared by CLI health checks."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DiagnosticResult:
11
+ """Result for a single environment diagnostic probe."""
12
+
13
+ passed: bool
14
+ detail: str
15
+
16
+
17
+ def _combined_output_excerpt(stdout: str, stderr: str, limit: int = 500) -> str:
18
+ combined = "\n".join(part.strip() for part in (stderr, stdout) if part.strip())
19
+ if len(combined) <= limit:
20
+ return combined
21
+ return combined[-limit:]
22
+
23
+
24
+ def check_build_venv_support(
25
+ python_executable: str | None = None,
26
+ timeout: float = 10.0,
27
+ ) -> DiagnosticResult:
28
+ """Check that build-index can create pip-enabled Sphinx environments.
29
+
30
+ ``build-index`` creates a disposable Sphinx virtual environment with pip.
31
+ Debian/Ubuntu hosts without the matching ``pythonX.Y-venv`` package can run
32
+ the server but fail when that environment needs ``ensurepip``.
33
+ """
34
+ executable = python_executable or sys.executable
35
+ package_name = f"python{sys.version_info.major}.{sys.version_info.minor}-venv"
36
+ command = [executable, "-c", "import ensurepip; import venv"]
37
+
38
+ try:
39
+ result = subprocess.run(
40
+ command,
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=timeout,
44
+ )
45
+ except FileNotFoundError:
46
+ return DiagnosticResult(
47
+ passed=False,
48
+ detail=f"{executable} not found; build-index cannot create Sphinx venvs",
49
+ )
50
+ except subprocess.TimeoutExpired:
51
+ return DiagnosticResult(
52
+ passed=False,
53
+ detail=(
54
+ f"{executable} timed out while checking venv/ensurepip support "
55
+ "for build-index"
56
+ ),
57
+ )
58
+
59
+ if result.returncode == 0:
60
+ return DiagnosticResult(
61
+ passed=True,
62
+ detail=f"{executable} has venv and ensurepip available for build-index",
63
+ )
64
+
65
+ detail = (
66
+ f"{executable} cannot import venv/ensurepip; build-index needs them to "
67
+ "create Sphinx venvs. On Debian/Ubuntu, install "
68
+ f"{package_name} for this interpreter."
69
+ )
70
+ output = _combined_output_excerpt(result.stdout, result.stderr)
71
+ if output:
72
+ detail = f"{detail} Output: {output}"
73
+
74
+ return DiagnosticResult(passed=False, detail=detail)
@@ -0,0 +1,33 @@
1
+ """Error taxonomy for python-docs-mcp-server.
2
+
3
+ Named exceptions for predictable failure modes, mapped to MCP error responses.
4
+ See build guide section 10.
5
+ """
6
+
7
+
8
+ class DocsServerError(Exception):
9
+ """Base error for all docs server errors."""
10
+
11
+
12
+ class VersionNotFoundError(DocsServerError):
13
+ """Requested Python version not found in index."""
14
+
15
+
16
+ class SymbolNotFoundError(DocsServerError):
17
+ """Requested symbol not found in index."""
18
+
19
+
20
+ class PageNotFoundError(DocsServerError):
21
+ """Requested page/slug not found in index."""
22
+
23
+
24
+ class IndexNotBuiltError(DocsServerError):
25
+ """No index.db found at expected cache path."""
26
+
27
+
28
+ class IngestionError(DocsServerError):
29
+ """Error during index building/ingestion."""
30
+
31
+
32
+ class FTS5UnavailableError(DocsServerError):
33
+ """SQLite FTS5 extension not available in this build."""
@@ -0,0 +1 @@
1
+ """Ingestion layer: objects.inv parsing and content ingestion."""
@@ -0,0 +1,32 @@
1
+ """Pinned CPython documentation build targets."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Final, TypedDict
5
+
6
+
7
+ class CPythonDocsBuildConfig(TypedDict):
8
+ """Build settings for one CPython documentation release."""
9
+
10
+ tag: str
11
+ sphinx_pin: str
12
+
13
+
14
+ SUPPORTED_DOC_VERSIONS: Final[tuple[str, ...]] = (
15
+ "3.10",
16
+ "3.11",
17
+ "3.12",
18
+ "3.13",
19
+ "3.14",
20
+ )
21
+
22
+ SUPPORTED_DOC_VERSIONS_CSV: Final[str] = ",".join(SUPPORTED_DOC_VERSIONS)
23
+
24
+ # CPython git tags are pinned so content builds are reproducible and do not
25
+ # drift when a maintenance branch receives new commits.
26
+ CPYTHON_DOCS_BUILD_CONFIG: Final[dict[str, CPythonDocsBuildConfig]] = {
27
+ "3.10": {"tag": "v3.10.20", "sphinx_pin": "sphinx==3.4.3"},
28
+ "3.11": {"tag": "v3.11.15", "sphinx_pin": "sphinx~=7.2.0"},
29
+ "3.12": {"tag": "v3.12.13", "sphinx_pin": "sphinx~=8.2.0"},
30
+ "3.13": {"tag": "v3.13.13", "sphinx_pin": "sphinx<9.0.0"},
31
+ "3.14": {"tag": "v3.14.4", "sphinx_pin": "sphinx<9.0.0"},
32
+ }
@@ -0,0 +1,178 @@
1
+ """Ingest symbols from Python docs objects.inv via sphobjinv."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import sqlite3
6
+
7
+ import sphobjinv as soi
8
+
9
+ from mcp_server_python_docs.storage.db import bootstrap_schema
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Priority ordering for duplicate symbol resolution (INGR-I-05)
14
+ # Lower number = higher priority
15
+ ROLE_PRIORITY: dict[str, int] = {
16
+ "class": 0,
17
+ "exception": 1,
18
+ "function": 2,
19
+ "method": 3,
20
+ "attribute": 4,
21
+ "data": 5,
22
+ "module": 6,
23
+ }
24
+
25
+
26
+ def _expand_uri(obj: soi.DataObjStr) -> str:
27
+ """Expand $ shorthand in URI to full qualified name (INGR-I-03).
28
+
29
+ objects.inv uses $ as shorthand for the object name.
30
+ E.g., uri="library/asyncio-task.html#$" with name="asyncio.TaskGroup"
31
+ becomes "library/asyncio-task.html#asyncio.TaskGroup"
32
+ """
33
+ uri: str = obj.uri # type: ignore[assignment] # sphobjinv lacks type stubs
34
+ if "$" in uri:
35
+ uri = uri.replace("$", obj.name) # type: ignore[arg-type]
36
+ return uri
37
+
38
+
39
+ def _get_display_name(obj: soi.DataObjStr) -> str:
40
+ """Get display name, falling back to obj.name when dispname is '-' (INGR-I-04)."""
41
+ if obj.dispname == "-": # type: ignore[comparison-overlap] # sphobjinv lacks stubs
42
+ return obj.name # type: ignore[return-value]
43
+ return obj.dispname # type: ignore[return-value]
44
+
45
+
46
+ def _extract_module(qualified_name: str) -> str | None:
47
+ """Extract module from qualified name.
48
+
49
+ 'asyncio.TaskGroup' -> 'asyncio'
50
+ 'json.dumps' -> 'json'
51
+ 'os.path.join' -> 'os.path'
52
+ """
53
+ parts = qualified_name.rsplit(".", 1)
54
+ if len(parts) > 1:
55
+ return parts[0]
56
+ return None
57
+
58
+
59
+ def _normalize_name(name: str) -> str:
60
+ """Normalize a symbol name for search.
61
+
62
+ Lowercases and strips leading/trailing whitespace.
63
+ Preserves dots and underscores for identifier matching.
64
+ """
65
+ return name.strip().lower()
66
+
67
+
68
+ def ingest_inventory(
69
+ conn: sqlite3.Connection, version: str, *, is_default: bool = False
70
+ ) -> int:
71
+ """Download objects.inv for a Python version and populate symbols.
72
+
73
+ Args:
74
+ conn: Read-write SQLite connection
75
+ version: Python version string (e.g., "3.13")
76
+ is_default: Whether this version is the default for queries (MVER-02)
77
+
78
+ Returns:
79
+ Number of symbols inserted
80
+
81
+ Steps:
82
+ 1. Bootstrap schema (idempotent)
83
+ 2. Insert/update doc_set for this version
84
+ 3. Download objects.inv via sphobjinv
85
+ 4. Parse each DataObjStr into a symbol row
86
+ 5. Handle duplicates via priority ordering (INGR-I-05)
87
+ 6. Populate symbols_fts in same transaction (INGR-I-06)
88
+ """
89
+ # Validate version format first, before any DB writes (WR-02)
90
+ import re
91
+
92
+ if not re.match(r"^\d+\.\d+$", version):
93
+ from mcp_server_python_docs.errors import IngestionError
94
+
95
+ raise IngestionError(f"Invalid version format: {version!r}")
96
+
97
+ bootstrap_schema(conn)
98
+
99
+ # Upsert doc_set for this version
100
+ conn.execute(
101
+ "INSERT OR REPLACE INTO doc_sets "
102
+ "(source, version, language, label, is_default, base_url) "
103
+ "VALUES (?, ?, ?, ?, ?, ?)",
104
+ (
105
+ "python-docs",
106
+ version,
107
+ "en",
108
+ f"Python {version}",
109
+ 1 if is_default else 0,
110
+ f"https://docs.python.org/{version}/",
111
+ ),
112
+ )
113
+ doc_set_id = conn.execute(
114
+ "SELECT id FROM doc_sets "
115
+ "WHERE source = 'python-docs' AND version = ? AND language = 'en'",
116
+ (version,),
117
+ ).fetchone()[0]
118
+
119
+ # Clear existing symbols for this doc_set (re-ingestion support)
120
+ conn.execute("DELETE FROM symbols WHERE doc_set_id = ?", (doc_set_id,))
121
+
122
+ # Download and parse objects.inv (INGR-I-01)
123
+ url = f"https://docs.python.org/{version}/objects.inv"
124
+ logger.info("Downloading %s...", url)
125
+ inv = soi.Inventory(url=url) # type: ignore[call-arg] # sphobjinv lacks type stubs
126
+ logger.info("Downloaded %d inventory objects", len(inv.objects))
127
+
128
+ # Filter to Python domain objects and collect by qualified_name
129
+ # for duplicate resolution (INGR-I-05)
130
+ best_symbols: dict[str, tuple[soi.DataObjStr, int]] = {}
131
+
132
+ for obj in inv.objects:
133
+ if obj.domain != "py":
134
+ continue
135
+
136
+ name = obj.name
137
+ role = obj.role
138
+ priority = ROLE_PRIORITY.get(role, 99)
139
+
140
+ key = name # Group by qualified_name for dedup
141
+ if key not in best_symbols or priority < best_symbols[key][1]:
142
+ best_symbols[key] = (obj, priority)
143
+
144
+ # Insert symbols (INGR-I-02)
145
+ count = 0
146
+ for name, (obj, _priority) in best_symbols.items():
147
+ uri = _expand_uri(obj)
148
+ module = _extract_module(name)
149
+ anchor_part = uri.split("#", 1)[1] if "#" in uri else None
150
+
151
+ conn.execute(
152
+ "INSERT OR IGNORE INTO symbols "
153
+ "(doc_set_id, qualified_name, normalized_name, module, "
154
+ "symbol_type, uri, anchor) "
155
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
156
+ (
157
+ doc_set_id,
158
+ name,
159
+ _normalize_name(name),
160
+ module,
161
+ obj.role,
162
+ uri,
163
+ anchor_part,
164
+ ),
165
+ )
166
+ count += 1
167
+
168
+ conn.commit()
169
+
170
+ # Rebuild symbols_fts from symbols table (INGR-I-06)
171
+ # For external-content FTS5 tables, use the 'rebuild' command instead of
172
+ # DELETE + INSERT. The rebuild command re-reads all content from the
173
+ # content table and repopulates the FTS index atomically.
174
+ conn.execute("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')")
175
+ conn.commit()
176
+
177
+ logger.info("Ingested %d symbols for Python %s", count, version)
178
+ return count