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.
- datasheets_md-2.0.0a11/PKG-INFO +119 -0
- datasheets_md-2.0.0a11/README.md +100 -0
- datasheets_md-2.0.0a11/pyproject.toml +42 -0
- datasheets_md-2.0.0a11/setup.cfg +4 -0
- datasheets_md-2.0.0a11/src/datasheets_md/__init__.py +4 -0
- datasheets_md-2.0.0a11/src/datasheets_md/agents.md +88 -0
- datasheets_md-2.0.0a11/src/datasheets_md/auth.py +216 -0
- datasheets_md-2.0.0a11/src/datasheets_md/cli.py +370 -0
- datasheets_md-2.0.0a11/src/datasheets_md/gui/__init__.py +0 -0
- datasheets_md-2.0.0a11/src/datasheets_md/gui/app.py +375 -0
- datasheets_md-2.0.0a11/src/datasheets_md/gui/icon.png +0 -0
- datasheets_md-2.0.0a11/src/datasheets_md/http.py +92 -0
- datasheets_md-2.0.0a11/src/datasheets_md/library.py +353 -0
- datasheets_md-2.0.0a11/src/datasheets_md/merge.py +206 -0
- datasheets_md-2.0.0a11/src/datasheets_md/repo.py +238 -0
- datasheets_md-2.0.0a11/src/datasheets_md/sync.py +675 -0
- datasheets_md-2.0.0a11/src/datasheets_md/update.py +144 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/PKG-INFO +119 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/SOURCES.txt +26 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/dependency_links.txt +1 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/entry_points.txt +3 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/requires.txt +13 -0
- datasheets_md-2.0.0a11/src/datasheets_md.egg-info/top_level.txt +1 -0
- datasheets_md-2.0.0a11/tests/test_gui_package.py +17 -0
- datasheets_md-2.0.0a11/tests/test_merge.py +99 -0
- datasheets_md-2.0.0a11/tests/test_resume.py +216 -0
- datasheets_md-2.0.0a11/tests/test_sync.py +250 -0
- 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,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
|