endstation 0.2.0__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.
- endstation-0.2.0/LICENSE +28 -0
- endstation-0.2.0/PKG-INFO +240 -0
- endstation-0.2.0/README.md +201 -0
- endstation-0.2.0/pyproject.toml +155 -0
- endstation-0.2.0/setup.cfg +4 -0
- endstation-0.2.0/src/endstation/__init__.py +60 -0
- endstation-0.2.0/src/endstation/docs/__init__.py +34 -0
- endstation-0.2.0/src/endstation/docs/index.py +138 -0
- endstation-0.2.0/src/endstation/docs/markdown.py +289 -0
- endstation-0.2.0/src/endstation/docs/mathtext.py +173 -0
- endstation-0.2.0/src/endstation/forms.py +126 -0
- endstation-0.2.0/src/endstation/optional.py +218 -0
- endstation-0.2.0/src/endstation/paths.py +116 -0
- endstation-0.2.0/src/endstation/progress.py +131 -0
- endstation-0.2.0/src/endstation/py.typed +0 -0
- endstation-0.2.0/src/endstation/qt/__init__.py +121 -0
- endstation-0.2.0/src/endstation/qt/canvas.py +173 -0
- endstation-0.2.0/src/endstation/qt/jobs.py +230 -0
- endstation-0.2.0/src/endstation/qt/liveness.py +48 -0
- endstation-0.2.0/src/endstation/qt/sizing.py +38 -0
- endstation-0.2.0/src/endstation/qt/tokens.py +76 -0
- endstation-0.2.0/src/endstation/testing/__init__.py +0 -0
- endstation-0.2.0/src/endstation/testing/boundary.py +136 -0
- endstation-0.2.0/src/endstation/testing/plugin.py +116 -0
- endstation-0.2.0/src/endstation/tools/__init__.py +33 -0
- endstation-0.2.0/src/endstation/tools/make_app.py +252 -0
- endstation-0.2.0/src/endstation/tools/make_icon.py +142 -0
- endstation-0.2.0/src/endstation/tools/publish_wiki.py +121 -0
- endstation-0.2.0/src/endstation.egg-info/PKG-INFO +240 -0
- endstation-0.2.0/src/endstation.egg-info/SOURCES.txt +45 -0
- endstation-0.2.0/src/endstation.egg-info/dependency_links.txt +1 -0
- endstation-0.2.0/src/endstation.egg-info/entry_points.txt +7 -0
- endstation-0.2.0/src/endstation.egg-info/requires.txt +26 -0
- endstation-0.2.0/src/endstation.egg-info/top_level.txt +1 -0
- endstation-0.2.0/tests/test_boundary_helper.py +103 -0
- endstation-0.2.0/tests/test_core_has_no_dependencies.py +77 -0
- endstation-0.2.0/tests/test_docs.py +210 -0
- endstation-0.2.0/tests/test_forms.py +65 -0
- endstation-0.2.0/tests/test_optional.py +120 -0
- endstation-0.2.0/tests/test_packaging.py +216 -0
- endstation-0.2.0/tests/test_paths.py +92 -0
- endstation-0.2.0/tests/test_progress.py +89 -0
- endstation-0.2.0/tests/test_qt_jobs.py +141 -0
- endstation-0.2.0/tests/test_qt_tokens.py +45 -0
- endstation-0.2.0/tests/test_qt_widgets.py +203 -0
- endstation-0.2.0/tests/test_template.py +206 -0
- endstation-0.2.0/tests/test_tools.py +233 -0
endstation-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Jake McCoy
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: endstation
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared scaffolding for desktop scientific applications: optional-dependency guards, cancellable workers, Qt/matplotlib panes, and macOS app bundling
|
|
5
|
+
Author: Jake McCoy
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamccoy/endstation
|
|
8
|
+
Keywords: gui,pyside6,matplotlib,macos,scientific,packaging
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: qt
|
|
20
|
+
Requires-Dist: PySide6-Essentials>=6.7; extra == "qt"
|
|
21
|
+
Requires-Dist: matplotlib>=3.8; extra == "qt"
|
|
22
|
+
Provides-Extra: mpl
|
|
23
|
+
Requires-Dist: matplotlib>=3.8; extra == "mpl"
|
|
24
|
+
Provides-Extra: docs
|
|
25
|
+
Requires-Dist: matplotlib>=3.8; extra == "docs"
|
|
26
|
+
Provides-Extra: testing
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "testing"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-qt>=4.5; extra == "dev"
|
|
31
|
+
Requires-Dist: PySide6-Essentials>=6.7; extra == "dev"
|
|
32
|
+
Requires-Dist: matplotlib>=3.8; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
34
|
+
Requires-Dist: copier>=9; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
36
|
+
Requires-Dist: PyYAML>=6; extra == "dev"
|
|
37
|
+
Requires-Dist: tomli>=2; python_version < "3.11" and extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# endstation
|
|
41
|
+
|
|
42
|
+
Shared scaffolding for desktop scientific applications.
|
|
43
|
+
|
|
44
|
+
Not a science library. This is the apparatus that surrounds an analysis:
|
|
45
|
+
guarding optional dependencies, reporting and cancelling slow work, collecting
|
|
46
|
+
form errors, locating research data that is deliberately not in the checkout,
|
|
47
|
+
running work off the UI thread, embedding a figure, rendering a documentation
|
|
48
|
+
page, and building a macOS bundle.
|
|
49
|
+
|
|
50
|
+
The name is the point. At a synchrotron the endstation is the shared apparatus
|
|
51
|
+
every experiment bolts onto — goniometer, detector, motion control — distinct
|
|
52
|
+
from the science each experiment does.
|
|
53
|
+
|
|
54
|
+
## Why it exists
|
|
55
|
+
|
|
56
|
+
Four projects were four independent builds of the same artifact: a Python
|
|
57
|
+
science package with an optional PySide6/matplotlib front-end, a hand-rolled
|
|
58
|
+
macOS `.app`, private research data held outside the checkout, and a test suite
|
|
59
|
+
that has to stay runnable headless. Every one of those concerns had been solved
|
|
60
|
+
three or four times, and the copies had already drifted:
|
|
61
|
+
|
|
62
|
+
- One project derives its layout floor from platform metrics after the
|
|
63
|
+
hardcoded-width bug shipped twice unnoticed; two others still hardcode a
|
|
64
|
+
pixel count.
|
|
65
|
+
- One worker fixed a slot-ordering bug and a garbage-collection crash during
|
|
66
|
+
teardown; two others have neither fix.
|
|
67
|
+
- The test that keeps a toolkit out of the pure layer exists three times, and
|
|
68
|
+
the strongest version — a subprocess import, which catches *transitive*
|
|
69
|
+
imports — is in only one of them.
|
|
70
|
+
|
|
71
|
+
Each of those is a fix that landed in one place and not the others. That is the
|
|
72
|
+
problem this package exists to stop.
|
|
73
|
+
|
|
74
|
+
## The one rule
|
|
75
|
+
|
|
76
|
+
**The core has no dependencies.**
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
dependencies = []
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`eblfrac` declares exactly that, so its fracture engine and GPF writer install
|
|
83
|
+
anywhere. A shared package that quietly pulled numpy in would take that away at
|
|
84
|
+
install time, from every downstream user, with no error to notice.
|
|
85
|
+
|
|
86
|
+
So `endstation`, `endstation.optional`, `endstation.progress`,
|
|
87
|
+
`endstation.forms` and `endstation.paths` import nothing third-party at all, and
|
|
88
|
+
`tests/test_core_has_no_dependencies.py` fails if that ever stops being true.
|
|
89
|
+
Everything heavier lives behind an extra:
|
|
90
|
+
|
|
91
|
+
| Subpackage | Extra | Needs |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `endstation.qt` | `qt` | PySide6, matplotlib |
|
|
94
|
+
| `endstation.docs` | `docs` | matplotlib, for math only |
|
|
95
|
+
| `endstation.testing` | `testing` | pytest |
|
|
96
|
+
| `endstation.tools` | — | macOS `sips`/`iconutil` at runtime |
|
|
97
|
+
|
|
98
|
+
`requires-python = ">=3.10"`, because `eblfrac` declares 3.10. A shared package
|
|
99
|
+
must not raise its consumers' floor.
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
From PyPI, from 0.2.0 on:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install "endstation[qt]"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Or straight from git, pinning a tag for anything that matters:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install "endstation @ git+https://github.com/jamccoy/endstation.git@v0.2.0"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For work on endstation itself:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e '.[dev]'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Publishing
|
|
122
|
+
|
|
123
|
+
`.github/workflows/publish.yml` releases to PyPI when a `v*` tag is pushed,
|
|
124
|
+
using Trusted Publishing — there is no API token anywhere, so there is nothing
|
|
125
|
+
to leak or rotate. It needs a one-time *pending publisher* registered at
|
|
126
|
+
<https://pypi.org/manage/account/publishing/> for `jamccoy/endstation`,
|
|
127
|
+
workflow `publish.yml`, environment `pypi`.
|
|
128
|
+
|
|
129
|
+
## What is in it
|
|
130
|
+
|
|
131
|
+
### `endstation.optional` — dependencies that explain themselves
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from endstation import require_extra, optional_module
|
|
135
|
+
|
|
136
|
+
require_extra("PySide6", "gui", still_works="The analysis works without it.")
|
|
137
|
+
|
|
138
|
+
astropy = optional_module("astropy", "trapezoid", package="als_eff_meas")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`require_extra` is a check that uses `find_spec`, so it costs nothing when the
|
|
142
|
+
dependency is present and is safe to call from a module forbidden to import a
|
|
143
|
+
toolkit. `optional_module` is a lazy proxy, so one missing package breaks only
|
|
144
|
+
the functions that actually need it rather than every function in the module.
|
|
145
|
+
|
|
146
|
+
The install command in the message is quoted, and the quotes are load-bearing:
|
|
147
|
+
square brackets are glob characters, and zsh answers an unquoted
|
|
148
|
+
`pip install .[gui]` with `no matches found`.
|
|
149
|
+
|
|
150
|
+
### `endstation.progress` — cancellation that actually stops the work
|
|
151
|
+
|
|
152
|
+
One callback, `progress(done, total)`. A callback that wants to stop raises
|
|
153
|
+
`Cancelled`, and the loops let it propagate.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from endstation.progress import cancelling, throttled
|
|
157
|
+
|
|
158
|
+
report = cancelling(throttled(on_progress), cancel_event.is_set)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A Python thread cannot be killed and a NumPy-bound call has no check point, so
|
|
162
|
+
the progress callback is the only moment a caller gets to interrupt. Without it
|
|
163
|
+
cancelling stops the *waiting* — the CPU keeps working and the result is
|
|
164
|
+
computed and thrown away.
|
|
165
|
+
|
|
166
|
+
Reporting is throttled; the cancellation check is not. A Cancel click lands at
|
|
167
|
+
the next unit of work, not the next percent.
|
|
168
|
+
|
|
169
|
+
### `endstation.forms` — errors collected, not raised on the first failure
|
|
170
|
+
|
|
171
|
+
A form with three bad fields reports three problems, rather than making the user
|
|
172
|
+
fix them one at a time and rediscover the next on each attempt.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
errors = ErrorCollector()
|
|
176
|
+
if period <= 0:
|
|
177
|
+
errors.add("period", "must be positive")
|
|
178
|
+
if not 0 <= trim < MAX_TRIM:
|
|
179
|
+
errors.add("trim", f"must be between 0 and {MAX_TRIM}")
|
|
180
|
+
errors.raise_if_any()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `endstation.paths` — data that is deliberately not in the checkout
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
scans = find_data("GRATINGLAB_AFM_DIR", "~/Documents/afm_scans",
|
|
187
|
+
hint="docs/DATA.md")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A missing root is reported by name. `FileNotFoundError: [Errno 2]` on a path the
|
|
191
|
+
user never typed is unactionable; naming the variable and the resolved path
|
|
192
|
+
makes it a one-line fix.
|
|
193
|
+
|
|
194
|
+
### `endstation.testing` — a headless suite without the conftest preamble
|
|
195
|
+
|
|
196
|
+
Registered through the `pytest11` entry point, so no conftest has to name it. It
|
|
197
|
+
sets `QT_QPA_PLATFORM=offscreen`, `QT_API`, `PYTEST_QT_API` and `MPLBACKEND` —
|
|
198
|
+
all with `setdefault`, so `QT_QPA_PLATFORM=cocoa pytest tests/test_gui.py` still
|
|
199
|
+
opens a real window. `ENDSTATION_NO_HEADLESS=1` disables it entirely.
|
|
200
|
+
|
|
201
|
+
## Tests
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
.venv/bin/python -m pytest -q
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## The template
|
|
208
|
+
|
|
209
|
+
The pyproject skeleton, the CI workflow, the headless conftest and the ignore
|
|
210
|
+
rules are duplicated across these projects too — but they must not become a
|
|
211
|
+
runtime dependency, so they live in a [copier](https://copier.readthedocs.io)
|
|
212
|
+
template rather than in the package:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pipx run copier copy gh:jamccoy/endstation my-project
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
It asks whether the project has a PySide6 window and whether its tests read data
|
|
219
|
+
held outside the checkout, and renders accordingly — a library project gets no
|
|
220
|
+
`gui/` directory, and no apt step installing Qt system libraries into a CI run
|
|
221
|
+
that will never open a window.
|
|
222
|
+
|
|
223
|
+
What it carries that is easy to get wrong:
|
|
224
|
+
|
|
225
|
+
- The CI step that **constructs a `QApplication`** rather than merely importing
|
|
226
|
+
PySide6. The import succeeds long before the platform plugin is asked to load,
|
|
227
|
+
which is where a missing system library actually bites — and it fails next to
|
|
228
|
+
the apt step that would be at fault.
|
|
229
|
+
- The check that data-dependent tests **skipped rather than failed**, and the
|
|
230
|
+
floor on how many tests must pass. A suite that quietly shrinks goes green
|
|
231
|
+
while testing almost nothing.
|
|
232
|
+
- No `qt_api` in `[tool.pytest.ini_options]`. It is a pytest-qt option, so
|
|
233
|
+
declaring it makes the suite unrunnable without the toolkit — the plugin
|
|
234
|
+
exports `PYTEST_QT_API` instead.
|
|
235
|
+
- Local working notes ignored from the first commit, as a category, so a
|
|
236
|
+
scratch file never lands in a published repository by accident.
|
|
237
|
+
|
|
238
|
+
`tests/test_template.py` renders both variants and inspects the output, so a
|
|
239
|
+
Jinja typo or a `${{ }}` the renderer ate fails here rather than the next time
|
|
240
|
+
someone starts a project.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# endstation
|
|
2
|
+
|
|
3
|
+
Shared scaffolding for desktop scientific applications.
|
|
4
|
+
|
|
5
|
+
Not a science library. This is the apparatus that surrounds an analysis:
|
|
6
|
+
guarding optional dependencies, reporting and cancelling slow work, collecting
|
|
7
|
+
form errors, locating research data that is deliberately not in the checkout,
|
|
8
|
+
running work off the UI thread, embedding a figure, rendering a documentation
|
|
9
|
+
page, and building a macOS bundle.
|
|
10
|
+
|
|
11
|
+
The name is the point. At a synchrotron the endstation is the shared apparatus
|
|
12
|
+
every experiment bolts onto — goniometer, detector, motion control — distinct
|
|
13
|
+
from the science each experiment does.
|
|
14
|
+
|
|
15
|
+
## Why it exists
|
|
16
|
+
|
|
17
|
+
Four projects were four independent builds of the same artifact: a Python
|
|
18
|
+
science package with an optional PySide6/matplotlib front-end, a hand-rolled
|
|
19
|
+
macOS `.app`, private research data held outside the checkout, and a test suite
|
|
20
|
+
that has to stay runnable headless. Every one of those concerns had been solved
|
|
21
|
+
three or four times, and the copies had already drifted:
|
|
22
|
+
|
|
23
|
+
- One project derives its layout floor from platform metrics after the
|
|
24
|
+
hardcoded-width bug shipped twice unnoticed; two others still hardcode a
|
|
25
|
+
pixel count.
|
|
26
|
+
- One worker fixed a slot-ordering bug and a garbage-collection crash during
|
|
27
|
+
teardown; two others have neither fix.
|
|
28
|
+
- The test that keeps a toolkit out of the pure layer exists three times, and
|
|
29
|
+
the strongest version — a subprocess import, which catches *transitive*
|
|
30
|
+
imports — is in only one of them.
|
|
31
|
+
|
|
32
|
+
Each of those is a fix that landed in one place and not the others. That is the
|
|
33
|
+
problem this package exists to stop.
|
|
34
|
+
|
|
35
|
+
## The one rule
|
|
36
|
+
|
|
37
|
+
**The core has no dependencies.**
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
dependencies = []
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`eblfrac` declares exactly that, so its fracture engine and GPF writer install
|
|
44
|
+
anywhere. A shared package that quietly pulled numpy in would take that away at
|
|
45
|
+
install time, from every downstream user, with no error to notice.
|
|
46
|
+
|
|
47
|
+
So `endstation`, `endstation.optional`, `endstation.progress`,
|
|
48
|
+
`endstation.forms` and `endstation.paths` import nothing third-party at all, and
|
|
49
|
+
`tests/test_core_has_no_dependencies.py` fails if that ever stops being true.
|
|
50
|
+
Everything heavier lives behind an extra:
|
|
51
|
+
|
|
52
|
+
| Subpackage | Extra | Needs |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `endstation.qt` | `qt` | PySide6, matplotlib |
|
|
55
|
+
| `endstation.docs` | `docs` | matplotlib, for math only |
|
|
56
|
+
| `endstation.testing` | `testing` | pytest |
|
|
57
|
+
| `endstation.tools` | — | macOS `sips`/`iconutil` at runtime |
|
|
58
|
+
|
|
59
|
+
`requires-python = ">=3.10"`, because `eblfrac` declares 3.10. A shared package
|
|
60
|
+
must not raise its consumers' floor.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
From PyPI, from 0.2.0 on:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "endstation[qt]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or straight from git, pinning a tag for anything that matters:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install "endstation @ git+https://github.com/jamccoy/endstation.git@v0.2.0"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For work on endstation itself:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e '.[dev]'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Publishing
|
|
83
|
+
|
|
84
|
+
`.github/workflows/publish.yml` releases to PyPI when a `v*` tag is pushed,
|
|
85
|
+
using Trusted Publishing — there is no API token anywhere, so there is nothing
|
|
86
|
+
to leak or rotate. It needs a one-time *pending publisher* registered at
|
|
87
|
+
<https://pypi.org/manage/account/publishing/> for `jamccoy/endstation`,
|
|
88
|
+
workflow `publish.yml`, environment `pypi`.
|
|
89
|
+
|
|
90
|
+
## What is in it
|
|
91
|
+
|
|
92
|
+
### `endstation.optional` — dependencies that explain themselves
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from endstation import require_extra, optional_module
|
|
96
|
+
|
|
97
|
+
require_extra("PySide6", "gui", still_works="The analysis works without it.")
|
|
98
|
+
|
|
99
|
+
astropy = optional_module("astropy", "trapezoid", package="als_eff_meas")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`require_extra` is a check that uses `find_spec`, so it costs nothing when the
|
|
103
|
+
dependency is present and is safe to call from a module forbidden to import a
|
|
104
|
+
toolkit. `optional_module` is a lazy proxy, so one missing package breaks only
|
|
105
|
+
the functions that actually need it rather than every function in the module.
|
|
106
|
+
|
|
107
|
+
The install command in the message is quoted, and the quotes are load-bearing:
|
|
108
|
+
square brackets are glob characters, and zsh answers an unquoted
|
|
109
|
+
`pip install .[gui]` with `no matches found`.
|
|
110
|
+
|
|
111
|
+
### `endstation.progress` — cancellation that actually stops the work
|
|
112
|
+
|
|
113
|
+
One callback, `progress(done, total)`. A callback that wants to stop raises
|
|
114
|
+
`Cancelled`, and the loops let it propagate.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from endstation.progress import cancelling, throttled
|
|
118
|
+
|
|
119
|
+
report = cancelling(throttled(on_progress), cancel_event.is_set)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A Python thread cannot be killed and a NumPy-bound call has no check point, so
|
|
123
|
+
the progress callback is the only moment a caller gets to interrupt. Without it
|
|
124
|
+
cancelling stops the *waiting* — the CPU keeps working and the result is
|
|
125
|
+
computed and thrown away.
|
|
126
|
+
|
|
127
|
+
Reporting is throttled; the cancellation check is not. A Cancel click lands at
|
|
128
|
+
the next unit of work, not the next percent.
|
|
129
|
+
|
|
130
|
+
### `endstation.forms` — errors collected, not raised on the first failure
|
|
131
|
+
|
|
132
|
+
A form with three bad fields reports three problems, rather than making the user
|
|
133
|
+
fix them one at a time and rediscover the next on each attempt.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
errors = ErrorCollector()
|
|
137
|
+
if period <= 0:
|
|
138
|
+
errors.add("period", "must be positive")
|
|
139
|
+
if not 0 <= trim < MAX_TRIM:
|
|
140
|
+
errors.add("trim", f"must be between 0 and {MAX_TRIM}")
|
|
141
|
+
errors.raise_if_any()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `endstation.paths` — data that is deliberately not in the checkout
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
scans = find_data("GRATINGLAB_AFM_DIR", "~/Documents/afm_scans",
|
|
148
|
+
hint="docs/DATA.md")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A missing root is reported by name. `FileNotFoundError: [Errno 2]` on a path the
|
|
152
|
+
user never typed is unactionable; naming the variable and the resolved path
|
|
153
|
+
makes it a one-line fix.
|
|
154
|
+
|
|
155
|
+
### `endstation.testing` — a headless suite without the conftest preamble
|
|
156
|
+
|
|
157
|
+
Registered through the `pytest11` entry point, so no conftest has to name it. It
|
|
158
|
+
sets `QT_QPA_PLATFORM=offscreen`, `QT_API`, `PYTEST_QT_API` and `MPLBACKEND` —
|
|
159
|
+
all with `setdefault`, so `QT_QPA_PLATFORM=cocoa pytest tests/test_gui.py` still
|
|
160
|
+
opens a real window. `ENDSTATION_NO_HEADLESS=1` disables it entirely.
|
|
161
|
+
|
|
162
|
+
## Tests
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
.venv/bin/python -m pytest -q
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## The template
|
|
169
|
+
|
|
170
|
+
The pyproject skeleton, the CI workflow, the headless conftest and the ignore
|
|
171
|
+
rules are duplicated across these projects too — but they must not become a
|
|
172
|
+
runtime dependency, so they live in a [copier](https://copier.readthedocs.io)
|
|
173
|
+
template rather than in the package:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pipx run copier copy gh:jamccoy/endstation my-project
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It asks whether the project has a PySide6 window and whether its tests read data
|
|
180
|
+
held outside the checkout, and renders accordingly — a library project gets no
|
|
181
|
+
`gui/` directory, and no apt step installing Qt system libraries into a CI run
|
|
182
|
+
that will never open a window.
|
|
183
|
+
|
|
184
|
+
What it carries that is easy to get wrong:
|
|
185
|
+
|
|
186
|
+
- The CI step that **constructs a `QApplication`** rather than merely importing
|
|
187
|
+
PySide6. The import succeeds long before the platform plugin is asked to load,
|
|
188
|
+
which is where a missing system library actually bites — and it fails next to
|
|
189
|
+
the apt step that would be at fault.
|
|
190
|
+
- The check that data-dependent tests **skipped rather than failed**, and the
|
|
191
|
+
floor on how many tests must pass. A suite that quietly shrinks goes green
|
|
192
|
+
while testing almost nothing.
|
|
193
|
+
- No `qt_api` in `[tool.pytest.ini_options]`. It is a pytest-qt option, so
|
|
194
|
+
declaring it makes the suite unrunnable without the toolkit — the plugin
|
|
195
|
+
exports `PYTEST_QT_API` instead.
|
|
196
|
+
- Local working notes ignored from the first commit, as a category, so a
|
|
197
|
+
scratch file never lands in a published repository by accident.
|
|
198
|
+
|
|
199
|
+
`tests/test_template.py` renders both variants and inspects the output, so a
|
|
200
|
+
Jinja typo or a `${{ }}` the renderer ate fails here rather than the next time
|
|
201
|
+
someone starts a project.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "endstation"
|
|
7
|
+
# Read from `endstation.__version__` rather than restated here. Two literals
|
|
8
|
+
# drift, and the one that drifts is always the one nobody looks at.
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "Shared scaffolding for desktop scientific applications: optional-dependency guards, cancellable workers, Qt/matplotlib panes, and macOS app bundling"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
# 3.10 rather than 3.12: `eblfrac` declares >=3.10 and `gratinglab` >=3.11.
|
|
13
|
+
# A shared package cannot raise the floor of the projects it serves.
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
license = { text = "BSD-3-Clause" }
|
|
16
|
+
authors = [{ name = "Jake McCoy" }]
|
|
17
|
+
keywords = ["gui", "pyside6", "matplotlib", "macos", "scientific", "packaging"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: OSI Approved :: BSD License",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Scientific/Engineering",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Deliberately empty, and the one line in this file worth defending.
|
|
29
|
+
#
|
|
30
|
+
# `eblfrac` declares `dependencies = []` on purpose -- its fracture engine and
|
|
31
|
+
# GPF writer install anywhere, including on a machine with no scientific stack
|
|
32
|
+
# at all. A shared package that forced numpy on it would take that away, and it
|
|
33
|
+
# would take it away silently, at install time, from every downstream user.
|
|
34
|
+
# Everything here that needs numpy, matplotlib or a toolkit lives behind an
|
|
35
|
+
# extra and is imported inside the function that needs it.
|
|
36
|
+
dependencies = []
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# PySide6-Essentials rather than the PySide6 meta-package: the Addons
|
|
40
|
+
# (WebEngine, Multimedia, 3D) are hundreds of megabytes no window served by
|
|
41
|
+
# this package touches. Essentials still carries QtSvg, which matplotlib's Qt
|
|
42
|
+
# shim imports unconditionally.
|
|
43
|
+
#
|
|
44
|
+
# PySide6 (LGPLv3) rather than PyQt5/PyQt6 (GPL). This package is BSD-3, and an
|
|
45
|
+
# optional, dynamically-linked LGPL dependency keeps it that way -- the same
|
|
46
|
+
# reasoning every project downstream of it already applies to its own GUI extra.
|
|
47
|
+
#
|
|
48
|
+
# matplotlib belongs here, not only in `mpl`. `endstation.qt.canvas` embeds a
|
|
49
|
+
# figure and `use_qt_backend` pins the backend, so a `qt` install without it
|
|
50
|
+
# gives a FigurePane that raises on construction -- and raises with a bare
|
|
51
|
+
# ModuleNotFoundError, which is the exact failure this package exists to
|
|
52
|
+
# replace. Declaring it is the fix; `require_extra` in use_qt_backend is the
|
|
53
|
+
# backstop for anyone who installs the pieces by hand.
|
|
54
|
+
qt = ["PySide6-Essentials>=6.7", "matplotlib>=3.8"]
|
|
55
|
+
mpl = ["matplotlib>=3.8"]
|
|
56
|
+
# Rendering documentation needs matplotlib for the equations and nothing else:
|
|
57
|
+
# the markdown subset Qt actually renders is small enough that a full CommonMark
|
|
58
|
+
# implementation would mostly be output this cannot use, and the conversion has
|
|
59
|
+
# to interleave with math extraction anyway.
|
|
60
|
+
docs = ["matplotlib>=3.8"]
|
|
61
|
+
testing = ["pytest>=7"]
|
|
62
|
+
dev = [
|
|
63
|
+
"pytest>=7",
|
|
64
|
+
"pytest-qt>=4.5",
|
|
65
|
+
"PySide6-Essentials>=6.7",
|
|
66
|
+
"matplotlib>=3.8",
|
|
67
|
+
"ruff>=0.16",
|
|
68
|
+
"copier>=9",
|
|
69
|
+
"pytest-cov>=4",
|
|
70
|
+
# Named rather than inherited. `tests/test_packaging.py` reads the CI
|
|
71
|
+
# workflow as YAML, and PyYAML reaches this environment only as a transitive
|
|
72
|
+
# dependency of copier -- so the day copier stops needing it, a packaging
|
|
73
|
+
# test errors on import instead of skipping, and it does so in a file that
|
|
74
|
+
# has nothing to do with templates.
|
|
75
|
+
"PyYAML>=6",
|
|
76
|
+
# tomllib is 3.11+; this package supports 3.10 to match eblfrac's floor, and
|
|
77
|
+
# the packaging tests read pyproject on every version. A backport is cheaper
|
|
78
|
+
# than a skipif per test file -- the second of which is how the same gap
|
|
79
|
+
# reached CI twice.
|
|
80
|
+
"tomli>=2; python_version < '3.11'",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[project.scripts]
|
|
84
|
+
endstation-icon = "endstation.tools.make_icon:main"
|
|
85
|
+
endstation-app = "endstation.tools.make_app:main"
|
|
86
|
+
endstation-publish-wiki = "endstation.tools.publish_wiki:main"
|
|
87
|
+
|
|
88
|
+
[project.urls]
|
|
89
|
+
Homepage = "https://github.com/jamccoy/endstation"
|
|
90
|
+
|
|
91
|
+
# The pytest plugin is registered here rather than requiring every downstream
|
|
92
|
+
# conftest to name it. `endstation.testing.plugin` is import-safe without
|
|
93
|
+
# pytest installed at the module level only because pytest is what loads it.
|
|
94
|
+
[project.entry-points.pytest11]
|
|
95
|
+
endstation = "endstation.testing.plugin"
|
|
96
|
+
|
|
97
|
+
[tool.setuptools.dynamic]
|
|
98
|
+
version = { attr = "endstation.__version__" }
|
|
99
|
+
|
|
100
|
+
[tool.setuptools.packages.find]
|
|
101
|
+
where = ["src"]
|
|
102
|
+
|
|
103
|
+
# PEP 561. Every module here is annotated, and without this marker a consumer's
|
|
104
|
+
# type checker silently ignores all of it -- the annotations exist and do
|
|
105
|
+
# nothing, which is worse than not having them.
|
|
106
|
+
[tool.setuptools.package-data]
|
|
107
|
+
endstation = ["py.typed"]
|
|
108
|
+
|
|
109
|
+
[tool.pytest.ini_options]
|
|
110
|
+
testpaths = ["tests"]
|
|
111
|
+
filterwarnings = ["error"]
|
|
112
|
+
# No `qt_api` here on purpose: it is a pytest-qt option, and this suite has
|
|
113
|
+
# to run in an environment that has never installed a toolkit. The plugin
|
|
114
|
+
# exports PYTEST_QT_API instead, which pytest-qt honours and which costs
|
|
115
|
+
# nothing when pytest-qt is absent.
|
|
116
|
+
|
|
117
|
+
# Coverage is what the CI "did the suite shrink" step was reaching for. That
|
|
118
|
+
# step counted passing tests, which answers a different question badly: a floor
|
|
119
|
+
# of 130 against a suite of 186 tolerated a 30% collapse, and the number needed
|
|
120
|
+
# raising by hand every time the suite grew -- so it only ever rotted downward.
|
|
121
|
+
#
|
|
122
|
+
# **Measure with `coverage run -m pytest`, never `pytest --cov`.** This package
|
|
123
|
+
# registers a `pytest11` entry point, and pytest imports entry-point plugins
|
|
124
|
+
# before it imports its own conftest -- which is the whole reason
|
|
125
|
+
# `testing.plugin` is a plugin rather than a fixture. `endstation.testing.plugin`
|
|
126
|
+
# imports `..paths`, which imports the package, which imports the entire
|
|
127
|
+
# dependency-free core. Under `--cov` that all happens before coverage starts
|
|
128
|
+
# recording, so `forms.py` reports 31% when it is 88% covered and `plugin.py`
|
|
129
|
+
# reports 0%. The difference is 8 points of total coverage that do not exist.
|
|
130
|
+
# `coverage run` starts first and sees the imports.
|
|
131
|
+
[tool.coverage.run]
|
|
132
|
+
source = ["endstation"]
|
|
133
|
+
branch = true
|
|
134
|
+
|
|
135
|
+
[tool.coverage.report]
|
|
136
|
+
# A ratchet, not a target: it sits just under what the suite currently reports,
|
|
137
|
+
# so a regression fails and an improvement is free. Raise it when a phase lands,
|
|
138
|
+
# never lower it to make a red run green.
|
|
139
|
+
fail_under = 78
|
|
140
|
+
show_missing = true
|
|
141
|
+
exclude_also = [
|
|
142
|
+
"if TYPE_CHECKING:",
|
|
143
|
+
"raise NotImplementedError",
|
|
144
|
+
"\\.\\.\\.",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
[tool.ruff]
|
|
148
|
+
target-version = "py310"
|
|
149
|
+
|
|
150
|
+
[tool.ruff.lint]
|
|
151
|
+
# Ruff's own zero-config default before 0.x expanded it. Pyflakes (F) and the
|
|
152
|
+
# handful of pycodestyle error codes that are real bugs rather than style
|
|
153
|
+
# opinions -- undefined names, syntax errors, bare except -- not the ~900-rule
|
|
154
|
+
# everything selection ruff now applies with no config at all.
|
|
155
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared scaffolding for desktop scientific applications.
|
|
2
|
+
|
|
3
|
+
Not a science library. Everything here is the apparatus that surrounds an
|
|
4
|
+
analysis: guarding optional dependencies, reporting and cancelling slow work,
|
|
5
|
+
collecting form errors, locating research data that is deliberately not in the
|
|
6
|
+
checkout, running work off the UI thread, embedding a figure, rendering a
|
|
7
|
+
documentation page, and building a macOS bundle.
|
|
8
|
+
|
|
9
|
+
**The core imports nothing.** ``endstation``, ``endstation.optional``,
|
|
10
|
+
``endstation.progress``, ``endstation.forms`` and ``endstation.paths`` have no
|
|
11
|
+
third-party dependencies at all, so a package that declares
|
|
12
|
+
``dependencies = []`` can adopt them without giving that up. numpy, matplotlib
|
|
13
|
+
and a Qt binding live behind extras and are imported inside the functions that
|
|
14
|
+
need them:
|
|
15
|
+
|
|
16
|
+
======================== =========== ====================================
|
|
17
|
+
Subpackage Extra Needs
|
|
18
|
+
======================== =========== ====================================
|
|
19
|
+
``endstation.qt`` ``qt`` PySide6, matplotlib
|
|
20
|
+
``endstation.docs`` ``docs`` matplotlib, for math only
|
|
21
|
+
``endstation.testing`` ``testing`` pytest
|
|
22
|
+
``endstation.tools`` -- macOS ``sips``/``iconutil`` at runtime
|
|
23
|
+
======================== =========== ====================================
|
|
24
|
+
|
|
25
|
+
Importing this module must therefore stay cheap and toolkit-free; the names
|
|
26
|
+
re-exported below are from the dependency-free core only. Reach into the
|
|
27
|
+
subpackages explicitly for anything else.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from .forms import ErrorCollector, FieldError, FormErrors
|
|
33
|
+
from .optional import MissingExtra, optional_module, require_extra
|
|
34
|
+
from .paths import MissingData, data_root, find_data, find_project_root, require_dir
|
|
35
|
+
from .progress import Cancelled, cancelling, report, throttled
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
# optional dependencies
|
|
42
|
+
"MissingExtra",
|
|
43
|
+
"optional_module",
|
|
44
|
+
"require_extra",
|
|
45
|
+
# progress and cancellation
|
|
46
|
+
"Cancelled",
|
|
47
|
+
"cancelling",
|
|
48
|
+
"report",
|
|
49
|
+
"throttled",
|
|
50
|
+
# form validation
|
|
51
|
+
"ErrorCollector",
|
|
52
|
+
"FieldError",
|
|
53
|
+
"FormErrors",
|
|
54
|
+
# paths
|
|
55
|
+
"MissingData",
|
|
56
|
+
"data_root",
|
|
57
|
+
"find_data",
|
|
58
|
+
"find_project_root",
|
|
59
|
+
"require_dir",
|
|
60
|
+
]
|