easyeda-monkey 2026.5.26__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.
@@ -0,0 +1,3 @@
1
+ """EasyEDA / LCSC / JLCPCB schematic symbol, footprint, and 3D model parser."""
2
+
3
+ __version__ = "2026.5.26"
easyeda_monkey/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ """Top-level command-line orchestrator for EasyEDA Monkey."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Callable, Sequence
8
+ from typing import cast
9
+
10
+ from . import __version__
11
+ from .cli_commands import COMMANDS as CLI_COMMANDS
12
+ from .cli_commands import register_commands
13
+ from .cli_commands.fetch_part import normalize_lcsc_id
14
+
15
+ __all__ = ["CLI_COMMANDS", "build_parser", "main", "normalize_lcsc_id"]
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ """Build the EasyEDA Monkey argument parser."""
20
+ parser = argparse.ArgumentParser(
21
+ prog="easyeda-monkey",
22
+ description="EasyEDA / LCSC parsing utilities.",
23
+ )
24
+ parser.add_argument(
25
+ "--version",
26
+ action="version",
27
+ version=f"%(prog)s {__version__}",
28
+ )
29
+
30
+ subparsers = parser.add_subparsers(dest="command", required=True)
31
+ register_commands(subparsers)
32
+ return parser
33
+
34
+
35
+ def main(argv: Sequence[str] | None = None) -> int:
36
+ """Run the EasyEDA Monkey CLI."""
37
+ parser = build_parser()
38
+ args = parser.parse_args(argv)
39
+ handler = getattr(args, "handler", None)
40
+ if handler is None:
41
+ parser.print_help(sys.stderr)
42
+ return 2
43
+ return cast("CommandHandler", handler)(args)
44
+
45
+
46
+ CommandHandler = Callable[[argparse.Namespace], int]
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ """Shared CLI command metadata types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CliCommandSpec:
10
+ """Public CLI command metadata used by docs and signoff tests."""
11
+
12
+ name: str
13
+ design_doc: str
14
+ help: str
@@ -0,0 +1,19 @@
1
+ """CLI subcommand registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..cli_command_types import CliCommandSpec
8
+ from . import fetch_part
9
+
10
+ COMMANDS: tuple[CliCommandSpec, ...] = (
11
+ fetch_part.COMMAND,
12
+ )
13
+
14
+
15
+ def register_commands(
16
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
17
+ ) -> None:
18
+ """Register all public CLI subcommands with an argparse parser."""
19
+ fetch_part.register(subparsers)
@@ -0,0 +1,171 @@
1
+ """Implementation of the fetch-part CLI subcommand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import cast
11
+
12
+ from ..cli_command_types import CliCommandSpec
13
+ from ..easyeda_api import EasyEdaApiClient
14
+
15
+ JsonScalar = str | int | float | bool | None
16
+ JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
17
+ JsonObject = dict[str, JsonValue]
18
+
19
+ COMMAND = CliCommandSpec(
20
+ name="fetch-part",
21
+ design_doc="cli/fetch-part.html",
22
+ help="Fetch an EasyEDA / LCSC component by C-number.",
23
+ )
24
+
25
+
26
+ def register(
27
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
28
+ ) -> None:
29
+ """Register the fetch-part parser."""
30
+ parser = subparsers.add_parser(
31
+ COMMAND.name,
32
+ help=COMMAND.help,
33
+ description="Fetch an EasyEDA / LCSC component by C-number.",
34
+ )
35
+ parser.add_argument("lcsc_id", help="LCSC part number, such as C21190 or 21190.")
36
+ parser.add_argument(
37
+ "--cache-dir",
38
+ type=Path,
39
+ default=None,
40
+ help="Optional directory for cached API JSON responses.",
41
+ )
42
+ parser.add_argument(
43
+ "--output",
44
+ type=Path,
45
+ default=None,
46
+ help="Optional output JSON file. Defaults to stdout.",
47
+ )
48
+ parser.add_argument(
49
+ "--raw",
50
+ action="store_true",
51
+ help="Emit the raw EasyEDA API response instead of the compact summary.",
52
+ )
53
+ parser.add_argument(
54
+ "--timeout",
55
+ type=int,
56
+ default=15,
57
+ help="HTTP timeout in seconds when a network fetch is needed.",
58
+ )
59
+ parser.add_argument(
60
+ "--rate-limit-seconds",
61
+ type=float,
62
+ default=0.5,
63
+ help="Minimum delay between live API requests for this process.",
64
+ )
65
+ parser.set_defaults(handler=run)
66
+
67
+
68
+ def normalize_lcsc_id(value: str) -> str:
69
+ """Normalize a user supplied LCSC C-number."""
70
+ lcsc_id = value.strip().upper()
71
+ if not lcsc_id:
72
+ raise ValueError("LCSC id cannot be empty")
73
+ if not lcsc_id.startswith("C"):
74
+ lcsc_id = f"C{lcsc_id}"
75
+ return lcsc_id
76
+
77
+
78
+ def summarize_component(component_data: Mapping[str, JsonValue], lcsc_id: str) -> JsonObject:
79
+ """Build a compact summary for an EasyEDA component API response."""
80
+ result = component_data.get("result")
81
+ if not isinstance(result, dict):
82
+ return {
83
+ "lcsc_id": lcsc_id,
84
+ "found": False,
85
+ "title": "",
86
+ "symbol": {"shape_count": 0},
87
+ "footprint": {"shape_count": 0},
88
+ }
89
+
90
+ data_str = _dict_or_empty(result.get("dataStr"))
91
+ symbol_shapes = _list_or_empty(data_str.get("shape"))
92
+
93
+ package_detail = _dict_or_empty(result.get("packageDetail"))
94
+ footprint_data = _dict_or_empty(package_detail.get("dataStr"))
95
+ footprint_shapes = _list_or_empty(footprint_data.get("shape"))
96
+
97
+ return {
98
+ "lcsc_id": _lcsc_id_from_result(result, default=lcsc_id),
99
+ "found": True,
100
+ "title": _str_or_empty(result.get("title")),
101
+ "uuid": _str_or_empty(result.get("uuid")),
102
+ "symbol": {
103
+ "shape_count": len(symbol_shapes),
104
+ "has_data": bool(symbol_shapes),
105
+ },
106
+ "footprint": {
107
+ "shape_count": len(footprint_shapes),
108
+ "has_data": bool(footprint_shapes),
109
+ },
110
+ }
111
+
112
+
113
+ def run(args: argparse.Namespace) -> int:
114
+ """Run the fetch-part subcommand."""
115
+ try:
116
+ lcsc_id = normalize_lcsc_id(args.lcsc_id)
117
+ except ValueError as exc:
118
+ sys.stderr.write(f"error: {exc}\n")
119
+ return 2
120
+
121
+ client = EasyEdaApiClient(
122
+ cache_dir=args.cache_dir,
123
+ timeout=args.timeout,
124
+ rate_limit_seconds=args.rate_limit_seconds,
125
+ )
126
+ component_data = cast(JsonObject, client.fetch_component(lcsc_id))
127
+ if not component_data:
128
+ sys.stderr.write(f"error: no EasyEDA component data found for {lcsc_id}\n")
129
+ return 1
130
+
131
+ payload = component_data if args.raw else summarize_component(component_data, lcsc_id)
132
+ _emit_json(payload, args.output)
133
+ return 0
134
+
135
+
136
+ def _dict_or_empty(value: JsonValue | object) -> Mapping[str, JsonValue]:
137
+ """Return a JSON object view when value is a dictionary."""
138
+ if isinstance(value, dict):
139
+ return cast(Mapping[str, JsonValue], value)
140
+ return {}
141
+
142
+
143
+ def _list_or_empty(value: JsonValue | object) -> list[JsonValue]:
144
+ """Return a JSON list when value is a list."""
145
+ if isinstance(value, list):
146
+ return cast(list[JsonValue], value)
147
+ return []
148
+
149
+
150
+ def _str_or_empty(value: JsonValue | object) -> str:
151
+ """Return a string value or an empty string."""
152
+ return value if isinstance(value, str) else ""
153
+
154
+
155
+ def _lcsc_id_from_result(result: Mapping[str, JsonValue], *, default: str) -> str:
156
+ """Extract the LCSC C-number from a result object."""
157
+ lcsc = _dict_or_empty(result.get("lcsc"))
158
+ szlcsc = _dict_or_empty(result.get("szlcsc"))
159
+ return _str_or_empty(lcsc.get("number")) or _str_or_empty(szlcsc.get("number")) or default
160
+
161
+
162
+ def _emit_json(payload: JsonObject, output: Path | None) -> None:
163
+ """Write JSON to stdout or an output file."""
164
+ text = json.dumps(payload, indent=2, sort_keys=True)
165
+ if output is None:
166
+ sys.stdout.write(text)
167
+ sys.stdout.write("\n")
168
+ return
169
+
170
+ output.parent.mkdir(parents=True, exist_ok=True)
171
+ output.write_text(text + "\n", encoding="utf-8")
@@ -0,0 +1,213 @@
1
+ """
2
+ EasyEDA 3D model reference and download support.
3
+
4
+ 3D models are referenced by UUID in the footprint data and downloaded
5
+ from the EasyEDA modules API as STEP or OBJ files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ _STEP_URL_BASE = "https://modules.easyeda.com/qAxj6KHrDKw4blvCG8QJPs7Y"
19
+ _OBJ_URL_BASE = "https://modules.easyeda.com/3dmodel"
20
+
21
+ if TYPE_CHECKING:
22
+ from .easyeda_footprint import EasyEdaFootprint
23
+
24
+
25
+ @dataclass
26
+ class EasyEda3DModel:
27
+ """3D model reference extracted from an EasyEDA footprint."""
28
+
29
+ uuid: str = ""
30
+ title: str = ""
31
+ translation_x: float = 0.0
32
+ translation_y: float = 0.0
33
+ translation_z: float = 0.0
34
+ rotation_x: float = 0.0
35
+ rotation_y: float = 0.0
36
+ rotation_z: float = 0.0
37
+ raw_attrs: dict[str, Any] = field(default_factory=dict)
38
+
39
+ @property
40
+ def step_url(self) -> str:
41
+ """URL to download STEP file."""
42
+ if not self.uuid:
43
+ return ""
44
+ return f"{_STEP_URL_BASE}/{self.uuid}"
45
+
46
+ @property
47
+ def obj_url(self) -> str:
48
+ """URL to download OBJ file."""
49
+ if not self.uuid:
50
+ return ""
51
+ return f"{_OBJ_URL_BASE}/{self.uuid}"
52
+
53
+ def to_json(self) -> dict[str, Any]:
54
+ return {
55
+ "uuid": self.uuid,
56
+ "title": self.title,
57
+ "translation_x": self.translation_x,
58
+ "translation_y": self.translation_y,
59
+ "translation_z": self.translation_z,
60
+ "rotation_x": self.rotation_x,
61
+ "rotation_y": self.rotation_y,
62
+ "rotation_z": self.rotation_z,
63
+ "step_url": self.step_url,
64
+ "obj_url": self.obj_url,
65
+ "raw_attrs": self.raw_attrs,
66
+ }
67
+
68
+ @classmethod
69
+ def from_json(cls, data: dict[str, Any]) -> EasyEda3DModel:
70
+ return cls(
71
+ uuid=data.get("uuid", ""),
72
+ title=data.get("title", ""),
73
+ translation_x=float(data.get("translation_x", 0)),
74
+ translation_y=float(data.get("translation_y", 0)),
75
+ translation_z=float(data.get("translation_z", 0)),
76
+ rotation_x=float(data.get("rotation_x", 0)),
77
+ rotation_y=float(data.get("rotation_y", 0)),
78
+ rotation_z=float(data.get("rotation_z", 0)),
79
+ raw_attrs=data.get("raw_attrs", {}),
80
+ )
81
+
82
+ def download_step(self, output_path: str | Path, timeout: int = 30) -> bool:
83
+ """Download STEP file to the given path. Returns True on success."""
84
+ if not self.uuid:
85
+ log.warning("No 3D model UUID — cannot download")
86
+ return False
87
+
88
+ import requests
89
+
90
+ url = self.step_url
91
+ log.info("Downloading STEP from %s", url)
92
+ try:
93
+ resp = requests.get(url, timeout=timeout)
94
+ resp.raise_for_status()
95
+ Path(output_path).write_bytes(resp.content)
96
+ log.info("Saved STEP (%d bytes) to %s", len(resp.content), output_path)
97
+ return True
98
+ except Exception as e:
99
+ log.error("Failed to download STEP: %s", e)
100
+ return False
101
+
102
+ def download_obj(self, output_path: str | Path, timeout: int = 30) -> bool:
103
+ """Download OBJ file to the given path. Returns True on success."""
104
+ if not self.uuid:
105
+ return False
106
+
107
+ import requests
108
+
109
+ url = self.obj_url
110
+ log.info("Downloading OBJ from %s", url)
111
+ try:
112
+ resp = requests.get(url, timeout=timeout)
113
+ resp.raise_for_status()
114
+ Path(output_path).write_text(resp.text, encoding="utf-8")
115
+ log.info("Saved OBJ (%d bytes) to %s", len(resp.content), output_path)
116
+ return True
117
+ except Exception as e:
118
+ log.error("Failed to download OBJ: %s", e)
119
+ return False
120
+
121
+
122
+ def extract_3d_model_from_footprint(ee_fp: EasyEdaFootprint) -> EasyEda3DModel | None:
123
+ """
124
+ Extract 3D model reference from an EasyEdaFootprint's raw data.
125
+
126
+ EasyEDA stores 3D model info in SVGNODE elements within the footprint
127
+ shape list, containing JSON-encoded attributes with UUID, translation,
128
+ and rotation.
129
+ """
130
+ # Look for the 3D model UUID in the footprint info
131
+ model_uuid = ee_fp.info.model_3d_uuid
132
+ if model_uuid:
133
+ return EasyEda3DModel(uuid=model_uuid, title=ee_fp.info.name)
134
+
135
+ # Fallback: search passthrough for SVGNODE-derived data
136
+ return None
137
+
138
+
139
+ def extract_3d_models_from_api_response(api_data: dict[str, Any]) -> list[EasyEda3DModel]:
140
+ """
141
+ Extract all 3D model references from a raw API response.
142
+
143
+ Searches packageDetail.dataStr.shape for SVGNODE entries containing
144
+ 3D model metadata.
145
+ """
146
+ models: list[EasyEda3DModel] = []
147
+ result = api_data.get("result", {})
148
+ pkg_detail = result.get("packageDetail", {})
149
+ data_str = pkg_detail.get("dataStr", {})
150
+ shapes = data_str.get("shape", [])
151
+
152
+ for shape_str in shapes:
153
+ if not isinstance(shape_str, str):
154
+ continue
155
+ if "SVGNODE" not in shape_str and "3dmodel" not in shape_str.lower():
156
+ continue
157
+
158
+ # Try to extract JSON from the SVGNODE
159
+ model = _parse_svgnode_3d(shape_str)
160
+ if model:
161
+ models.append(model)
162
+
163
+ # Also check head attributes for 3D model references
164
+ head = data_str.get("head", "")
165
+ if isinstance(head, str) and "3d" in head.lower():
166
+ # Some footprints embed the UUID in head attributes
167
+ uuid_match = re.search(r"([0-9a-f]{32}|[0-9a-f-]{36})", head)
168
+ if uuid_match and not models:
169
+ models.append(EasyEda3DModel(
170
+ uuid=uuid_match.group(1),
171
+ title=result.get("title", ""),
172
+ ))
173
+
174
+ return models
175
+
176
+
177
+ def _parse_svgnode_3d(shape_str: str) -> EasyEda3DModel | None:
178
+ """Try to extract a 3D model reference from an SVGNODE shape string."""
179
+ # SVGNODE shapes contain JSON-like attribute strings
180
+ # Look for c_origin (translation) and c_rotation patterns
181
+ try:
182
+ # Extract UUID — look for a 32-char hex string or standard UUID
183
+ uuid_match = re.search(r'"uuid"\s*:\s*"([^"]+)"', shape_str)
184
+ if not uuid_match:
185
+ uuid_match = re.search(r"([0-9a-f]{32})", shape_str)
186
+ if not uuid_match:
187
+ return None
188
+
189
+ uuid = uuid_match.group(1)
190
+
191
+ # Extract translation
192
+ tx = ty = tz = 0.0
193
+ origin_match = re.search(r'"c_origin"\s*:\s*"([^"]*)"', shape_str)
194
+ if origin_match:
195
+ parts = origin_match.group(1).split(",")
196
+ if len(parts) >= 3:
197
+ tx, ty, tz = float(parts[0]), float(parts[1]), float(parts[2])
198
+
199
+ # Extract rotation
200
+ rx = ry = rz = 0.0
201
+ rot_match = re.search(r'"c_rotation"\s*:\s*"([^"]*)"', shape_str)
202
+ if rot_match:
203
+ parts = rot_match.group(1).split(",")
204
+ if len(parts) >= 3:
205
+ rx, ry, rz = float(parts[0]), float(parts[1]), float(parts[2])
206
+
207
+ return EasyEda3DModel(
208
+ uuid=uuid,
209
+ translation_x=tx, translation_y=ty, translation_z=tz,
210
+ rotation_x=rx, rotation_y=ry, rotation_z=rz,
211
+ )
212
+ except (ValueError, IndexError, AttributeError):
213
+ return None
@@ -0,0 +1,118 @@
1
+ """
2
+ LCSC / EasyEDA component API client.
3
+
4
+ Fetches symbol, footprint, and 3D model data by LCSC part number.
5
+ Uses ``requests`` for HTTP — this is the only module with an external dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .easyeda_footprint import EasyEdaFootprint
17
+ from .easyeda_symbol import EasyEdaSymbol
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ _API_BASE = "https://easyeda.com/api/products"
22
+ _API_VERSION = "6.4.19.5"
23
+ _USER_AGENT = "wn_pcb_tools/1.0"
24
+ _DEFAULT_TIMEOUT = 15
25
+
26
+
27
+ class EasyEdaApiClient:
28
+ """
29
+ Client for the LCSC / EasyEDA component API.
30
+
31
+ Fetches component data by LCSC part number (e.g. ``C21190``).
32
+ Supports optional disk caching for offline use and test fixture generation.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ cache_dir: str | Path | None = None,
38
+ timeout: int = _DEFAULT_TIMEOUT,
39
+ rate_limit_seconds: float = 0.5,
40
+ ) -> None:
41
+ self._cache_dir = Path(cache_dir) if cache_dir else None
42
+ self._timeout = timeout
43
+ self._rate_limit = rate_limit_seconds
44
+ self._last_request_time: float = 0.0
45
+
46
+ if self._cache_dir:
47
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ def fetch_component(self, lcsc_id: str) -> dict[str, Any]:
50
+ """
51
+ Fetch raw API response for an LCSC component.
52
+
53
+ Returns the full JSON response dict. Caches to disk if cache_dir set.
54
+ """
55
+ lcsc_id = lcsc_id.strip().upper()
56
+ if not lcsc_id.startswith("C"):
57
+ lcsc_id = f"C{lcsc_id}"
58
+
59
+ # Check cache first
60
+ if self._cache_dir:
61
+ cache_path = self._cache_dir / f"{lcsc_id}.json"
62
+ if cache_path.exists():
63
+ log.debug("Cache hit for %s", lcsc_id)
64
+ return json.loads(cache_path.read_text(encoding="utf-8"))
65
+
66
+ # Rate limiting
67
+ now = time.monotonic()
68
+ elapsed = now - self._last_request_time
69
+ if elapsed < self._rate_limit:
70
+ time.sleep(self._rate_limit - elapsed)
71
+
72
+ import requests
73
+
74
+ url = f"{_API_BASE}/{lcsc_id}/components?version={_API_VERSION}"
75
+ headers = {
76
+ "User-Agent": _USER_AGENT,
77
+ "Accept": "application/json",
78
+ }
79
+
80
+ log.info("Fetching %s from LCSC API", lcsc_id)
81
+ response = requests.get(url, headers=headers, timeout=self._timeout)
82
+ self._last_request_time = time.monotonic()
83
+ response.raise_for_status()
84
+
85
+ data = response.json()
86
+
87
+ if not data.get("success") or not data.get("result"):
88
+ log.warning("API returned empty/failed result for %s", lcsc_id)
89
+ return {}
90
+
91
+ # Cache to disk
92
+ if self._cache_dir:
93
+ cache_path = self._cache_dir / f"{lcsc_id}.json"
94
+ cache_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
95
+ log.debug("Cached %s (%d bytes)", lcsc_id, cache_path.stat().st_size)
96
+
97
+ return data
98
+
99
+ def fetch_symbol(self, lcsc_id: str) -> EasyEdaSymbol:
100
+ """Fetch and parse a schematic symbol by LCSC part number."""
101
+ data = self.fetch_component(lcsc_id)
102
+ if not data:
103
+ raise ValueError(f"No component data for {lcsc_id}")
104
+ return EasyEdaSymbol.from_json(data)
105
+
106
+ def fetch_footprint(self, lcsc_id: str) -> EasyEdaFootprint:
107
+ """Fetch and parse a PCB footprint by LCSC part number."""
108
+ data = self.fetch_component(lcsc_id)
109
+ if not data:
110
+ raise ValueError(f"No component data for {lcsc_id}")
111
+ return EasyEdaFootprint.from_json(data)
112
+
113
+ def fetch_both(self, lcsc_id: str) -> tuple[EasyEdaSymbol, EasyEdaFootprint]:
114
+ """Fetch and parse both symbol and footprint in a single API call."""
115
+ data = self.fetch_component(lcsc_id)
116
+ if not data:
117
+ raise ValueError(f"No component data for {lcsc_id}")
118
+ return EasyEdaSymbol.from_json(data), EasyEdaFootprint.from_json(data)