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.
Files changed (47) hide show
  1. endstation-0.2.0/LICENSE +28 -0
  2. endstation-0.2.0/PKG-INFO +240 -0
  3. endstation-0.2.0/README.md +201 -0
  4. endstation-0.2.0/pyproject.toml +155 -0
  5. endstation-0.2.0/setup.cfg +4 -0
  6. endstation-0.2.0/src/endstation/__init__.py +60 -0
  7. endstation-0.2.0/src/endstation/docs/__init__.py +34 -0
  8. endstation-0.2.0/src/endstation/docs/index.py +138 -0
  9. endstation-0.2.0/src/endstation/docs/markdown.py +289 -0
  10. endstation-0.2.0/src/endstation/docs/mathtext.py +173 -0
  11. endstation-0.2.0/src/endstation/forms.py +126 -0
  12. endstation-0.2.0/src/endstation/optional.py +218 -0
  13. endstation-0.2.0/src/endstation/paths.py +116 -0
  14. endstation-0.2.0/src/endstation/progress.py +131 -0
  15. endstation-0.2.0/src/endstation/py.typed +0 -0
  16. endstation-0.2.0/src/endstation/qt/__init__.py +121 -0
  17. endstation-0.2.0/src/endstation/qt/canvas.py +173 -0
  18. endstation-0.2.0/src/endstation/qt/jobs.py +230 -0
  19. endstation-0.2.0/src/endstation/qt/liveness.py +48 -0
  20. endstation-0.2.0/src/endstation/qt/sizing.py +38 -0
  21. endstation-0.2.0/src/endstation/qt/tokens.py +76 -0
  22. endstation-0.2.0/src/endstation/testing/__init__.py +0 -0
  23. endstation-0.2.0/src/endstation/testing/boundary.py +136 -0
  24. endstation-0.2.0/src/endstation/testing/plugin.py +116 -0
  25. endstation-0.2.0/src/endstation/tools/__init__.py +33 -0
  26. endstation-0.2.0/src/endstation/tools/make_app.py +252 -0
  27. endstation-0.2.0/src/endstation/tools/make_icon.py +142 -0
  28. endstation-0.2.0/src/endstation/tools/publish_wiki.py +121 -0
  29. endstation-0.2.0/src/endstation.egg-info/PKG-INFO +240 -0
  30. endstation-0.2.0/src/endstation.egg-info/SOURCES.txt +45 -0
  31. endstation-0.2.0/src/endstation.egg-info/dependency_links.txt +1 -0
  32. endstation-0.2.0/src/endstation.egg-info/entry_points.txt +7 -0
  33. endstation-0.2.0/src/endstation.egg-info/requires.txt +26 -0
  34. endstation-0.2.0/src/endstation.egg-info/top_level.txt +1 -0
  35. endstation-0.2.0/tests/test_boundary_helper.py +103 -0
  36. endstation-0.2.0/tests/test_core_has_no_dependencies.py +77 -0
  37. endstation-0.2.0/tests/test_docs.py +210 -0
  38. endstation-0.2.0/tests/test_forms.py +65 -0
  39. endstation-0.2.0/tests/test_optional.py +120 -0
  40. endstation-0.2.0/tests/test_packaging.py +216 -0
  41. endstation-0.2.0/tests/test_paths.py +92 -0
  42. endstation-0.2.0/tests/test_progress.py +89 -0
  43. endstation-0.2.0/tests/test_qt_jobs.py +141 -0
  44. endstation-0.2.0/tests/test_qt_tokens.py +45 -0
  45. endstation-0.2.0/tests/test_qt_widgets.py +203 -0
  46. endstation-0.2.0/tests/test_template.py +206 -0
  47. endstation-0.2.0/tests/test_tools.py +233 -0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]