gfp-mcp 0.2.4__tar.gz → 0.3.2__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 (54) hide show
  1. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/PKG-INFO +13 -1
  2. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/README.md +9 -0
  3. {gfp_mcp-0.2.4/mcp_standalone → gfp_mcp-0.3.2/gfp_mcp}/__init__.py +10 -8
  4. {gfp_mcp-0.2.4/mcp_standalone → gfp_mcp-0.3.2/gfp_mcp}/client.py +0 -14
  5. gfp_mcp-0.3.2/gfp_mcp/config.py +161 -0
  6. gfp_mcp-0.3.2/gfp_mcp/render.py +139 -0
  7. gfp_mcp-0.3.2/gfp_mcp/samples.py +206 -0
  8. gfp_mcp-0.3.2/gfp_mcp/server.py +235 -0
  9. gfp_mcp-0.3.2/gfp_mcp/tools/__init__.py +134 -0
  10. gfp_mcp-0.3.2/gfp_mcp/tools/base.py +235 -0
  11. gfp_mcp-0.3.2/gfp_mcp/tools/bbox.py +115 -0
  12. gfp_mcp-0.3.2/gfp_mcp/tools/build.py +159 -0
  13. gfp_mcp-0.3.2/gfp_mcp/tools/cells.py +103 -0
  14. gfp_mcp-0.3.2/gfp_mcp/tools/connectivity.py +70 -0
  15. gfp_mcp-0.3.2/gfp_mcp/tools/drc.py +379 -0
  16. gfp_mcp-0.3.2/gfp_mcp/tools/freeze.py +82 -0
  17. gfp_mcp-0.3.2/gfp_mcp/tools/lvs.py +86 -0
  18. gfp_mcp-0.3.2/gfp_mcp/tools/pdk.py +47 -0
  19. gfp_mcp-0.3.2/gfp_mcp/tools/port.py +82 -0
  20. gfp_mcp-0.3.2/gfp_mcp/tools/project.py +160 -0
  21. gfp_mcp-0.3.2/gfp_mcp/tools/samples.py +215 -0
  22. gfp_mcp-0.3.2/gfp_mcp/tools/simulation.py +153 -0
  23. gfp_mcp-0.3.2/gfp_mcp/utils.py +55 -0
  24. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/gfp_mcp.egg-info/PKG-INFO +13 -1
  25. gfp_mcp-0.3.2/gfp_mcp.egg-info/SOURCES.txt +45 -0
  26. gfp_mcp-0.3.2/gfp_mcp.egg-info/entry_points.txt +2 -0
  27. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/gfp_mcp.egg-info/requires.txt +4 -0
  28. gfp_mcp-0.3.2/gfp_mcp.egg-info/top_level.txt +1 -0
  29. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/pyproject.toml +7 -3
  30. gfp_mcp-0.3.2/tests/test_gfp_phase4.py +308 -0
  31. gfp_mcp-0.3.2/tests/test_gfp_server.py +331 -0
  32. gfp_mcp-0.3.2/tests/test_gfp_tool_handlers.py +602 -0
  33. gfp_mcp-0.3.2/tests/test_gfp_tools_base.py +313 -0
  34. gfp_mcp-0.3.2/tests/test_gfp_tools_init.py +170 -0
  35. gfp_mcp-0.3.2/tests/test_gfp_utils.py +83 -0
  36. gfp_mcp-0.3.2/tests/test_mcp_config.py +208 -0
  37. gfp_mcp-0.2.4/tests/test_mcp_mappings.py → gfp_mcp-0.3.2/tests/test_mcp_handlers.py +319 -123
  38. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/tests/test_mcp_integration.py +103 -91
  39. gfp_mcp-0.3.2/tests/test_mcp_render.py +299 -0
  40. gfp_mcp-0.3.2/tests/test_mcp_samples.py +491 -0
  41. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/tests/test_mcp_tools.py +73 -17
  42. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/tests/test_registry.py +1 -1
  43. gfp_mcp-0.2.4/gfp_mcp.egg-info/SOURCES.txt +0 -21
  44. gfp_mcp-0.2.4/gfp_mcp.egg-info/entry_points.txt +0 -2
  45. gfp_mcp-0.2.4/gfp_mcp.egg-info/top_level.txt +0 -1
  46. gfp_mcp-0.2.4/mcp_standalone/config.py +0 -50
  47. gfp_mcp-0.2.4/mcp_standalone/mappings.py +0 -565
  48. gfp_mcp-0.2.4/mcp_standalone/server.py +0 -282
  49. gfp_mcp-0.2.4/mcp_standalone/tools.py +0 -466
  50. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/LICENSE +0 -0
  51. {gfp_mcp-0.2.4/mcp_standalone → gfp_mcp-0.3.2/gfp_mcp}/registry.py +0 -0
  52. {gfp_mcp-0.2.4/mcp_standalone → gfp_mcp-0.3.2/gfp_mcp}/resources.py +0 -0
  53. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/gfp_mcp.egg-info/dependency_links.txt +0 -0
  54. {gfp_mcp-0.2.4 → gfp_mcp-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gfp-mcp
3
- Version: 0.2.4
3
+ Version: 0.3.2
4
4
  Summary: Model Context Protocol (MCP) server for GDSFactory+ photonic IC design
5
5
  Author: GDSFactory+ Team
6
6
  License: MIT
@@ -27,7 +27,10 @@ License-File: LICENSE
27
27
  Requires-Dist: mcp>=1.7.1
28
28
  Requires-Dist: httpx>=0.25.0
29
29
  Requires-Dist: typing-extensions>=4.0.0; python_version < "3.11"
30
+ Requires-Dist: tomli>=2.0.0; python_version < "3.11"
30
31
  Requires-Dist: psutil>=5.9.0
32
+ Provides-Extra: render
33
+ Requires-Dist: klayout>=0.28.0; extra == "render"
31
34
  Provides-Extra: dev
32
35
  Requires-Dist: pytest>=7.0.0; extra == "dev"
33
36
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -170,6 +173,15 @@ Try these commands with your AI assistant:
170
173
  - **check_drc** - Run Design Rule Check verification (returns structured format with all violations including simplified location data for LLM-friendly troubleshooting)
171
174
  - **check_connectivity** - Run connectivity verification
172
175
  - **check_lvs** - Run Layout vs. Schematic verification
176
+ - **simulate_component** - Run SAX circuit simulations with custom parameters
177
+ - Basic: `{"name": "mzi"}` - Simulate with default parameters
178
+ - Custom layout: `{"name": "mzi", "layout": {"length_mmi": 12, "gap_mmi": 0.3}}` - Customize component geometry
179
+ - Wavelength sweep: `{"name": "coupler", "model": {"wl": [1.5, 1.55, 1.6]}}` - Simulate at multiple wavelengths
180
+ - Full example: `{"name": "mzi", "layout": {"length": 100}, "model": {"wl": [1.5, 1.55, 1.6], "loss": 0.2}, "how": "from_layout"}`
181
+ - **get_port_center** - Get physical coordinates of component ports
182
+ - **generate_bbox** - Generate bounding box GDS from layout
183
+ - **freeze_cell** - Freeze parametric cell as static netlist
184
+ - **get_pdk_info** - Get current PDK information
173
185
 
174
186
  ## Multi-Project Support
175
187
 
@@ -131,6 +131,15 @@ Try these commands with your AI assistant:
131
131
  - **check_drc** - Run Design Rule Check verification (returns structured format with all violations including simplified location data for LLM-friendly troubleshooting)
132
132
  - **check_connectivity** - Run connectivity verification
133
133
  - **check_lvs** - Run Layout vs. Schematic verification
134
+ - **simulate_component** - Run SAX circuit simulations with custom parameters
135
+ - Basic: `{"name": "mzi"}` - Simulate with default parameters
136
+ - Custom layout: `{"name": "mzi", "layout": {"length_mmi": 12, "gap_mmi": 0.3}}` - Customize component geometry
137
+ - Wavelength sweep: `{"name": "coupler", "model": {"wl": [1.5, 1.55, 1.6]}}` - Simulate at multiple wavelengths
138
+ - Full example: `{"name": "mzi", "layout": {"length": 100}, "model": {"wl": [1.5, 1.55, 1.6], "loss": 0.2}, "how": "from_layout"}`
139
+ - **get_port_center** - Get physical coordinates of component ports
140
+ - **generate_bbox** - Generate bounding box GDS from layout
141
+ - **freeze_cell** - Freeze parametric cell as static netlist
142
+ - **get_pdk_info** - Get current PDK information
134
143
 
135
144
  ## Multi-Project Support
136
145
 
@@ -1,22 +1,22 @@
1
- """MCP Standalone Server for GDSFactory+.
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
- - Standalone MCP server (this package)
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 gdsfactoryplus.mcp_standalone import main
15
+ from gfp_mcp import main
16
16
  main()
17
17
 
18
18
  Or via CLI:
19
- gfp mcp-serve
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.4"
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)
@@ -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')
@@ -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
@@ -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
+ )