llm-api-scope 0.5.1__tar.gz → 0.6.0__tar.gz

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 (34) hide show
  1. {llm_api_scope-0.5.1/llm_api_scope.egg-info → llm_api_scope-0.6.0}/PKG-INFO +7 -1
  2. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/README.md +6 -0
  3. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/config.py +19 -0
  4. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/main.py +3 -1
  5. llm_api_scope-0.6.0/apiscope/repo/__init__.py +6 -0
  6. llm_api_scope-0.6.0/apiscope/repo/app.py +152 -0
  7. llm_api_scope-0.6.0/apiscope/repo/fetch.py +132 -0
  8. llm_api_scope-0.6.0/apiscope/repo/schema.py +53 -0
  9. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/schema.py +2 -0
  10. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0/llm_api_scope.egg-info}/PKG-INFO +7 -1
  11. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/llm_api_scope.egg-info/SOURCES.txt +4 -0
  12. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/pyproject.toml +1 -1
  13. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/LICENSE +0 -0
  14. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/__init__.py +0 -0
  15. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/__init__.py +0 -0
  16. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/app.py +0 -0
  17. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/fetch.py +0 -0
  18. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/reader.py +0 -0
  19. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/schema.py +0 -0
  20. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/spec/__init__.py +0 -0
  21. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/spec/app.py +0 -0
  22. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/openapi/spec/schema.py +0 -0
  23. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/__init__.py +0 -0
  24. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/app.py +0 -0
  25. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/fetch.py +0 -0
  26. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/parse_txt.py +0 -0
  27. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/parse_xml.py +0 -0
  28. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/schema.py +0 -0
  29. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/apiscope/rfc/search.py +0 -0
  30. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/llm_api_scope.egg-info/dependency_links.txt +0 -0
  31. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/llm_api_scope.egg-info/entry_points.txt +0 -0
  32. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/llm_api_scope.egg-info/requires.txt +0 -0
  33. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/llm_api_scope.egg-info/top_level.txt +0 -0
  34. {llm_api_scope-0.5.1 → llm_api_scope-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-api-scope
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: read and cache structured documents from remote for LLM agents
5
5
  Author-email: D7x7z49 <85430783+D7x7z49@users.noreply.github.com>
6
6
  License: MIT License
@@ -79,6 +79,12 @@ read, search, and navigate RFC documents from the IETF.
79
79
 
80
80
  the metadata index is mirrored once via rsync. individual text files are fetched on demand and cached locally. you can browse the table of contents, jump to a section (XML) or page (TXT), filter the index by status or source, and run keyword searches against fulltext content.
81
81
 
82
+ ### repo
83
+
84
+ sync documentation directories from any git repository to a local cache.
85
+
86
+ register repositories by URL with a target subdirectory and a ref — a branch, tag, or commit. the sync command clones with shallow depth, blobless filter, and sparse checkout so only the needed tree and files come over the wire. extracted docs land in the cache under a stable hash path, and a configurable TTL avoids redundant re-fetches. provider-agnostic, zero auth.
87
+
82
88
  ## future
83
89
 
84
90
  - read academic papers from arxiv
@@ -30,6 +30,12 @@ read, search, and navigate RFC documents from the IETF.
30
30
 
31
31
  the metadata index is mirrored once via rsync. individual text files are fetched on demand and cached locally. you can browse the table of contents, jump to a section (XML) or page (TXT), filter the index by status or source, and run keyword searches against fulltext content.
32
32
 
33
+ ### repo
34
+
35
+ sync documentation directories from any git repository to a local cache.
36
+
37
+ register repositories by URL with a target subdirectory and a ref — a branch, tag, or commit. the sync command clones with shallow depth, blobless filter, and sparse checkout so only the needed tree and files come over the wire. extracted docs land in the cache under a stable hash path, and a configurable TTL avoids redundant re-fetches. provider-agnostic, zero auth.
38
+
33
39
  ## future
34
40
 
35
41
  - read academic papers from arxiv
@@ -23,6 +23,8 @@ DEFAULT_CONFIG_SCHEMA_PATH = DEFAULT_ROOT / "config.schema.json"
23
23
 
24
24
  CACHE_ROOT = DEFAULT_ROOT / "cache"
25
25
 
26
+ TMP_ROOT = DEFAULT_ROOT / "tmp"
27
+
26
28
  # ==============================================================================
27
29
  # config model
28
30
  # ==============================================================================
@@ -51,6 +53,21 @@ class OpenapiConfig(BaseConfig):
51
53
 
52
54
 
53
55
  class RfcConfig(BaseConfig):
56
+ # override
57
+ cache_ttl: int = Field(default=60 * 60 * 24 * 30) # 1 month
58
+
59
+
60
+ class RepoEntryConfig(BaseModel):
61
+ dir: str
62
+
63
+ # format - branch:<branch>, tag:<tag>, commit:<short-hash>
64
+ # e.g. branch:main, tag:v1.0, commit:abc1234
65
+ target: str = Field(default="branch:main")
66
+
67
+
68
+ class RepoConfig(BaseConfig):
69
+ entries: dict[str, RepoEntryConfig] = Field(default_factory=dict)
70
+
54
71
  # override
55
72
  cache_ttl: int = Field(default=60 * 60 * 24 * 7) # 1 week
56
73
 
@@ -58,6 +75,7 @@ class RfcConfig(BaseConfig):
58
75
  class Config(BaseModel):
59
76
  openapi: OpenapiConfig = Field(default_factory=OpenapiConfig)
60
77
  rfc: RfcConfig = Field(default_factory=RfcConfig)
78
+ repo: RepoConfig = Field(default_factory=RepoConfig)
61
79
 
62
80
  @classmethod
63
81
  def read(cls, path: Path) -> "Config":
@@ -85,6 +103,7 @@ class Config(BaseModel):
85
103
  merged.openapi.alias |= other.openapi.alias
86
104
  if other.openapi.proxy is not None:
87
105
  merged.openapi.proxy = other.openapi.proxy
106
+ merged.repo.entries = self.repo.entries | other.repo.entries
88
107
  return merged
89
108
 
90
109
 
@@ -25,6 +25,7 @@ from apiscope.config import (
25
25
  get_project_config_path,
26
26
  )
27
27
  from apiscope.openapi import openapi_app
28
+ from apiscope.repo import check_repo_deps, repo_app
28
29
  from apiscope.rfc import check_rfc_deps, rfc_app
29
30
  from apiscope.schema import CommandContext
30
31
 
@@ -57,6 +58,7 @@ def callback(ctx: typer.Context) -> None:
57
58
 
58
59
  app.add_typer(openapi_app, name="openapi")
59
60
  app.add_typer(rfc_app, name="rfc")
61
+ app.add_typer(repo_app, name="repo")
60
62
 
61
63
  # ==============================================================================
62
64
  # commands
@@ -74,7 +76,7 @@ def health(
74
76
 
75
77
  # gather issues from all modules
76
78
  issues: list[str] = []
77
- for label, check in [("rfc", check_rfc_deps)]:
79
+ for label, check in [("rfc", check_rfc_deps), ("repo", check_repo_deps)]:
78
80
  err = check()
79
81
  if err is not None:
80
82
  issues.append(f"[{label}] {err}")
@@ -0,0 +1,6 @@
1
+ # apiscope/repo/__init__.py
2
+
3
+ from apiscope.repo.app import app as repo_app
4
+ from apiscope.repo.app import check_deps as check_repo_deps
5
+
6
+ __all__ = ["repo_app", "check_repo_deps"]
@@ -0,0 +1,152 @@
1
+ # apiscope/repo/app.py
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from apiscope.config import (
9
+ CACHE_ROOT,
10
+ DEFAULT_CONFIG_PATH,
11
+ TMP_ROOT,
12
+ Config,
13
+ RepoEntryConfig,
14
+ get_project_config_path,
15
+ )
16
+ from apiscope.repo.fetch import sync_entry
17
+ from apiscope.repo.schema import RepoCommandContext, RepoEntry
18
+
19
+ app = typer.Typer(help="sync documentation from git repositories")
20
+
21
+
22
+ # ==============================================================================
23
+ # public helpers
24
+ # ==============================================================================
25
+
26
+
27
+ def check_deps() -> str | None:
28
+ if shutil.which("git") is None:
29
+ return "git is required but not found in PATH"
30
+ return None
31
+
32
+
33
+ # ==============================================================================
34
+ # helpers
35
+ # ==============================================================================
36
+
37
+
38
+ def _resolve_target(global_flag: bool) -> Path:
39
+ if global_flag:
40
+ return DEFAULT_CONFIG_PATH
41
+ project = get_project_config_path()
42
+ if project is not None and project.exists():
43
+ return project
44
+ return DEFAULT_CONFIG_PATH
45
+
46
+
47
+ # ==============================================================================
48
+ # callback
49
+ # ==============================================================================
50
+
51
+
52
+ @app.callback()
53
+ def repo_callback(
54
+ ctx: typer.Context,
55
+ global_flag: bool = typer.Option(
56
+ False, "--global", "-g", help="edit global config instead of project config"
57
+ ),
58
+ ) -> None:
59
+ # check required external tools
60
+ err = check_deps()
61
+ if err is not None:
62
+ typer.echo(err, err=True)
63
+ raise typer.Exit(code=1)
64
+
65
+ # prepare cache directories
66
+ repo_cache_dir = CACHE_ROOT / "repo"
67
+ repo_tmp_dir = TMP_ROOT
68
+ repo_cache_dir.mkdir(parents=True, exist_ok=True)
69
+ repo_tmp_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ # inject context for subcommands
72
+ ctx.obj.repo_command_context = RepoCommandContext(
73
+ cache_dir=repo_cache_dir,
74
+ tmp_dir=repo_tmp_dir,
75
+ global_flag=global_flag,
76
+ )
77
+
78
+
79
+ # ==============================================================================
80
+ # commands
81
+ # ==============================================================================
82
+
83
+
84
+ @app.command(name="add", help="register a repo for syncing")
85
+ def add_repo(
86
+ ctx: typer.Context,
87
+ url: str = typer.Argument(help="git clone URL"),
88
+ dir: str = typer.Argument(help="directory within repo to extract"),
89
+ target: str = typer.Option(
90
+ "branch:main", "--target", help="ref target (branch:, tag:, commit:)"
91
+ ),
92
+ ) -> None:
93
+ # duplicate check against merged entries
94
+ if url in ctx.obj.config.repo.entries:
95
+ typer.echo(f"<{url}> already registered", err=True)
96
+ raise typer.Exit(code=1)
97
+
98
+ target_path = _resolve_target(ctx.obj.repo_command_context.global_flag)
99
+ with Config.edit(target_path) as cfg:
100
+ cfg.repo.entries[url] = RepoEntryConfig(dir=dir, target=target)
101
+
102
+ typer.echo(f"registered <{url}>")
103
+
104
+
105
+ @app.command(name="remove", help="remove a registered repo")
106
+ def remove_repo(
107
+ ctx: typer.Context,
108
+ url: str = typer.Argument(help="git clone URL"),
109
+ ) -> None:
110
+ target_path = _resolve_target(ctx.obj.repo_command_context.global_flag)
111
+ with Config.edit(target_path) as cfg:
112
+ if url not in cfg.repo.entries:
113
+ typer.echo(f"<{url}> not registered", err=True)
114
+ raise typer.Exit(code=1)
115
+ del cfg.repo.entries[url]
116
+
117
+ typer.echo(f"removed <{url}>")
118
+
119
+
120
+ @app.command(name="list", help="list registered repos")
121
+ def list_repos(ctx: typer.Context) -> None:
122
+ entries = RepoEntry.from_config(ctx.obj.config.repo)
123
+ if not entries:
124
+ typer.echo("no repos registered")
125
+ return
126
+
127
+ cache_dir = ctx.obj.repo_command_context.cache_dir
128
+
129
+ for entry in entries:
130
+ doc_path = cache_dir / entry.sha256_id
131
+ typer.echo(f"- [{entry.url}] [{entry.target}@{entry.dir}] <{doc_path}>")
132
+
133
+
134
+ @app.command(name="sync", help="sync all registered repos to cache")
135
+ def sync_repos(
136
+ ctx: typer.Context,
137
+ force: bool = typer.Option(False, "--force", help="force re-sync ignoring cache TTL"),
138
+ ) -> None:
139
+ entries = RepoEntry.from_config(ctx.obj.config.repo)
140
+ if not entries:
141
+ typer.echo("no repos registered")
142
+ return
143
+
144
+ rc = ctx.obj.repo_command_context
145
+ ttl = ctx.obj.config.repo.cache_ttl
146
+
147
+ for entry in entries:
148
+ err = sync_entry(entry, rc.tmp_dir, rc.cache_dir, force=force, ttl=ttl)
149
+ if err is not None:
150
+ typer.echo(f"sync failed for <{entry.url}>: {err}", err=True)
151
+ else:
152
+ typer.echo(f"synced <{entry.url}>")
@@ -0,0 +1,132 @@
1
+ # apiscope/repo/fetch.py
2
+
3
+ import shutil
4
+ import subprocess
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from apiscope.repo.schema import RepoEntry
9
+
10
+ # ==============================================================================
11
+ # git helpers
12
+ # ==============================================================================
13
+
14
+
15
+ def _run_git(args: list[str], cwd: Path | None = None) -> str | None:
16
+ result = subprocess.run(
17
+ ["git", *args],
18
+ capture_output=True,
19
+ text=True,
20
+ cwd=cwd,
21
+ )
22
+ if result.returncode != 0:
23
+ return result.stderr.strip()
24
+ return None
25
+
26
+
27
+ def _clone_repo(url: str, work_dir: Path, branch: str | None = None) -> str | None:
28
+ args = [
29
+ "clone",
30
+ "--depth=1",
31
+ "--filter=blob:none",
32
+ "--sparse",
33
+ ]
34
+ if branch is not None:
35
+ args.extend(["--branch", branch])
36
+ args.extend([url, str(work_dir)])
37
+ return _run_git(args)
38
+
39
+
40
+ def _fetch_commit(work_dir: Path, commit: str) -> str | None:
41
+ err = _run_git(["fetch", "origin", "--depth=1", commit], cwd=work_dir)
42
+ if err is not None:
43
+ return err
44
+ return _run_git(["checkout", "FETCH_HEAD"], cwd=work_dir)
45
+
46
+
47
+ def _sparse_set(work_dir: Path, directory: str) -> str | None:
48
+ return _run_git(["sparse-checkout", "set", directory], cwd=work_dir)
49
+
50
+
51
+ # ==============================================================================
52
+ # sync
53
+ # ==============================================================================
54
+
55
+
56
+ def _copy_docs(work_dir: Path, directory: str, dst: Path) -> str | None:
57
+ src = work_dir / directory
58
+ if not src.exists():
59
+ return f"directory [{directory}] not found in <{work_dir}>"
60
+ shutil.rmtree(dst, ignore_errors=True)
61
+ dst.mkdir(parents=True, exist_ok=True)
62
+ try:
63
+ shutil.copytree(src, dst, dirs_exist_ok=True)
64
+ except shutil.Error as e:
65
+ return str(e)
66
+ return None
67
+
68
+
69
+ def sync_entry(
70
+ entry: RepoEntry,
71
+ tmp_dir: Path,
72
+ cache_dir: Path,
73
+ *,
74
+ force: bool = False,
75
+ ttl: int,
76
+ ) -> str | None:
77
+ work_dir = tmp_dir / entry.sha256_id
78
+ dst_dir = cache_dir / entry.sha256_id
79
+
80
+ # skip if cache is fresh (not stale) and not forced
81
+ if not force and not _is_stale(dst_dir, ttl):
82
+ return None
83
+
84
+ # cleanup previous state
85
+ shutil.rmtree(work_dir, ignore_errors=True)
86
+ shutil.rmtree(dst_dir, ignore_errors=True)
87
+
88
+ ref = entry.reference
89
+
90
+ if ref is not None and ref[0] == "commit":
91
+ # commit ref: clone default branch first, then fetch + checkout specific commit
92
+ ref_name = ref[1]
93
+ err = _clone_repo(entry.url, work_dir)
94
+ if err is not None:
95
+ return err
96
+ err = _fetch_commit(work_dir, ref_name)
97
+ if err is not None:
98
+ return err
99
+ else:
100
+ # branch or tag: --branch accepts both (tags checkout in detached HEAD)
101
+ branch = ref[1] if ref is not None else "main"
102
+ err = _clone_repo(entry.url, work_dir, branch=branch)
103
+ if err is not None:
104
+ return err
105
+
106
+ # sparse checkout target directory
107
+ err = _sparse_set(work_dir, entry.dir)
108
+ if err is not None:
109
+ return err
110
+
111
+ # copy extracted docs to cache
112
+ err = _copy_docs(work_dir, entry.dir, dst_dir)
113
+ if err is not None:
114
+ return err
115
+
116
+ # cleanup working directory
117
+ shutil.rmtree(work_dir, ignore_errors=True)
118
+
119
+ return None
120
+
121
+
122
+ # ==============================================================================
123
+ # freshness
124
+ # ==============================================================================
125
+
126
+
127
+ def _is_stale(path: Path, ttl: int) -> bool:
128
+ if not path.exists():
129
+ return True
130
+ stat = path.stat()
131
+ latest = max(stat.st_mtime, stat.st_ctime)
132
+ return time.time() - latest > ttl
@@ -0,0 +1,53 @@
1
+ # apiscope/repo/schema.py
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel
8
+
9
+ if TYPE_CHECKING:
10
+ from apiscope.config import RepoConfig
11
+
12
+
13
+ class RepoEntry(BaseModel):
14
+ url: str
15
+ dir: str
16
+ target: str
17
+
18
+ def __hash__(self) -> int:
19
+ return hash(self.sha256_id)
20
+
21
+ @property
22
+ def sha256_id(self) -> str:
23
+ # first 8 chars of sha256 hex digest; sufficient for collision resistance
24
+ return hashlib.sha256(self.url.encode()).hexdigest()[:8]
25
+
26
+ @property
27
+ def reference(self) -> tuple[str, str] | None:
28
+ if self.target.startswith("branch:"):
29
+ return "branch", self.target[len("branch:") :]
30
+ elif self.target.startswith("tag:"):
31
+ return "tag", self.target[len("tag:") :]
32
+ elif self.target.startswith("commit:"):
33
+ return "commit", self.target[len("commit:") :]
34
+ else:
35
+ return None
36
+
37
+ @classmethod
38
+ def from_config(cls, config: "RepoConfig") -> set["RepoEntry"]:
39
+ return {cls(url=url, dir=ec.dir, target=ec.target) for url, ec in config.entries.items()}
40
+
41
+
42
+ # ==============================================================================
43
+ # context
44
+ # ==============================================================================
45
+
46
+
47
+ class RepoCommandContext(BaseModel):
48
+ # computed properties
49
+ cache_dir: Path
50
+ tmp_dir: Path
51
+
52
+ # sub command options
53
+ global_flag: bool
@@ -4,6 +4,7 @@ from pydantic import BaseModel
4
4
 
5
5
  from apiscope.config import Config
6
6
  from apiscope.openapi.schema import OpenapiCommandContext
7
+ from apiscope.repo.schema import RepoCommandContext
7
8
  from apiscope.rfc.schema import RfcCommandContext
8
9
 
9
10
 
@@ -11,3 +12,4 @@ class CommandContext(BaseModel):
11
12
  config: Config
12
13
  openapi_command_context: OpenapiCommandContext | None = None
13
14
  rfc_command_context: RfcCommandContext | None = None
15
+ repo_command_context: RepoCommandContext | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-api-scope
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: read and cache structured documents from remote for LLM agents
5
5
  Author-email: D7x7z49 <85430783+D7x7z49@users.noreply.github.com>
6
6
  License: MIT License
@@ -79,6 +79,12 @@ read, search, and navigate RFC documents from the IETF.
79
79
 
80
80
  the metadata index is mirrored once via rsync. individual text files are fetched on demand and cached locally. you can browse the table of contents, jump to a section (XML) or page (TXT), filter the index by status or source, and run keyword searches against fulltext content.
81
81
 
82
+ ### repo
83
+
84
+ sync documentation directories from any git repository to a local cache.
85
+
86
+ register repositories by URL with a target subdirectory and a ref — a branch, tag, or commit. the sync command clones with shallow depth, blobless filter, and sparse checkout so only the needed tree and files come over the wire. extracted docs land in the cache under a stable hash path, and a configurable TTL avoids redundant re-fetches. provider-agnostic, zero auth.
87
+
82
88
  ## future
83
89
 
84
90
  - read academic papers from arxiv
@@ -13,6 +13,10 @@ apiscope/openapi/schema.py
13
13
  apiscope/openapi/spec/__init__.py
14
14
  apiscope/openapi/spec/app.py
15
15
  apiscope/openapi/spec/schema.py
16
+ apiscope/repo/__init__.py
17
+ apiscope/repo/app.py
18
+ apiscope/repo/fetch.py
19
+ apiscope/repo/schema.py
16
20
  apiscope/rfc/__init__.py
17
21
  apiscope/rfc/app.py
18
22
  apiscope/rfc/fetch.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "llm-api-scope"
7
- version = "0.5.1"
7
+ version = "0.6.0"
8
8
  description = "read and cache structured documents from remote for LLM agents"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
File without changes
File without changes