mfgparams 2.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mfgparams/__init__.py +72 -0
- mfgparams/__main__.py +386 -0
- mfgparams/config.py +112 -0
- mfgparams/console/__init__.py +19 -0
- mfgparams/console/__main__.py +22 -0
- mfgparams/console/cli.py +97 -0
- mfgparams/console/i18n.py +136 -0
- mfgparams/console/locales/__init__.py +26 -0
- mfgparams/console/locales/en.py +203 -0
- mfgparams/console/tui/__init__.py +9 -0
- mfgparams/console/tui/app.py +1540 -0
- mfgparams/console/tui/forms.py +245 -0
- mfgparams/console/tui/machining_menu.py +102 -0
- mfgparams/console/tui/material_picker.py +330 -0
- mfgparams/console/tui/menu.py +110 -0
- mfgparams/console/tui/screens/__init__.py +3 -0
- mfgparams/console/tui/screens/about.py +24 -0
- mfgparams/console/tui/screens/configuration.py +118 -0
- mfgparams/console/tui/screens/drilling.py +298 -0
- mfgparams/console/tui/screens/help.py +19 -0
- mfgparams/console/tui/screens/milling.py +468 -0
- mfgparams/console/tui/screens/split_pane.py +476 -0
- mfgparams/console/tui/screens/turning.py +340 -0
- mfgparams/console/tui/terminal_capability.py +65 -0
- mfgparams/data/__init__.py +8 -0
- mfgparams/data/materials.toml +175 -0
- mfgparams/i18n.py +151 -0
- mfgparams/locales/__init__.py +14 -0
- mfgparams/locales/en.py +161 -0
- mfgparams/logging_setup.py +34 -0
- mfgparams/models.py +283 -0
- mfgparams/processes/__init__.py +21 -0
- mfgparams/processes/machining/__init__.py +20 -0
- mfgparams/processes/machining/drilling/__init__.py +524 -0
- mfgparams/processes/machining/drilling/data/__init__.py +8 -0
- mfgparams/processes/machining/drilling/data/tools.toml +28 -0
- mfgparams/processes/machining/drilling/formulas.py +175 -0
- mfgparams/processes/machining/drilling/tools.py +152 -0
- mfgparams/processes/machining/milling/__init__.py +13 -0
- mfgparams/processes/machining/milling/_calculate.py +638 -0
- mfgparams/processes/machining/milling/_shared.py +342 -0
- mfgparams/processes/machining/milling/_tool_registry.py +151 -0
- mfgparams/processes/machining/milling/end_milling/__init__.py +183 -0
- mfgparams/processes/machining/milling/end_milling/data/__init__.py +8 -0
- mfgparams/processes/machining/milling/end_milling/data/tools.toml +40 -0
- mfgparams/processes/machining/milling/end_milling/formulas.py +154 -0
- mfgparams/processes/machining/milling/end_milling/tools.py +71 -0
- mfgparams/processes/machining/milling/face_milling/__init__.py +186 -0
- mfgparams/processes/machining/milling/face_milling/data/__init__.py +8 -0
- mfgparams/processes/machining/milling/face_milling/data/tools.toml +36 -0
- mfgparams/processes/machining/milling/face_milling/formulas.py +156 -0
- mfgparams/processes/machining/milling/face_milling/tools.py +68 -0
- mfgparams/processes/machining/turning/__init__.py +779 -0
- mfgparams/processes/machining/turning/data/__init__.py +1 -0
- mfgparams/processes/machining/turning/data/tools.toml +32 -0
- mfgparams/processes/machining/turning/formulas.py +497 -0
- mfgparams/processes/machining/turning/tools.py +143 -0
- mfgparams/registry.py +473 -0
- mfgparams/registry_config.py +450 -0
- mfgparams/units.py +197 -0
- mfgparams/validation.py +718 -0
- mfgparams-2.5.0.dist-info/METADATA +703 -0
- mfgparams-2.5.0.dist-info/RECORD +67 -0
- mfgparams-2.5.0.dist-info/WHEEL +5 -0
- mfgparams-2.5.0.dist-info/entry_points.txt +2 -0
- mfgparams-2.5.0.dist-info/licenses/LICENSE.md +89 -0
- mfgparams-2.5.0.dist-info/top_level.txt +1 -0
mfgparams/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""mfgparams: metal machining calculations library and interactive CLI.
|
|
2
|
+
|
|
3
|
+
Public surface (contracts/library-api.md)::
|
|
4
|
+
|
|
5
|
+
from mfgparams import (
|
|
6
|
+
calculate,
|
|
7
|
+
list_material_types,
|
|
8
|
+
list_materials,
|
|
9
|
+
list_tools,
|
|
10
|
+
UnitSystem,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Materials are grouped into categories (``"metal"``, ``"wood"``, ...) that
|
|
14
|
+
drive the CLI's two-step type-then-material selection flow; see
|
|
15
|
+
``list_material_types`` and ``list_materials(material_type=...)``
|
|
16
|
+
(specs/008-material-categorization).
|
|
17
|
+
|
|
18
|
+
Exposes drilling calculations (``processes.machining.drilling``), milling
|
|
19
|
+
calculations (``processes.machining.milling.end_milling`` and
|
|
20
|
+
``processes.machining.milling.face_milling``, see
|
|
21
|
+
``specs/009-milling-calculations/contracts/library-api-milling.md``), and turning
|
|
22
|
+
calculations (``processes.machining.turning``, see
|
|
23
|
+
``specs/019-turning-calculations/contracts/library-api-turning.md``). Modules are
|
|
24
|
+
grouped process-first: a process contains its operations, and each operation lives
|
|
25
|
+
in its own ``mfgparams.processes.<process>.<operation>`` package per Constitution
|
|
26
|
+
Principle VI, so adding one never changes another's contract. A future process
|
|
27
|
+
(welding, joining, forming) attaches beside ``machining`` rather than editing it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from mfgparams.models import (
|
|
33
|
+
CalculationMode,
|
|
34
|
+
CalculationResult,
|
|
35
|
+
ErrorInfo,
|
|
36
|
+
MachiningOperation,
|
|
37
|
+
MillingSubOperation,
|
|
38
|
+
UnitSystem,
|
|
39
|
+
)
|
|
40
|
+
from mfgparams.processes.machining.drilling import calculate
|
|
41
|
+
from mfgparams.processes.machining.drilling.tools import list_tools
|
|
42
|
+
from mfgparams.processes.machining.milling.end_milling import (
|
|
43
|
+
calculate_end_milling,
|
|
44
|
+
list_end_mill_tools,
|
|
45
|
+
)
|
|
46
|
+
from mfgparams.processes.machining.milling.face_milling import (
|
|
47
|
+
calculate_face_milling,
|
|
48
|
+
list_face_mill_tools,
|
|
49
|
+
)
|
|
50
|
+
from mfgparams.processes.machining.turning import calculate_turning, list_turning_tools
|
|
51
|
+
from mfgparams.registry import list_material_types, list_materials
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"calculate",
|
|
55
|
+
"calculate_end_milling",
|
|
56
|
+
"calculate_face_milling",
|
|
57
|
+
"calculate_turning",
|
|
58
|
+
"list_end_mill_tools",
|
|
59
|
+
"list_face_mill_tools",
|
|
60
|
+
"list_material_types",
|
|
61
|
+
"list_materials",
|
|
62
|
+
"list_tools",
|
|
63
|
+
"list_turning_tools",
|
|
64
|
+
"UnitSystem",
|
|
65
|
+
"CalculationMode",
|
|
66
|
+
"CalculationResult",
|
|
67
|
+
"ErrorInfo",
|
|
68
|
+
"MachiningOperation",
|
|
69
|
+
"MillingSubOperation",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
__version__ = "2.5.0"
|
mfgparams/__main__.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Entry points for ``mfgparams`` (console script) and ``python -m mfgparams``.
|
|
2
|
+
|
|
3
|
+
This module is the **single exemption** to the rule that the calculation core
|
|
4
|
+
never imports the console (FR-008): ``python -m mfgparams`` requires a
|
|
5
|
+
``__main__.py`` at the package root and the interpreter accepts no other
|
|
6
|
+
location. The exemption is narrowed by keeping the import inside
|
|
7
|
+
:func:`main`, so importing the ``mfgparams`` package itself still never pulls
|
|
8
|
+
in the console. Both halves are enforced by
|
|
9
|
+
``tests/static/test_core_does_not_import_console.py``, not by convention.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
#: Message ID for the FR-011 guard. Its English text lives in the **core**
|
|
22
|
+
#: catalog and must stay there when slice 015 relocates the other catalogs
|
|
23
|
+
#: into the console: a message whose whole purpose is to say the console is
|
|
24
|
+
#: unavailable cannot be looked up from inside the console
|
|
25
|
+
#: (contracts/console-entry-contract.md).
|
|
26
|
+
_MISSING_CONSOLE_MESSAGE_ID = "console.missing_dependency"
|
|
27
|
+
|
|
28
|
+
#: The requirement that installs the console's dependencies. Named once so the
|
|
29
|
+
#: message, the tests and the contract cannot drift apart about it.
|
|
30
|
+
_CONSOLE_EXTRA_REQUIREMENT = "mfgparams[console]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_broken_core(module: str | None) -> bool:
|
|
34
|
+
"""Is a failure to import ``module`` a broken install rather than a missing extra?
|
|
35
|
+
|
|
36
|
+
Two kinds of failure reach the guard below, and telling the user the wrong
|
|
37
|
+
one wastes their time on a fix that cannot work:
|
|
38
|
+
|
|
39
|
+
* a module inside our own distribution -- the install is damaged;
|
|
40
|
+
* a **core runtime requirement**, i.e. something a default
|
|
41
|
+
``pip install mfgparams`` was supposed to bring in (``tomli`` below
|
|
42
|
+
Python 3.11). Its absence is equally a damaged install, and it is *not*
|
|
43
|
+
a third-party name, so a check that only looked for the ``mfgparams``
|
|
44
|
+
prefix would misreport it as a missing console extra and send the user
|
|
45
|
+
to install something they already have.
|
|
46
|
+
|
|
47
|
+
Anything else is a genuine console dependency, so the friendly path applies.
|
|
48
|
+
|
|
49
|
+
The console extra wins a tie. A requirement can be declared *both* without
|
|
50
|
+
an extra and under ``console`` -- a core dependency carrying an environment
|
|
51
|
+
marker that does not apply to the running interpreter, say, re-declared for
|
|
52
|
+
the console. Ranking core first there would re-raise for a module
|
|
53
|
+
``pip install mfgparams[console]`` would genuinely have supplied, which is
|
|
54
|
+
the one case where the friendly message is not just kind but correct. The
|
|
55
|
+
question this function really answers is *"is this something the console
|
|
56
|
+
extra cannot fix?"*, and that ordering is what keeps the answer honest.
|
|
57
|
+
|
|
58
|
+
Both lookups compare an **import** name against **distribution** names, and
|
|
59
|
+
those differ for a large class of packages (``PyYAML``/``yaml``). What that
|
|
60
|
+
costs depends on which caller is asking, so the two are worth separating:
|
|
61
|
+
|
|
62
|
+
* At **import time** it is free. The two lookups miss *together* -- a name
|
|
63
|
+
matching neither set falls through to the friendly path, which is already
|
|
64
|
+
the right answer there.
|
|
65
|
+
* At **execution time** it is a real, bounded gap. That caller's default is
|
|
66
|
+
to re-raise, so this function returning ``False`` is what *permits* the
|
|
67
|
+
friendly message; a core requirement whose import name differs from its
|
|
68
|
+
distribution name (a core ``PyYAML``, imported lazily by the console and
|
|
69
|
+
missing) would be answered with ``pip install mfgparams[console]``, which
|
|
70
|
+
cannot fix it.
|
|
71
|
+
|
|
72
|
+
The gap is not closable from here: resolving a distribution name to the
|
|
73
|
+
import name it provides needs that distribution's own metadata, which is by
|
|
74
|
+
definition absent at the moment its import fails. What bounds it instead is
|
|
75
|
+
how few core requirements there are -- ``tomli`` today, whose two names
|
|
76
|
+
match. A core requirement whose names diverge is the case to think twice
|
|
77
|
+
about, and this paragraph is the warning that it is not covered.
|
|
78
|
+
"""
|
|
79
|
+
if not module:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
root = module.split(".")[0]
|
|
83
|
+
if root == "mfgparams":
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if _normalise(root) in _console_extra_roots():
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
return _normalise(root) in _core_requirement_roots()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalise(name: str) -> str:
|
|
93
|
+
return name.replace("-", "_").lower()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: The extra a requirement is gated by, as written in the ``;`` marker. Quotes
|
|
97
|
+
#: may be single or double and the spacing around ``==`` is not guaranteed, so
|
|
98
|
+
#: the name is *parsed* rather than matched as a fixed substring: a marker this
|
|
99
|
+
#: failed to recognise would put an extra's dependency into the core set, where
|
|
100
|
+
#: :func:`_is_broken_core` calls it a damaged install and re-raises the very
|
|
101
|
+
#: traceback FR-011 exists to prevent.
|
|
102
|
+
_EXTRA_MARKER = re.compile(r"""extra\s*==\s*['"]([^'"]+)['"]""")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _declared_requirements() -> list[tuple[str, str | None]]:
|
|
106
|
+
"""``(root_name, gating_extra)`` for every requirement in installed metadata.
|
|
107
|
+
|
|
108
|
+
``gating_extra`` is ``None`` for a requirement a default install pulls in.
|
|
109
|
+
Read from metadata rather than restated here, so no set derived from it can
|
|
110
|
+
drift from ``pyproject.toml``.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
from importlib.metadata import requires
|
|
114
|
+
|
|
115
|
+
declared = requires("mfgparams") or []
|
|
116
|
+
except Exception: # pragma: no cover - metadata missing (a source-tree run)
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
parsed = []
|
|
120
|
+
for requirement in declared:
|
|
121
|
+
head, _, marker = requirement.partition(";")
|
|
122
|
+
name = re.split(r"[^A-Za-z0-9._-]", head.strip(), maxsplit=1)[0]
|
|
123
|
+
if name:
|
|
124
|
+
found = _EXTRA_MARKER.search(marker)
|
|
125
|
+
parsed.append((_normalise(name), found.group(1) if found else None))
|
|
126
|
+
return parsed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _core_requirement_roots() -> frozenset:
|
|
130
|
+
"""Distribution names a default (no-extras) install pulls in.
|
|
131
|
+
|
|
132
|
+
Requirements gated by an extra are excluded: those are exactly the ones the
|
|
133
|
+
guard's friendly path is for.
|
|
134
|
+
"""
|
|
135
|
+
return frozenset(root for root, extra in _declared_requirements() if extra is None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _console_extra_roots() -> frozenset:
|
|
139
|
+
"""Distribution names the ``console`` extra would install.
|
|
140
|
+
|
|
141
|
+
Empty on delivery, because the extra is (``pyproject.toml`` explains why).
|
|
142
|
+
Read from metadata rather than restated, so it starts covering a dependency
|
|
143
|
+
the day one is added with no second place to remember to update.
|
|
144
|
+
|
|
145
|
+
Used only by :func:`_is_broken_core`, to decide whether the extra could
|
|
146
|
+
supply a name that is *also* declared as a core requirement. It is
|
|
147
|
+
deliberately **not** how a run-time failure is attributed to the console --
|
|
148
|
+
see :func:`_requested_by_the_console` for why matching a distribution name
|
|
149
|
+
against an import name cannot work there.
|
|
150
|
+
"""
|
|
151
|
+
return frozenset(root for root, extra in _declared_requirements() if extra == "console")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
#: The console package, as an absolute path. Used to attribute a run-time
|
|
155
|
+
#: import failure to whoever asked for the module -- see
|
|
156
|
+
#: :func:`_requested_by_the_console`.
|
|
157
|
+
_CONSOLE_DIR = Path(__file__).parent / "console"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
#: The standard library's ``importlib`` package directory. Compared as a *path*
|
|
161
|
+
#: rather than by the directory's name: a vendored ``somelib/importlib/`` would
|
|
162
|
+
#: otherwise be mistaken for the import system's own frames.
|
|
163
|
+
_IMPORT_MACHINERY_DIR = os.path.dirname(importlib.__file__)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_import_machinery(filename: str) -> bool:
|
|
167
|
+
"""Is this frame the import system's, rather than someone's code?
|
|
168
|
+
|
|
169
|
+
A failed import contributes two shapes: the frozen bootstrap modules
|
|
170
|
+
(``<frozen importlib._bootstrap>``), and ``importlib/__init__.py`` when the
|
|
171
|
+
import went through :func:`importlib.import_module`.
|
|
172
|
+
"""
|
|
173
|
+
return filename.startswith("<") or os.path.dirname(filename) == _IMPORT_MACHINERY_DIR
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: Frame names the import system itself runs on the importer's behalf: a
|
|
177
|
+
#: module executing its own body, and PEP 562's module-level ``__getattr__``,
|
|
178
|
+
#: which is how a library spells a lazy submodule.
|
|
179
|
+
_IMPORT_SYSTEM_CALLBACKS = frozenset({"<module>", "__getattr__"})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _requested_by_the_console(exc: BaseException) -> bool:
|
|
183
|
+
"""Did the console *import* the module that turned out to be missing?
|
|
184
|
+
|
|
185
|
+
Attribution by *name* cannot work here, which is the whole reason this
|
|
186
|
+
function is about provenance instead. The gating extra records a
|
|
187
|
+
**distribution** name (``PyYAML``) while the exception carries an **import**
|
|
188
|
+
name (``yaml``), and the two differ for a large class of packages --
|
|
189
|
+
``Pillow``/``PIL``, ``beautifulsoup4``/``bs4``, ``python-dateutil``/
|
|
190
|
+
``dateutil``. Mapping between them needs the distribution's own metadata,
|
|
191
|
+
which is precisely what is *not* installed at the moment the import fails.
|
|
192
|
+
A name comparison would therefore miss every such package, and because this
|
|
193
|
+
guard's default is to re-raise, every miss fails in the direction FR-011
|
|
194
|
+
forbids.
|
|
195
|
+
|
|
196
|
+
So the question is asked of the *frames*, in two steps:
|
|
197
|
+
|
|
198
|
+
1. Find the innermost frame belonging to the console. No such frame means
|
|
199
|
+
the console had no part in this, and it re-raises.
|
|
200
|
+
2. Ask what the import system ran **directly below** it. Exactly three
|
|
201
|
+
things appear there when the console's own statement asks for a name:
|
|
202
|
+
the import **machinery**, a **module body**, or a module-level
|
|
203
|
+
**``__getattr__``** (PEP 562, how a library spells a lazy submodule).
|
|
204
|
+
Anything else is an ordinary call frame -- the console called a
|
|
205
|
+
function, and the failure is that function's own lazy import.
|
|
206
|
+
|
|
207
|
+
Step 2 is the whole discrimination, and it has been wrong twice by
|
|
208
|
+
inspecting the wrong part of the traceback. Both directions matter:
|
|
209
|
+
|
|
210
|
+
===================================== ======================================
|
|
211
|
+
Rule Shape it misreads
|
|
212
|
+
===================================== ======================================
|
|
213
|
+
deepest frame that is not machinery ``rich`` installed with its own
|
|
214
|
+
``pygments`` missing: the deepest code
|
|
215
|
+
frame is inside ``rich``, so it blames
|
|
216
|
+
``rich`` and re-raises.
|
|
217
|
+
...and not a module body a library resolving its imports in a
|
|
218
|
+
helper (``def _setup(): import
|
|
219
|
+
pygments`` called at module scope, as
|
|
220
|
+
``matplotlib`` does): a *call* frame
|
|
221
|
+
sits at the bottom of what is still
|
|
222
|
+
the console's import.
|
|
223
|
+
any import evidence anywhere below over-corrects: a library the console
|
|
224
|
+
*called* that does its own
|
|
225
|
+
``importlib.import_module`` leaves a
|
|
226
|
+
machinery frame down there, and its
|
|
227
|
+
bug becomes "install the extra".
|
|
228
|
+
===================================== ======================================
|
|
229
|
+
|
|
230
|
+
The first two shapes are the same failure -- a **half-installed extra**,
|
|
231
|
+
which ``pip install mfgparams[console]`` repairs and FR-011 exists to
|
|
232
|
+
describe. So is PEP 562, and the ``__getattr__`` marker is what catches it:
|
|
233
|
+
``from lib import thing`` puts that call frame directly below the console
|
|
234
|
+
with the cascade beneath *it*.
|
|
235
|
+
|
|
236
|
+
Both other markers are needed too. CPython elides the
|
|
237
|
+
``importlib._bootstrap`` frames from a plain failing ``import``, so a module
|
|
238
|
+
body is often the only evidence an import ran at all, while
|
|
239
|
+
``importlib.import_module`` leaves machinery frames and no module body.
|
|
240
|
+
|
|
241
|
+
What is deliberately *not* answered: a library whose import is simply
|
|
242
|
+
**wrong** -- a typo, or an undeclared dependency imported unconditionally.
|
|
243
|
+
Its traceback is identical in shape to the half-installed one, both being
|
|
244
|
+
"the console imported X; something under X was missing", so no rule reading
|
|
245
|
+
frames can separate them. Decided in favour of FR-011, which is a
|
|
246
|
+
requirement, over a clearer diagnostic for a library bug, which is not.
|
|
247
|
+
|
|
248
|
+
A ``__getattr__`` reached by *attribute access* rather than by an import
|
|
249
|
+
statement (``import lib`` then ``lib.thing``) is indistinguishable here and
|
|
250
|
+
takes the friendly path too. That is the right answer for the PEP 562 case
|
|
251
|
+
it is there for, and a class's ``__getattr__`` doing a failing import is
|
|
252
|
+
rare enough to accept as the cost.
|
|
253
|
+
"""
|
|
254
|
+
frames = []
|
|
255
|
+
traceback = exc.__traceback__
|
|
256
|
+
while traceback is not None:
|
|
257
|
+
code = traceback.tb_frame.f_code
|
|
258
|
+
frames.append((code.co_filename, code.co_name))
|
|
259
|
+
traceback = traceback.tb_next
|
|
260
|
+
|
|
261
|
+
console = _CONSOLE_DIR.resolve()
|
|
262
|
+
innermost = None
|
|
263
|
+
for index, (filename, _) in enumerate(frames):
|
|
264
|
+
if _is_import_machinery(filename):
|
|
265
|
+
continue
|
|
266
|
+
# `Path.is_relative_to` is 3.9+, which is this project's floor.
|
|
267
|
+
if Path(filename).resolve().is_relative_to(console):
|
|
268
|
+
innermost = index
|
|
269
|
+
|
|
270
|
+
if innermost is None:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
below = frames[innermost + 1 :]
|
|
274
|
+
if not below:
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
filename, function = below[0]
|
|
278
|
+
return _is_import_machinery(filename) or function in _IMPORT_SYSTEM_CALLBACKS
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
#: Stands in for the module name when the exception did not carry one (an
|
|
282
|
+
#: import hook may omit it). This is user-facing prose inside a user-facing
|
|
283
|
+
#: sentence, so Principle VIII puts it in the catalog rather than inline here:
|
|
284
|
+
#: spliced in as a literal it would stay English in a translated message.
|
|
285
|
+
_UNNAMED_DEPENDENCY_MESSAGE_ID = "console.missing_dependency.unnamed"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _install_command() -> str:
|
|
289
|
+
"""The command that actually installs the console extra, as the user must type it.
|
|
290
|
+
|
|
291
|
+
FR-011 promises *the exact command that fixes it*, so this is a command
|
|
292
|
+
that has to survive being pasted into a real shell, on a real machine:
|
|
293
|
+
|
|
294
|
+
* the requirement is **quoted**. Bare, ``mfgparams[console]`` is a glob, and
|
|
295
|
+
zsh -- the default shell on macOS -- matches nothing and aborts the whole
|
|
296
|
+
line with ``no matches found`` before pip is invoked. The user is then
|
|
297
|
+
told the fix does not exist, which is worse than the traceback the guard
|
|
298
|
+
replaced.
|
|
299
|
+
* it names **this interpreter** rather than a bare ``pip``. A machine with
|
|
300
|
+
more than one Python is the common case, not the exotic one -- a system
|
|
301
|
+
Python beside a venv, a Debian box where only ``pip3`` exists -- and
|
|
302
|
+
there a bare ``pip`` installs into some other interpreter's
|
|
303
|
+
site-packages, after which the console is still unavailable and this
|
|
304
|
+
same message appears again.
|
|
305
|
+
|
|
306
|
+
Falls back to ``python`` when :data:`sys.executable` is empty, which happens
|
|
307
|
+
in an embedded interpreter.
|
|
308
|
+
"""
|
|
309
|
+
interpreter = shlex.quote(sys.executable) if sys.executable else "python"
|
|
310
|
+
return f'{interpreter} -m pip install "{_CONSOLE_EXTRA_REQUIREMENT}"'
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _report_missing_console(module: str | None) -> int:
|
|
314
|
+
"""Print the FR-011 message for ``module`` and return the exit status."""
|
|
315
|
+
from mfgparams.i18n import get_locale, translate
|
|
316
|
+
|
|
317
|
+
locale = get_locale()
|
|
318
|
+
named = repr(module) if module else translate(locale, _UNNAMED_DEPENDENCY_MESSAGE_ID)
|
|
319
|
+
message = translate(
|
|
320
|
+
locale, _MISSING_CONSOLE_MESSAGE_ID, module=named, command=_install_command()
|
|
321
|
+
)
|
|
322
|
+
print(message, file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> int:
|
|
327
|
+
"""Start the interactive console, or explain how to install it (FR-011).
|
|
328
|
+
|
|
329
|
+
Returns the process exit status: whatever the console returns once it has
|
|
330
|
+
run to completion (``0`` when it returns nothing), or ``1`` if the console's
|
|
331
|
+
dependencies are unavailable.
|
|
332
|
+
|
|
333
|
+
Both the import *and* the run are guarded. #63 explicitly permits the
|
|
334
|
+
console to import a heavy dependency lazily, inside the call rather than at
|
|
335
|
+
module scope -- and such an import fails after this function has already
|
|
336
|
+
committed to running, where an unguarded call would emit exactly the raw
|
|
337
|
+
traceback FR-011 forbids. The two guards ask different questions, because
|
|
338
|
+
what identifies a console dependency differs once the console is running;
|
|
339
|
+
see :func:`_requested_by_the_console`.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
from mfgparams.console.cli import main as _console_main
|
|
344
|
+
except ModuleNotFoundError as exc:
|
|
345
|
+
# A damaged install must surface its own error rather than being
|
|
346
|
+
# misreported as a missing extra -- the user cannot fix a missing core
|
|
347
|
+
# dependency by installing `mfgparams[console]`.
|
|
348
|
+
if _is_broken_core(exc.name):
|
|
349
|
+
raise
|
|
350
|
+
return _report_missing_console(exc.name)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Pass the console's exit status through rather than assuming success.
|
|
354
|
+
# The console returns None today, but swallowing a future non-zero
|
|
355
|
+
# return is a silent failure, and this function documents itself as
|
|
356
|
+
# returning the process exit status.
|
|
357
|
+
status = _console_main()
|
|
358
|
+
except ModuleNotFoundError as exc:
|
|
359
|
+
# Provenance answers "did the console ask for this?", not "can the
|
|
360
|
+
# extra supply it?" -- and inside the console those differ. A missing
|
|
361
|
+
# module of *ours* was requested by the console yet is a damaged
|
|
362
|
+
# install, so it needs the import-time question asked here too;
|
|
363
|
+
# otherwise a lazy `from mfgparams.processes... import x` that failed
|
|
364
|
+
# would be answered with "pip install mfgparams[console]", advice that
|
|
365
|
+
# cannot work (contracts/console-entry-contract.md).
|
|
366
|
+
if _is_broken_core(exc.name) or not _requested_by_the_console(exc):
|
|
367
|
+
raise
|
|
368
|
+
return _report_missing_console(exc.name)
|
|
369
|
+
|
|
370
|
+
if status is None:
|
|
371
|
+
return 0
|
|
372
|
+
if isinstance(status, int):
|
|
373
|
+
return status
|
|
374
|
+
|
|
375
|
+
# `sys.exit("message")` semantics: a non-int status is a message, not a
|
|
376
|
+
# number. `int()` on it raises ValueError *inside the entry point*,
|
|
377
|
+
# replacing whatever the console was trying to say with a traceback.
|
|
378
|
+
# `bool` is an `int` and takes the branch above, so `return False` exits 0
|
|
379
|
+
# and `return True` exits 1 -- the same quirk `sys.exit` has, kept rather
|
|
380
|
+
# than special-cased so the two agree.
|
|
381
|
+
print(status, file=sys.stderr)
|
|
382
|
+
return 1
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
raise SystemExit(main())
|
mfgparams/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared TOML configuration loading (FR-018; research.md #3, #5).
|
|
2
|
+
|
|
3
|
+
Configuration overrides the default validation bounds (max diameter/depth).
|
|
4
|
+
When the file or a given key is absent, built-in defaults are used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try: # Python 3.11+ ships tomllib in the standard library.
|
|
13
|
+
import tomllib
|
|
14
|
+
except ModuleNotFoundError: # Python 3.9 / 3.10 fall back to the tomli backport.
|
|
15
|
+
import tomli as tomllib # type: ignore[no-redef] # tomli is a drop-in tomllib backport; mypy sees this as an invalid redefinition, but it's the intended fallback for Python <3.11
|
|
16
|
+
|
|
17
|
+
DEFAULT_MAX_DIAMETER_MM = 100.0
|
|
18
|
+
DEFAULT_MAX_DEPTH_MM = 500.0
|
|
19
|
+
DEFAULT_MAX_MILL_DIAMETER_MM = 200.0
|
|
20
|
+
DEFAULT_MAX_DEPTH_OF_CUT_MM = 50.0
|
|
21
|
+
DEFAULT_MAX_LENGTH_OF_CUT_MM = 1000.0
|
|
22
|
+
DEFAULT_MAX_TURNING_DIAMETER_MM = 500.0
|
|
23
|
+
DEFAULT_MAX_TURNING_DEPTH_OF_CUT_MM = 10.0
|
|
24
|
+
DEFAULT_MAX_TURNING_LENGTH_OF_CUT_MM = 1000.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Configuration:
|
|
29
|
+
"""Effective validation bounds, in canonical metric units.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
max_diameter_mm: Maximum allowed drill diameter, in mm.
|
|
33
|
+
max_depth_mm: Maximum allowed hole depth, in mm.
|
|
34
|
+
max_mill_diameter_mm: Maximum allowed end-mill/face-mill cutter
|
|
35
|
+
diameter, in mm (specs/009-milling-calculations FR-018).
|
|
36
|
+
max_depth_of_cut_mm: Maximum allowed axial depth of cut and radial
|
|
37
|
+
depth/width of cut, in mm (FR-018).
|
|
38
|
+
max_length_of_cut_mm: Maximum allowed milling length of cut, in mm
|
|
39
|
+
(FR-018).
|
|
40
|
+
max_turning_diameter_mm: Maximum allowed turning workpiece diameter,
|
|
41
|
+
in mm (specs/019-turning-calculations FR-010). A distinct field
|
|
42
|
+
from ``max_diameter_mm``/``max_mill_diameter_mm``, following the
|
|
43
|
+
same per-operation-bound precedent those two already set.
|
|
44
|
+
max_turning_depth_of_cut_mm: Maximum allowed turning depth of cut,
|
|
45
|
+
in mm (FR-010). Deliberately much smaller than milling's
|
|
46
|
+
``max_depth_of_cut_mm`` (50 mm): a single-point turning tool's
|
|
47
|
+
realistic depth of cut per pass is far shallower than a milling
|
|
48
|
+
cutter's, so the two bounds cannot share one field
|
|
49
|
+
(specs/019-turning-calculations research.md #3).
|
|
50
|
+
max_turning_length_of_cut_mm: Maximum allowed turning length of cut,
|
|
51
|
+
in mm (FR-010).
|
|
52
|
+
|
|
53
|
+
The milling and turning bounds are generous sanity limits intended to
|
|
54
|
+
catch typos and unit mistakes, not machining recommendations; they are
|
|
55
|
+
overridable through the same optional TOML file as the drilling bounds
|
|
56
|
+
(specs/009-milling-calculations/research.md #8).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
max_diameter_mm: float = DEFAULT_MAX_DIAMETER_MM
|
|
60
|
+
max_depth_mm: float = DEFAULT_MAX_DEPTH_MM
|
|
61
|
+
max_mill_diameter_mm: float = DEFAULT_MAX_MILL_DIAMETER_MM
|
|
62
|
+
max_depth_of_cut_mm: float = DEFAULT_MAX_DEPTH_OF_CUT_MM
|
|
63
|
+
max_length_of_cut_mm: float = DEFAULT_MAX_LENGTH_OF_CUT_MM
|
|
64
|
+
max_turning_diameter_mm: float = DEFAULT_MAX_TURNING_DIAMETER_MM
|
|
65
|
+
max_turning_depth_of_cut_mm: float = DEFAULT_MAX_TURNING_DEPTH_OF_CUT_MM
|
|
66
|
+
max_turning_length_of_cut_mm: float = DEFAULT_MAX_TURNING_LENGTH_OF_CUT_MM
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_configuration(config_path: str | None = None) -> Configuration:
|
|
70
|
+
"""Load a :class:`Configuration` from an optional TOML file.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config_path: Path to a TOML file with optional ``max_diameter_mm``,
|
|
74
|
+
``max_depth_mm``, ``max_mill_diameter_mm``,
|
|
75
|
+
``max_depth_of_cut_mm``, ``max_length_of_cut_mm``,
|
|
76
|
+
``max_turning_diameter_mm``, ``max_turning_depth_of_cut_mm``,
|
|
77
|
+
and ``max_turning_length_of_cut_mm`` keys — one shared file for
|
|
78
|
+
every operation's bounds, not one file per operation
|
|
79
|
+
(specs/009-milling-calculations FR-018). If ``None`` or the
|
|
80
|
+
file does not exist, built-in defaults are used. If the file
|
|
81
|
+
exists but a key is missing, that key's default is used.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A :class:`Configuration` with the effective bounds.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if config_path is None:
|
|
88
|
+
return Configuration()
|
|
89
|
+
|
|
90
|
+
path = Path(config_path)
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
return Configuration()
|
|
93
|
+
|
|
94
|
+
with path.open("rb") as fh:
|
|
95
|
+
data = tomllib.load(fh)
|
|
96
|
+
|
|
97
|
+
return Configuration(
|
|
98
|
+
max_diameter_mm=float(data.get("max_diameter_mm", DEFAULT_MAX_DIAMETER_MM)),
|
|
99
|
+
max_depth_mm=float(data.get("max_depth_mm", DEFAULT_MAX_DEPTH_MM)),
|
|
100
|
+
max_mill_diameter_mm=float(data.get("max_mill_diameter_mm", DEFAULT_MAX_MILL_DIAMETER_MM)),
|
|
101
|
+
max_depth_of_cut_mm=float(data.get("max_depth_of_cut_mm", DEFAULT_MAX_DEPTH_OF_CUT_MM)),
|
|
102
|
+
max_length_of_cut_mm=float(data.get("max_length_of_cut_mm", DEFAULT_MAX_LENGTH_OF_CUT_MM)),
|
|
103
|
+
max_turning_diameter_mm=float(
|
|
104
|
+
data.get("max_turning_diameter_mm", DEFAULT_MAX_TURNING_DIAMETER_MM)
|
|
105
|
+
),
|
|
106
|
+
max_turning_depth_of_cut_mm=float(
|
|
107
|
+
data.get("max_turning_depth_of_cut_mm", DEFAULT_MAX_TURNING_DEPTH_OF_CUT_MM)
|
|
108
|
+
),
|
|
109
|
+
max_turning_length_of_cut_mm=float(
|
|
110
|
+
data.get("max_turning_length_of_cut_mm", DEFAULT_MAX_TURNING_LENGTH_OF_CUT_MM)
|
|
111
|
+
),
|
|
112
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Interactive console for mfgparams -- presentation only.
|
|
2
|
+
|
|
3
|
+
This sub-package holds the text GUI (:mod:`mfgparams.console.tui`, specs/017
|
|
4
|
+
-console-text-gui): screens, dialogs, and the process/operation menus. It
|
|
5
|
+
owns no calculation logic; everything numeric lives under
|
|
6
|
+
:mod:`mfgparams.processes`.
|
|
7
|
+
|
|
8
|
+
**The calculation core MUST NOT import this package**, at module import time
|
|
9
|
+
or otherwise. The dependency runs one way: the console imports the core, never
|
|
10
|
+
the reverse. Keeping it one-way is what lets ``pip install mfgparams`` carry
|
|
11
|
+
only what the calculations need, with the console's dependencies behind the
|
|
12
|
+
``console`` extra.
|
|
13
|
+
|
|
14
|
+
The single exemption is ``mfgparams/__main__.py``, which the interpreter
|
|
15
|
+
requires at the package root for ``python -m mfgparams``; its import of this
|
|
16
|
+
package is inside the function body, never at module scope. Both halves of the
|
|
17
|
+
rule are enforced by ``tests/static/test_core_does_not_import_console.py``
|
|
18
|
+
rather than by convention (FR-008).
|
|
19
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Entry point for ``python -m mfgparams.console``.
|
|
2
|
+
|
|
3
|
+
Delegates to the package-root :func:`mfgparams.__main__.main` rather than
|
|
4
|
+
calling the console directly, so this form is guarded exactly like the other
|
|
5
|
+
two. FR-011 is unqualified -- *invoking the console* when its dependencies are
|
|
6
|
+
unavailable must produce the actionable message and never a stack trace -- and
|
|
7
|
+
this is a form a user reaches by guessing, not one reserved for maintainers.
|
|
8
|
+
An earlier version called ``console.cli.main`` straight through, on the
|
|
9
|
+
reasoning that a bypass is useful for debugging a broken console install; that
|
|
10
|
+
made one of the three ways to start the console behave differently from the
|
|
11
|
+
other two, with nothing to tell a user which one they had picked.
|
|
12
|
+
|
|
13
|
+
Debugging the raw import error is still one line, and an explicit one:
|
|
14
|
+
``python -c "from mfgparams.console.cli import main; main()"``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from mfgparams.__main__ import main
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
raise SystemExit(main())
|