llm-api-scope 0.3.0__tar.gz → 0.4.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 (46) hide show
  1. llm_api_scope-0.4.0/PKG-INFO +84 -0
  2. llm_api_scope-0.4.0/README.md +35 -0
  3. llm_api_scope-0.4.0/apiscope/config.py +117 -0
  4. llm_api_scope-0.4.0/apiscope/main.py +68 -0
  5. llm_api_scope-0.4.0/apiscope/openapi/__init__.py +5 -0
  6. llm_api_scope-0.4.0/apiscope/openapi/app.py +147 -0
  7. llm_api_scope-0.4.0/apiscope/openapi/fetch.py +49 -0
  8. llm_api_scope-0.4.0/apiscope/openapi/reader.py +85 -0
  9. llm_api_scope-0.4.0/apiscope/openapi/schema.py +15 -0
  10. llm_api_scope-0.4.0/apiscope/openapi/spec/__init__.py +5 -0
  11. llm_api_scope-0.4.0/apiscope/openapi/spec/app.py +95 -0
  12. llm_api_scope-0.4.0/apiscope/openapi/spec/schema.py +7 -0
  13. llm_api_scope-0.4.0/apiscope/schema.py +11 -0
  14. llm_api_scope-0.4.0/llm_api_scope.egg-info/PKG-INFO +84 -0
  15. llm_api_scope-0.4.0/llm_api_scope.egg-info/SOURCES.txt +21 -0
  16. llm_api_scope-0.4.0/llm_api_scope.egg-info/entry_points.txt +2 -0
  17. llm_api_scope-0.4.0/llm_api_scope.egg-info/requires.txt +5 -0
  18. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/pyproject.toml +69 -16
  19. llm_api_scope-0.3.0/PKG-INFO +0 -86
  20. llm_api_scope-0.3.0/README.md +0 -39
  21. llm_api_scope-0.3.0/apiscope/cli.py +0 -37
  22. llm_api_scope-0.3.0/apiscope/commands/__init__.py +0 -1
  23. llm_api_scope-0.3.0/apiscope/commands/describe.py +0 -377
  24. llm_api_scope-0.3.0/apiscope/commands/init.py +0 -127
  25. llm_api_scope-0.3.0/apiscope/commands/list.py +0 -80
  26. llm_api_scope-0.3.0/apiscope/commands/note/__init__.py +0 -2
  27. llm_api_scope-0.3.0/apiscope/commands/note/commands.py +0 -688
  28. llm_api_scope-0.3.0/apiscope/commands/note/constants.py +0 -191
  29. llm_api_scope-0.3.0/apiscope/commands/note/core.py +0 -133
  30. llm_api_scope-0.3.0/apiscope/commands/note/utils.py +0 -57
  31. llm_api_scope-0.3.0/apiscope/commands/search.py +0 -230
  32. llm_api_scope-0.3.0/apiscope/core/__init__.py +0 -1
  33. llm_api_scope-0.3.0/apiscope/core/clustering.py +0 -199
  34. llm_api_scope-0.3.0/apiscope/core/config.py +0 -236
  35. llm_api_scope-0.3.0/apiscope/core/output.py +0 -177
  36. llm_api_scope-0.3.0/apiscope/core/parser.py +0 -193
  37. llm_api_scope-0.3.0/apiscope/core/trie.py +0 -59
  38. llm_api_scope-0.3.0/llm_api_scope.egg-info/PKG-INFO +0 -86
  39. llm_api_scope-0.3.0/llm_api_scope.egg-info/SOURCES.txt +0 -27
  40. llm_api_scope-0.3.0/llm_api_scope.egg-info/entry_points.txt +0 -2
  41. llm_api_scope-0.3.0/llm_api_scope.egg-info/requires.txt +0 -3
  42. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/LICENSE +0 -0
  43. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/apiscope/__init__.py +0 -0
  44. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/llm_api_scope.egg-info/dependency_links.txt +0 -0
  45. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/llm_api_scope.egg-info/top_level.txt +0 -0
  46. {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/setup.cfg +0 -0
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-api-scope
3
+ Version: 0.4.0
4
+ Summary: read and cache structured documents from remote for LLM agents
5
+ Author-email: D7x7z49 <85430783+D7x7z49@users.noreply.github.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 D7x7z49
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/D7x7z49/llm-api-scope
29
+ Project-URL: Repository, https://github.com/D7x7z49/llm-api-scope.git
30
+ Project-URL: Issues, https://github.com/D7x7z49/llm-api-scope/issues
31
+ Keywords: agent-tool,document-reader,openapi,specification
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Classifier: Topic :: Utilities
38
+ Classifier: Development Status :: 3 - Alpha
39
+ Classifier: Intended Audience :: Developers
40
+ Requires-Python: <4.0,>=3.12
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: typer>=0.26.3
44
+ Requires-Dist: pydantic>=2.13.4
45
+ Requires-Dist: python-dotenv>=1.2.2
46
+ Requires-Dist: pyyaml>=6.0.3
47
+ Requires-Dist: httpx>=0.28.1
48
+ Dynamic: license-file
49
+
50
+ # LLM API Scope (apiscope)
51
+
52
+ a tool for LLM agents to read and cache structured documents from remote.
53
+
54
+ ## install
55
+
56
+ use [pipx](https://github.com/pypa/pipx) for isolated installation:
57
+
58
+ ```bash
59
+ pipx install llm-api-scope
60
+ ```
61
+
62
+ ## how it works
63
+
64
+ apiscope fetches specifications from local files or remote URLs, caches them, and outputs structured JSON that agents can consume directly. no html parsing, no keyword ranking — just faithful extraction from the source document.
65
+
66
+ ## commands
67
+
68
+ run `apiscope --help` to see all available commands.
69
+
70
+ ### openapi
71
+
72
+ browse OpenAPI specifications with subcommands for discovering, listing, and describing operations.
73
+
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
+
76
+ ## future
77
+
78
+ - read RFC documents by number
79
+ - read academic papers from arxiv
80
+ - more formal document formats as the need arises
81
+
82
+ ## license
83
+
84
+ MIT
@@ -0,0 +1,35 @@
1
+ # LLM API Scope (apiscope)
2
+
3
+ a tool for LLM agents to read and cache structured documents from remote.
4
+
5
+ ## install
6
+
7
+ use [pipx](https://github.com/pypa/pipx) for isolated installation:
8
+
9
+ ```bash
10
+ pipx install llm-api-scope
11
+ ```
12
+
13
+ ## how it works
14
+
15
+ apiscope fetches specifications from local files or remote URLs, caches them, and outputs structured JSON that agents can consume directly. no html parsing, no keyword ranking — just faithful extraction from the source document.
16
+
17
+ ## commands
18
+
19
+ run `apiscope --help` to see all available commands.
20
+
21
+ ### openapi
22
+
23
+ browse OpenAPI specifications with subcommands for discovering, listing, and describing operations.
24
+
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
+
27
+ ## future
28
+
29
+ - read RFC documents by number
30
+ - read academic papers from arxiv
31
+ - more formal document formats as the need arises
32
+
33
+ ## license
34
+
35
+ MIT
@@ -0,0 +1,117 @@
1
+ # apiscope/config.py
2
+
3
+ import json
4
+ from contextlib import contextmanager
5
+ from os import environ
6
+ from pathlib import Path
7
+ from typing import Generator
8
+
9
+ from dotenv import load_dotenv
10
+ from pydantic import BaseModel, Field
11
+
12
+ # load .env file at module load time
13
+ load_dotenv()
14
+
15
+ # brand identity
16
+ # ==============
17
+ APP_NAME = "apiscope"
18
+ DEFAULT_HOME = Path(environ.get("APISCOPE_HOME", str(Path.home())))
19
+ DEFAULT_ROOT = DEFAULT_HOME / ".apiscope"
20
+ DEFAULT_CONFIG_PATH = DEFAULT_ROOT / "config.json"
21
+ DEFAULT_CONFIG_SCHEMA_PATH = DEFAULT_ROOT / "config.schema.json"
22
+
23
+ CACHE_ROOT = DEFAULT_ROOT / "cache"
24
+
25
+ # ==============================================================================
26
+ # config model
27
+ # ==============================================================================
28
+
29
+
30
+ class OpenapiConfig(BaseModel):
31
+ proxy: str | None = Field(default=None)
32
+ alias: dict[str, str] = Field(default_factory=dict)
33
+
34
+
35
+ class Config(BaseModel):
36
+ openapi: OpenapiConfig = Field(default_factory=OpenapiConfig)
37
+
38
+ @classmethod
39
+ def read(cls, path: Path) -> "Config":
40
+ if not path.exists():
41
+ raise FileNotFoundError(f"[{path}] not found.")
42
+ return cls.model_validate_json(path.read_text())
43
+
44
+ @classmethod
45
+ @contextmanager
46
+ def edit(cls, path: Path) -> Generator["Config", None, None]:
47
+ if not path.exists():
48
+ raise FileNotFoundError(f"[{path}] not found.")
49
+ config = cls.model_validate_json(path.read_text())
50
+ yield config
51
+ if isinstance(config, Config):
52
+ config.write(path)
53
+
54
+ def write(self, path: Path) -> None:
55
+ data = {"$schema": DEFAULT_CONFIG_SCHEMA_PATH.as_uri()} | self.model_dump()
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(json.dumps(data, indent=2) + "\n")
58
+
59
+ def merge(self, other: "Config") -> "Config":
60
+ merged = self.model_copy(deep=True)
61
+ merged.openapi.alias |= other.openapi.alias
62
+ if other.openapi.proxy is not None:
63
+ merged.openapi.proxy = other.openapi.proxy
64
+ return merged
65
+
66
+
67
+ # ==============================================================================
68
+ # helpers
69
+ # ==============================================================================
70
+
71
+
72
+ def _get_project_root() -> Path | None:
73
+ workpath = Path.cwd()
74
+ while True:
75
+ if (workpath / ".git").exists():
76
+ return workpath
77
+ parent = workpath.parent
78
+ if parent == workpath:
79
+ return None
80
+ workpath = parent
81
+
82
+
83
+ # ==============================================================================
84
+ # public API
85
+ # ==============================================================================
86
+
87
+
88
+ DEFAULT_CONFIG = Config()
89
+
90
+
91
+ def get_project_config_path() -> Path | None:
92
+ project_root = _get_project_root()
93
+ if project_root is None:
94
+ return None
95
+ return project_root / f".{APP_NAME}.config.json"
96
+
97
+
98
+ def get_config() -> Config:
99
+ # ensure the default config exists
100
+ if not DEFAULT_CONFIG_PATH.exists():
101
+ DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
102
+ DEFAULT_CONFIG.write(DEFAULT_CONFIG_PATH)
103
+
104
+ # ensure the default config schema exists
105
+ DEFAULT_CONFIG_SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True)
106
+ DEFAULT_CONFIG_SCHEMA_PATH.write_text(json.dumps(Config.model_json_schema(), indent=2))
107
+
108
+ # load the global config
109
+ global_config = Config.model_validate_json(DEFAULT_CONFIG_PATH.read_text())
110
+
111
+ # load and merge the project config
112
+ project_config_path = get_project_config_path()
113
+ if project_config_path is not None and project_config_path.exists():
114
+ project_config = Config.model_validate_json(project_config_path.read_text())
115
+ return global_config.merge(project_config)
116
+
117
+ return global_config
@@ -0,0 +1,68 @@
1
+ # apiscope/main.py
2
+ #
3
+ # structure convention:
4
+ #
5
+ # subcommands live under apiscope/<group>/ as subdirectory packages.
6
+ # each subdirectory contains:
7
+ #
8
+ # app.py — command definitions (typer.Typer app)
9
+ # schema.py — context object for this command group (read-only contract)
10
+ #
11
+ # parent callbacks inject ctx.obj with CommandContext(config=...).
12
+ # subcommand callbacks extend ctx.obj.extras with group-specific keys.
13
+ # schema.py exists solely to document the context shape — no runtime logic.
14
+
15
+ import typer
16
+
17
+ from apiscope.config import APP_NAME, DEFAULT_ROOT, get_config
18
+ from apiscope.openapi import openapi_app
19
+ from apiscope.schema import CommandContext
20
+
21
+ # ==============================================================================
22
+ # app
23
+ # ==============================================================================
24
+
25
+ app = typer.Typer(
26
+ rich_markup_mode=None,
27
+ pretty_exceptions_enable=False,
28
+ help="a reader for network resources",
29
+ )
30
+
31
+ # ==============================================================================
32
+ # callback
33
+ # ==============================================================================
34
+
35
+
36
+ @app.callback(invoke_without_command=True)
37
+ def callback(ctx: typer.Context) -> None:
38
+ if ctx.invoked_subcommand is None:
39
+ typer.echo(ctx.get_help())
40
+ return
41
+ ctx.obj = CommandContext(config=get_config())
42
+
43
+
44
+ # ==============================================================================
45
+ # subcommands
46
+ # ==============================================================================
47
+
48
+ app.add_typer(openapi_app, name="openapi")
49
+
50
+ # ==============================================================================
51
+ # commands
52
+ # ==============================================================================
53
+
54
+
55
+ @app.command(help="check that apiscope is installed and working")
56
+ def health(ctx: typer.Context) -> None:
57
+ cache_dir = DEFAULT_ROOT / "cache"
58
+ DEFAULT_ROOT.mkdir(parents=True, exist_ok=True)
59
+ cache_dir.mkdir(parents=True, exist_ok=True)
60
+ typer.echo(f"{APP_NAME} is healthy")
61
+
62
+
63
+ # ==============================================================================
64
+ # entry point
65
+ # ==============================================================================
66
+
67
+ if __name__ == "__main__":
68
+ app()
@@ -0,0 +1,5 @@
1
+ # apiscope/openapi/__init__.py
2
+
3
+ from apiscope.openapi.app import app as openapi_app
4
+
5
+ __all__ = ["openapi_app"]
@@ -0,0 +1,147 @@
1
+ # apiscope/openapi/app.py
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from apiscope.config import CACHE_ROOT, Config
10
+ from apiscope.openapi.fetch import fetch_openapi_spec
11
+ from apiscope.openapi.reader import HttpMethod, OpenapiReader
12
+ from apiscope.openapi.schema import OpenapiCommandContext
13
+ from apiscope.openapi.spec import spec_app
14
+
15
+ app = typer.Typer(help="browse OpenAPI specifications")
16
+
17
+
18
+ # ==============================================================================
19
+ # helpers
20
+ # ==============================================================================
21
+
22
+
23
+ def _resolve_source(alias_or_source: str, config: Config) -> str:
24
+ if alias_or_source in config.openapi.alias:
25
+ return config.openapi.alias[alias_or_source]
26
+ return alias_or_source
27
+
28
+
29
+ def _load_reader(source: str, cache_dir: Path, proxy: str | None = None) -> OpenapiReader:
30
+ cached = fetch_openapi_spec(source, cache_dir, proxy)
31
+ return OpenapiReader.load(cached)
32
+
33
+
34
+ def _get_reader(source: str, ctx: typer.Context) -> OpenapiReader:
35
+ resolved = _resolve_source(source, ctx.obj.config)
36
+ cache_dir = ctx.obj.openapi_command_context.cache_dir
37
+ proxy = ctx.obj.config.openapi.proxy
38
+ return _load_reader(resolved, cache_dir, proxy)
39
+
40
+
41
+ # ==============================================================================
42
+ # callback
43
+ # ==============================================================================
44
+
45
+
46
+ @app.callback()
47
+ def openapi_callback(ctx: typer.Context) -> None:
48
+ cache_dir = CACHE_ROOT / "openapi"
49
+ cache_dir.mkdir(parents=True, exist_ok=True)
50
+ ctx.obj.openapi_command_context = OpenapiCommandContext(cache_dir=cache_dir)
51
+
52
+
53
+ # ==============================================================================
54
+ # operation commands
55
+ # ==============================================================================
56
+
57
+
58
+ @app.command(name="list", help="list operations from an OpenAPI spec")
59
+ def list_operations(
60
+ ctx: typer.Context,
61
+ source: str = typer.Argument(help="alias or path to the OpenAPI spec"),
62
+ tag: str | None = typer.Option(default=None, help="filter by tag"),
63
+ method: str | None = typer.Option(default=None, help="filter by HTTP method"),
64
+ ) -> None:
65
+ # load and resolve the spec
66
+ reader = _get_reader(source, ctx)
67
+
68
+ # collect all operations (path + method + identity fields)
69
+ operations: list[dict[str, Any]] = []
70
+ methods = set(m.value for m in HttpMethod)
71
+ for path_name, path_item in reader.paths.items():
72
+ for mthd in path_item:
73
+ if mthd not in methods:
74
+ continue
75
+ # summary comes from the operation, not the path-item
76
+ op = path_item[mthd]
77
+ summary = op.get("summary", "")
78
+ operation_id = op.get("operationId", "")
79
+ tags = op.get("tags", [])
80
+
81
+ # apply filters
82
+ if tag is not None and tag not in tags:
83
+ continue
84
+ if method is not None and mthd != method:
85
+ continue
86
+
87
+ operations.append(
88
+ {
89
+ "path": path_name,
90
+ "method": mthd,
91
+ "summary": summary,
92
+ "operationId": operation_id,
93
+ "tags": tags,
94
+ }
95
+ )
96
+
97
+ typer.echo(json.dumps(operations, ensure_ascii=False))
98
+
99
+
100
+ @app.command(name="describe", help="describe a single operation")
101
+ def describe_operation(
102
+ ctx: typer.Context,
103
+ source: str = typer.Argument(help="alias or path to the OpenAPI spec"),
104
+ path: str = typer.Argument(help="operation path"),
105
+ method: str = typer.Argument(help="HTTP method"),
106
+ request: bool = typer.Option(
107
+ default=False, show_default=False, help="show only request fields, omit responses"
108
+ ),
109
+ ) -> None:
110
+ # load and resolve the spec
111
+ reader = _get_reader(source, ctx)
112
+
113
+ # merge path-item parameters with operation parameters
114
+ path_item = reader.paths.get(path, {})
115
+ path_params = path_item.get("parameters", [])
116
+ op = reader.get_operation(path, HttpMethod(method))
117
+ op_params = op.get("parameters", [])
118
+
119
+ # build resolved operation dict
120
+ merged = {**op, "parameters": path_params + op_params}
121
+ resolved = reader.resolve_ref(merged)
122
+
123
+ # strip response fields when --request is set
124
+ if request:
125
+ resolved.pop("responses", None)
126
+ # also drop any $ref key that resolved to a response-like structure
127
+
128
+ typer.echo(json.dumps(resolved, ensure_ascii=False))
129
+
130
+
131
+ @app.command(name="info", help="show OpenAPI spec metadata")
132
+ def show_info(
133
+ ctx: typer.Context,
134
+ source: str = typer.Argument(help="alias or path to the OpenAPI spec"),
135
+ ) -> None:
136
+ reader = _get_reader(source, ctx)
137
+
138
+ META_KEYS = ("openapi", "info", "servers", "tags", "security", "externalDocs")
139
+ meta = {k: v for k, v in reader.raw.items() if k in META_KEYS}
140
+ typer.echo(json.dumps(meta, ensure_ascii=False))
141
+
142
+
143
+ # ==============================================================================
144
+ # subcommands
145
+ # ==============================================================================
146
+
147
+ app.add_typer(spec_app, name="spec")
@@ -0,0 +1,49 @@
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)
@@ -0,0 +1,85 @@
1
+ # apiscope/openapi/reader.py
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ import yaml
9
+
10
+
11
+ class HttpMethod(StrEnum):
12
+ GET = "get"
13
+ PUT = "put"
14
+ POST = "post"
15
+ DELETE = "delete"
16
+ OPTIONS = "options"
17
+ HEAD = "head"
18
+ PATCH = "patch"
19
+ TRACE = "trace"
20
+
21
+
22
+ class OpenapiReader:
23
+ _payload: dict[str, Any]
24
+
25
+ def __init__(self, payload: dict[str, Any]) -> None:
26
+ self._payload = payload
27
+
28
+ @classmethod
29
+ def load(cls, path: Path) -> "OpenapiReader":
30
+ text = path.read_text(encoding="utf-8")
31
+ if path.suffix in (".yaml", ".yml"):
32
+ payload = yaml.safe_load(text)
33
+ else:
34
+ payload = json.loads(text)
35
+ return cls(payload)
36
+
37
+ @property
38
+ def paths(self) -> dict[str, dict[str, Any]]:
39
+ return cast(dict[str, dict[str, Any]], self._payload.get("paths", {}))
40
+
41
+ @property
42
+ def raw(self) -> dict[str, Any]:
43
+ return self._payload
44
+
45
+ def filter_paths(self, *methods: HttpMethod) -> dict[str, dict[str, Any]]:
46
+ method_set = set(methods)
47
+ result: dict[str, dict[str, Any]] = {}
48
+ for path_name, path_item in self.paths.items():
49
+ filtered: dict[str, Any] = {k: v for k, v in path_item.items() if k in method_set}
50
+ if filtered:
51
+ result[path_name] = filtered
52
+ return result
53
+
54
+ def get_operation(self, path: str, method: HttpMethod) -> dict[str, Any]:
55
+ path_item: dict[str, Any] = self._payload.get("paths", {}).get(path, {})
56
+ return cast(dict[str, Any], path_item.get(method, {}))
57
+
58
+ def resolve_ref(self, data: dict[str, Any]) -> dict[str, Any]:
59
+ result: dict[str, Any] = {}
60
+ for k, v in data.items():
61
+ if k == "$ref" and isinstance(v, str):
62
+ resolved = self._deref(v)
63
+ if resolved is not None:
64
+ return self.resolve_ref(resolved)
65
+ result[k] = v
66
+ elif isinstance(v, dict):
67
+ result[k] = self.resolve_ref(v)
68
+ elif isinstance(v, list):
69
+ result[k] = [
70
+ self.resolve_ref(item) if isinstance(item, dict) else item for item in v
71
+ ]
72
+ else:
73
+ result[k] = v
74
+ return result
75
+
76
+ def _deref(self, ref: str) -> dict[str, Any] | None:
77
+ if not ref.startswith("#/"):
78
+ return None
79
+ parts = ref.removeprefix("#/").split("/")
80
+ target: Any = self._payload
81
+ for part in parts:
82
+ if not isinstance(target, dict) or part not in target:
83
+ return None
84
+ target = target[part]
85
+ return target if isinstance(target, dict) else None
@@ -0,0 +1,15 @@
1
+ # apiscope/openapi/schema.py
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from apiscope.openapi.spec.schema import SpecCommandContext
8
+
9
+
10
+ class OpenapiCommandContext(BaseModel):
11
+ # openapi settings
12
+ cache_dir: Path
13
+
14
+ # sub command contexts
15
+ spec_command_context: SpecCommandContext | None = None
@@ -0,0 +1,5 @@
1
+ # apiscope/openapi/spec/__init__.py
2
+
3
+ from apiscope.openapi.spec.app import app as spec_app
4
+
5
+ __all__ = ["spec_app"]