ecadlib 0.3.0__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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/forge.py ADDED
@@ -0,0 +1,267 @@
1
+ """Git hosting for the library agent and upkeep: GitHub pull requests or GitLab merge requests.
2
+
3
+ Which host: [agent] git_host = "auto" (default) | "github" | "gitlab". auto picks GitLab when the origin remote is on a
4
+ host named gitlab* or on the host of [agent] gitlab_api (self-hosted), else GitHub (today's behaviour).
5
+
6
+ GitLab (REST v4, standard library HTTP or a requests-like session):
7
+ [agent]
8
+ git_host = "gitlab" # or leave "auto"
9
+ gitlab_api = "https://gitlab.example.com/api/v4" # self-hosted; default https://gitlab.com/api/v4
10
+ gitlab_project = "group/sub/library" # "" = from the origin remote; a numeric id works too
11
+ gitlab_token_env = "GITLAB_TOKEN" # NAME of the env var with a project/personal access token (api scope)
12
+
13
+ The token is sent as PRIVATE-TOKEN and never written anywhere. Merge requests are found by source branch, updated in
14
+ place (never duplicated), commented with notes and closed with state_event=close: the same steps the GitHub code takes
15
+ with pulls / issues comments / state=closed."""
16
+ import json, re
17
+ from urllib.parse import quote, urlsplit
18
+
19
+ GITLAB_API = "https://gitlab.com/api/v4"
20
+
21
+
22
+ class GitLabError(Exception):
23
+ status = None # the HTTP status of a refused API call
24
+
25
+
26
+ # ------------------------------------------------------------------ which host
27
+ def _host_of(url):
28
+ """Host name of a git remote or API URL: https://h/x, ssh://git@h:22/x, git@h:x/y."""
29
+ url = (url or "").strip()
30
+ if "://" in url:
31
+ return (urlsplit(url).hostname or "").lower()
32
+ m = re.match(r"^(?:[^@/]+@)?([^:/]+):", url) # scp-like git@host:group/proj.git
33
+ return m.group(1).lower() if m else ""
34
+
35
+
36
+ def host_kind(cfg, remote):
37
+ """'github' or 'gitlab' for the [agent] settings and the origin remote URL."""
38
+ want = (cfg.get("git_host") or "auto").lower()
39
+ if want in ("github", "gitlab"):
40
+ return want
41
+ h = _host_of(remote)
42
+ if h and (h.startswith("gitlab") or ".gitlab." in h or h == _host_of(cfg.get("gitlab_api") or GITLAB_API)):
43
+ return "gitlab"
44
+ if not h and cfg.get("gitlab_project") and not cfg.get("github_repo"):
45
+ return "gitlab"
46
+ return "github"
47
+
48
+
49
+ def wrong_api_host(api, remote):
50
+ """Why the token must not go to `api` for this origin remote, or None. The one case refused: the API is the default
51
+ gitlab.com while origin is on another, fully qualified host (a self-hosted GitLab whose [agent] gitlab_api was not
52
+ set): that host's token would be sent to gitlab.com. A configured gitlab_api is the person's choice, and a dotless
53
+ remote host (an ssh alias such as gitlab-work) cannot be judged, so both are used as given. Callers ask only when the
54
+ project itself came from the remote (an explicit gitlab_project names a project on the configured API)."""
55
+ api_host, h = _host_of(api or GITLAB_API), _host_of(remote)
56
+ if api_host != "gitlab.com" or not h or "." not in h or h == "gitlab.com" or h.endswith(".gitlab.com"):
57
+ return None
58
+ return (f"origin is on {h} but [agent] gitlab_api is {GITLAB_API}: set gitlab_api = \"https://{h}/api/v4\" "
59
+ "(the token was not sent)")
60
+
61
+
62
+ def project_from_remote(remote, api=GITLAB_API):
63
+ """group/sub/project of a GitLab remote, or None. A self-hosted GitLab under a path (https://h/gitlab/api/v4)
64
+ has that path in front of the project path in its remotes; it is taken off."""
65
+ remote = (remote or "").strip()
66
+ if not remote:
67
+ return None
68
+ if "://" in remote:
69
+ path = urlsplit(remote).path
70
+ else:
71
+ m = re.match(r"^(?:[^@/]+@)?[^:/]+:(.+)$", remote)
72
+ if not m:
73
+ return None
74
+ path = m.group(1)
75
+ path = "/" + path.strip("/")
76
+ root = re.sub(r"/api/v4/?$", "", urlsplit(api or GITLAB_API).path or "").rstrip("/")
77
+ if root and path.startswith(root + "/"):
78
+ path = path[len(root):]
79
+ path = re.sub(r"\.git$", "", path.strip("/"))
80
+ return path if re.fullmatch(r"[\w.-]+(/[\w.-]+)+", path) else None
81
+
82
+
83
+ # ------------------------------------------------------------------ HTTP (no requests needed)
84
+ class _Resp:
85
+ def __init__(self, status, body):
86
+ self.status_code, self.content = status, body
87
+ self.text = body.decode("utf-8", "replace")
88
+
89
+ def json(self):
90
+ return json.loads(self.text)
91
+
92
+
93
+ def no_redirect_opener():
94
+ """urllib opener that never follows a redirect: urllib copies every request header (PRIVATE-TOKEN, Authorization)
95
+ to wherever a 30x points, any host. A redirect comes back as its 3xx status (an HTTPError) instead."""
96
+ import urllib.request
97
+
98
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
99
+ def redirect_request(self, *a, **k):
100
+ return None
101
+ return urllib.request.build_opener(_NoRedirect)
102
+
103
+
104
+ class _UrllibSession:
105
+ def request(self, method, url, json=None, timeout=60, headers=None):
106
+ import json as _j, urllib.error, urllib.request
107
+ data = _j.dumps(json).encode() if json is not None else None
108
+ req = urllib.request.Request(url, data=data, method=method, headers=dict(headers or {}, **(
109
+ {"Content-Type": "application/json"} if data is not None else {})))
110
+ try:
111
+ with no_redirect_opener().open(req, timeout=timeout) as r:
112
+ return _Resp(r.status, r.read())
113
+ except urllib.error.HTTPError as e:
114
+ return _Resp(e.code, e.read() or b"")
115
+
116
+
117
+ # ------------------------------------------------------------------ GitLab REST v4
118
+ class GitLab:
119
+ """Merge requests, notes and issues of one project. Paths are relative to /projects/<id>/."""
120
+ kind = "gitlab"
121
+
122
+ def __init__(self, project, token, session=None, api=GITLAB_API):
123
+ if not project:
124
+ raise GitLabError("the GitLab project is unknown: set [agent] gitlab_project (group/name) or use a GitLab origin remote")
125
+ if not token:
126
+ raise GitLabError("no GitLab token")
127
+ self.project, self.token = str(project), token
128
+ self.session = session or _UrllibSession()
129
+ self.API = (api or GITLAB_API).rstrip("/")
130
+ self._web = None
131
+
132
+ def api(self, method, path, body=None):
133
+ url = f"{self.API}/projects/{quote(self.project, safe='')}" + (f"/{path}" if path else "")
134
+ r = self.session.request(method, url, json=body, timeout=60,
135
+ headers={"PRIVATE-TOKEN": self.token, "Accept": "application/json"})
136
+ if not 200 <= r.status_code < 300:
137
+ e = GitLabError(f"GitLab {method} /projects/{self.project}/{path}: HTTP {r.status_code} "
138
+ f"{str(getattr(r, 'text', ''))[:200]}")
139
+ e.status = r.status_code
140
+ raise e
141
+ return r.json() if r.status_code != 204 and getattr(r, "content", b"") else None
142
+
143
+ # web links
144
+ def web(self):
145
+ """https://host/group/project (from the project path; a numeric id asks the API once)."""
146
+ if self._web is None:
147
+ if not self.project.isdigit():
148
+ self._web = f"{re.sub(r'/api/v4$', '', self.API)}/{self.project}"
149
+ else:
150
+ self._web = (self.api("GET", "") or {}).get("web_url") or ""
151
+ return self._web
152
+
153
+ def raw_url(self, sha, path):
154
+ return f"{self.web()}/-/raw/{sha}/{quote(path)}"
155
+
156
+ # merge requests
157
+ def find_open_mr(self, branch):
158
+ mrs = self.api("GET", f"merge_requests?state=opened&source_branch={quote(branch, safe='')}&per_page=20") or []
159
+ own = [m for m in mrs if str(m.get("source_project_id")) == str(m.get("target_project_id"))
160
+ or m.get("source_project_id") is None] # never a fork's MR with the same branch name
161
+ return min(own, key=lambda m: m["iid"]) if own else None
162
+
163
+ def get_mr(self, iid):
164
+ return self.api("GET", f"merge_requests/{iid}")
165
+
166
+ def create_mr(self, source, target, title, description, labels=()):
167
+ body = {"source_branch": source, "target_branch": target, "title": title, "description": description,
168
+ "remove_source_branch": True}
169
+ if labels:
170
+ body["labels"] = ",".join(labels)
171
+ return self.api("POST", "merge_requests", body)
172
+
173
+ def update_mr(self, iid, title, description):
174
+ return self.api("PUT", f"merge_requests/{iid}", {"title": title, "description": description})
175
+
176
+ def comment_mr(self, iid, text):
177
+ return self.api("POST", f"merge_requests/{iid}/notes", {"body": text})
178
+
179
+ def close_mr(self, iid):
180
+ return self.api("PUT", f"merge_requests/{iid}", {"state_event": "close"})
181
+
182
+ # issues
183
+ def ensure_label(self, name, color="#0e8a16", description=""):
184
+ try:
185
+ self.api("POST", "labels", {"name": name, "color": color, "description": description})
186
+ except GitLabError:
187
+ pass # 409: it exists already
188
+
189
+ def rolling_issue(self, label):
190
+ iss = self.api("GET", f"issues?labels={quote(label, safe='')}&state=opened&per_page=100") or []
191
+ return min(iss, key=lambda i: i["iid"]) if iss else None
192
+
193
+ def create_issue(self, title, description, labels=()):
194
+ return self.api("POST", "issues", {"title": title, "description": description, "labels": ",".join(labels)})
195
+
196
+ def update_issue(self, iid, title, description):
197
+ return self.api("PUT", f"issues/{iid}", {"title": title, "description": description})
198
+
199
+ def comment_issue(self, iid, text):
200
+ return self.api("POST", f"issues/{iid}/notes", {"body": text})
201
+
202
+
203
+ def upsert_mr(gl, rec, title, body, base):
204
+ """Open or update the merge request for rec["branch"] (the agent's batch record): a closed or merged MR is not a
205
+ review any more, so a new one is opened; an open one is updated, never duplicated. Updates rec in place."""
206
+ num = rec.get("pr_number")
207
+ if num:
208
+ try:
209
+ cur = gl.get_mr(num)
210
+ except GitLabError as e: # 404: the MR was deleted; like a closed one it is not a review
211
+ if e.status != 404:
212
+ raise
213
+ cur = {"state": "closed"}
214
+ if isinstance(cur, dict) and cur.get("state") in ("closed", "merged", "locked"):
215
+ rec.setdefault("closed_prs", []).append(rec.get("pr_url"))
216
+ num, rec["pr_number"], rec["pr_url"] = None, None, None
217
+ if not num:
218
+ mr = gl.find_open_mr(rec["branch"])
219
+ num = mr["iid"] if mr else None
220
+ rec["pr_url"] = mr.get("web_url") if mr else rec.get("pr_url")
221
+ if num:
222
+ r = gl.update_mr(num, title, body)
223
+ rec["pr_action"] = "updated"
224
+ else:
225
+ r = gl.create_mr(rec["branch"], base, title, body)
226
+ rec["pr_action"] = "opened"
227
+ rec.update(pr_number=(r or {}).get("iid") or num, pr_url=(r or {}).get("web_url") or rec.get("pr_url"), host="gitlab")
228
+ return rec
229
+
230
+
231
+ def upkeep_issue(gl, label, title, body, n_new, date):
232
+ """upkeep's rolling issue on GitLab (same behaviour as upkeep.publish_issue on GitHub)."""
233
+ iss = gl.rolling_issue(label)
234
+ if iss:
235
+ gl.update_issue(iss["iid"], title, body)
236
+ gl.comment_issue(iss["iid"], f"{n_new} new change(s) on {date}: the description now has this week's report.")
237
+ return {"kind": "issue", "action": "updated", "number": iss["iid"], "url": iss.get("web_url")}
238
+ gl.ensure_label(label, description="ecadlib weekly library upkeep")
239
+ new = gl.create_issue(title, body, [label])
240
+ return {"kind": "issue", "action": "opened", "number": new["iid"], "url": new.get("web_url")}
241
+
242
+
243
+ def upkeep_mr(gl, label, branch, base, title, body):
244
+ """upkeep's merge request for an already pushed branch (same behaviour as upkeep.publish_pr on GitHub)."""
245
+ mr = gl.find_open_mr(branch)
246
+ if mr:
247
+ gl.update_mr(mr["iid"], title, body)
248
+ return {"kind": "pr", "action": "updated", "number": mr["iid"], "url": mr.get("web_url"), "branch": branch}
249
+ gl.ensure_label(label, description="ecadlib weekly library upkeep")
250
+ new = gl.create_mr(branch, base, title, body, labels=[label])
251
+ return {"kind": "pr", "action": "opened", "number": new["iid"], "url": new.get("web_url"), "branch": branch}
252
+
253
+
254
+ def client(cfg, remote, env, session=None):
255
+ """(kind, client or None, why not) for the [agent] settings, the origin remote and the environment."""
256
+ kind = host_kind(cfg, remote)
257
+ if kind == "gitlab":
258
+ proj = cfg.get("gitlab_project") or project_from_remote(remote, cfg.get("gitlab_api") or GITLAB_API)
259
+ tok_env = cfg.get("gitlab_token_env") or ""
260
+ token = env.get(tok_env) if tok_env else None
261
+ if not (remote and proj and token):
262
+ return kind, None, "no GitLab remote or token"
263
+ bad = None if cfg.get("gitlab_project") else wrong_api_host(cfg.get("gitlab_api") or GITLAB_API, remote)
264
+ if bad:
265
+ return kind, None, bad
266
+ return kind, GitLab(proj, token, session, cfg.get("gitlab_api") or GITLAB_API), None
267
+ return kind, None, None # GitHub: the caller builds upkeep.GitHub as before
ecadlib/fp_share.py ADDED
@@ -0,0 +1,95 @@
1
+ """When two parts may share one footprint file (librarian, `ecadlib datasheet batch`, `ecadlib resolve`).
2
+
3
+ The librarian writes one <out>/models/PcbLib/<name>.PcbLib per footprint name, and the name comes from the BOM's
4
+ footprint field. Two BOM lines can name the same package for different land patterns (`SOIC-14` on a 3.9 mm and on
5
+ a 5.3 mm body; `QFN-16` at 0.5 and 0.65 mm pitch). Before this module the second part silently got the first
6
+ part's file. Now a part shares a file only when the footprints are the same: the same pads and courtyard (the
7
+ geometry hash `ecadlib dedupe` uses). Otherwise it gets its own name, tried in this order:
8
+
9
+ 1. the real package name of its footprint (the KiCad / standard / EasyEDA footprint name), when it starts with
10
+ the BOM name, else <BOM name>_<real name>; e.g. SOIC-14 -> SOIC-14_5.3x10.2mm_P1.27mm
11
+ 2. <BOM name>_<part name>
12
+
13
+ and a name already holding the same pads is shared again. The renamed name becomes the plan item's footprint ref
14
+ (`footprint.ref`; the BOM's name stays in `footprint.bom_ref`), the footprint's name inside the .PcbLib, the
15
+ symbol's footprint link, the Altium 365 footprint item and the KiCad export name, so every consumer that keys by
16
+ ref sees two footprints. The first part to take a name keeps it: re-runs never rename a file a part already has.
17
+
18
+ Workspaces written before this check: a part whose footprint record says "shared with ..." but has no hash was
19
+ never compared, so the librarian resolves it again (it shares again when the pads match)."""
20
+ import hashlib
21
+ from pathlib import Path
22
+
23
+ from . import altium_lib as AL, sexpr
24
+
25
+
26
+ def digest(text):
27
+ """Geometry hash of a .kicad_mod: dedupe's footprint hash (pads + courtyard); a text hash when it has no pads."""
28
+ from . import dedupe as DD
29
+ try:
30
+ h = DD.fp_hash(DD.fp_geometry_kicad(text))
31
+ except Exception: # unparsable: only an identical file is "the same"
32
+ h = None
33
+ return h or "text:" + hashlib.sha256(str(text).encode()).hexdigest()[:16]
34
+
35
+
36
+ def real_name(text, source=""):
37
+ """The footprint's own name: (footprint "X") / (module "X") in the text, else the KiCad / standard source id."""
38
+ try:
39
+ t = sexpr.parse(text)
40
+ if isinstance(t, list) and len(t) > 1 and t[0] in ("footprint", "module") and isinstance(t[1], str):
41
+ return t[1].split(":")[-1]
42
+ except Exception:
43
+ pass
44
+ src = str(source or "")
45
+ return src.rsplit(":", 1)[-1] if src.startswith(("kicad:", "standard:", "local:")) else ""
46
+
47
+
48
+ def candidates(name, real, part):
49
+ """File names to try for a footprint the BOM calls `name`, in order (see the module doc)."""
50
+ out = [name]
51
+ if real and AL.file_stem(real).lower() != AL.file_stem(name).lower():
52
+ out.append(real if real.lower().startswith(name.lower()) else f"{name}_{real}")
53
+ out.append(f"{name}_{part}")
54
+ return list(dict.fromkeys(out))
55
+
56
+
57
+ def base_of(rec):
58
+ """The BOM footprint name a footprint record was written for."""
59
+ return rec.get("renamed_from") or rec.get("name") or Path(str(rec.get("file") or "")).stem
60
+
61
+
62
+ def hash_of(rec, out, cache):
63
+ """A footprint record's geometry hash: recorded, else from its .kicad_mod (workspaces from before this check)."""
64
+ if rec.get("hash"):
65
+ return rec["hash"]
66
+ k = rec.get("kicad_mod") or ""
67
+ if k not in cache:
68
+ p = Path(out) / k if k else None
69
+ cache[k] = digest(p.read_text(encoding="utf-8")) if p and p.is_file() else None
70
+ return cache[k]
71
+
72
+
73
+ def unchecked_share(rec):
74
+ """A footprint record that got another part's file by name only (written before the pads were compared)."""
75
+ rec = rec or {}
76
+ return not rec.get("hash") and not rec.get("chosen") and any(str(n).startswith("shared with ") for n in rec.get("notes", []))
77
+
78
+
79
+ def pick(written, models, name, text, source, part, out, cache):
80
+ """-> (name, path, record to share or None). written: {PcbLib path: footprint record} of this run."""
81
+ h, real = digest(text), real_name(text, source)
82
+ for rec in written.values(): # same pads under a name already made for this BOM name: share it
83
+ f = str(rec.get("file") or "")
84
+ if f.endswith(".PcbLib") and AL.file_stem(base_of(rec)) == AL.file_stem(name) and hash_of(rec, out, cache) == h:
85
+ return rec.get("name") or Path(f).stem, str(Path(out) / f), rec, h
86
+ cands = candidates(name, real, part)
87
+ for i, c in enumerate(cands):
88
+ pcb = str(Path(models) / "PcbLib" / f"{AL.file_stem(c)}.PcbLib")
89
+ rec = written.get(pcb)
90
+ if rec is None: # nobody in this run has it (a file left from an earlier run of
91
+ return c, pcb, None, h # this part, or of a part no longer in the BOM, is rewritten)
92
+ if hash_of(rec, out, cache) == h:
93
+ return c, pcb, rec, h
94
+ c = cands[-1] # <name>_<part> is this part's own: rewrite it
95
+ return c, str(Path(models) / "PcbLib" / f"{AL.file_stem(c)}.PcbLib"), None, h