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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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
|