mobile-docs-mcp 0.1.0__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.
- mobile_docs_mcp/__init__.py +3 -0
- mobile_docs_mcp/embeddings.py +131 -0
- mobile_docs_mcp/ingest/__init__.py +6 -0
- mobile_docs_mcp/ingest/_common.py +47 -0
- mobile_docs_mcp/ingest/android.py +155 -0
- mobile_docs_mcp/ingest/apple.py +132 -0
- mobile_docs_mcp/ingest/seed.py +209 -0
- mobile_docs_mcp/models.py +145 -0
- mobile_docs_mcp/server.py +184 -0
- mobile_docs_mcp/store.py +170 -0
- mobile_docs_mcp-0.1.0.dist-info/METADATA +246 -0
- mobile_docs_mcp-0.1.0.dist-info/RECORD +15 -0
- mobile_docs_mcp-0.1.0.dist-info/WHEEL +4 -0
- mobile_docs_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- mobile_docs_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Pluggable embedding + rerank providers.
|
|
2
|
+
|
|
3
|
+
Design goal (mirrors a multi-provider model gateway): the base server is fully
|
|
4
|
+
offline and dependency-light — BM25 only — while vector recall and cross-encoder
|
|
5
|
+
rerank switch on via env var with *no code change* and *degrade gracefully* if the
|
|
6
|
+
optional dependency or API key is missing.
|
|
7
|
+
|
|
8
|
+
MOBILE_DOCS_EMBEDDINGS = none (default) | local | openai
|
|
9
|
+
MOBILE_DOCS_RERANK = none (default) | local
|
|
10
|
+
|
|
11
|
+
`Store` treats an embedder with ``dim == 0`` as "vectors off", and detects the
|
|
12
|
+
disabled reranker by the class name ``NullReranker``. Keep those two contracts
|
|
13
|
+
stable and providers can be added freely.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import List, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- interfaces --------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class EmbeddingProvider(Protocol):
|
|
25
|
+
dim: int
|
|
26
|
+
def embed(self, texts: List[str]) -> List[List[float]]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Reranker(Protocol):
|
|
31
|
+
def rerank(self, query: str, docs: List[str]) -> List[float]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- embedding providers -----------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class NullEmbedder:
|
|
37
|
+
"""BM25-only mode. dim == 0 tells the Store to skip vector recall."""
|
|
38
|
+
dim = 0
|
|
39
|
+
|
|
40
|
+
def embed(self, texts: List[str]) -> List[List[float]]:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LocalEmbedder:
|
|
45
|
+
"""sentence-transformers, runs fully local. `pip install .[vector]`."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, model_name: str = "") -> None:
|
|
48
|
+
from sentence_transformers import SentenceTransformer # lazy: optional dep
|
|
49
|
+
|
|
50
|
+
model_name = model_name or os.getenv(
|
|
51
|
+
"MOBILE_DOCS_EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
|
|
52
|
+
)
|
|
53
|
+
self._model = SentenceTransformer(model_name)
|
|
54
|
+
self.dim = int(self._model.get_sentence_embedding_dimension())
|
|
55
|
+
|
|
56
|
+
def embed(self, texts: List[str]) -> List[List[float]]:
|
|
57
|
+
vecs = self._model.encode(
|
|
58
|
+
list(texts), normalize_embeddings=True, convert_to_numpy=True
|
|
59
|
+
)
|
|
60
|
+
return [v.tolist() for v in vecs]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OpenAIEmbedder:
|
|
64
|
+
"""OpenAI embeddings. `pip install .[openai]`, needs OPENAI_API_KEY."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, model_name: str = "") -> None:
|
|
67
|
+
from openai import OpenAI # lazy: optional dep
|
|
68
|
+
|
|
69
|
+
self._client = OpenAI()
|
|
70
|
+
self._model = model_name or os.getenv(
|
|
71
|
+
"MOBILE_DOCS_OPENAI_EMBED_MODEL", "text-embedding-3-small"
|
|
72
|
+
)
|
|
73
|
+
# dims for the -3 family; hard-coded to avoid a probe call at import.
|
|
74
|
+
self.dim = 1536 if "small" in self._model else 3072
|
|
75
|
+
|
|
76
|
+
def embed(self, texts: List[str]) -> List[List[float]]:
|
|
77
|
+
resp = self._client.embeddings.create(model=self._model, input=list(texts))
|
|
78
|
+
return [d.embedding for d in resp.data]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_embedding_provider() -> EmbeddingProvider:
|
|
82
|
+
"""Select an embedder from MOBILE_DOCS_EMBEDDINGS. Never raises: any missing
|
|
83
|
+
dependency or credential falls back to BM25-only (NullEmbedder)."""
|
|
84
|
+
mode = os.getenv("MOBILE_DOCS_EMBEDDINGS", "none").strip().lower()
|
|
85
|
+
try:
|
|
86
|
+
if mode in ("local", "st", "sentence-transformers"):
|
|
87
|
+
return LocalEmbedder()
|
|
88
|
+
if mode == "openai":
|
|
89
|
+
return OpenAIEmbedder()
|
|
90
|
+
except Exception: # optional dep / key absent → graceful degrade
|
|
91
|
+
return NullEmbedder()
|
|
92
|
+
return NullEmbedder()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- rerankers ---------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
class NullReranker:
|
|
98
|
+
"""Disabled reranker. Store checks for this class name by design."""
|
|
99
|
+
|
|
100
|
+
def rerank(self, query: str, docs: List[str]) -> List[float]:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LocalReranker:
|
|
105
|
+
"""Cross-encoder rerank over the fused shortlist. `pip install .[vector]`."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, model_name: str = "") -> None:
|
|
108
|
+
from sentence_transformers import CrossEncoder # lazy: optional dep
|
|
109
|
+
|
|
110
|
+
model_name = model_name or os.getenv(
|
|
111
|
+
"MOBILE_DOCS_RERANK_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
112
|
+
)
|
|
113
|
+
self._model = CrossEncoder(model_name)
|
|
114
|
+
|
|
115
|
+
def rerank(self, query: str, docs: List[str]) -> List[float]:
|
|
116
|
+
if not docs:
|
|
117
|
+
return []
|
|
118
|
+
pairs = [[query, d] for d in docs]
|
|
119
|
+
return [float(s) for s in self._model.predict(pairs)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_reranker() -> Reranker:
|
|
123
|
+
"""Select a reranker from MOBILE_DOCS_RERANK. Falls back to NullReranker if
|
|
124
|
+
the optional dependency is unavailable."""
|
|
125
|
+
mode = os.getenv("MOBILE_DOCS_RERANK", "none").strip().lower()
|
|
126
|
+
try:
|
|
127
|
+
if mode in ("local", "cross-encoder", "ce"):
|
|
128
|
+
return LocalReranker()
|
|
129
|
+
except Exception:
|
|
130
|
+
return NullReranker()
|
|
131
|
+
return NullReranker()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared plumbing for the batch ingesters.
|
|
2
|
+
|
|
3
|
+
The ingesters are *offline-first*: each ships a small curated, version-verified
|
|
4
|
+
table so `MOBILE_DOCS_DATA` growth is reproducible and testable with no network,
|
|
5
|
+
and falls back to a best-effort live fetch (stdlib urllib, short timeout) for refs
|
|
6
|
+
not in the table. Either way the output is the same shape the seed and runtime
|
|
7
|
+
Store already speak: a `symbols.json` list and a `chunks.json` list of the exact
|
|
8
|
+
dicts `ApiSymbol.from_dict` / `DocChunk.from_dict` consume.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterable, Optional
|
|
17
|
+
|
|
18
|
+
from ..models import ApiSymbol, DocChunk
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch(url: str, timeout: float = 8.0, accept: str = "text/html") -> Optional[str]:
|
|
22
|
+
"""Best-effort GET. Returns the body text, or None on any failure so the
|
|
23
|
+
caller can fall back to the curated table instead of crashing a crawl."""
|
|
24
|
+
req = urllib.request.Request(
|
|
25
|
+
url, headers={"User-Agent": "mobile-docs-mcp-ingest/0.1", "Accept": accept}
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
29
|
+
charset = resp.headers.get_content_charset() or "utf-8"
|
|
30
|
+
return resp.read().decode(charset, "replace")
|
|
31
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_corpus(out_dir: str | Path, symbols: Iterable[ApiSymbol],
|
|
36
|
+
chunks: Iterable[DocChunk]) -> tuple[Path, Path]:
|
|
37
|
+
"""Serialize a corpus to <out_dir>/symbols.json + chunks.json."""
|
|
38
|
+
out = Path(out_dir)
|
|
39
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
syms = list(symbols)
|
|
41
|
+
chks = list(chunks)
|
|
42
|
+
sp, cp = out / "symbols.json", out / "chunks.json"
|
|
43
|
+
sp.write_text(json.dumps([s.to_dict() for s in syms], indent=2))
|
|
44
|
+
cp.write_text(json.dumps([c.to_dict() for c in chks], indent=2))
|
|
45
|
+
print(f"wrote {len(syms)} symbols -> {sp}")
|
|
46
|
+
print(f"wrote {len(chks)} chunks -> {cp}")
|
|
47
|
+
return sp, cp
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Android (Jetpack / androidx / Compose) ingester.
|
|
2
|
+
|
|
3
|
+
Batch, offline. Produces a version-tagged corpus (symbols.json + chunks.json)
|
|
4
|
+
under --out, which the runtime server layers in via MOBILE_DOCS_DATA.
|
|
5
|
+
|
|
6
|
+
python -m mobile_docs_mcp.ingest.android --out data/android \
|
|
7
|
+
--refs androidx.compose.foundation.lazy.LazyRow \
|
|
8
|
+
androidx.compose.foundation.pager.HorizontalPager \
|
|
9
|
+
--release-notes compose-foundation
|
|
10
|
+
|
|
11
|
+
Refs are fully-qualified androidx names. Known refs come from a curated,
|
|
12
|
+
version-verified table (works with no network); unknown refs are fetched
|
|
13
|
+
best-effort from developer.android.com and, if that fails, emitted with
|
|
14
|
+
version-unknown metadata and a warning rather than silently dropped.
|
|
15
|
+
|
|
16
|
+
The curated entries here are intentionally *disjoint* from the built-in seed, so
|
|
17
|
+
running this ingester demonstrably grows the index.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
from ..models import PLATFORM_ANDROID, ApiSymbol, DocChunk, VersionRange
|
|
24
|
+
from ._common import fetch, write_corpus
|
|
25
|
+
|
|
26
|
+
REF_DOC_BASE = "https://developer.android.com/reference/kotlin/"
|
|
27
|
+
RELEASE_NOTES_BASE = "https://developer.android.com/jetpack/androidx/releases/"
|
|
28
|
+
|
|
29
|
+
# Curated, version-verified androidx symbols keyed by fully-qualified name.
|
|
30
|
+
# scheme "androidx" = library semver.
|
|
31
|
+
_CURATED: dict[str, dict] = {
|
|
32
|
+
"androidx.compose.foundation.lazy.LazyRow": dict(
|
|
33
|
+
name="LazyRow", kind="composable", package="androidx.compose.foundation.lazy",
|
|
34
|
+
signature="fun LazyRow(modifier: Modifier = Modifier, state: LazyListState = ..., content: LazyListScope.() -> Unit)",
|
|
35
|
+
summary="A horizontally scrolling list that only composes and lays out the "
|
|
36
|
+
"currently visible items.",
|
|
37
|
+
since="1.0.0", tags=["compose", "list", "lazy"]),
|
|
38
|
+
"androidx.compose.foundation.pager.HorizontalPager": dict(
|
|
39
|
+
name="HorizontalPager", kind="composable", package="androidx.compose.foundation.pager",
|
|
40
|
+
signature="fun HorizontalPager(state: PagerState, modifier: Modifier = Modifier, ...)",
|
|
41
|
+
summary="A horizontally scrolling pager. Graduated from Accompanist into "
|
|
42
|
+
"foundation and became stable in 1.6.0.",
|
|
43
|
+
since="1.6.0", tags=["compose", "pager"]),
|
|
44
|
+
"androidx.compose.foundation.lazy.rememberLazyListState": dict(
|
|
45
|
+
name="rememberLazyListState", kind="method", package="androidx.compose.foundation.lazy",
|
|
46
|
+
signature="fun rememberLazyListState(initialFirstVisibleItemIndex: Int = 0, ...): LazyListState",
|
|
47
|
+
summary="Creates and remembers a LazyListState for controlling and observing "
|
|
48
|
+
"a lazy list's scroll position.",
|
|
49
|
+
since="1.0.0", tags=["compose", "lazy", "state"]),
|
|
50
|
+
"androidx.compose.material3.pulltorefresh.PullToRefreshBox": dict(
|
|
51
|
+
name="PullToRefreshBox", kind="composable", package="androidx.compose.material3.pulltorefresh",
|
|
52
|
+
signature="fun PullToRefreshBox(isRefreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier, ...)",
|
|
53
|
+
summary="Material 3 pull-to-refresh container. Stable since material3 1.3.0.",
|
|
54
|
+
since="1.3.0", tags=["compose", "material3", "refresh"]),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Curated release-note chunks keyed by library id.
|
|
58
|
+
_RELEASE_NOTES: dict[str, list[dict]] = {
|
|
59
|
+
"compose-foundation": [
|
|
60
|
+
dict(version="1.6.0",
|
|
61
|
+
title="Compose Foundation 1.6.0 — pager graduates",
|
|
62
|
+
text="foundation 1.6.0 promotes HorizontalPager and VerticalPager from "
|
|
63
|
+
"Accompanist to stable, and improves lazy-layout performance."),
|
|
64
|
+
dict(version="1.7.0",
|
|
65
|
+
title="Compose Foundation 1.7.0 — animateItem",
|
|
66
|
+
text="foundation 1.7.0 adds Modifier.animateItem (appearance + removal + "
|
|
67
|
+
"placement) and deprecates Modifier.animateItemPlacement."),
|
|
68
|
+
],
|
|
69
|
+
"compose-material3": [
|
|
70
|
+
dict(version="1.3.0",
|
|
71
|
+
title="Compose Material3 1.3.0 — pull to refresh",
|
|
72
|
+
text="material3 1.3.0 ships PullToRefreshBox and the pull-to-refresh "
|
|
73
|
+
"APIs as stable, plus loading indicators and carousel updates."),
|
|
74
|
+
],
|
|
75
|
+
"navigation": [
|
|
76
|
+
dict(version="2.8.0",
|
|
77
|
+
title="Navigation 2.8.0 — type-safe navigation",
|
|
78
|
+
text="navigation 2.8.0 introduces type-safe routes built on Kotlin "
|
|
79
|
+
"serialization for the Navigation Compose graph."),
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _symbol_from_curated(ref: str, meta: dict) -> ApiSymbol:
|
|
85
|
+
return ApiSymbol(
|
|
86
|
+
id=f"android:ingest:{meta['name']}", platform=PLATFORM_ANDROID,
|
|
87
|
+
name=meta["name"], kind=meta["kind"], package=meta["package"],
|
|
88
|
+
signature=meta["signature"], summary=meta["summary"],
|
|
89
|
+
version=VersionRange(scheme="androidx", since=meta.get("since"),
|
|
90
|
+
deprecated=meta.get("deprecated"), removed=meta.get("removed")),
|
|
91
|
+
url=REF_DOC_BASE + meta["package"].replace(".", "/") + "/package-summary",
|
|
92
|
+
tags=meta.get("tags", []),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _symbol_from_fetch(ref: str) -> ApiSymbol:
|
|
97
|
+
"""Best-effort live path for an un-curated ref. Confirms the page exists and
|
|
98
|
+
grabs a summary; leaves version unknown (the honest default) so downstream
|
|
99
|
+
verify() says 'exists but version unclear' rather than inventing a bound."""
|
|
100
|
+
pkg, _, name = ref.rpartition(".")
|
|
101
|
+
url = REF_DOC_BASE + pkg.replace(".", "/") + "/package-summary"
|
|
102
|
+
html = fetch(url)
|
|
103
|
+
summary = f"{name} in {pkg} (fetched from developer.android.com; version metadata not parsed)."
|
|
104
|
+
if html is None:
|
|
105
|
+
print(f" ! could not fetch {ref}; emitting version-unknown record")
|
|
106
|
+
summary = f"{name} in {pkg} (offline: not verified against the doc site)."
|
|
107
|
+
return ApiSymbol(
|
|
108
|
+
id=f"android:ingest:{name}", platform=PLATFORM_ANDROID, name=name,
|
|
109
|
+
kind="unknown", package=pkg, signature=f"{name}(...)", summary=summary,
|
|
110
|
+
version=VersionRange(scheme="androidx"), url=url, tags=["compose"],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run(refs: list[str], release_notes: list[str], out: str) -> None:
|
|
115
|
+
symbols: list[ApiSymbol] = []
|
|
116
|
+
chunks: list[DocChunk] = []
|
|
117
|
+
|
|
118
|
+
for ref in refs:
|
|
119
|
+
if ref in _CURATED:
|
|
120
|
+
sym = _symbol_from_curated(ref, _CURATED[ref])
|
|
121
|
+
else:
|
|
122
|
+
sym = _symbol_from_fetch(ref)
|
|
123
|
+
symbols.append(sym)
|
|
124
|
+
chunks.append(DocChunk(
|
|
125
|
+
id=f"{sym.id}#ref", symbol_id=sym.id, platform=PLATFORM_ANDROID,
|
|
126
|
+
version=sym.version, title=f"{sym.name} — {sym.package}",
|
|
127
|
+
text=sym.summary, source="reference", url=sym.url,
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
for lib in release_notes:
|
|
131
|
+
for i, note in enumerate(_RELEASE_NOTES.get(lib, [])):
|
|
132
|
+
chunks.append(DocChunk(
|
|
133
|
+
id=f"android:notes:{lib}:{note['version']}:{i}", symbol_id="",
|
|
134
|
+
platform=PLATFORM_ANDROID,
|
|
135
|
+
version=VersionRange(scheme="androidx", since=note["version"]),
|
|
136
|
+
title=note["title"], text=note["text"], source="release_notes",
|
|
137
|
+
url=RELEASE_NOTES_BASE + lib,
|
|
138
|
+
))
|
|
139
|
+
if lib not in _RELEASE_NOTES:
|
|
140
|
+
print(f" ! no curated release notes for '{lib}' (add to _RELEASE_NOTES or crawl)")
|
|
141
|
+
|
|
142
|
+
write_corpus(out, symbols, chunks)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
p = argparse.ArgumentParser(description="Ingest androidx/Compose docs into a version-tagged corpus.")
|
|
147
|
+
p.add_argument("--out", required=True, help="output dir (holds symbols.json + chunks.json)")
|
|
148
|
+
p.add_argument("--refs", nargs="*", default=[], help="fully-qualified androidx names")
|
|
149
|
+
p.add_argument("--release-notes", nargs="*", default=[], help="library ids, e.g. compose-foundation")
|
|
150
|
+
a = p.parse_args()
|
|
151
|
+
run(a.refs, a.release_notes, a.out)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Apple (SwiftUI / UIKit) ingester.
|
|
2
|
+
|
|
3
|
+
Batch, offline. Produces a version-tagged corpus (symbols.json + chunks.json)
|
|
4
|
+
under --out, layered into the runtime server via MOBILE_DOCS_DATA.
|
|
5
|
+
|
|
6
|
+
python -m mobile_docs_mcp.ingest.apple --out data/apple \
|
|
7
|
+
--paths swiftui/navigationlink swiftui/view/scrollposition
|
|
8
|
+
|
|
9
|
+
Paths are doc-site paths (lowercase, as they appear after
|
|
10
|
+
developer.apple.com/documentation/). Known paths come from a curated,
|
|
11
|
+
version-verified table; unknown paths are fetched best-effort from Apple's JSON
|
|
12
|
+
documentation API (the same feed the doc site renders from) and, if that fails,
|
|
13
|
+
emitted with version-unknown metadata and a warning.
|
|
14
|
+
|
|
15
|
+
Curated entries are disjoint from the built-in seed, so running this ingester
|
|
16
|
+
demonstrably grows the index.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
|
|
23
|
+
from ..models import PLATFORM_APPLE, ApiSymbol, DocChunk, VersionRange
|
|
24
|
+
from ._common import fetch, write_corpus
|
|
25
|
+
|
|
26
|
+
# Apple renders the doc site from this JSON feed.
|
|
27
|
+
JSON_API_BASE = "https://developer.apple.com/tutorials/data/documentation/"
|
|
28
|
+
DOC_BASE = "https://developer.apple.com/documentation/"
|
|
29
|
+
|
|
30
|
+
# Curated, version-verified SwiftUI symbols keyed by doc-site path.
|
|
31
|
+
# scheme "ios" = iOS platform version.
|
|
32
|
+
_CURATED: dict[str, dict] = {
|
|
33
|
+
"swiftui/navigationlink": dict(
|
|
34
|
+
name="NavigationLink", kind="struct", package="SwiftUI",
|
|
35
|
+
signature="struct NavigationLink<Label, Destination> where Label: View",
|
|
36
|
+
summary="A view that controls a navigation presentation, pushing a "
|
|
37
|
+
"destination when tapped. Works inside NavigationStack.",
|
|
38
|
+
since="13.0", tags=["navigation", "swiftui"]),
|
|
39
|
+
"swiftui/view/scrollposition": dict(
|
|
40
|
+
name="scrollPosition", kind="method", package="SwiftUI.View",
|
|
41
|
+
signature="func scrollPosition(id: Binding<(some Hashable)?>, anchor: UnitPoint? = nil) -> some View",
|
|
42
|
+
summary="Associates a binding to the identity of the scroll view's visible "
|
|
43
|
+
"content, for reading and programmatic scrolling. Added in iOS 17.0.",
|
|
44
|
+
since="17.0", tags=["scrollview", "swiftui"]),
|
|
45
|
+
"swiftui/grid": dict(
|
|
46
|
+
name="Grid", kind="struct", package="SwiftUI",
|
|
47
|
+
signature="struct Grid<Content> where Content: View",
|
|
48
|
+
summary="A container that arranges its child views in a two-dimensional "
|
|
49
|
+
"layout of rows and columns. Introduced at iOS 16.0.",
|
|
50
|
+
since="16.0", tags=["layout", "swiftui"]),
|
|
51
|
+
"swiftui/observable": dict(
|
|
52
|
+
name="Observable", kind="protocol", package="Observation",
|
|
53
|
+
signature="@attached(member) macro Observable()",
|
|
54
|
+
summary="The Observation macro that replaces ObservableObject for SwiftUI "
|
|
55
|
+
"state. Introduced at iOS 17.0.",
|
|
56
|
+
since="17.0", tags=["state", "observation", "swiftui"]),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _symbol_from_curated(path: str, meta: dict) -> ApiSymbol:
|
|
61
|
+
return ApiSymbol(
|
|
62
|
+
id=f"apple:ingest:{meta['name']}", platform=PLATFORM_APPLE,
|
|
63
|
+
name=meta["name"], kind=meta["kind"], package=meta["package"],
|
|
64
|
+
signature=meta["signature"], summary=meta["summary"],
|
|
65
|
+
version=VersionRange(scheme="ios", since=meta.get("since"),
|
|
66
|
+
deprecated=meta.get("deprecated"), removed=meta.get("removed")),
|
|
67
|
+
url=DOC_BASE + path, tags=meta.get("tags", []),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_ios_since(doc: dict) -> str | None:
|
|
72
|
+
"""Pull the iOS introduced-version from an Apple doc-API JSON payload."""
|
|
73
|
+
for plat in doc.get("platforms", []) or []:
|
|
74
|
+
if str(plat.get("name", "")).lower().startswith("ios") and plat.get("introducedAt"):
|
|
75
|
+
return str(plat["introducedAt"])
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _symbol_from_fetch(path: str) -> ApiSymbol:
|
|
80
|
+
"""Best-effort live path for an un-curated doc path via Apple's JSON API."""
|
|
81
|
+
name = path.rstrip("/").split("/")[-1]
|
|
82
|
+
url = DOC_BASE + path
|
|
83
|
+
body = fetch(JSON_API_BASE + path + ".json", accept="application/json")
|
|
84
|
+
since = None
|
|
85
|
+
summary = f"{name} (SwiftUI). Fetched from Apple's doc API."
|
|
86
|
+
if body:
|
|
87
|
+
try:
|
|
88
|
+
doc = json.loads(body)
|
|
89
|
+
since = _parse_ios_since(doc)
|
|
90
|
+
title = (doc.get("metadata", {}) or {}).get("title") or name
|
|
91
|
+
name = title
|
|
92
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
93
|
+
print(f" ! could not parse doc JSON for {path}")
|
|
94
|
+
else:
|
|
95
|
+
print(f" ! could not fetch {path}; emitting version-unknown record")
|
|
96
|
+
summary = f"{name} (SwiftUI). Offline: not verified against Apple docs."
|
|
97
|
+
return ApiSymbol(
|
|
98
|
+
id=f"apple:ingest:{name}", platform=PLATFORM_APPLE, name=name,
|
|
99
|
+
kind="unknown", package="SwiftUI", signature=f"{name}",
|
|
100
|
+
summary=summary, version=VersionRange(scheme="ios", since=since),
|
|
101
|
+
url=url, tags=["swiftui"],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run(paths: list[str], out: str) -> None:
|
|
106
|
+
symbols: list[ApiSymbol] = []
|
|
107
|
+
chunks: list[DocChunk] = []
|
|
108
|
+
for path in paths:
|
|
109
|
+
key = path.strip("/").lower()
|
|
110
|
+
if key in _CURATED:
|
|
111
|
+
sym = _symbol_from_curated(key, _CURATED[key])
|
|
112
|
+
else:
|
|
113
|
+
sym = _symbol_from_fetch(key)
|
|
114
|
+
symbols.append(sym)
|
|
115
|
+
chunks.append(DocChunk(
|
|
116
|
+
id=f"{sym.id}#ref", symbol_id=sym.id, platform=PLATFORM_APPLE,
|
|
117
|
+
version=sym.version, title=f"{sym.name} — {sym.package}",
|
|
118
|
+
text=sym.summary, source="reference", url=sym.url,
|
|
119
|
+
))
|
|
120
|
+
write_corpus(out, symbols, chunks)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
p = argparse.ArgumentParser(description="Ingest SwiftUI/UIKit docs into a version-tagged corpus.")
|
|
125
|
+
p.add_argument("--out", required=True, help="output dir (holds symbols.json + chunks.json)")
|
|
126
|
+
p.add_argument("--paths", nargs="*", default=[], help="doc-site paths, e.g. swiftui/navigationlink")
|
|
127
|
+
a = p.parse_args()
|
|
128
|
+
run(a.paths, a.out)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|