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.
- llm_api_scope-0.4.0/PKG-INFO +84 -0
- llm_api_scope-0.4.0/README.md +35 -0
- llm_api_scope-0.4.0/apiscope/config.py +117 -0
- llm_api_scope-0.4.0/apiscope/main.py +68 -0
- llm_api_scope-0.4.0/apiscope/openapi/__init__.py +5 -0
- llm_api_scope-0.4.0/apiscope/openapi/app.py +147 -0
- llm_api_scope-0.4.0/apiscope/openapi/fetch.py +49 -0
- llm_api_scope-0.4.0/apiscope/openapi/reader.py +85 -0
- llm_api_scope-0.4.0/apiscope/openapi/schema.py +15 -0
- llm_api_scope-0.4.0/apiscope/openapi/spec/__init__.py +5 -0
- llm_api_scope-0.4.0/apiscope/openapi/spec/app.py +95 -0
- llm_api_scope-0.4.0/apiscope/openapi/spec/schema.py +7 -0
- llm_api_scope-0.4.0/apiscope/schema.py +11 -0
- llm_api_scope-0.4.0/llm_api_scope.egg-info/PKG-INFO +84 -0
- llm_api_scope-0.4.0/llm_api_scope.egg-info/SOURCES.txt +21 -0
- llm_api_scope-0.4.0/llm_api_scope.egg-info/entry_points.txt +2 -0
- llm_api_scope-0.4.0/llm_api_scope.egg-info/requires.txt +5 -0
- {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/pyproject.toml +69 -16
- llm_api_scope-0.3.0/PKG-INFO +0 -86
- llm_api_scope-0.3.0/README.md +0 -39
- llm_api_scope-0.3.0/apiscope/cli.py +0 -37
- llm_api_scope-0.3.0/apiscope/commands/__init__.py +0 -1
- llm_api_scope-0.3.0/apiscope/commands/describe.py +0 -377
- llm_api_scope-0.3.0/apiscope/commands/init.py +0 -127
- llm_api_scope-0.3.0/apiscope/commands/list.py +0 -80
- llm_api_scope-0.3.0/apiscope/commands/note/__init__.py +0 -2
- llm_api_scope-0.3.0/apiscope/commands/note/commands.py +0 -688
- llm_api_scope-0.3.0/apiscope/commands/note/constants.py +0 -191
- llm_api_scope-0.3.0/apiscope/commands/note/core.py +0 -133
- llm_api_scope-0.3.0/apiscope/commands/note/utils.py +0 -57
- llm_api_scope-0.3.0/apiscope/commands/search.py +0 -230
- llm_api_scope-0.3.0/apiscope/core/__init__.py +0 -1
- llm_api_scope-0.3.0/apiscope/core/clustering.py +0 -199
- llm_api_scope-0.3.0/apiscope/core/config.py +0 -236
- llm_api_scope-0.3.0/apiscope/core/output.py +0 -177
- llm_api_scope-0.3.0/apiscope/core/parser.py +0 -193
- llm_api_scope-0.3.0/apiscope/core/trie.py +0 -59
- llm_api_scope-0.3.0/llm_api_scope.egg-info/PKG-INFO +0 -86
- llm_api_scope-0.3.0/llm_api_scope.egg-info/SOURCES.txt +0 -27
- llm_api_scope-0.3.0/llm_api_scope.egg-info/entry_points.txt +0 -2
- llm_api_scope-0.3.0/llm_api_scope.egg-info/requires.txt +0 -3
- {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/LICENSE +0 -0
- {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/apiscope/__init__.py +0 -0
- {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/llm_api_scope.egg-info/dependency_links.txt +0 -0
- {llm_api_scope-0.3.0 → llm_api_scope-0.4.0}/llm_api_scope.egg-info/top_level.txt +0 -0
- {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,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
|