datasheets-md 2.0.0a11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. datasheets_md-2.0.0a11/PKG-INFO +119 -0
  2. datasheets_md-2.0.0a11/README.md +100 -0
  3. datasheets_md-2.0.0a11/pyproject.toml +42 -0
  4. datasheets_md-2.0.0a11/setup.cfg +4 -0
  5. datasheets_md-2.0.0a11/src/datasheets_md/__init__.py +4 -0
  6. datasheets_md-2.0.0a11/src/datasheets_md/agents.md +88 -0
  7. datasheets_md-2.0.0a11/src/datasheets_md/auth.py +216 -0
  8. datasheets_md-2.0.0a11/src/datasheets_md/cli.py +370 -0
  9. datasheets_md-2.0.0a11/src/datasheets_md/gui/__init__.py +0 -0
  10. datasheets_md-2.0.0a11/src/datasheets_md/gui/app.py +375 -0
  11. datasheets_md-2.0.0a11/src/datasheets_md/gui/icon.png +0 -0
  12. datasheets_md-2.0.0a11/src/datasheets_md/http.py +92 -0
  13. datasheets_md-2.0.0a11/src/datasheets_md/library.py +353 -0
  14. datasheets_md-2.0.0a11/src/datasheets_md/merge.py +206 -0
  15. datasheets_md-2.0.0a11/src/datasheets_md/repo.py +238 -0
  16. datasheets_md-2.0.0a11/src/datasheets_md/sync.py +675 -0
  17. datasheets_md-2.0.0a11/src/datasheets_md/update.py +144 -0
  18. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/PKG-INFO +119 -0
  19. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/SOURCES.txt +26 -0
  20. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/dependency_links.txt +1 -0
  21. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/entry_points.txt +3 -0
  22. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/requires.txt +13 -0
  23. datasheets_md-2.0.0a11/src/datasheets_md.egg-info/top_level.txt +1 -0
  24. datasheets_md-2.0.0a11/tests/test_gui_package.py +17 -0
  25. datasheets_md-2.0.0a11/tests/test_merge.py +99 -0
  26. datasheets_md-2.0.0a11/tests/test_resume.py +216 -0
  27. datasheets_md-2.0.0a11/tests/test_sync.py +250 -0
  28. datasheets_md-2.0.0a11/tests/test_update.py +148 -0
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: datasheets-md
3
+ Version: 2.0.0a11
4
+ Summary: dsmd: a git-like working copy of your datasheets.md workspace (parameters, pins, CAD config, KiCad library)
5
+ Author: datasheets.md
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://datasheets.md
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.31
11
+ Requires-Dist: packaging>=22
12
+ Requires-Dist: platformdirs>=3
13
+ Requires-Dist: pyyaml>=6
14
+ Requires-Dist: tomli>=2; python_version < "3.11"
15
+ Provides-Extra: gui
16
+ Requires-Dist: PySide6>=6.6; extra == "gui"
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+
20
+ # datasheets-md (`dsmd`, dBsync)
21
+
22
+ A git-like working copy of your [datasheets.md](https://datasheets.md) workspace:
23
+ the parts' unified parameters, pin tables and CAD configuration as plain text
24
+ files you can grep, edit and push back, plus the workspace's KiCad library.
25
+
26
+ ```bash
27
+ pip install datasheets-md # the dsmd CLI
28
+ pip install datasheets-md-gui # CLI + the dBsync desktop app (adds PySide6 to an existing install)
29
+ ```
30
+
31
+ Ubuntu/Debian refuse a system-wide `pip install` ("externally-managed-environment").
32
+ Install into a venv and link the command onto your PATH:
33
+
34
+ ```bash
35
+ python3 -m venv ~/.venvs/dsmd && ~/.venvs/dsmd/bin/pip install datasheets-md
36
+ ln -s ~/.venvs/dsmd/bin/dsmd ~/.local/bin/dsmd
37
+ ```
38
+
39
+ or let pipx do both: `pipx install datasheets-md`.
40
+
41
+ `dsmd` looks for a new release once a day and prints one line when there is
42
+ one. `dsmd self-update` upgrades the install the way it was made (pipx, uv tool,
43
+ or the pip of its venv). Set `DSMD_NO_UPDATE_CHECK=1` to turn the check off; it
44
+ also stays off under `CI`, with `--json` or `--quiet`, and when stderr is not a
45
+ terminal.
46
+
47
+ A clone of a large workspace can stop part way (network, Ctrl-C, a closed laptop)
48
+ without losing what it downloaded: files reach the disk as they arrive, and running
49
+ the same `dsmd clone` command again continues it. Until it finishes, `dsmd status`
50
+ says so and `fetch`, `pull` and `push` refuse. If only the library step fails, the
51
+ parts and datasheets are in place, `dsmd` exits with code 2, and `dsmd pull --library`
52
+ (or the next `dsmd pull`) builds `library/`.
53
+
54
+ ## Use
55
+
56
+ ```bash
57
+ dsmd login # browser sign-in (DSMD_TOKEN=dsh_... for headless use)
58
+ dsmd workspaces # pick one
59
+ dsmd clone "My workspace" lib && cd lib
60
+ dsmd status # what you changed
61
+ dsmd diff # ... as a diff
62
+ dsmd push -m "Fix VOH typ from page 5"
63
+ dsmd pull # server changes, merged row by row
64
+ dsmd log [PART] # server-side history
65
+ dsmd part add SN74HC00N [--manufacturer TI]
66
+ dsmd part rm SN74HC00N
67
+ dsmd pdf SN74HC00N # PDF into .dsmd/cache/
68
+ dsmd open SN74HC00N # the part's web page
69
+ dsmd categories # catalogue category ids, for settings/mapping.yaml
70
+ dsmd feedback "a setting I need" # tell the datasheets.md team (--kind bug|suggestion)
71
+ dsmd gui # the desktop app (needs datasheets-md-gui)
72
+ dsmd self-update # upgrade to the latest release (--check only asks)
73
+ dsmd help agents # the guide for AI agents
74
+ ```
75
+
76
+ Every command takes `--json`, `--quiet` and `-C <dir>`. Exit codes: `0` ok,
77
+ `1` error, `2` done but with conflicts or stale/rejected parts.
78
+
79
+ `DSMD_SERVER` (or `--server`) points at another instance, e.g.
80
+ `https://api-dev.datasheets.md`.
81
+
82
+ ## The working copy
83
+
84
+ ```
85
+ .dsmd/ state, pristine base copies, cache (gitignored)
86
+ dsmd.toml what to pull, which library outputs to build
87
+ settings/mapping.yaml workspace mapping (categories, parameter names, CAD fields) -- editable, admin
88
+ settings/symbol_style.yaml symbol generation settings and reference designators -- editable, admin
89
+ parts/<MFR>/<PN>/
90
+ part.yaml identity, and `datasheet:` -> its datasheet.md (read-only)
91
+ parameters.jsonl unified parameters, one row per line -- editable
92
+ pins.csv physical pins, one per line -- editable
93
+ cad.yaml CAD configuration -- editable
94
+ datasheets/<MFR>/<PDF name>/ one per family, shared by its parts
95
+ datasheet.md digitised datasheet with its element ids (read-only)
96
+ images/ the figures datasheet.md links to (read-only)
97
+ library/ generated KiCad library (rebuilt on pull)
98
+ datasheets.sqlite, datasheets.kicad_dbl, datasheets.kicad_sym,
99
+ datasheets.pretty/, sym-lib-table, fp-lib-table
100
+ ```
101
+
102
+ How it differs from git: one remote, no branches, no staging or local commits.
103
+ `push` is applied atomically per part; the server may normalise what you
104
+ pushed and sends the result straight back. Merges are by row key (`k`) and pin
105
+ `id`, so edits to different rows never conflict. History lives on the server.
106
+
107
+ To use the library in KiCad, open `library/` as a project folder (the lib
108
+ tables point at the symbols and footprints) or add `datasheets.kicad_dbl`
109
+ as a database library (needs the SQLite ODBC driver).
110
+
111
+ ## Develop
112
+
113
+ ```bash
114
+ pip install -e ".[gui,test]"
115
+ pytest
116
+ ```
117
+
118
+ Standalone dBsync binaries (no Python needed) are built by
119
+ `.github/workflows/build.yml` with PyInstaller from `packaging/dbsync.py`.
@@ -0,0 +1,100 @@
1
+ # datasheets-md (`dsmd`, dBsync)
2
+
3
+ A git-like working copy of your [datasheets.md](https://datasheets.md) workspace:
4
+ the parts' unified parameters, pin tables and CAD configuration as plain text
5
+ files you can grep, edit and push back, plus the workspace's KiCad library.
6
+
7
+ ```bash
8
+ pip install datasheets-md # the dsmd CLI
9
+ pip install datasheets-md-gui # CLI + the dBsync desktop app (adds PySide6 to an existing install)
10
+ ```
11
+
12
+ Ubuntu/Debian refuse a system-wide `pip install` ("externally-managed-environment").
13
+ Install into a venv and link the command onto your PATH:
14
+
15
+ ```bash
16
+ python3 -m venv ~/.venvs/dsmd && ~/.venvs/dsmd/bin/pip install datasheets-md
17
+ ln -s ~/.venvs/dsmd/bin/dsmd ~/.local/bin/dsmd
18
+ ```
19
+
20
+ or let pipx do both: `pipx install datasheets-md`.
21
+
22
+ `dsmd` looks for a new release once a day and prints one line when there is
23
+ one. `dsmd self-update` upgrades the install the way it was made (pipx, uv tool,
24
+ or the pip of its venv). Set `DSMD_NO_UPDATE_CHECK=1` to turn the check off; it
25
+ also stays off under `CI`, with `--json` or `--quiet`, and when stderr is not a
26
+ terminal.
27
+
28
+ A clone of a large workspace can stop part way (network, Ctrl-C, a closed laptop)
29
+ without losing what it downloaded: files reach the disk as they arrive, and running
30
+ the same `dsmd clone` command again continues it. Until it finishes, `dsmd status`
31
+ says so and `fetch`, `pull` and `push` refuse. If only the library step fails, the
32
+ parts and datasheets are in place, `dsmd` exits with code 2, and `dsmd pull --library`
33
+ (or the next `dsmd pull`) builds `library/`.
34
+
35
+ ## Use
36
+
37
+ ```bash
38
+ dsmd login # browser sign-in (DSMD_TOKEN=dsh_... for headless use)
39
+ dsmd workspaces # pick one
40
+ dsmd clone "My workspace" lib && cd lib
41
+ dsmd status # what you changed
42
+ dsmd diff # ... as a diff
43
+ dsmd push -m "Fix VOH typ from page 5"
44
+ dsmd pull # server changes, merged row by row
45
+ dsmd log [PART] # server-side history
46
+ dsmd part add SN74HC00N [--manufacturer TI]
47
+ dsmd part rm SN74HC00N
48
+ dsmd pdf SN74HC00N # PDF into .dsmd/cache/
49
+ dsmd open SN74HC00N # the part's web page
50
+ dsmd categories # catalogue category ids, for settings/mapping.yaml
51
+ dsmd feedback "a setting I need" # tell the datasheets.md team (--kind bug|suggestion)
52
+ dsmd gui # the desktop app (needs datasheets-md-gui)
53
+ dsmd self-update # upgrade to the latest release (--check only asks)
54
+ dsmd help agents # the guide for AI agents
55
+ ```
56
+
57
+ Every command takes `--json`, `--quiet` and `-C <dir>`. Exit codes: `0` ok,
58
+ `1` error, `2` done but with conflicts or stale/rejected parts.
59
+
60
+ `DSMD_SERVER` (or `--server`) points at another instance, e.g.
61
+ `https://api-dev.datasheets.md`.
62
+
63
+ ## The working copy
64
+
65
+ ```
66
+ .dsmd/ state, pristine base copies, cache (gitignored)
67
+ dsmd.toml what to pull, which library outputs to build
68
+ settings/mapping.yaml workspace mapping (categories, parameter names, CAD fields) -- editable, admin
69
+ settings/symbol_style.yaml symbol generation settings and reference designators -- editable, admin
70
+ parts/<MFR>/<PN>/
71
+ part.yaml identity, and `datasheet:` -> its datasheet.md (read-only)
72
+ parameters.jsonl unified parameters, one row per line -- editable
73
+ pins.csv physical pins, one per line -- editable
74
+ cad.yaml CAD configuration -- editable
75
+ datasheets/<MFR>/<PDF name>/ one per family, shared by its parts
76
+ datasheet.md digitised datasheet with its element ids (read-only)
77
+ images/ the figures datasheet.md links to (read-only)
78
+ library/ generated KiCad library (rebuilt on pull)
79
+ datasheets.sqlite, datasheets.kicad_dbl, datasheets.kicad_sym,
80
+ datasheets.pretty/, sym-lib-table, fp-lib-table
81
+ ```
82
+
83
+ How it differs from git: one remote, no branches, no staging or local commits.
84
+ `push` is applied atomically per part; the server may normalise what you
85
+ pushed and sends the result straight back. Merges are by row key (`k`) and pin
86
+ `id`, so edits to different rows never conflict. History lives on the server.
87
+
88
+ To use the library in KiCad, open `library/` as a project folder (the lib
89
+ tables point at the symbols and footprints) or add `datasheets.kicad_dbl`
90
+ as a database library (needs the SQLite ODBC driver).
91
+
92
+ ## Develop
93
+
94
+ ```bash
95
+ pip install -e ".[gui,test]"
96
+ pytest
97
+ ```
98
+
99
+ Standalone dBsync binaries (no Python needed) are built by
100
+ `.github/workflows/build.yml` with PyInstaller from `packaging/dbsync.py`.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "datasheets-md"
7
+ dynamic = ["version"]
8
+ description = "dsmd: a git-like working copy of your datasheets.md workspace (parameters, pins, CAD config, KiCad library)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "datasheets.md" }]
13
+ dependencies = [
14
+ "requests>=2.31",
15
+ "packaging>=22",
16
+ "platformdirs>=3",
17
+ "pyyaml>=6",
18
+ "tomli>=2; python_version < '3.11'",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ gui = ["PySide6>=6.6"]
23
+ test = ["pytest>=8"]
24
+
25
+ [project.scripts]
26
+ dsmd = "datasheets_md.cli:main"
27
+ datasheets-md = "datasheets_md.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://datasheets.md"
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = { attr = "datasheets_md.__version__" }
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ datasheets_md = ["agents.md", "gui/icon.png"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """datasheets-md: the `dsmd` CLI and the dBsync GUI on one sync engine."""
2
+
3
+ __version__ = "2.0.0a11"
4
+ APP_NAME = "dBsync"
@@ -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.
@@ -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