gfp-mcp 0.2.4__py3-none-any.whl → 0.3.2__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.
- {mcp_standalone → gfp_mcp}/__init__.py +10 -8
- {mcp_standalone → gfp_mcp}/client.py +0 -14
- gfp_mcp/config.py +161 -0
- gfp_mcp/render.py +139 -0
- gfp_mcp/samples.py +206 -0
- gfp_mcp/server.py +235 -0
- gfp_mcp/tools/__init__.py +134 -0
- gfp_mcp/tools/base.py +235 -0
- gfp_mcp/tools/bbox.py +115 -0
- gfp_mcp/tools/build.py +159 -0
- gfp_mcp/tools/cells.py +103 -0
- gfp_mcp/tools/connectivity.py +70 -0
- gfp_mcp/tools/drc.py +379 -0
- gfp_mcp/tools/freeze.py +82 -0
- gfp_mcp/tools/lvs.py +86 -0
- gfp_mcp/tools/pdk.py +47 -0
- gfp_mcp/tools/port.py +82 -0
- gfp_mcp/tools/project.py +160 -0
- gfp_mcp/tools/samples.py +215 -0
- gfp_mcp/tools/simulation.py +153 -0
- gfp_mcp/utils.py +55 -0
- {gfp_mcp-0.2.4.dist-info → gfp_mcp-0.3.2.dist-info}/METADATA +13 -1
- gfp_mcp-0.3.2.dist-info/RECORD +29 -0
- gfp_mcp-0.3.2.dist-info/entry_points.txt +2 -0
- gfp_mcp-0.3.2.dist-info/top_level.txt +1 -0
- gfp_mcp-0.2.4.dist-info/RECORD +0 -14
- gfp_mcp-0.2.4.dist-info/entry_points.txt +0 -2
- gfp_mcp-0.2.4.dist-info/top_level.txt +0 -1
- mcp_standalone/config.py +0 -50
- mcp_standalone/mappings.py +0 -565
- mcp_standalone/server.py +0 -282
- mcp_standalone/tools.py +0 -466
- {mcp_standalone → gfp_mcp}/registry.py +0 -0
- {mcp_standalone → gfp_mcp}/resources.py +0 -0
- {gfp_mcp-0.2.4.dist-info → gfp_mcp-0.3.2.dist-info}/WHEEL +0 -0
- {gfp_mcp-0.2.4.dist-info → gfp_mcp-0.3.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
"""MCP
|
|
1
|
+
"""GDSFactory+ MCP Server.
|
|
2
2
|
|
|
3
3
|
This package provides a Model Context Protocol (MCP) server that exposes
|
|
4
4
|
GDSFactory+ operations as tools for AI assistants. The server uses STDIO
|
|
5
5
|
transport and proxies requests to the FastAPI backend.
|
|
6
6
|
|
|
7
7
|
Architecture:
|
|
8
|
-
-
|
|
8
|
+
- Tool handlers in gfp_mcp/tools/ with co-located definitions and transformers
|
|
9
9
|
- STDIO transport for universal compatibility
|
|
10
|
-
- HTTP proxy to FastAPI backend
|
|
10
|
+
- HTTP proxy to FastAPI backend via client.py
|
|
11
|
+
- Multi-project routing via server registry
|
|
11
12
|
- Zero changes to existing FastAPI server
|
|
12
|
-
- No database conflicts (only FastAPI touches SQLite)
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
15
|
-
from
|
|
15
|
+
from gfp_mcp import main
|
|
16
16
|
main()
|
|
17
17
|
|
|
18
18
|
Or via CLI:
|
|
19
|
-
gfp
|
|
19
|
+
gfp-mcp-serve
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
@@ -25,18 +25,20 @@ from .client import FastAPIClient
|
|
|
25
25
|
from .config import MCPConfig
|
|
26
26
|
from .resources import get_all_resources, get_resource_content
|
|
27
27
|
from .server import create_server, main, run_server
|
|
28
|
-
from .tools import get_all_tools, get_tool_by_name
|
|
28
|
+
from .tools import get_all_tools, get_handler, get_tool_by_name
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
|
+
"__version__",
|
|
31
32
|
"FastAPIClient",
|
|
32
33
|
"MCPConfig",
|
|
33
34
|
"create_server",
|
|
34
35
|
"main",
|
|
35
36
|
"run_server",
|
|
36
37
|
"get_all_tools",
|
|
38
|
+
"get_handler",
|
|
37
39
|
"get_tool_by_name",
|
|
38
40
|
"get_all_resources",
|
|
39
41
|
"get_resource_content",
|
|
40
42
|
]
|
|
41
43
|
|
|
42
|
-
__version__ = "0.2
|
|
44
|
+
__version__ = "0.3.2"
|
|
@@ -294,17 +294,3 @@ class FastAPIClient:
|
|
|
294
294
|
}
|
|
295
295
|
for server in servers
|
|
296
296
|
]
|
|
297
|
-
|
|
298
|
-
async def get_project_info(self, project: str) -> dict[str, Any]:
|
|
299
|
-
"""Get detailed information about a specific project.
|
|
300
|
-
|
|
301
|
-
Args:
|
|
302
|
-
project: Project name or path
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
Project information from the server's /info endpoint
|
|
306
|
-
|
|
307
|
-
Raises:
|
|
308
|
-
ValueError: If project not found
|
|
309
|
-
"""
|
|
310
|
-
return await self.request("GET", "/info", project=project)
|
gfp_mcp/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Configuration management for MCP standalone server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib # Python 3.11+
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
13
|
+
|
|
14
|
+
__all__ = ["MCPConfig", "get_gfp_api_key", "set_gfp_api_key"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MCPConfig:
|
|
18
|
+
"""Configuration for MCP standalone server.
|
|
19
|
+
|
|
20
|
+
Manages environment variables and default settings for the MCP server
|
|
21
|
+
that proxies requests to the FastAPI backend.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
API_URL: Final[str | None] = os.getenv("GFP_API_URL")
|
|
25
|
+
|
|
26
|
+
TIMEOUT: Final[int] = int(os.getenv("GFP_MCP_TIMEOUT", "300"))
|
|
27
|
+
|
|
28
|
+
DEBUG: Final[bool] = os.getenv("GFP_MCP_DEBUG", "false").lower() in (
|
|
29
|
+
"true",
|
|
30
|
+
"1",
|
|
31
|
+
"yes",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
REGISTRY_API_URL: Final[str] = "https://registry.gdsfactory.com"
|
|
35
|
+
|
|
36
|
+
MAX_RETRIES: Final[int] = 3
|
|
37
|
+
RETRY_BACKOFF: Final[float] = 0.5 # Initial backoff in seconds
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def get_api_url(cls, override: str | None = None) -> str | None:
|
|
41
|
+
"""Get the FastAPI base URL.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
override: Optional URL to override the environment variable
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The API base URL or None if not configured
|
|
48
|
+
"""
|
|
49
|
+
return override or cls.API_URL
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def get_timeout(cls) -> int:
|
|
53
|
+
"""Get the timeout for tool calls.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Timeout in seconds
|
|
57
|
+
"""
|
|
58
|
+
return cls.TIMEOUT
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_api_key_from_toml(file_path: Path) -> str | None:
|
|
62
|
+
"""Read the API key from a TOML configuration file.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
file_path: Path to the TOML file.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The API key string, or None if not found or file doesn't exist.
|
|
69
|
+
"""
|
|
70
|
+
if not file_path.exists():
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(file_path, "rb") as f:
|
|
75
|
+
config = tomllib.load(f)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
config.get("tool", {}).get("gdsfactoryplus", {}).get("api", {}).get("key")
|
|
79
|
+
)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_gfp_api_key() -> str | None:
|
|
85
|
+
"""Retrieve the GFP API key from environment variables or config files.
|
|
86
|
+
|
|
87
|
+
Checks sources in priority order:
|
|
88
|
+
1. GFP_API_KEY environment variable
|
|
89
|
+
2. ~/.gdsfactory/gdsfactoryplus.toml
|
|
90
|
+
3. ./pyproject.toml
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The API key string, or None if not found.
|
|
94
|
+
"""
|
|
95
|
+
# 1. Check environment variable (highest priority)
|
|
96
|
+
api_key = os.environ.get("GFP_API_KEY")
|
|
97
|
+
if api_key:
|
|
98
|
+
return api_key
|
|
99
|
+
|
|
100
|
+
# 2. Check global config file: ~/.gdsfactory/gdsfactoryplus.toml
|
|
101
|
+
global_config_path = Path.home() / ".gdsfactory" / "gdsfactoryplus.toml"
|
|
102
|
+
api_key = _read_api_key_from_toml(global_config_path)
|
|
103
|
+
if api_key:
|
|
104
|
+
return api_key
|
|
105
|
+
|
|
106
|
+
# 3. Check local project config: ./pyproject.toml
|
|
107
|
+
local_config_path = Path.cwd() / "pyproject.toml"
|
|
108
|
+
api_key = _read_api_key_from_toml(local_config_path)
|
|
109
|
+
if api_key:
|
|
110
|
+
return api_key
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_gfp_api_key(api_key: str) -> None:
|
|
116
|
+
"""Save the GFP API key to the global config file.
|
|
117
|
+
|
|
118
|
+
Writes to: ~/.gdsfactory/gdsfactoryplus.toml
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
api_key: The API key to save.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If api_key is empty or None.
|
|
125
|
+
"""
|
|
126
|
+
if not api_key:
|
|
127
|
+
raise ValueError("API key is required")
|
|
128
|
+
|
|
129
|
+
config_dir = Path.home() / ".gdsfactory"
|
|
130
|
+
config_path = config_dir / "gdsfactoryplus.toml"
|
|
131
|
+
|
|
132
|
+
# Create directory if it doesn't exist
|
|
133
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
# Load existing config or create new one
|
|
136
|
+
config: dict = {}
|
|
137
|
+
if config_path.exists():
|
|
138
|
+
with open(config_path, "rb") as f:
|
|
139
|
+
config = tomllib.load(f)
|
|
140
|
+
|
|
141
|
+
# Set the API key in the nested structure
|
|
142
|
+
if "tool" not in config:
|
|
143
|
+
config["tool"] = {}
|
|
144
|
+
if "gdsfactoryplus" not in config["tool"]:
|
|
145
|
+
config["tool"]["gdsfactoryplus"] = {}
|
|
146
|
+
if "api" not in config["tool"]["gdsfactoryplus"]:
|
|
147
|
+
config["tool"]["gdsfactoryplus"]["api"] = {}
|
|
148
|
+
|
|
149
|
+
config["tool"]["gdsfactoryplus"]["api"]["key"] = api_key
|
|
150
|
+
|
|
151
|
+
# Write back to file using tomli_w if available, otherwise manual formatting
|
|
152
|
+
try:
|
|
153
|
+
import tomli_w
|
|
154
|
+
|
|
155
|
+
with open(config_path, "wb") as f:
|
|
156
|
+
tomli_w.dump(config, f)
|
|
157
|
+
except ImportError:
|
|
158
|
+
# Fallback: write TOML manually for this simple structure
|
|
159
|
+
with open(config_path, "w") as f:
|
|
160
|
+
f.write("[tool.gdsfactoryplus.api]\n")
|
|
161
|
+
f.write(f'key = "{api_key}"\n')
|
gfp_mcp/render.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""GDS rendering utilities for MCP server.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for rendering GDS files to PNG images,
|
|
4
|
+
enabling automatic image return when building cells.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mcp.types import ImageContent
|
|
14
|
+
|
|
15
|
+
from .registry import ServerRegistry
|
|
16
|
+
from .utils import wait_for_gds_file
|
|
17
|
+
|
|
18
|
+
__all__ = ["render_gds_to_png", "render_built_cells", "HAS_KLAYOUT"]
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import klayout.db as db
|
|
24
|
+
import klayout.lay as lay
|
|
25
|
+
|
|
26
|
+
HAS_KLAYOUT = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_KLAYOUT = False
|
|
29
|
+
db = None # type: ignore[assignment]
|
|
30
|
+
lay = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_gds_to_png(
|
|
34
|
+
gds_path: Path,
|
|
35
|
+
width: int = 800,
|
|
36
|
+
height: int = 600,
|
|
37
|
+
) -> bytes | None:
|
|
38
|
+
"""Render GDS file to PNG image using klayout.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
gds_path: Path to the GDS file to render
|
|
42
|
+
width: Image width in pixels (default: 800)
|
|
43
|
+
height: Image height in pixels (default: 600)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
PNG image as bytes, or None if klayout is not available or rendering fails
|
|
47
|
+
"""
|
|
48
|
+
if not HAS_KLAYOUT:
|
|
49
|
+
logger.debug("klayout not available, skipping GDS rendering")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if not gds_path.exists():
|
|
53
|
+
logger.warning("GDS file not found for rendering: %s", gds_path)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
layout = db.Layout()
|
|
58
|
+
layout.read(str(gds_path))
|
|
59
|
+
|
|
60
|
+
layout_view = lay.LayoutView()
|
|
61
|
+
layout_view.load_layout(str(gds_path), True)
|
|
62
|
+
|
|
63
|
+
layout_view.max_hier()
|
|
64
|
+
|
|
65
|
+
layout_view.zoom_fit()
|
|
66
|
+
|
|
67
|
+
pixel_buffer = layout_view.get_pixels(width, height)
|
|
68
|
+
|
|
69
|
+
png_bytes = pixel_buffer.to_png_data()
|
|
70
|
+
|
|
71
|
+
logger.debug("Rendered GDS to PNG: %s (%dx%d)", gds_path, width, height)
|
|
72
|
+
return png_bytes
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning("Failed to render GDS to PNG: %s - %s", gds_path, e)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def render_built_cells(
|
|
80
|
+
cell_names: list[str],
|
|
81
|
+
project: str | None,
|
|
82
|
+
) -> list[ImageContent]:
|
|
83
|
+
"""Render built GDS cells to PNG images.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
cell_names: List of cell names to render
|
|
87
|
+
project: Optional project name for routing
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of ImageContent for successfully rendered cells
|
|
91
|
+
"""
|
|
92
|
+
images: list[ImageContent] = []
|
|
93
|
+
|
|
94
|
+
if not cell_names:
|
|
95
|
+
return images
|
|
96
|
+
|
|
97
|
+
if not HAS_KLAYOUT:
|
|
98
|
+
logger.debug("klayout not available, skipping cell rendering")
|
|
99
|
+
return images
|
|
100
|
+
|
|
101
|
+
registry = ServerRegistry()
|
|
102
|
+
server_info = None
|
|
103
|
+
|
|
104
|
+
if project:
|
|
105
|
+
server_info = registry.get_server_by_project(project)
|
|
106
|
+
if not server_info:
|
|
107
|
+
servers = registry.list_servers()
|
|
108
|
+
if servers:
|
|
109
|
+
server_info = servers[0]
|
|
110
|
+
|
|
111
|
+
if not server_info:
|
|
112
|
+
logger.debug(
|
|
113
|
+
"No server found in registry, cannot determine project path for GDS rendering"
|
|
114
|
+
)
|
|
115
|
+
return images
|
|
116
|
+
|
|
117
|
+
project_path = Path(server_info.project_path)
|
|
118
|
+
gds_dir = project_path / "build" / "gds"
|
|
119
|
+
|
|
120
|
+
for cell_name in cell_names:
|
|
121
|
+
gds_path = gds_dir / f"{cell_name}.gds"
|
|
122
|
+
|
|
123
|
+
if await wait_for_gds_file(gds_path):
|
|
124
|
+
png_bytes = render_gds_to_png(gds_path)
|
|
125
|
+
if png_bytes:
|
|
126
|
+
images.append(
|
|
127
|
+
ImageContent(
|
|
128
|
+
type="image",
|
|
129
|
+
data=base64.b64encode(png_bytes).decode("utf-8"),
|
|
130
|
+
mimeType="image/png",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
logger.info("Added PNG image for cell: %s", cell_name)
|
|
134
|
+
else:
|
|
135
|
+
logger.debug("Failed to render GDS to PNG for cell: %s", cell_name)
|
|
136
|
+
else:
|
|
137
|
+
logger.debug("GDS file not found within timeout for cell: %s", cell_name)
|
|
138
|
+
|
|
139
|
+
return images
|
gfp_mcp/samples.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Core module for downloading and extracting GDSFactory+ sample projects.
|
|
2
|
+
|
|
3
|
+
Provides async functions to download sample project ZIPs from the registry
|
|
4
|
+
and extract sample files (Python, YAML) for use by AI assistants.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import io
|
|
11
|
+
import logging
|
|
12
|
+
import mimetypes
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .config import MCPConfig
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GENERAL_PDK_PROJECTS",
|
|
22
|
+
"SAMPLE_EXTENSIONS",
|
|
23
|
+
"SampleFile",
|
|
24
|
+
"SampleProject",
|
|
25
|
+
"get_sample_file_content",
|
|
26
|
+
"list_sample_projects",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
GENERAL_PDK_PROJECTS = [
|
|
32
|
+
"photonics--basic--public--pdk",
|
|
33
|
+
"photonics--full--public--pdk",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SAMPLE_EXTENSIONS = {".py", ".yml", ".yaml"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SampleProject:
|
|
41
|
+
"""A sample project with its list of sample file paths."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
files: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SampleFile:
|
|
49
|
+
"""A single sample file with its content."""
|
|
50
|
+
|
|
51
|
+
project: str
|
|
52
|
+
path: str
|
|
53
|
+
content: str
|
|
54
|
+
mime_type: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _download_project_zip(api_key: str, project_name: str) -> bytes:
|
|
58
|
+
"""Download a project ZIP from the registry.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
api_key: GFP API key for authentication.
|
|
62
|
+
project_name: Name of the project to download.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Raw ZIP file bytes.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
httpx.HTTPStatusError: If the request fails.
|
|
69
|
+
"""
|
|
70
|
+
url = f"{MCPConfig.REGISTRY_API_URL}/project/download/{project_name}"
|
|
71
|
+
headers = {"X-API-Key": api_key}
|
|
72
|
+
|
|
73
|
+
async with httpx.AsyncClient() as client:
|
|
74
|
+
response = await client.get(url, headers=headers, timeout=300.0)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
return response.content
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_sample_files(zip_data: bytes, project_name: str) -> list[str]:
|
|
80
|
+
"""Extract sample file paths from a project ZIP.
|
|
81
|
+
|
|
82
|
+
Finds files under any ``samples/`` directory, filters by extension,
|
|
83
|
+
and excludes ``__init__.py`` and ``__pycache__`` entries.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
zip_data: Raw ZIP file bytes.
|
|
87
|
+
project_name: Project name (unused, kept for future use).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Sorted list of file paths within the ZIP that are sample files.
|
|
91
|
+
"""
|
|
92
|
+
sample_files = []
|
|
93
|
+
|
|
94
|
+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
95
|
+
for name in zf.namelist():
|
|
96
|
+
if zf.getinfo(name).is_dir():
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Must be under a samples/ directory
|
|
100
|
+
parts = name.split("/")
|
|
101
|
+
if "samples" not in parts:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Filter by extension
|
|
105
|
+
ext = "." + name.rsplit(".", 1)[-1] if "." in name else ""
|
|
106
|
+
if ext not in SAMPLE_EXTENSIONS:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Exclude __init__.py and __pycache__
|
|
110
|
+
basename = parts[-1]
|
|
111
|
+
if basename == "__init__.py" or "__pycache__" in parts:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
sample_files.append(name)
|
|
115
|
+
|
|
116
|
+
return sorted(sample_files)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_file_content(zip_data: bytes, file_path: str) -> str:
|
|
120
|
+
"""Extract a single file's content from a ZIP.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
zip_data: Raw ZIP file bytes.
|
|
124
|
+
file_path: Path within the ZIP to extract.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
UTF-8 decoded file content.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
KeyError: If the file is not found in the ZIP.
|
|
131
|
+
"""
|
|
132
|
+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
133
|
+
return zf.read(file_path).decode("utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_mime_type(path: str) -> str:
|
|
137
|
+
"""Determine MIME type from file path.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
path: File path to check.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
MIME type string, defaults to ``text/plain``.
|
|
144
|
+
"""
|
|
145
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
146
|
+
return mime_type or "text/plain"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def list_sample_projects(api_key: str) -> list[SampleProject]:
|
|
150
|
+
"""Download both General PDK ZIPs and list their sample files.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
api_key: GFP API key for authentication.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of SampleProject instances with file listings.
|
|
157
|
+
"""
|
|
158
|
+
tasks = [
|
|
159
|
+
_download_project_zip(api_key, project) for project in GENERAL_PDK_PROJECTS
|
|
160
|
+
]
|
|
161
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
162
|
+
|
|
163
|
+
projects = []
|
|
164
|
+
for project_name, result in zip(GENERAL_PDK_PROJECTS, results):
|
|
165
|
+
if isinstance(result, Exception):
|
|
166
|
+
logger.warning("Failed to download project %s: %s", project_name, result)
|
|
167
|
+
projects.append(SampleProject(name=project_name, files=[]))
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
files = _extract_sample_files(result, project_name)
|
|
171
|
+
projects.append(SampleProject(name=project_name, files=files))
|
|
172
|
+
|
|
173
|
+
return projects
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def get_sample_file_content(api_key: str, project: str, path: str) -> SampleFile:
|
|
177
|
+
"""Download a project ZIP and extract a specific file's content.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
api_key: GFP API key for authentication.
|
|
181
|
+
project: Project name (must be in GENERAL_PDK_PROJECTS).
|
|
182
|
+
path: File path within the ZIP.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
SampleFile with the file's content and metadata.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: If the project name is not recognized.
|
|
189
|
+
KeyError: If the file is not found in the ZIP.
|
|
190
|
+
"""
|
|
191
|
+
if project not in GENERAL_PDK_PROJECTS:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Unknown sample project: {project}. "
|
|
194
|
+
f"Available projects: {GENERAL_PDK_PROJECTS}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
zip_data = await _download_project_zip(api_key, project)
|
|
198
|
+
content = _extract_file_content(zip_data, path)
|
|
199
|
+
mime_type = _get_mime_type(path)
|
|
200
|
+
|
|
201
|
+
return SampleFile(
|
|
202
|
+
project=project,
|
|
203
|
+
path=path,
|
|
204
|
+
content=content,
|
|
205
|
+
mime_type=mime_type,
|
|
206
|
+
)
|