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.
Files changed (67) hide show
  1. mfgparams/__init__.py +72 -0
  2. mfgparams/__main__.py +386 -0
  3. mfgparams/config.py +112 -0
  4. mfgparams/console/__init__.py +19 -0
  5. mfgparams/console/__main__.py +22 -0
  6. mfgparams/console/cli.py +97 -0
  7. mfgparams/console/i18n.py +136 -0
  8. mfgparams/console/locales/__init__.py +26 -0
  9. mfgparams/console/locales/en.py +203 -0
  10. mfgparams/console/tui/__init__.py +9 -0
  11. mfgparams/console/tui/app.py +1540 -0
  12. mfgparams/console/tui/forms.py +245 -0
  13. mfgparams/console/tui/machining_menu.py +102 -0
  14. mfgparams/console/tui/material_picker.py +330 -0
  15. mfgparams/console/tui/menu.py +110 -0
  16. mfgparams/console/tui/screens/__init__.py +3 -0
  17. mfgparams/console/tui/screens/about.py +24 -0
  18. mfgparams/console/tui/screens/configuration.py +118 -0
  19. mfgparams/console/tui/screens/drilling.py +298 -0
  20. mfgparams/console/tui/screens/help.py +19 -0
  21. mfgparams/console/tui/screens/milling.py +468 -0
  22. mfgparams/console/tui/screens/split_pane.py +476 -0
  23. mfgparams/console/tui/screens/turning.py +340 -0
  24. mfgparams/console/tui/terminal_capability.py +65 -0
  25. mfgparams/data/__init__.py +8 -0
  26. mfgparams/data/materials.toml +175 -0
  27. mfgparams/i18n.py +151 -0
  28. mfgparams/locales/__init__.py +14 -0
  29. mfgparams/locales/en.py +161 -0
  30. mfgparams/logging_setup.py +34 -0
  31. mfgparams/models.py +283 -0
  32. mfgparams/processes/__init__.py +21 -0
  33. mfgparams/processes/machining/__init__.py +20 -0
  34. mfgparams/processes/machining/drilling/__init__.py +524 -0
  35. mfgparams/processes/machining/drilling/data/__init__.py +8 -0
  36. mfgparams/processes/machining/drilling/data/tools.toml +28 -0
  37. mfgparams/processes/machining/drilling/formulas.py +175 -0
  38. mfgparams/processes/machining/drilling/tools.py +152 -0
  39. mfgparams/processes/machining/milling/__init__.py +13 -0
  40. mfgparams/processes/machining/milling/_calculate.py +638 -0
  41. mfgparams/processes/machining/milling/_shared.py +342 -0
  42. mfgparams/processes/machining/milling/_tool_registry.py +151 -0
  43. mfgparams/processes/machining/milling/end_milling/__init__.py +183 -0
  44. mfgparams/processes/machining/milling/end_milling/data/__init__.py +8 -0
  45. mfgparams/processes/machining/milling/end_milling/data/tools.toml +40 -0
  46. mfgparams/processes/machining/milling/end_milling/formulas.py +154 -0
  47. mfgparams/processes/machining/milling/end_milling/tools.py +71 -0
  48. mfgparams/processes/machining/milling/face_milling/__init__.py +186 -0
  49. mfgparams/processes/machining/milling/face_milling/data/__init__.py +8 -0
  50. mfgparams/processes/machining/milling/face_milling/data/tools.toml +36 -0
  51. mfgparams/processes/machining/milling/face_milling/formulas.py +156 -0
  52. mfgparams/processes/machining/milling/face_milling/tools.py +68 -0
  53. mfgparams/processes/machining/turning/__init__.py +779 -0
  54. mfgparams/processes/machining/turning/data/__init__.py +1 -0
  55. mfgparams/processes/machining/turning/data/tools.toml +32 -0
  56. mfgparams/processes/machining/turning/formulas.py +497 -0
  57. mfgparams/processes/machining/turning/tools.py +143 -0
  58. mfgparams/registry.py +473 -0
  59. mfgparams/registry_config.py +450 -0
  60. mfgparams/units.py +197 -0
  61. mfgparams/validation.py +718 -0
  62. mfgparams-2.5.0.dist-info/METADATA +703 -0
  63. mfgparams-2.5.0.dist-info/RECORD +67 -0
  64. mfgparams-2.5.0.dist-info/WHEEL +5 -0
  65. mfgparams-2.5.0.dist-info/entry_points.txt +2 -0
  66. mfgparams-2.5.0.dist-info/licenses/LICENSE.md +89 -0
  67. 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())