llm-api-scope 0.5.0__tar.gz → 0.5.1__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 (31) hide show
  1. {llm_api_scope-0.5.0/llm_api_scope.egg-info → llm_api_scope-0.5.1}/PKG-INFO +7 -2
  2. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/README.md +6 -1
  3. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/config.py +25 -1
  4. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/app.py +15 -10
  5. llm_api_scope-0.5.1/apiscope/openapi/fetch.py +58 -0
  6. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/app.py +18 -4
  7. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1/llm_api_scope.egg-info}/PKG-INFO +7 -2
  8. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/pyproject.toml +1 -1
  9. llm_api_scope-0.5.0/apiscope/openapi/fetch.py +0 -49
  10. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/LICENSE +0 -0
  11. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/__init__.py +0 -0
  12. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/main.py +0 -0
  13. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/__init__.py +0 -0
  14. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/reader.py +0 -0
  15. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/schema.py +0 -0
  16. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/spec/__init__.py +0 -0
  17. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/spec/app.py +0 -0
  18. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/openapi/spec/schema.py +0 -0
  19. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/__init__.py +0 -0
  20. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/fetch.py +0 -0
  21. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/parse_txt.py +0 -0
  22. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/parse_xml.py +0 -0
  23. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/schema.py +0 -0
  24. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/rfc/search.py +0 -0
  25. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/apiscope/schema.py +0 -0
  26. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/llm_api_scope.egg-info/SOURCES.txt +0 -0
  27. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/llm_api_scope.egg-info/dependency_links.txt +0 -0
  28. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/llm_api_scope.egg-info/entry_points.txt +0 -0
  29. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/llm_api_scope.egg-info/requires.txt +0 -0
  30. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/llm_api_scope.egg-info/top_level.txt +0 -0
  31. {llm_api_scope-0.5.0 → llm_api_scope-0.5.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-api-scope
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -73,9 +73,14 @@ browse OpenAPI specifications with subcommands for discovering, listing, and des
73
73
 
74
74
  aliases let you register frequently used specs once and reference them by short name. fetching is transparent — local copies are cached for fast repeat access, and a proxy can be configured for restricted networks.
75
75
 
76
+ ### rfc
77
+
78
+ read, search, and navigate RFC documents from the IETF.
79
+
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
+
76
82
  ## future
77
83
 
78
- - read RFC documents by number
79
84
  - read academic papers from arxiv
80
85
  - more formal document formats as the need arises
81
86
 
@@ -24,9 +24,14 @@ browse OpenAPI specifications with subcommands for discovering, listing, and des
24
24
 
25
25
  aliases let you register frequently used specs once and reference them by short name. fetching is transparent — local copies are cached for fast repeat access, and a proxy can be configured for restricted networks.
26
26
 
27
+ ### rfc
28
+
29
+ read, search, and navigate RFC documents from the IETF.
30
+
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
+
27
33
  ## future
28
34
 
29
- - read RFC documents by number
30
35
  - read academic papers from arxiv
31
36
  - more formal document formats as the need arises
32
37
 
@@ -1,6 +1,7 @@
1
1
  # apiscope/config.py
2
2
 
3
3
  import json
4
+ import time
4
5
  from contextlib import contextmanager
5
6
  from os import environ
6
7
  from pathlib import Path
@@ -27,13 +28,36 @@ CACHE_ROOT = DEFAULT_ROOT / "cache"
27
28
  # ==============================================================================
28
29
 
29
30
 
30
- class OpenapiConfig(BaseModel):
31
+ class BaseConfig(BaseModel):
32
+ cache_ttl: int # seconds
33
+
34
+ def is_stale_since(self, timestamp: float) -> bool:
35
+ return time.time() - timestamp > self.cache_ttl
36
+
37
+ def is_stale_path(self, path: Path) -> bool:
38
+ if not path.exists():
39
+ return True
40
+ stat = path.stat()
41
+ latest = max(stat.st_mtime, stat.st_ctime)
42
+ return self.is_stale_since(latest)
43
+
44
+
45
+ class OpenapiConfig(BaseConfig):
31
46
  proxy: str | None = Field(default=None)
32
47
  alias: dict[str, str] = Field(default_factory=dict)
33
48
 
49
+ # override
50
+ cache_ttl: int = Field(default=60 * 60 * 24) # 1 day
51
+
52
+
53
+ class RfcConfig(BaseConfig):
54
+ # override
55
+ cache_ttl: int = Field(default=60 * 60 * 24 * 7) # 1 week
56
+
34
57
 
35
58
  class Config(BaseModel):
36
59
  openapi: OpenapiConfig = Field(default_factory=OpenapiConfig)
60
+ rfc: RfcConfig = Field(default_factory=RfcConfig)
37
61
 
38
62
  @classmethod
39
63
  def read(cls, path: Path) -> "Config":
@@ -7,7 +7,7 @@ from typing import Any
7
7
  import typer
8
8
 
9
9
  from apiscope.config import CACHE_ROOT, Config
10
- from apiscope.openapi.fetch import fetch_openapi_spec
10
+ from apiscope.openapi.fetch import fetch_openapi_spec, openapi_cache_path
11
11
  from apiscope.openapi.reader import HttpMethod, OpenapiReader
12
12
  from apiscope.openapi.schema import OpenapiCommandContext
13
13
  from apiscope.openapi.spec import spec_app
@@ -26,16 +26,20 @@ def _resolve_source(alias_or_source: str, config: Config) -> str:
26
26
  return alias_or_source
27
27
 
28
28
 
29
- def _load_reader(source: str, cache_dir: Path, proxy: str | None = None) -> OpenapiReader:
30
- cached = fetch_openapi_spec(source, cache_dir, proxy)
29
+ def _load_reader(
30
+ source: str, cache_dir: Path, proxy: str | None = None, refresh: bool = False
31
+ ) -> OpenapiReader:
32
+ cached = fetch_openapi_spec(source, cache_dir, proxy, refresh=refresh)
31
33
  return OpenapiReader.load(cached)
32
34
 
33
35
 
34
- def _get_reader(source: str, ctx: typer.Context) -> OpenapiReader:
36
+ def _get_reader(source: str, ctx: typer.Context, force: bool = False) -> OpenapiReader:
35
37
  resolved = _resolve_source(source, ctx.obj.config)
36
38
  cache_dir = ctx.obj.openapi_command_context.cache_dir
37
39
  proxy = ctx.obj.config.openapi.proxy
38
- return _load_reader(resolved, cache_dir, proxy)
40
+ cache_path = openapi_cache_path(resolved, cache_dir)
41
+ stale = ctx.obj.config.openapi.is_stale_path(cache_path)
42
+ return _load_reader(resolved, cache_dir, proxy, refresh=force or stale)
39
43
 
40
44
 
41
45
  # ==============================================================================
@@ -61,9 +65,9 @@ def list_operations(
61
65
  source: str = typer.Argument(help="alias or path to the OpenAPI spec"),
62
66
  tag: str | None = typer.Option(default=None, help="filter by tag"),
63
67
  method: str | None = typer.Option(default=None, help="filter by HTTP method"),
68
+ force: bool = typer.Option(False, "--force", help="force re-fetch ignoring cache"),
64
69
  ) -> None:
65
- # load and resolve the spec
66
- reader = _get_reader(source, ctx)
70
+ reader = _get_reader(source, ctx, force=force)
67
71
 
68
72
  # collect all operations (path + method + identity fields)
69
73
  operations: list[dict[str, Any]] = []
@@ -106,9 +110,9 @@ def describe_operation(
106
110
  request: bool = typer.Option(
107
111
  default=False, show_default=False, help="show only request fields, omit responses"
108
112
  ),
113
+ force: bool = typer.Option(False, "--force", help="force re-fetch ignoring cache"),
109
114
  ) -> None:
110
- # load and resolve the spec
111
- reader = _get_reader(source, ctx)
115
+ reader = _get_reader(source, ctx, force=force)
112
116
 
113
117
  # merge path-item parameters with operation parameters
114
118
  path_item = reader.paths.get(path, {})
@@ -132,8 +136,9 @@ def describe_operation(
132
136
  def show_info(
133
137
  ctx: typer.Context,
134
138
  source: str = typer.Argument(help="alias or path to the OpenAPI spec"),
139
+ force: bool = typer.Option(False, "--force", help="force re-fetch ignoring cache"),
135
140
  ) -> None:
136
- reader = _get_reader(source, ctx)
141
+ reader = _get_reader(source, ctx, force=force)
137
142
 
138
143
  META_KEYS = ("openapi", "info", "servers", "tags", "security", "externalDocs")
139
144
  meta = {k: v for k, v in reader.raw.items() if k in META_KEYS}
@@ -0,0 +1,58 @@
1
+ # apiscope/openapi/fetch.py
2
+
3
+ import shutil
4
+ from hashlib import sha256
5
+ from pathlib import Path
6
+ from urllib.parse import unquote, urlparse
7
+
8
+ import httpx
9
+
10
+ OPENAPI_EXTENSIONS = {".json", ".yaml", ".yml"}
11
+
12
+
13
+ def _cache_key(source: str) -> str:
14
+ return sha256(source.encode()).hexdigest()
15
+
16
+
17
+ def openapi_cache_path(source: str, cache_dir: Path) -> Path:
18
+ if "://" in source:
19
+ path = unquote(urlparse(source).path).rstrip()
20
+ else:
21
+ path = source
22
+ suffix = Path(path).suffix.lower()
23
+ ext = suffix if suffix in OPENAPI_EXTENSIONS else ".json"
24
+ return cache_dir / f"{_cache_key(source)}{ext}"
25
+
26
+
27
+ def _fetch_local(source: str, cache_dir: Path, refresh: bool = False) -> Path:
28
+ cache_path = openapi_cache_path(source, cache_dir)
29
+
30
+ if refresh or not cache_path.exists():
31
+ src = Path(source).expanduser().resolve()
32
+ shutil.copy2(src, cache_path)
33
+
34
+ return cache_path
35
+
36
+
37
+ def _fetch_remote(
38
+ url: str, cache_dir: Path, proxy: str | None = None, refresh: bool = False
39
+ ) -> Path:
40
+ cache_path = openapi_cache_path(url, cache_dir)
41
+
42
+ if refresh or not cache_path.exists():
43
+ client_kwargs: dict = {}
44
+ if proxy is not None:
45
+ client_kwargs["proxy"] = proxy
46
+ resp = httpx.get(url, follow_redirects=True, **client_kwargs)
47
+ resp.raise_for_status()
48
+ cache_path.write_bytes(resp.content)
49
+
50
+ return cache_path
51
+
52
+
53
+ def fetch_openapi_spec(
54
+ source: str, cache_dir: Path, proxy: str | None = None, refresh: bool = False
55
+ ) -> Path:
56
+ if "://" in source:
57
+ return _fetch_remote(source, cache_dir, proxy, refresh=refresh)
58
+ return _fetch_local(source, cache_dir, refresh=refresh)
@@ -88,10 +88,20 @@ def _show_page_info(content: str, number: int, rfc_ctx: RfcCommandContext) -> No
88
88
 
89
89
 
90
90
  @app.command(name="sync", help="download RFC metadata index via rsync")
91
- def sync_index(ctx: typer.Context) -> None:
91
+ def sync_index(
92
+ ctx: typer.Context,
93
+ force: bool = typer.Option(False, "--force", help="force re-sync ignoring cache TTL"),
94
+ ) -> None:
92
95
  rfc_ctx = ctx.obj.rfc_command_context
96
+ rfc_config = ctx.obj.config.rfc
97
+ index_dir = rfc_ctx.index_json_dir
98
+
99
+ if not force and not rfc_config.is_stale_path(index_dir):
100
+ typer.echo("index is up to date")
101
+ return
102
+
93
103
  typer.echo("syncing via rsync...")
94
- err = fetch_all_index_json(rfc_ctx.index_json_dir)
104
+ err = fetch_all_index_json(index_dir)
95
105
  if err is not None:
96
106
  typer.echo(f"sync failed\n{err}", err=True)
97
107
  raise typer.Exit(code=1)
@@ -139,8 +149,10 @@ def read_content(
139
149
  section: str | None = typer.Option(None, "--section", help="extract by section id (XML only)"),
140
150
  page: int | None = typer.Option(None, "--page", help="extract by page number (TXT only)"),
141
151
  json_output: bool = typer.Option(False, "--json", help="output as JSON"),
152
+ force: bool = typer.Option(False, "--force", help="force re-fetch ignoring cache TTL"),
142
153
  ) -> None:
143
154
  rfc_ctx = ctx.obj.rfc_command_context
155
+ rfc_config = ctx.obj.config.rfc
144
156
  meta = rfc_ctx.get_index_json(number)
145
157
  if meta is None:
146
158
  typer.echo(f"rfc {number} not found", err=True)
@@ -158,7 +170,7 @@ def read_content(
158
170
  raise typer.Exit(code=1)
159
171
 
160
172
  # ensure content is available locally
161
- if not content_path.exists():
173
+ if force or rfc_config.is_stale_path(content_path):
162
174
  typer.echo(f"fetching <{content_path.name}> via rsync...")
163
175
  err = fetch_content_by_number(number, content_path.parent, fmt)
164
176
  if err is not None:
@@ -235,8 +247,10 @@ def search_rfc(
235
247
  no_snippet: bool = typer.Option(False, "--no-snippet", help="hide content snippet"),
236
248
  limit: int = typer.Option(20, "--limit", help="max results shown"),
237
249
  offset: int = typer.Option(0, "--offset", help="skip first N results"),
250
+ force: bool = typer.Option(False, "--force", help="force re-fetch ignoring cache TTL"),
238
251
  ) -> None:
239
252
  rfc_ctx = ctx.obj.rfc_command_context
253
+ rfc_config = ctx.obj.config.rfc
240
254
  # step 1: dispatch mode
241
255
  index_filters = [status, since, until, author, title, abstract, keywords_field, source]
242
256
  fulltext_mode = number is not None and term is not None
@@ -272,7 +286,7 @@ def search_rfc(
272
286
  raise typer.Exit(code=1)
273
287
 
274
288
  content_path = rfc_ctx.get_content_txt_path(number)
275
- if not content_path.exists():
289
+ if force or rfc_config.is_stale_path(content_path):
276
290
  typer.echo(f"fetching <{content_path.name}> via rsync...")
277
291
  err = fetch_content_by_number(number, content_path.parent, "txt")
278
292
  if err is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-api-scope
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -73,9 +73,14 @@ browse OpenAPI specifications with subcommands for discovering, listing, and des
73
73
 
74
74
  aliases let you register frequently used specs once and reference them by short name. fetching is transparent — local copies are cached for fast repeat access, and a proxy can be configured for restricted networks.
75
75
 
76
+ ### rfc
77
+
78
+ read, search, and navigate RFC documents from the IETF.
79
+
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
+
76
82
  ## future
77
83
 
78
- - read RFC documents by number
79
84
  - read academic papers from arxiv
80
85
  - more formal document formats as the need arises
81
86
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "llm-api-scope"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "read and cache structured documents from remote for LLM agents"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -1,49 +0,0 @@
1
- # apiscope/openapi/fetch.py
2
-
3
- import shutil
4
- from hashlib import sha256
5
- from pathlib import Path
6
- from urllib.parse import unquote, urlparse
7
-
8
- import httpx
9
-
10
- OPENAPI_EXTENSIONS = {".json", ".yaml", ".yml"}
11
-
12
-
13
- def _cache_key(source: str) -> str:
14
- return sha256(source.encode()).hexdigest()
15
-
16
-
17
- def _fetch_local(source: str, cache_dir: Path) -> Path:
18
- suffix = Path(source).suffix.lower()
19
- ext = suffix if suffix in OPENAPI_EXTENSIONS else ".json"
20
- cache_path = cache_dir / f"{_cache_key(source)}{ext}"
21
-
22
- if not cache_path.exists():
23
- src = Path(source).expanduser().resolve()
24
- shutil.copy2(src, cache_path)
25
-
26
- return cache_path
27
-
28
-
29
- def _fetch_remote(url: str, cache_dir: Path, proxy: str | None = None) -> Path:
30
- path = unquote(urlparse(url).path).rstrip()
31
- suffix = Path(path).suffix.lower()
32
- ext = suffix if suffix in OPENAPI_EXTENSIONS else ".json"
33
- cache_path = cache_dir / f"{_cache_key(url)}{ext}"
34
-
35
- if not cache_path.exists():
36
- client_kwargs: dict = {}
37
- if proxy is not None:
38
- client_kwargs["proxy"] = proxy
39
- resp = httpx.get(url, follow_redirects=True, **client_kwargs)
40
- resp.raise_for_status()
41
- cache_path.write_bytes(resp.content)
42
-
43
- return cache_path
44
-
45
-
46
- def fetch_openapi_spec(source: str, cache_dir: Path, proxy: str | None = None) -> Path:
47
- if "://" in source:
48
- return _fetch_remote(source, cache_dir, proxy)
49
- return _fetch_local(source, cache_dir)
File without changes
File without changes