datasheets-md 2.0.0a11__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.
- datasheets_md/__init__.py +4 -0
- datasheets_md/agents.md +88 -0
- datasheets_md/auth.py +216 -0
- datasheets_md/cli.py +370 -0
- datasheets_md/gui/__init__.py +0 -0
- datasheets_md/gui/app.py +375 -0
- datasheets_md/gui/icon.png +0 -0
- datasheets_md/http.py +92 -0
- datasheets_md/library.py +353 -0
- datasheets_md/merge.py +206 -0
- datasheets_md/repo.py +238 -0
- datasheets_md/sync.py +675 -0
- datasheets_md/update.py +144 -0
- datasheets_md-2.0.0a11.dist-info/METADATA +119 -0
- datasheets_md-2.0.0a11.dist-info/RECORD +18 -0
- datasheets_md-2.0.0a11.dist-info/WHEEL +5 -0
- datasheets_md-2.0.0a11.dist-info/entry_points.txt +3 -0
- datasheets_md-2.0.0a11.dist-info/top_level.txt +1 -0
datasheets_md/agents.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# dsmd for agents
|
|
2
|
+
|
|
3
|
+
`dsmd` keeps a folder in sync with a datasheets.md workspace. Use it from a
|
|
4
|
+
shell; add `--json` to any command for machine-readable output.
|
|
5
|
+
|
|
6
|
+
## Loop
|
|
7
|
+
|
|
8
|
+
1. `dsmd pull` -- bring the working copy up to date.
|
|
9
|
+
2. Find what to change with grep, e.g. `grep -rn '"VOH"' parts/`.
|
|
10
|
+
3. Edit only these files:
|
|
11
|
+
- `parts/<MFR>/<PN>/parameters.jsonl` -- one parameter row per line.
|
|
12
|
+
- `parts/<MFR>/<PN>/pins.csv` -- one physical pin per line.
|
|
13
|
+
- `parts/<MFR>/<PN>/cad.yaml` -- the part's CAD configuration.
|
|
14
|
+
- `settings/mapping.yaml`, `settings/symbol_style.yaml` -- the workspace's
|
|
15
|
+
settings (see below; owner or admin only).
|
|
16
|
+
4. `dsmd diff` -- and show the diff to the user before pushing.
|
|
17
|
+
5. `dsmd push -m "<what changed and why>"`.
|
|
18
|
+
6. Exit code 2 from `clone` or `pull` with a `library/ was not built` warning means the parts and
|
|
19
|
+
datasheets are in place; run `dsmd pull --library` to retry. A clone that stopped continues when you
|
|
20
|
+
run the same command again. Exit code 2 from `push` means some parts were not applied: read the per-part lines
|
|
21
|
+
(`stale` -> run `dsmd pull`, then push again; `rejected` -> fix the named
|
|
22
|
+
`file:line` and push again).
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- Keep `k` (parameters.jsonl) and `id` (pins.csv) unchanged on rows you edit;
|
|
27
|
+
they are how the server knows which row you mean. A new row has no `k`; a
|
|
28
|
+
new pin has an empty `id`.
|
|
29
|
+
- A part's digitised PDF is the `datasheet.md` that `datasheet:` in its
|
|
30
|
+
`part.yaml` points at (`datasheets/<manufacturer>/<pdf name>/`, one per
|
|
31
|
+
family, shared by every part of it; null until the PDF is digitised). Its
|
|
32
|
+
`{{para:<id> ...}}`, `{{table:<id> ...}}`, `{{row:<id> ...}}` and
|
|
33
|
+
`{{figure:<id> ...}}` markers carry the element ids that a row's
|
|
34
|
+
`source.element_id` cites; figures are PNGs in `images/` next to it.
|
|
35
|
+
`dsmd pdf <part>` downloads the original PDF.
|
|
36
|
+
- When you correct a value, set `source` to the element you read it from:
|
|
37
|
+
`"source": {"element_id": "<id>"}`.
|
|
38
|
+
- To confirm a row is right, change its `review` from `unchecked` to
|
|
39
|
+
`confirmed`. Unconfirming is not supported.
|
|
40
|
+
- Never edit `library/` (generated), `part.yaml` or `datasheet.md`
|
|
41
|
+
(read-only); `push` refuses them. `dsmd restore <path>` undoes a local edit.
|
|
42
|
+
- Conflict markers (`<<<<<<< local` / `=======` / `>>>>>>> server`) after a
|
|
43
|
+
pull surround one row (or, in a YAML file, the whole file): keep the right
|
|
44
|
+
side, delete the markers, push.
|
|
45
|
+
|
|
46
|
+
## Workspace settings
|
|
47
|
+
|
|
48
|
+
Both files are YAML, pushed with the parts (settings first, so a category you
|
|
49
|
+
add is there for the parts in the same push). The server validates them like
|
|
50
|
+
the web settings pages and answers `rejected` with the reason.
|
|
51
|
+
|
|
52
|
+
`settings/mapping.yaml` -- how the workspace names and groups things (it
|
|
53
|
+
drives the web app, comparisons, the API, the MCP server and the CAD exports
|
|
54
|
+
listed in `used_by`):
|
|
55
|
+
- `custom_categories`: your category names; `category_parents`:
|
|
56
|
+
`{child: parent}` to nest them.
|
|
57
|
+
- `category_map`: `{catalogue category id: your category}`. `dsmd categories`
|
|
58
|
+
lists the catalogue's ids and names.
|
|
59
|
+
- `parameter_map`: `{your category: {parameter: {name, unit, export, ...}}}`
|
|
60
|
+
-- rename a parameter, set its unit, choose whether it is exported; a
|
|
61
|
+
column you invent has `custom: true` and may carry a fixed `value`.
|
|
62
|
+
- `custom_parameters`: `{name: {unit, units?, kind?, shortcut?}}`;
|
|
63
|
+
`custom_units`: `[{symbol, name?}]`.
|
|
64
|
+
- `cad_fields`: `{kicad: [...]}` -- the properties each KiCad symbol gets.
|
|
65
|
+
- `used_by`: which outputs use this mapping (`web_app`, `comparisons`,
|
|
66
|
+
`kicad_export`, `concept_hdl_export`, `altium_export`, `rest_api`,
|
|
67
|
+
`mcp_server`).
|
|
68
|
+
|
|
69
|
+
`settings/symbol_style.yaml` -- how symbols are generated:
|
|
70
|
+
- `style`: the workspace's overrides of `reference.system_style` (only the
|
|
71
|
+
keys you want different; unknown keys are rejected).
|
|
72
|
+
- `categories`: `{category: style overrides}` for one category; an empty
|
|
73
|
+
value removes the override. Category names are your mapping's categories
|
|
74
|
+
or catalogue category names.
|
|
75
|
+
- `designator_options`: the reference designators the workspace uses,
|
|
76
|
+
`[{id, say, value?}]` (`value` = the parameter shown as the symbol's
|
|
77
|
+
Value), or null for `reference.system_designator_options`.
|
|
78
|
+
- `reference_designators`: `{category: designator}`.
|
|
79
|
+
- `version` and `reference` are for reading only.
|
|
80
|
+
|
|
81
|
+
## When something is missing
|
|
82
|
+
|
|
83
|
+
If a setting, parameter, category or command you need does not exist, or the
|
|
84
|
+
server refuses something that should work, tell the datasheets.md team:
|
|
85
|
+
|
|
86
|
+
dsmd feedback --kind suggestion "what you tried, what you needed"
|
|
87
|
+
|
|
88
|
+
Use `--kind bug` for something broken. Then tell the user you sent it.
|
datasheets_md/auth.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Sign-in for dsmd and the GUI (Spec 27a 3.2).
|
|
2
|
+
|
|
3
|
+
Browser OAuth against main-api's authorization server: dynamic client
|
|
4
|
+
registration once per server, PKCE S256, and a loopback redirect on a free
|
|
5
|
+
port. Credentials live in the user config dir (mode 0600), keyed by server.
|
|
6
|
+
`DSMD_TOKEN` (a `dsh_` key) overrides them for headless use, `DSMD_SERVER`
|
|
7
|
+
the server.
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import webbrowser
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import platformdirs
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
DEFAULT_SERVER = "https://api.datasheets.md"
|
|
25
|
+
CLIENT_NAME = "dsmd"
|
|
26
|
+
REDIRECT_PATH = "/callback"
|
|
27
|
+
LOGIN_TIMEOUT = 300
|
|
28
|
+
# Refresh this long before the access token expires.
|
|
29
|
+
EXPIRY_MARGIN = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def credentials_path():
|
|
37
|
+
return Path(platformdirs.user_config_dir("datasheets-md")) / "credentials.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load():
|
|
41
|
+
path = credentials_path()
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(path.read_text())
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
return {"servers": {}}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save(data):
|
|
49
|
+
path = credentials_path()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
tmp = path.with_suffix(".tmp")
|
|
52
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
53
|
+
with os.fdopen(fd, "w") as fh:
|
|
54
|
+
json.dump(data, fh, indent=2)
|
|
55
|
+
os.replace(tmp, path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_server(explicit=None):
|
|
59
|
+
"""--server, else DSMD_SERVER, else the server last signed in to, else production."""
|
|
60
|
+
return (explicit or os.environ.get("DSMD_SERVER") or _load().get("default_server") or DEFAULT_SERVER).rstrip("/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def metadata(server):
|
|
64
|
+
resp = requests.get(f"{server}/.well-known/oauth-authorization-server", timeout=30)
|
|
65
|
+
if resp.status_code != 200:
|
|
66
|
+
raise AuthError(f"{server} does not offer sign-in (HTTP {resp.status_code})")
|
|
67
|
+
return resp.json()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Credentials:
|
|
71
|
+
"""The bearer for one server: DSMD_TOKEN when set, else the stored OAuth tokens."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, server):
|
|
74
|
+
self.server = server.rstrip("/")
|
|
75
|
+
self.env_token = os.environ.get("DSMD_TOKEN") or None
|
|
76
|
+
|
|
77
|
+
def stored(self):
|
|
78
|
+
return _load().get("servers", {}).get(self.server)
|
|
79
|
+
|
|
80
|
+
def token(self):
|
|
81
|
+
if self.env_token:
|
|
82
|
+
return self.env_token
|
|
83
|
+
entry = self.stored()
|
|
84
|
+
if not entry:
|
|
85
|
+
raise AuthError(f"not signed in to {self.server}; run dsmd login --server {self.server}")
|
|
86
|
+
if entry.get("expires_at", 0) - EXPIRY_MARGIN < time.time():
|
|
87
|
+
self.refresh()
|
|
88
|
+
entry = self.stored() or entry
|
|
89
|
+
return entry["access_token"]
|
|
90
|
+
|
|
91
|
+
def refresh(self):
|
|
92
|
+
"""Renew the access token with the refresh token; False when there is nothing to renew with."""
|
|
93
|
+
if self.env_token:
|
|
94
|
+
return False
|
|
95
|
+
data = _load()
|
|
96
|
+
entry = data.get("servers", {}).get(self.server)
|
|
97
|
+
if not entry or not entry.get("refresh_token"):
|
|
98
|
+
return False
|
|
99
|
+
resp = requests.post(entry["token_endpoint"], data={
|
|
100
|
+
"grant_type": "refresh_token", "refresh_token": entry["refresh_token"],
|
|
101
|
+
"client_id": entry["client_id"]}, timeout=30)
|
|
102
|
+
if resp.status_code != 200:
|
|
103
|
+
return False
|
|
104
|
+
_store_tokens(data, self.server, entry, resp.json())
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _store_tokens(data, server, entry, tokens):
|
|
109
|
+
entry.update(access_token=tokens["access_token"],
|
|
110
|
+
refresh_token=tokens.get("refresh_token") or entry.get("refresh_token"),
|
|
111
|
+
expires_at=int(time.time()) + int(tokens.get("expires_in") or 3600))
|
|
112
|
+
data.setdefault("servers", {})[server] = entry
|
|
113
|
+
data["default_server"] = server
|
|
114
|
+
_save(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pkce():
|
|
118
|
+
verifier = secrets.token_urlsafe(64)
|
|
119
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
120
|
+
return verifier, challenge
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Callback(http.server.BaseHTTPRequestHandler):
|
|
124
|
+
result = None
|
|
125
|
+
|
|
126
|
+
def do_GET(self): # noqa: N802 -- http.server's name
|
|
127
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
128
|
+
if parsed.path != REDIRECT_PATH:
|
|
129
|
+
self.send_response(404)
|
|
130
|
+
self.end_headers()
|
|
131
|
+
return
|
|
132
|
+
type(self).result = dict(urllib.parse.parse_qsl(parsed.query))
|
|
133
|
+
body = b"<html><body><h3>dsmd is signed in.</h3>You can close this window.</body></html>"
|
|
134
|
+
self.send_response(200)
|
|
135
|
+
self.send_header("Content-Type", "text/html")
|
|
136
|
+
self.send_header("Content-Length", str(len(body)))
|
|
137
|
+
self.end_headers()
|
|
138
|
+
self.wfile.write(body)
|
|
139
|
+
|
|
140
|
+
def log_message(self, *args):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def login(server, *, open_browser=True, say=print):
|
|
145
|
+
"""The browser flow. Returns the stored entry (without tokens)."""
|
|
146
|
+
server = server.rstrip("/")
|
|
147
|
+
meta = metadata(server)
|
|
148
|
+
data = _load()
|
|
149
|
+
entry = dict(data.get("servers", {}).get(server) or {})
|
|
150
|
+
if not entry.get("client_id"):
|
|
151
|
+
resp = requests.post(meta["registration_endpoint"], json={
|
|
152
|
+
"client_name": CLIENT_NAME, "redirect_uris": [f"http://127.0.0.1{REDIRECT_PATH}"],
|
|
153
|
+
"grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none",
|
|
154
|
+
}, timeout=30)
|
|
155
|
+
if resp.status_code not in (200, 201):
|
|
156
|
+
raise AuthError(f"client registration failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
157
|
+
entry["client_id"] = resp.json()["client_id"]
|
|
158
|
+
entry.update(token_endpoint=meta["token_endpoint"], revocation_endpoint=meta.get("revocation_endpoint"))
|
|
159
|
+
|
|
160
|
+
handler = type("Callback", (_Callback,), {"result": None})
|
|
161
|
+
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
|
162
|
+
redirect_uri = f"http://127.0.0.1:{httpd.server_port}{REDIRECT_PATH}"
|
|
163
|
+
verifier, challenge = _pkce()
|
|
164
|
+
state = secrets.token_urlsafe(16)
|
|
165
|
+
url = meta["authorization_endpoint"] + "?" + urllib.parse.urlencode({
|
|
166
|
+
"response_type": "code", "client_id": entry["client_id"], "redirect_uri": redirect_uri,
|
|
167
|
+
"code_challenge": challenge, "code_challenge_method": "S256", "state": state})
|
|
168
|
+
say(f"Opening the browser to sign in. If it does not open, visit:\n {url}")
|
|
169
|
+
if open_browser:
|
|
170
|
+
webbrowser.open(url)
|
|
171
|
+
thread = threading.Thread(target=_serve_until_called, args=(httpd, handler), daemon=True)
|
|
172
|
+
thread.start()
|
|
173
|
+
thread.join(LOGIN_TIMEOUT)
|
|
174
|
+
httpd.server_close()
|
|
175
|
+
result = handler.result
|
|
176
|
+
if not result:
|
|
177
|
+
raise AuthError("timed out waiting for the browser sign-in")
|
|
178
|
+
if result.get("state") != state:
|
|
179
|
+
raise AuthError("sign-in answered with the wrong state; try again")
|
|
180
|
+
if "code" not in result:
|
|
181
|
+
raise AuthError(f"sign-in refused: {result.get('error', 'no code returned')}")
|
|
182
|
+
resp = requests.post(meta["token_endpoint"], data={
|
|
183
|
+
"grant_type": "authorization_code", "code": result["code"], "redirect_uri": redirect_uri,
|
|
184
|
+
"client_id": entry["client_id"], "code_verifier": verifier}, timeout=30)
|
|
185
|
+
if resp.status_code != 200:
|
|
186
|
+
raise AuthError(f"token exchange failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
187
|
+
_store_tokens(data, server, entry, resp.json())
|
|
188
|
+
return {"server": server, "client_id": entry["client_id"]}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _serve_until_called(httpd, handler):
|
|
192
|
+
deadline = time.time() + LOGIN_TIMEOUT
|
|
193
|
+
httpd.timeout = 1
|
|
194
|
+
while handler.result is None and time.time() < deadline:
|
|
195
|
+
httpd.handle_request()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def logout(server):
|
|
199
|
+
"""Revoke the refresh token and forget the server's credentials. True when there were any."""
|
|
200
|
+
server = server.rstrip("/")
|
|
201
|
+
data = _load()
|
|
202
|
+
entry = data.get("servers", {}).pop(server, None)
|
|
203
|
+
if entry is None:
|
|
204
|
+
return False
|
|
205
|
+
if entry.get("revocation_endpoint") and entry.get("refresh_token"):
|
|
206
|
+
try:
|
|
207
|
+
requests.post(entry["revocation_endpoint"], data={"token": entry["refresh_token"]}, timeout=15)
|
|
208
|
+
except requests.RequestException:
|
|
209
|
+
pass
|
|
210
|
+
if data.get("default_server") == server:
|
|
211
|
+
data.pop("default_server")
|
|
212
|
+
if data.get("servers"):
|
|
213
|
+
_save(data)
|
|
214
|
+
else:
|
|
215
|
+
credentials_path().unlink(missing_ok=True)
|
|
216
|
+
return True
|
datasheets_md/cli.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""`dsmd` -- a git-like working copy of a datasheets.md workspace.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok; 1 error; 2 completed, but with conflicts, stale/rejected parts or no library/.
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from importlib import resources
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from . import __version__, auth, sync, update
|
|
15
|
+
from .http import ApiError, Client
|
|
16
|
+
from .repo import Repo, RepoError
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_ERROR, EXIT_PARTIAL = 0, 1, 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Output:
|
|
22
|
+
def __init__(self, as_json=False, quiet=False):
|
|
23
|
+
self.as_json, self.quiet = as_json, quiet
|
|
24
|
+
|
|
25
|
+
def say(self, *lines):
|
|
26
|
+
"""Progress: stderr, so --json stdout stays one document."""
|
|
27
|
+
if not self.quiet:
|
|
28
|
+
for line in lines:
|
|
29
|
+
print(line, file=sys.stderr)
|
|
30
|
+
|
|
31
|
+
def result(self, data, text=None):
|
|
32
|
+
if self.as_json:
|
|
33
|
+
print(json.dumps(data, indent=2, default=str))
|
|
34
|
+
elif text and not self.quiet:
|
|
35
|
+
print(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _client(server=None, workspace_id=None):
|
|
39
|
+
server = auth.default_server(server)
|
|
40
|
+
return Client(server, auth.Credentials(server), workspace_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _repo_client(args):
|
|
44
|
+
repo = Repo.find(args.C or ".")
|
|
45
|
+
return repo, _client(repo.state["server"], repo.state["workspace_id"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----- commands ----------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def cmd_login(args, out):
|
|
51
|
+
server = auth.default_server(args.server)
|
|
52
|
+
done = auth.login(server, open_browser=not args.no_browser, say=out.say)
|
|
53
|
+
me = _client(server).get("/users/me/", workspace=False)
|
|
54
|
+
out.result({**done, "email": me.get("email")}, f"Signed in to {server} as {me.get('email')}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_logout(args, out):
|
|
58
|
+
server = auth.default_server(args.server)
|
|
59
|
+
had = auth.logout(server)
|
|
60
|
+
out.result({"server": server, "signed_out": had}, f"Signed out of {server}" if had else f"Not signed in to {server}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_whoami(args, out):
|
|
64
|
+
client = _client(args.server)
|
|
65
|
+
me = client.get("/users/me/", workspace=False)
|
|
66
|
+
via = "DSMD_TOKEN" if os.environ.get("DSMD_TOKEN") else "dsmd login"
|
|
67
|
+
out.result({"server": client.server, "email": me.get("email"), "username": me.get("username"), "via": via},
|
|
68
|
+
f"{me.get('email')} on {client.server} (via {via})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_workspaces(args, out):
|
|
72
|
+
rows = _client(args.server).get("/workspaces/", workspace=False)
|
|
73
|
+
text = "\n".join(f"{w['id']:>6} {w.get('name')}{' (default)' if w.get('is_default') else ''} [{w.get('user_role')}]"
|
|
74
|
+
for w in rows)
|
|
75
|
+
out.result(rows, text or "No workspaces.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_clone(args, out):
|
|
79
|
+
client = _client(args.server)
|
|
80
|
+
parts = [p.strip() for p in args.parts.split(",") if p.strip()] if args.parts else None
|
|
81
|
+
directory = args.dir or str(args.workspace).replace(" ", "-").lower()
|
|
82
|
+
done = sync.clone(client, args.workspace, os.path.join(args.C or ".", directory), parts=parts, say=out.say)
|
|
83
|
+
lines = [f"Cloned {done['parts']} parts into {done['root']}"]
|
|
84
|
+
lines += [f"warning {done['library_error']}"] if done["library_error"] else []
|
|
85
|
+
out.result(done, "\n".join(lines))
|
|
86
|
+
return EXIT_PARTIAL if done["library_error"] else EXIT_OK
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_fetch(args, out):
|
|
90
|
+
repo, client = _repo_client(args)
|
|
91
|
+
with repo.lock():
|
|
92
|
+
got = sync.fetch(repo, client, say=out.say)
|
|
93
|
+
out.result(got, (f"{got['changed_files']} files and {got['changed_datasheets']} datasheets changed on the server "
|
|
94
|
+
f"({got['new_parts']} new parts, {got['removed_parts']} removed, {got['moved_parts']} moved); "
|
|
95
|
+
"run dsmd pull to merge them"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_pull(args, out):
|
|
99
|
+
repo, client = _repo_client(args)
|
|
100
|
+
with repo.lock():
|
|
101
|
+
report = sync.pull(repo, client, rebuild_library=args.library, say=out.say)
|
|
102
|
+
lines = [f"Pulled: {report['changed_files']} files, {report['changed_datasheets']} datasheets, "
|
|
103
|
+
f"{report['new_parts']} new parts, "
|
|
104
|
+
f"{report['removed_parts']} removed, {report['moved_parts']} moved"]
|
|
105
|
+
lines += [f"merged {p}" for p in report["merged"]]
|
|
106
|
+
lines += [f"CONFLICT {c['path']} ({c['conflicts']} rows)" for c in report["conflicts"]]
|
|
107
|
+
lines += [f"saved {p} (your edit of a read-only file)" for p in report["saved_local"]]
|
|
108
|
+
lines += [f"warning {w}" for w in report["warnings"]]
|
|
109
|
+
out.result(report, "\n".join(lines))
|
|
110
|
+
return EXIT_PARTIAL if report["conflicts"] or report.get("library_error") else EXIT_OK
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_status(args, out):
|
|
114
|
+
repo = Repo.find(args.C or ".")
|
|
115
|
+
report = sync.status(repo)
|
|
116
|
+
lines = [f"Workspace {report['workspace']['name']} ({report['workspace']['id']}) on {report['server']}"]
|
|
117
|
+
for part in report["parts"]:
|
|
118
|
+
lines.append(f"{part['path']}/")
|
|
119
|
+
lines += [f" {f['state']:<18} {f['name']}" for f in part["files"]]
|
|
120
|
+
lines += [f"{s['state']:<22} {s['path']}" for s in report["settings"] + report["datasheets"]]
|
|
121
|
+
lines += [f"generated, will be overwritten: {p}" for p in report["generated_edited"]]
|
|
122
|
+
lines += [f"untracked, ignored: {p}/" for p in report["untracked"]]
|
|
123
|
+
if report["incomplete"]:
|
|
124
|
+
lines.append(f"The clone did not finish; continue it with: {report['incomplete']}")
|
|
125
|
+
elif report["library_missing"]:
|
|
126
|
+
lines.append("library/ is not built; run dsmd pull --library")
|
|
127
|
+
if report["clean"]:
|
|
128
|
+
lines.append("Nothing to push; working copy clean.")
|
|
129
|
+
elif report["nothing_to_push"]:
|
|
130
|
+
lines.append("Nothing to push.")
|
|
131
|
+
out.result(report, "\n".join(lines))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_diff(args, out):
|
|
135
|
+
repo = Repo.find(args.C or ".")
|
|
136
|
+
text = sync.diff(repo, args.target)
|
|
137
|
+
out.result({"diff": text}, text.rstrip("\n") if text else "No local changes.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_restore(args, out):
|
|
141
|
+
repo = Repo.find(args.C or ".")
|
|
142
|
+
with repo.lock():
|
|
143
|
+
done = sync.restore(repo, args.target)
|
|
144
|
+
out.result({"restored": done}, "\n".join(f"restored {p}" for p in done) or "Nothing to restore.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _where(path, error):
|
|
148
|
+
"""`parts/X/Y/pins.csv:3` for a part's file, `settings/mapping.yaml:3` for a settings file."""
|
|
149
|
+
name = error.get("file")
|
|
150
|
+
where = path if not name or name == path else f"{path}/{name}"
|
|
151
|
+
return where + (f":{error['line']}" if error.get("line") else "")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_push(args, out):
|
|
155
|
+
repo, client = _repo_client(args)
|
|
156
|
+
with repo.lock():
|
|
157
|
+
result = sync.push(repo, client, args.message, say=out.say)
|
|
158
|
+
lines = [f"applied {r['path']}" for r in result["applied"]]
|
|
159
|
+
lines += [f"stale {r['path']}: changed on the server; run dsmd pull, then push again" for r in result["stale"]]
|
|
160
|
+
for r in result["rejected"]:
|
|
161
|
+
lines += [f"rejected {_where(r['path'], e)}: {e['error']}" for e in r["errors"]] or [f"rejected {r['path']}"]
|
|
162
|
+
out.result(result, "\n".join(lines) or "Nothing to push.")
|
|
163
|
+
return EXIT_PARTIAL if result["stale"] or result["rejected"] else EXIT_OK
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_log(args, out):
|
|
167
|
+
repo, client = _repo_client(args)
|
|
168
|
+
data = sync.log(repo, client, args.part, args.page)
|
|
169
|
+
lines = []
|
|
170
|
+
for e in data["entries"]:
|
|
171
|
+
counts = ", ".join(f"{k} {v}" if not isinstance(v, dict) else
|
|
172
|
+
f"{k}: " + " ".join(f"{op} {n}" for op, n in v.items() if n)
|
|
173
|
+
for k, v in (e.get("summary") or {}).items())
|
|
174
|
+
lines.append(f"{e['at'][:19]} {e.get('user') or '?'} {e.get('part_number') or e['part_uuid']} "
|
|
175
|
+
f"{e.get('message') or ''}\n {counts}")
|
|
176
|
+
out.result(data, "\n".join(lines) or "No pushes yet.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_part(args, out):
|
|
180
|
+
repo, client = _repo_client(args)
|
|
181
|
+
with repo.lock():
|
|
182
|
+
if args.part_cmd == "add":
|
|
183
|
+
done = sync.part_add(repo, client, args.part_number, args.manufacturer, say=out.say)
|
|
184
|
+
out.result(done, f"Added {args.part_number}")
|
|
185
|
+
return
|
|
186
|
+
uuid = repo.resolve(args.ident)
|
|
187
|
+
path = repo.parts()[uuid]["path"]
|
|
188
|
+
if not args.yes:
|
|
189
|
+
if not sys.stdin.isatty():
|
|
190
|
+
raise RepoError("refusing to remove without confirmation; pass --yes")
|
|
191
|
+
if input(f"Remove {path} from the workspace on the server? [y/N] ").strip().lower() != "y":
|
|
192
|
+
out.result({"removed": False}, "Cancelled.")
|
|
193
|
+
return
|
|
194
|
+
done = sync.part_rm(repo, client, uuid, say=out.say)
|
|
195
|
+
out.result(done, f"Removed {path}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_pdf(args, out):
|
|
199
|
+
repo, client = _repo_client(args)
|
|
200
|
+
path = sync.pdf(repo, client, args.part)
|
|
201
|
+
out.result({"path": path}, path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_open(args, out):
|
|
205
|
+
repo = Repo.find(args.C or ".")
|
|
206
|
+
url = sync.open_part(repo, args.part)
|
|
207
|
+
out.result({"url": url}, url)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_categories(args, out):
|
|
211
|
+
_, client = _repo_client(args)
|
|
212
|
+
rows = sync.categories(client)
|
|
213
|
+
names = {r["id"]: r["name"] for r in rows}
|
|
214
|
+
out.result(rows, "\n".join(f"{r['id']} {r['name']}"
|
|
215
|
+
+ (f" (under {names.get(r['parent_id'], '?')})" if r["parent_id"] else "")
|
|
216
|
+
+ (f" [{r['parts']} parts]" if r["parts"] else "") for r in rows))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_feedback(args, out):
|
|
220
|
+
try:
|
|
221
|
+
_, client = _repo_client(args)
|
|
222
|
+
except RepoError: # outside a working copy: no workspace, still fine
|
|
223
|
+
client = _client(args.server)
|
|
224
|
+
sync.feedback(client, " ".join(args.message), args.kind)
|
|
225
|
+
out.result({"sent": True}, "Sent. Thank you -- the datasheets.md team reads every note.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_gui(args, out):
|
|
229
|
+
try:
|
|
230
|
+
from .gui.app import main as gui_main
|
|
231
|
+
except ImportError as exc:
|
|
232
|
+
raise RepoError(f'the GUI needs PySide6: pip install datasheets-md-gui ({exc})') from None
|
|
233
|
+
return gui_main(["--foreground"] if args.foreground else [])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cmd_self_update(args, out):
|
|
237
|
+
try:
|
|
238
|
+
latest = update.latest_version(timeout=15)
|
|
239
|
+
except (requests.RequestException, ValueError) as exc:
|
|
240
|
+
raise RepoError(f"cannot ask {update.index_url()} for the latest version: {exc}") from None
|
|
241
|
+
report = {"current": __version__, "latest": latest, "updated": False}
|
|
242
|
+
if not update.newer(latest):
|
|
243
|
+
out.result(report, f"dsmd {__version__} is the latest version")
|
|
244
|
+
return
|
|
245
|
+
if args.check:
|
|
246
|
+
out.result(report, f"dsmd {latest} is available (you have {__version__}); run: dsmd self-update")
|
|
247
|
+
return
|
|
248
|
+
argv, why_not = update.upgrade_command(latest)
|
|
249
|
+
if argv is None:
|
|
250
|
+
raise RepoError(why_not)
|
|
251
|
+
out.say(f"Updating dsmd {__version__} -> {latest}: {shlex.join(argv)}")
|
|
252
|
+
code = update.run(argv)
|
|
253
|
+
if code:
|
|
254
|
+
raise RepoError(f"the update failed (exit {code}); the command above shows why")
|
|
255
|
+
if code is None:
|
|
256
|
+
out.result({**report, "deferred": True}, f"The update to {latest} continues in a new window once dsmd exits.")
|
|
257
|
+
return
|
|
258
|
+
installed = update.installed_version()
|
|
259
|
+
if installed != latest: # pipx and uv exit 0 when they find nothing to do
|
|
260
|
+
raise RepoError(f"dsmd is still {installed} after the update, not {latest}; it was probably installed "
|
|
261
|
+
"without --pre or from another index, so reinstall it the way you installed it")
|
|
262
|
+
out.result({**report, "updated": True}, f"Updated to dsmd {latest}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_help(args, out):
|
|
266
|
+
if args.topic != "agents":
|
|
267
|
+
raise RepoError("unknown help topic; try: dsmd help agents")
|
|
268
|
+
text = resources.files("datasheets_md").joinpath("agents.md").read_text(encoding="utf-8")
|
|
269
|
+
out.result({"agents": text}, text.rstrip("\n"))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ----- parser ------------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def _globals(suppress):
|
|
275
|
+
"""The global flags. Subcommands accept them too, with SUPPRESS defaults so
|
|
276
|
+
`dsmd --json status` is not reset by the subparser's own default."""
|
|
277
|
+
kw = {"default": argparse.SUPPRESS} if suppress else {}
|
|
278
|
+
flags = argparse.ArgumentParser(add_help=False)
|
|
279
|
+
flags.add_argument("--json", action="store_true", help="machine-readable output on stdout", **kw)
|
|
280
|
+
flags.add_argument("--quiet", "-q", action="store_true", help="no progress output", **kw)
|
|
281
|
+
flags.add_argument("-C", metavar="DIR", help="run as if started in DIR", **kw)
|
|
282
|
+
return flags
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parser():
|
|
286
|
+
common = _globals(suppress=True)
|
|
287
|
+
p = argparse.ArgumentParser(prog="dsmd", parents=[_globals(suppress=False)],
|
|
288
|
+
description="A git-like working copy of your datasheets.md workspace.",
|
|
289
|
+
epilog="Settings are files: edit settings/mapping.yaml or settings/symbol_style.yaml "
|
|
290
|
+
"(owner/admin), then dsmd push. Guide: dsmd help agents")
|
|
291
|
+
p.add_argument("--version", action="version", version=f"dsmd {__version__}")
|
|
292
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
293
|
+
|
|
294
|
+
def add(name, fn, help_text):
|
|
295
|
+
cmd = sub.add_parser(name, parents=[common], help=help_text, description=help_text)
|
|
296
|
+
cmd.set_defaults(fn=fn)
|
|
297
|
+
return cmd
|
|
298
|
+
|
|
299
|
+
for name, fn, text in (("login", cmd_login, "sign in through the browser"),
|
|
300
|
+
("logout", cmd_logout, "sign out and revoke the stored session"),
|
|
301
|
+
("whoami", cmd_whoami, "show who you are signed in as")):
|
|
302
|
+
cmd = add(name, fn, text)
|
|
303
|
+
cmd.add_argument("--server", help=f"API server (default: DSMD_SERVER or {auth.DEFAULT_SERVER})")
|
|
304
|
+
if name == "login":
|
|
305
|
+
cmd.add_argument("--no-browser", action="store_true", help="print the sign-in URL instead of opening it")
|
|
306
|
+
add("workspaces", cmd_workspaces, "list your workspaces").add_argument("--server")
|
|
307
|
+
cmd = add("clone", cmd_clone, "create a working copy of a workspace")
|
|
308
|
+
cmd.add_argument("workspace", help="workspace id or name")
|
|
309
|
+
cmd.add_argument("dir", nargs="?", help="target folder (default: the workspace name)")
|
|
310
|
+
cmd.add_argument("--parts", help="comma-separated part uuids for a sparse clone")
|
|
311
|
+
cmd.add_argument("--server")
|
|
312
|
+
add("fetch", cmd_fetch, "download server changes without merging them")
|
|
313
|
+
add("pull", cmd_pull, "fetch and merge server changes; rebuild library/").add_argument(
|
|
314
|
+
"--library", action="store_true", help="rebuild library/ even when nothing changed")
|
|
315
|
+
add("status", cmd_status, "show local changes")
|
|
316
|
+
add("diff", cmd_diff, "show local changes as a diff").add_argument("target", nargs="?", help="part or path")
|
|
317
|
+
add("restore", cmd_restore, "discard local changes").add_argument("target", nargs="?", help="part or path")
|
|
318
|
+
add("push", cmd_push, "send local changes (parts and settings) to the workspace").add_argument(
|
|
319
|
+
"-m", "--message", required=True, help="what changed and why")
|
|
320
|
+
cmd = add("log", cmd_log, "server-side history of pushes")
|
|
321
|
+
cmd.add_argument("part", nargs="?")
|
|
322
|
+
cmd.add_argument("--page", type=int, default=1)
|
|
323
|
+
part = add("part", cmd_part, "add or remove parts in the workspace")
|
|
324
|
+
part_sub = part.add_subparsers(dest="part_cmd", required=True, metavar="<add|rm>")
|
|
325
|
+
padd = part_sub.add_parser("add", parents=[common], help="add a public catalogue part")
|
|
326
|
+
padd.add_argument("part_number")
|
|
327
|
+
padd.add_argument("--manufacturer")
|
|
328
|
+
prm = part_sub.add_parser("rm", parents=[common], help="remove a part from the workspace")
|
|
329
|
+
prm.add_argument("ident", help="part number, uuid or folder")
|
|
330
|
+
prm.add_argument("--yes", "-y", action="store_true", help="do not ask")
|
|
331
|
+
add("pdf", cmd_pdf, "download a part's PDF into .dsmd/cache and print its path").add_argument("part")
|
|
332
|
+
add("open", cmd_open, "open a part's web page").add_argument("part")
|
|
333
|
+
add("categories", cmd_categories, "list the catalogue categories (the ids settings/mapping.yaml's category_map uses)")
|
|
334
|
+
cmd = add("feedback", cmd_feedback, "tell the datasheets.md team about a bug or a missing setting")
|
|
335
|
+
cmd.add_argument("message", nargs="+")
|
|
336
|
+
cmd.add_argument("--kind", choices=sync.FEEDBACK_KINDS, default="other")
|
|
337
|
+
cmd.add_argument("--server")
|
|
338
|
+
add("gui", cmd_gui, "launch the desktop app in the background (needs datasheets-md-gui)").add_argument(
|
|
339
|
+
"--foreground", action="store_true", help="stay attached to the terminal and print its output")
|
|
340
|
+
add("self-update", cmd_self_update, "upgrade dsmd to the latest release").add_argument(
|
|
341
|
+
"--check", action="store_true", help="only say whether a newer release exists")
|
|
342
|
+
add("help", cmd_help, "extra help topics").add_argument("topic", choices=["agents"])
|
|
343
|
+
return p
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main(argv=None):
|
|
347
|
+
args = parser().parse_args(argv)
|
|
348
|
+
out = Output(args.json, args.quiet)
|
|
349
|
+
try:
|
|
350
|
+
code = args.fn(args, out)
|
|
351
|
+
except (ApiError, RepoError, auth.AuthError) as exc:
|
|
352
|
+
if args.json:
|
|
353
|
+
print(json.dumps({"error": str(exc), "status": getattr(exc, "status", None)}))
|
|
354
|
+
else:
|
|
355
|
+
print(f"dsmd: {exc}", file=sys.stderr)
|
|
356
|
+
return EXIT_ERROR
|
|
357
|
+
except KeyboardInterrupt:
|
|
358
|
+
return EXIT_ERROR
|
|
359
|
+
if args.command != "self-update" and not (args.json or args.quiet) and sys.stderr.isatty():
|
|
360
|
+
try:
|
|
361
|
+
line = update.notice()
|
|
362
|
+
except Exception: # a broken check never fails the command
|
|
363
|
+
line = None
|
|
364
|
+
if line:
|
|
365
|
+
print(line, file=sys.stderr)
|
|
366
|
+
return code or EXIT_OK
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
sys.exit(main())
|