From d7fce51f394a5830f233d57588d8e9bd43ec1d8b Mon Sep 17 00:00:00 2001 From: HugoFara Date: Sat, 12 Sep 2026 12:09:51 +0200 Subject: [PATCH 1/2] refactor: define and freeze the public surface Closes #22 and #23. A name is public when it is in a package's __all__; modules inside a package are implementation; each name has one home. That rule is now stated on the Deprecations page and enforced by tests/test_public_api.py, which pins every package's __all__ name by name, so the surface cannot change without a diff in that file. Applying the rule to the 379 names that were exported: - 54 implementation names leave __all__ but stay importable: numba kernels in solver, per-entity serialization halves and typing aliases in mechanism and synthesis, styling constants in visualizer, lookup helpers in assur, _AnchorProxy. No warning, since nothing outside the package imports them and star-import is not a promise. - 11 sibling re-exports become deprecated aliases that warn: the eight frame/actuator/container names in dyads, optimization.Ensemble, and assur.MobilityResult / StructuralAnalysis, which nothing produced. Same objects, same isinstance behaviour. - 80 names are marked provisional in the freeze: topology, solver, co-design, multi-topology synthesis, hierarchical hypergraphs, CAD export. Exported and documented, not yet promised. - Promotions: every subpackage is a top-level attribute (only five were), the definition path is importable from pylinkage directly, and mechanism exports TrackerJoint and ArcDriverLink, which leggedsnake and the editor backend were fetching from submodules. For the definition path itself, the README gains a "Which API should I use?" section naming components / actuators / dyads / simulation and saying in one line each what MechanismBuilder, hypergraph, assur and Mechanism are for; the API reference index opens with the same guidance. README and changelog link the policy. Nothing is removed. The full suite passes with DeprecationWarning as an error, so no internal path trips its own shims. --- CHANGELOG.md | 61 +++ README.md | 26 + docs/source/api/modules.rst | 12 + docs/source/deprecations.md | 77 +++ docs/source/tutorials/getting_started.rst | 4 +- src/pylinkage/__init__.py | 57 +- src/pylinkage/actuators/__init__.py | 1 - src/pylinkage/assur/__init__.py | 32 +- src/pylinkage/components/__init__.py | 1 - src/pylinkage/dimensions.py | 3 + src/pylinkage/dyads/__init__.py | 66 ++- src/pylinkage/exceptions.py | 9 +- src/pylinkage/mechanism/__init__.py | 25 +- src/pylinkage/optimization/__init__.py | 18 +- .../optimization/collections/__init__.py | 2 +- src/pylinkage/solver/__init__.py | 86 ++-- src/pylinkage/symbolic/__init__.py | 3 +- src/pylinkage/synthesis/__init__.py | 18 +- src/pylinkage/visualizer/__init__.py | 5 - tests/cam/test_cam_follower.py | 2 +- tests/dyads/test_cam_dyads.py | 2 +- tests/dyads/test_conversion.py | 8 +- tests/dyads/test_dyads.py | 9 +- tests/test_public_api.py | 485 ++++++++++++++++++ 24 files changed, 864 insertions(+), 148 deletions(-) create mode 100644 tests/test_public_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ce1f0..ff9ed6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,42 @@ All notable changes to pylinkage are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +What the version number promises, and how a name is retired, is in +the [Deprecations](https://hugofara.github.io/pylinkage/deprecations.html) page. ## [Unreleased] ### Added +- **A defined public surface.** A name is public when it is in a package's + `__all__`; modules inside a package are implementation; each name has one + home. The rule is on the Deprecations page, and the surface is pinned name + by name in `tests/test_public_api.py`, so it cannot change without a diff in + that file. Newer subsystems are marked provisional there (`topology`, + `solver`, co-design, multi-topology synthesis, hierarchical hypergraphs, CAD + export): exported and documented, but not yet promised. + +- **Every subpackage is reachable from the top level** (`pylinkage.components`, + `pylinkage.hypergraph`, `pylinkage.optimization`, …; before, only `assur`, + `dyads`, `mechanism`, `symbolic` and `synthesis` were), and the definition + path is importable straight from `pylinkage`: `Ground`, `PointTracker`, + `Crank`, `ArcCrank`, `LinearActuator`, `RRRDyad`, `RRPDyad`, `PPDyad`, + `FixedDyad`, alongside `Linkage`. + +- **`pylinkage.mechanism.TrackerJoint` and `ArcDriverLink`** are exported. + Both leggedsnake and the editor's backend were importing them from the + `mechanism.joint` and `mechanism.link` modules because the package did not + offer them. + +- **`pylinkage.dimensions` and `pylinkage.exceptions` have an `__all__`** + (`Dimensions`, `DriverAngle`; the four exception classes). + +- **A "Which API should I use?" section in the README**, naming + `components` / `actuators` / `dyads` / `simulation` as the definition path + and saying in one line each what `MechanismBuilder`, `hypergraph`, `assur` + and `Mechanism` are for. The API reference index opens with the same + guidance instead of an alphabetical list. + - **`pylinkage.synthesis.crank_angle_limits(crank, coupler, rocker, ground)`**, the angular range of a crank that cannot rotate fully. It sits beside `grashof_check` and `is_crank_rocker`, and returns `None` for a crank that @@ -46,6 +77,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **54 implementation names left the `__all__` of their packages.** They stay + importable exactly as before; they are no longer advertised as public and + `import *` no longer pulls them in. Among them: the numba kernels and + `step_single*` helpers in `solver` (`simulate`, `simulate_with_kinematics`, + `SolverData`, the `JOINT_*` constants and the conversion functions remain + public); `AnyJoint`, `AnyLink`, the per-entity `joint_to_dict` / + `link_from_dict` halves of `mechanism_to_dict` and `is_legacy_format` in + `mechanism`; the `Point2D` / `ComplexPoint` / `AnglePair` aliases, + `point_to_complex`, `complex_to_point` and `compute_metrics` in `synthesis`; + `SymCoord` in `symbolic`; the styling constants and `LinkStyle` / + `SymbolType` in `visualizer`; `DYAD_TYPES`, `identify_dyad_type`, + `identify_group_type` in `assur`; `DEFAULT_ANGULAR_VELOCITY` in `actuators`; + `MutableAgent` in `optimization.collections`; `_AnchorProxy` in `components` + and `dyads`. + - **`path_generation()` is 2-8x faster**, with byte-identical results. Verifying a candidate by simulating it was 67% of the runtime, and ran through the pure-Python `Linkage.step()`; it now uses the numba solver, which executes the @@ -77,6 +123,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated +- **The sibling re-exports in `pylinkage.dyads`**: `Ground`, `PointTracker`, + `Component`, `ConnectedComponent`, `Crank`, `ArcCrank`, `LinearActuator` and + `Linkage`. Each still resolves to the same object and warns, naming its home + (`pylinkage.components`, `pylinkage.actuators`, `pylinkage.simulation`). + `pylinkage.dyads.Ground` and `pylinkage.components.Ground` were both correct, + which is why tutorials disagreed on which to write. + +- **`pylinkage.optimization.Ensemble`**, in favour of + `pylinkage.population.Ensemble`, its home. + +- **`pylinkage.assur.MobilityResult` and `StructuralAnalysis`.** Nothing in + the package produced them; they were dataclasses exported without a function + to fill them. The mobility analysis that exists is + `pylinkage.topology.compute_mobility()`, returning a `MobilityInfo`. + - **`n_orientation_samples`, which never denoted a number of samples.** It was folded into a per-axis grid resolution through `max(6, round(n_samples ** (1 / free)))`, so the floor swallowed it: with four diff --git a/README.md b/README.md index f5ff3f1..6092582 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,24 @@ show_linkage(my_linkage) ![A four-bar linkage animated](https://github.com/HugoFara/pylinkage/raw/main/docs/assets/Kinematic%20My%20four-bar%20linkage.gif) +### Which API should I use? + +The one above. `components` (frame points), `actuators` (motor inputs), +`dyads` (the constrained pairs that close the loops) and `simulation.Linkage` +(the container) are *the* way to define a mechanism, and every tutorial starts +there. The same names are importable straight from `pylinkage` as a shortcut. + +Pylinkage has other representations, each with a job: + +| Representation | Use it when | +|---|---| +| `mechanism.MechanismBuilder` | you think in link lengths first, joints second (below) | +| `hypergraph` | you compose mechanisms from reusable sub-assemblies, or need a topology (not geometry) to reason about | +| `assur` | you want the structural decomposition of a mechanism into Assur groups | +| `mechanism.Mechanism` | you need the low-level links-and-joints model everything else lowers to | + +They all convert to and from each other; none is needed to get started. + ### Alternative: Links-First Builder For a more mechanical engineering-oriented approach, use `MechanismBuilder` to define links with their lengths first, then connect them: @@ -207,6 +225,14 @@ Level 4: Applications → Optimization, Synthesis, Symbolic, Visualization +## Stability + +Pylinkage follows [semantic versioning](https://semver.org/spec/v2.0.0.html). +What that covers, and how a name is retired, is on one page: +[Deprecations](https://hugofara.github.io/pylinkage/deprecations.html). +The public surface is pinned in +[`tests/test_public_api.py`](https://github.com/HugoFara/pylinkage/blob/main/tests/test_public_api.py). + ## Requirements - Python >= 3.10 diff --git a/docs/source/api/modules.rst b/docs/source/api/modules.rst index 67f219c..e3ffaa7 100644 --- a/docs/source/api/modules.rst +++ b/docs/source/api/modules.rst @@ -1,6 +1,18 @@ pylinkage ========= +The packages below are listed alphabetically. To define and simulate a +mechanism you need four of them, in this order: :mod:`pylinkage.components` +(frame points), :mod:`pylinkage.actuators` (motor inputs), +:mod:`pylinkage.dyads` (the constrained pairs that close the loops) and +:mod:`pylinkage.simulation` (the ``Linkage`` container). The +:doc:`getting started tutorial ` walks through +them; the rest are what you reach for afterwards. + +A name is public when it appears in a package's ``__all__``; modules inside a +package are implementation. See :doc:`/deprecations` for the rule and for +every name currently on its way out. + .. toctree:: :maxdepth: 4 diff --git a/docs/source/deprecations.md b/docs/source/deprecations.md index 13f9916..f763032 100644 --- a/docs/source/deprecations.md +++ b/docs/source/deprecations.md @@ -2,6 +2,38 @@ Names that still work but are on their way out, with what to use instead. +## What is public + +A name is public when it is listed in the `__all__` of a package, and a +package is public when it is reachable as an attribute of `pylinkage` +(`pylinkage.synthesis`, `pylinkage.optimization.collections`, …). That is the +whole rule. The policy below applies to those names and nothing else. + +Two consequences: + +- **Modules inside a package are implementation.** `from + pylinkage.synthesis.path_generation import path_generation` works today and + may stop working without notice; `from pylinkage.synthesis import + path_generation` is the promise. Newer modules carry a leading underscore + to make this visible (`population._member`, `synthesis._types`); older ones + will be renamed as they are touched. +- **Each name has one home.** `Ground` lives in `pylinkage.components`, not + also in `pylinkage.dyads`. The top-level package re-exports the definition + path (`Ground`, `Crank`, `RRRDyad`, `Linkage`, …) for convenience; every + other second location is a deprecated alias, listed below. + +The surface is pinned name by name in `tests/test_public_api.py`, so it cannot +change without a diff in that file. + +Some public names are **provisional**: exported and documented, but their +shape may still change in a minor release. They are the newer subsystems — +`pylinkage.topology`, `pylinkage.solver`, the co-design family in +`pylinkage.optimization` (`co_optimize`, `CoOptimizationConfig`, …), the +multi-topology synthesis in `pylinkage.synthesis` (`NBarSolution`, +`generalized_synthesis`, …), hierarchical composition in +`pylinkage.hypergraph`, and the CAD export in `pylinkage.visualizer`. A change +to one is announced in the changelog, not through a warning. + ## How deprecation works here pylinkage follows [semantic versioning](https://semver.org/spec/v2.0.0.html). @@ -33,8 +65,32 @@ whether an upgrade will affect you. | `pylinkage.dyads.Dyad` | `pylinkage.components.Component` | 2.0.0 | | `pylinkage.dyads.ConnectedDyad` | `pylinkage.components.ConnectedComponent` | 2.0.0 | | `pylinkage.synthesis.Dyad` | `pylinkage.synthesis.BurmesterDyad` | 2.0.0 | +| `pylinkage.dyads.Ground` | `pylinkage.components.Ground` | 2.0.0 | +| `pylinkage.dyads.PointTracker` | `pylinkage.components.PointTracker` | 2.0.0 | +| `pylinkage.dyads.Component` | `pylinkage.components.Component` | 2.0.0 | +| `pylinkage.dyads.ConnectedComponent` | `pylinkage.components.ConnectedComponent` | 2.0.0 | +| `pylinkage.dyads.Crank` | `pylinkage.actuators.Crank` | 2.0.0 | +| `pylinkage.dyads.ArcCrank` | `pylinkage.actuators.ArcCrank` | 2.0.0 | +| `pylinkage.dyads.LinearActuator` | `pylinkage.actuators.LinearActuator` | 2.0.0 | +| `pylinkage.dyads.Linkage` | `pylinkage.simulation.Linkage` | 2.0.0 | +| `pylinkage.optimization.Ensemble` | `pylinkage.population.Ensemble` | 2.0.0 | +| `pylinkage.assur.MobilityResult` | `pylinkage.topology.MobilityInfo` | 2.0.0 | +| `pylinkage.assur.StructuralAnalysis` | `pylinkage.topology.compute_mobility()` | 2.0.0 | | `path_generation(n_orientation_samples=...)` | `orientation_resolution=` | 2.0.0 | +### Why the `dyads` re-exports are going away + +`pylinkage.dyads` used to re-export the frame, actuator and container classes +"for convenience", so `pylinkage.dyads.Ground` and `pylinkage.components.Ground` +were both correct and tutorials disagreed on which to use. Under the one-home +rule above, `dyads` defines dyads. The objects are unchanged. + +### Why `MobilityResult` and `StructuralAnalysis` are going away + +They were exported from `pylinkage.assur` with no function anywhere in the +package that produced them. The mobility analysis that exists is +`pylinkage.topology.compute_mobility()`, which returns a `MobilityInfo`. + ### Why the `Dyad` names are going away Three unrelated classes were reachable as `Dyad`, and only two of them were @@ -89,6 +145,27 @@ from pylinkage.components import Component, ConnectedComponent from pylinkage.synthesis import BurmesterDyad ``` +```python +# Before +from pylinkage.dyads import Ground, Crank, RRRDyad, Linkage + +# After -- one home per name, or the top-level shortcut +from pylinkage.components import Ground +from pylinkage.actuators import Crank +from pylinkage.dyads import RRRDyad +from pylinkage.simulation import Linkage + +from pylinkage import Ground, Crank, RRRDyad, Linkage # equivalent +``` + +```python +# Before +from pylinkage.optimization import Ensemble + +# After +from pylinkage.population import Ensemble +``` + ```python # Before path_generation(points, n_orientation_samples=36) diff --git a/docs/source/tutorials/getting_started.rst b/docs/source/tutorials/getting_started.rst index b47ecf8..913773e 100644 --- a/docs/source/tutorials/getting_started.rst +++ b/docs/source/tutorials/getting_started.rst @@ -24,7 +24,9 @@ Core Concepts Pylinkage models planar linkage mechanisms with three kinds of objects from the ``components`` / ``actuators`` / ``dyads`` packages, plus a thin -container: +container. This is the recommended way to define a mechanism; the other +representations (``mechanism``, ``hypergraph``, ``assur``) are covered in +later tutorials and convert to and from this one. 1. **Components**: building blocks of the mechanism (``Ground`` anchors, ``Crank`` / ``LinearActuator`` drivers, and dyads such as ``RRRDyad``, diff --git a/src/pylinkage/__init__.py b/src/pylinkage/__init__.py index f0cbc5e..da72c2b 100644 --- a/src/pylinkage/__init__.py +++ b/src/pylinkage/__init__.py @@ -12,16 +12,36 @@ """ __all__ = [ - # Assur group module + # Subpackages. Heavy or optional ones load on first access. + "actuators", "assur", - # Dyads module (user-facing API for mechanism building) + "cam", + "components", + "dimensions", "dyads", - # Mechanism module (new Links + Joints model) + "exceptions", + "geometry", + "hypergraph", + "linkage", "mechanism", - # Symbolic computation module (lazy, requires sympy) + "optimization", + "population", + "simulation", + "solver", "symbolic", - # Synthesis module (lazy, requires scipy) "synthesis", + "topology", + "visualizer", + # The definition path: frame, actuators, dyads, container + "Ground", + "PointTracker", + "Crank", + "ArcCrank", + "LinearActuator", + "RRRDyad", + "RRPDyad", + "PPDyad", + "FixedDyad", # Canonical types (from _types.py) "JointType", "NodeRole", @@ -69,14 +89,19 @@ import importlib as _importlib # --- Eager imports (lightweight, always available) --- -# Assur group module for graph-based linkage representation +from . import actuators as actuators from . import assur as assur - -# Dyads module - user-facing API for mechanism building +from . import cam as cam +from . import components as components +from . import dimensions as dimensions from . import dyads as dyads - -# Mechanism module - new Links + Joints model +from . import exceptions as exceptions +from . import geometry as geometry +from . import hypergraph as hypergraph +from . import linkage as linkage from . import mechanism as mechanism +from . import simulation as simulation +from . import topology as topology from ._simulation_context import ( Simulation as Simulation, ) @@ -103,6 +128,15 @@ from ._types import ( PortId as PortId, ) +from .actuators import ArcCrank as ArcCrank +from .actuators import Crank as Crank +from .actuators import LinearActuator as LinearActuator +from .components import Ground as Ground +from .components import PointTracker as PointTracker +from .dyads import FixedDyad as FixedDyad +from .dyads import PPDyad as PPDyad +from .dyads import RRPDyad as RRPDyad +from .dyads import RRRDyad as RRRDyad from .exceptions import ( NotCompletelyDefinedError as NotCompletelyDefinedError, ) @@ -149,9 +183,12 @@ # --- Lazy imports (heavy optional dependencies) --- _LAZY_SUBMODULES = { + "optimization", "population", + "solver", "symbolic", "synthesis", + "visualizer", } _LAZY_ATTRS: dict[str, str] = { diff --git a/src/pylinkage/actuators/__init__.py b/src/pylinkage/actuators/__init__.py index bc2dae9..be0386e 100644 --- a/src/pylinkage/actuators/__init__.py +++ b/src/pylinkage/actuators/__init__.py @@ -45,6 +45,5 @@ __all__ = [ "ArcCrank", "Crank", - "DEFAULT_ANGULAR_VELOCITY", "LinearActuator", ] diff --git a/src/pylinkage/assur/__init__.py b/src/pylinkage/assur/__init__.py index 5d483a5..585cc08 100644 --- a/src/pylinkage/assur/__init__.py +++ b/src/pylinkage/assur/__init__.py @@ -42,7 +42,7 @@ "EdgeId", # Wrapper class "AssurMechanism", - # Analysis results + # Analysis results (deprecated, served by __getattr__ below) "MobilityResult", "StructuralAnalysis", # Graph structures @@ -53,14 +53,11 @@ "AssurGroup", "Dyad", "Triad", - "identify_group_type", # Backwards-compatible aliases "DyadRRR", "DyadRRP", "DyadRPR", "DyadPRR", - "DYAD_TYPES", - "identify_dyad_type", # Signature parsing and hypergraph generation "AssurGroupClass", "AssurSignature", @@ -84,8 +81,10 @@ "graph_from_json", ] +from .._deprecation import DeprecatedAlias, deprecated_getattr from ._types import EdgeId, JointType, NodeId, NodeRole -from .analysis import MobilityResult, StructuralAnalysis +from .analysis import MobilityResult as _MobilityResult +from .analysis import StructuralAnalysis as _StructuralAnalysis from .assur_mechanism import AssurMechanism from .decomposition import ( DecompositionResult, @@ -94,7 +93,7 @@ ) from .graph import Edge, LinkageGraph, Node from .groups import ( - DYAD_TYPES, + DYAD_TYPES, # noqa: F401 AssurGroup, Dyad, DyadPRR, @@ -102,8 +101,8 @@ DyadRRP, DyadRRR, Triad, - identify_dyad_type, - identify_group_type, + identify_dyad_type, # noqa: F401 + identify_group_type, # noqa: F401 ) from .hypergraph_conversion import from_hypergraph, to_hypergraph from .mechanism_conversion import graph_to_mechanism, mechanism_to_graph @@ -120,3 +119,20 @@ signature_to_group_class, signature_to_hypergraph, ) + +_ANALYSIS_REASON = ( + "Nothing in pylinkage produces these; they were exported without a " + "function to fill them. pylinkage.topology.compute_mobility() is the " + "mobility analysis, returning a MobilityInfo." +) + +_DEPRECATED = { + "MobilityResult": DeprecatedAlias( + _MobilityResult, "pylinkage.topology.MobilityInfo", "2.0.0", _ANALYSIS_REASON + ), + "StructuralAnalysis": DeprecatedAlias( + _StructuralAnalysis, "pylinkage.topology.compute_mobility", "2.0.0", _ANALYSIS_REASON + ), +} + +__getattr__ = deprecated_getattr(__name__, _DEPRECATED) diff --git a/src/pylinkage/components/__init__.py b/src/pylinkage/components/__init__.py index 8634f7e..7f7b007 100644 --- a/src/pylinkage/components/__init__.py +++ b/src/pylinkage/components/__init__.py @@ -28,7 +28,6 @@ "ConnectedComponent", "Ground", "PointTracker", - "_AnchorProxy", # Deprecated aliases, served by __getattr__ below. "Dyad", "ConnectedDyad", diff --git a/src/pylinkage/dimensions.py b/src/pylinkage/dimensions.py index 97f28bb..c556bb0 100644 --- a/src/pylinkage/dimensions.py +++ b/src/pylinkage/dimensions.py @@ -14,6 +14,9 @@ from collections.abc import Iterable +__all__ = ["Dimensions", "DriverAngle"] + + @dataclass class DriverAngle: """Angular parameters for a driver joint. diff --git a/src/pylinkage/dyads/__init__.py b/src/pylinkage/dyads/__init__.py index bab37dd..f5f2fc3 100644 --- a/src/pylinkage/dyads/__init__.py +++ b/src/pylinkage/dyads/__init__.py @@ -48,31 +48,33 @@ for positions in linkage.step(): print(positions) -Backwards Compatibility: - For convenience, this module re-exports items from their canonical locations: - - Ground: from pylinkage.components - - Crank, LinearActuator: from pylinkage.actuators - - Linkage: from pylinkage.simulation - - Dyad, ConnectedDyad: deprecated aliases, see the Deprecations page +Deprecated re-exports: + This module used to re-export ``Ground``, ``PointTracker``, ``Crank``, + ``ArcCrank``, ``LinearActuator``, ``Linkage``, ``Component`` and + ``ConnectedComponent`` from their home modules. Those names still resolve + here but warn; import them from ``pylinkage.components``, + ``pylinkage.actuators`` and ``pylinkage.simulation``. See the + Deprecations page. """ -# Primary exports are the true Assur groups defined in this package; the rest -# are re-exports kept for backwards compatibility. +# Primary exports are the true Assur groups defined in this package. Names +# from sibling packages are served as deprecated aliases by __getattr__ below, +# so they are imported under private names to keep them out of the namespace. from .._deprecation import DeprecatedAlias, deprecated_getattr -from ..actuators import ArcCrank as ArcCrank -from ..actuators import Crank as Crank -from ..actuators import LinearActuator as LinearActuator +from ..actuators import ArcCrank as _ArcCrank +from ..actuators import Crank as _Crank +from ..actuators import LinearActuator as _LinearActuator from ..components import _ALIAS_REASON -from ..components import Component as Component -from ..components import ConnectedComponent as ConnectedComponent +from ..components import Component as _Component +from ..components import ConnectedComponent as _ConnectedComponent # Ground and sensors from components -from ..components import Ground as Ground -from ..components import PointTracker as PointTracker +from ..components import Ground as _Ground +from ..components import PointTracker as _PointTracker from ..components import _AnchorProxy as _AnchorProxy # Linkage container -from ..simulation import Linkage as Linkage +from ..simulation import Linkage as _Linkage from ._base import BinaryDyad as BinaryDyad from ._conversion import to_mechanism as to_mechanism from .factory import create_dyad as create_dyad @@ -103,32 +105,52 @@ "get_isomer_geometry", "get_required_anchors", "get_required_constraints", - # Re-exports for backwards compatibility + # Deprecated aliases, served by __getattr__ below. "Ground", "PointTracker", "Crank", "ArcCrank", "LinearActuator", "Linkage", - # Base classes "Component", "ConnectedComponent", - "_AnchorProxy", - # Deprecated aliases, served by __getattr__ below. "Dyad", "ConnectedDyad", ] +_REEXPORT_REASON = ( + "pylinkage.dyads only defines dyads; each of these names has a single home " + "module, and importing it from there is the documented path." +) + _DEPRECATED = { + "Ground": DeprecatedAlias(_Ground, "pylinkage.components.Ground", "2.0.0", _REEXPORT_REASON), + "PointTracker": DeprecatedAlias( + _PointTracker, "pylinkage.components.PointTracker", "2.0.0", _REEXPORT_REASON + ), + "Component": DeprecatedAlias( + _Component, "pylinkage.components.Component", "2.0.0", _REEXPORT_REASON + ), + "ConnectedComponent": DeprecatedAlias( + _ConnectedComponent, "pylinkage.components.ConnectedComponent", "2.0.0", _REEXPORT_REASON + ), + "Crank": DeprecatedAlias(_Crank, "pylinkage.actuators.Crank", "2.0.0", _REEXPORT_REASON), + "ArcCrank": DeprecatedAlias( + _ArcCrank, "pylinkage.actuators.ArcCrank", "2.0.0", _REEXPORT_REASON + ), + "LinearActuator": DeprecatedAlias( + _LinearActuator, "pylinkage.actuators.LinearActuator", "2.0.0", _REEXPORT_REASON + ), + "Linkage": DeprecatedAlias(_Linkage, "pylinkage.simulation.Linkage", "2.0.0", _REEXPORT_REASON), "Dyad": DeprecatedAlias( - value=Component, + value=_Component, replacement="pylinkage.components.Component", removed_in="2.0.0", reason=_ALIAS_REASON, ), "ConnectedDyad": DeprecatedAlias( - value=ConnectedComponent, + value=_ConnectedComponent, replacement="pylinkage.components.ConnectedComponent", removed_in="2.0.0", reason=_ALIAS_REASON, diff --git a/src/pylinkage/exceptions.py b/src/pylinkage/exceptions.py index cc1ee16..d631561 100644 --- a/src/pylinkage/exceptions.py +++ b/src/pylinkage/exceptions.py @@ -13,6 +13,14 @@ from .linkage.linkage import Linkage +__all__ = [ + "UnbuildableError", + "UnderconstrainedError", + "NotCompletelyDefinedError", + "OptimizationError", +] + + class UnbuildableError(Exception): """Should be raised when the constraints cannot be solved.""" @@ -40,7 +48,6 @@ def __init__( super().__init__(message) - class NotCompletelyDefinedError(Exception): """The linkage definition is incomplete.""" diff --git a/src/pylinkage/mechanism/__init__.py b/src/pylinkage/mechanism/__init__.py index 178892d..3800bec 100644 --- a/src/pylinkage/mechanism/__init__.py +++ b/src/pylinkage/mechanism/__init__.py @@ -54,17 +54,19 @@ slider_crank, ) from .joint import ( - AnyJoint, + AnyJoint, # noqa: F401 GroundJoint, Joint, JointType, PrismaticJoint, RevoluteJoint, + TrackerJoint, ) # Link classes from .link import ( - AnyLink, + AnyLink, # noqa: F401 + ArcDriverLink, DriverLink, GroundLink, Link, @@ -76,11 +78,11 @@ # Serialization from .serialization import ( - is_legacy_format, - joint_from_dict, - joint_to_dict, - link_from_dict, - link_to_dict, + is_legacy_format, # noqa: F401 + joint_from_dict, # noqa: F401 + joint_to_dict, # noqa: F401 + link_from_dict, # noqa: F401 + link_to_dict, # noqa: F401 mechanism_from_dict, mechanism_from_json, mechanism_to_dict, @@ -93,14 +95,14 @@ "RevoluteJoint", "PrismaticJoint", "GroundJoint", + "TrackerJoint", "JointType", - "AnyJoint", # Link classes "Link", "GroundLink", "DriverLink", + "ArcDriverLink", "LinkType", - "AnyLink", # Mechanism "Mechanism", # Builder @@ -113,9 +115,4 @@ "mechanism_from_dict", "mechanism_to_json", "mechanism_from_json", - "joint_to_dict", - "joint_from_dict", - "link_to_dict", - "link_from_dict", - "is_legacy_format", ] diff --git a/src/pylinkage/optimization/__init__.py b/src/pylinkage/optimization/__init__.py index 8aa7865..ddd6a2c 100644 --- a/src/pylinkage/optimization/__init__.py +++ b/src/pylinkage/optimization/__init__.py @@ -7,6 +7,7 @@ "CoOptimizationResult", "CoOptSolution", "collections", + # Deprecated, served by __getattr__ below. "Ensemble", "differential_evolution_optimization", "differential_evolution_optimization_async", @@ -52,7 +53,6 @@ # Lazy-loaded attributes (require scipy / pyswarms) _LAZY_ATTRS: dict[str, tuple[str, str]] = { - "Ensemble": ("..population", "Ensemble"), "chain_optimizers": (".scipy_optimize", "chain_optimizers"), "differential_evolution_optimization": ( ".scipy_optimize", @@ -101,6 +101,22 @@ def __getattr__(name: str) -> object: + if name == "Ensemble": + # Resolved lazily: population imports this package. + from warnings import warn + + from .._deprecation import DeprecatedAlias + from ..population import Ensemble + + alias = DeprecatedAlias( + Ensemble, + "pylinkage.population.Ensemble", + "2.0.0", + "Ensemble is the population container the optimizers return; " + "pylinkage.population is its home.", + ) + warn(alias.message(__name__, name), DeprecationWarning, stacklevel=2) + return Ensemble if name in _LAZY_ATTRS: module_path, attr_name = _LAZY_ATTRS[name] mod = _importlib.import_module(module_path, __name__) diff --git a/src/pylinkage/optimization/collections/__init__.py b/src/pylinkage/optimization/collections/__init__.py index 36ec76c..75e0315 100644 --- a/src/pylinkage/optimization/collections/__init__.py +++ b/src/pylinkage/optimization/collections/__init__.py @@ -2,7 +2,7 @@ Package for collection objects. """ -__all__ = ["Agent", "MutableAgent", "ParetoFront", "ParetoSolution"] +__all__ = ["Agent", "ParetoFront", "ParetoSolution"] from .agent import Agent as Agent from .mutable_agent import MutableAgent as MutableAgent diff --git a/src/pylinkage/solver/__init__.py b/src/pylinkage/solver/__init__.py index 71cae76..7684fbb 100644 --- a/src/pylinkage/solver/__init__.py +++ b/src/pylinkage/solver/__init__.py @@ -23,40 +23,41 @@ ... ) """ -# Pure numba components (no Python object dependencies) +# Pure numba components (no Python object dependencies). The kernels stay +# importable from here but are not public: see tests/test_public_api.py. from .acceleration import ( - solve_crank_acceleration, - solve_fixed_acceleration, - solve_prismatic_acceleration, - solve_revolute_acceleration, - solve_rigid_body_acceleration, + solve_crank_acceleration, # noqa: F401 + solve_fixed_acceleration, # noqa: F401 + solve_prismatic_acceleration, # noqa: F401 + solve_revolute_acceleration, # noqa: F401 + solve_rigid_body_acceleration, # noqa: F401 ) # Group solvers (standalone functions for Assur groups) from .groups import ( - solve_rrp_dyad, - solve_rrr_dyad, + solve_rrp_dyad, # noqa: F401 + solve_rrr_dyad, # noqa: F401 ) from .joints import ( - solve_crank, - solve_fixed, - solve_linear, - solve_revolute, + solve_crank, # noqa: F401 + solve_fixed, # noqa: F401 + solve_linear, # noqa: F401 + solve_revolute, # noqa: F401 ) from .simulation import ( - first_nan_step, - has_nan_positions, + first_nan_step, # noqa: F401 + has_nan_positions, # noqa: F401 simulate, simulate_with_kinematics, - step_single, - step_single_acceleration, - step_single_velocity, + step_single, # noqa: F401 + step_single_acceleration, # noqa: F401 + step_single_velocity, # noqa: F401 ) # High-level solving API from .solve import ( - solve_decomposition, - solve_group, + solve_decomposition, # noqa: F401 + solve_group, # noqa: F401 ) from .types import ( JOINT_CRANK, @@ -68,17 +69,16 @@ SolverData, ) from .velocity import ( - solve_crank_velocity, - solve_fixed_velocity, - solve_prismatic_velocity, - solve_revolute_velocity, - solve_rigid_body_velocity, + solve_crank_velocity, # noqa: F401 + solve_fixed_velocity, # noqa: F401 + solve_prismatic_velocity, # noqa: F401 + solve_revolute_velocity, # noqa: F401 + solve_rigid_body_velocity, # noqa: F401 ) -# Conversion functions are loaded lazily to avoid circular imports. -# They live in pylinkage.bridge for architectural reasons -# (keeps solver pure, with no Python object dependencies). -# For new code, prefer importing from pylinkage.bridge directly. +# Conversion functions are loaded lazily to avoid circular imports. They are +# implemented in pylinkage.bridge (which keeps this package free of Python +# object dependencies) but this package is their public home. _conversion_attrs = { "linkage_to_solver_data", "solver_data_to_linkage", @@ -105,38 +105,10 @@ def __getattr__(name: str) -> object: "JOINT_FIXED", "JOINT_PRISMATIC", "MAX_PARENTS", - # Joint solvers (position) - "solve_crank", - "solve_revolute", - "solve_fixed", - "solve_linear", - # Joint solvers (velocity) - "solve_crank_velocity", - "solve_revolute_velocity", - "solve_fixed_velocity", - "solve_prismatic_velocity", - "solve_rigid_body_velocity", - # Joint solvers (acceleration) - "solve_crank_acceleration", - "solve_revolute_acceleration", - "solve_fixed_acceleration", - "solve_prismatic_acceleration", - "solve_rigid_body_acceleration", - # Group solvers - "solve_rrr_dyad", - "solve_rrp_dyad", - # High-level solving - "solve_group", - "solve_decomposition", # Simulation - "step_single", - "step_single_velocity", - "step_single_acceleration", "simulate", "simulate_with_kinematics", - "has_nan_positions", - "first_nan_step", - # Conversion (lazy-loaded for backwards compatibility) + # Conversion (lazy-loaded to avoid a circular import through pylinkage.bridge) "linkage_to_solver_data", "solver_data_to_linkage", "update_solver_constraints", diff --git a/src/pylinkage/symbolic/__init__.py b/src/pylinkage/symbolic/__init__.py index 4a86e0e..a7da9dd 100644 --- a/src/pylinkage/symbolic/__init__.py +++ b/src/pylinkage/symbolic/__init__.py @@ -65,7 +65,6 @@ def objective(trajectories): __all__ = [ # Type definitions - "SymCoord", "theta", # Joint classes "SymJoint", @@ -97,7 +96,7 @@ def objective(trajectories): "fourbar_symbolic", ] -from ._types import SymCoord, theta +from ._types import SymCoord, theta # noqa: F401 from .conversion import ( fourbar_symbolic, get_numeric_parameters, diff --git a/src/pylinkage/synthesis/__init__.py b/src/pylinkage/synthesis/__init__.py index bc42dd3..bdb5c14 100644 --- a/src/pylinkage/synthesis/__init__.py +++ b/src/pylinkage/synthesis/__init__.py @@ -124,10 +124,7 @@ __all__ = [ # Type definitions - "Point2D", - "ComplexPoint", "PrecisionPoint", - "AnglePair", "Pose", "SynthesisType", "FourBarSolution", @@ -156,7 +153,6 @@ "generalized_synthesis", "multi_topology_synthesize", "nbar_solution_to_linkage", - "compute_metrics", # Burmester theory functions "compute_pole", "compute_all_poles", @@ -176,18 +172,16 @@ "is_crank_rocker", "GrashofType", "validate_fourbar", - "point_to_complex", - "complex_to_point", ] # Type definitions from .._deprecation import DeprecatedAlias, deprecated_getattr from ._types import ( - AnglePair, - ComplexPoint, + AnglePair, # noqa: F401 + ComplexPoint, # noqa: F401 DyadSolution, FourBarSolution, - Point2D, + Point2D, # noqa: F401 Pose, PrecisionPoint, SynthesisType, @@ -195,11 +189,11 @@ # Burmester theory from .burmester import ( - complex_to_point, + complex_to_point, # noqa: F401 compute_all_poles, compute_circle_point_curve, compute_pole, - point_to_complex, + point_to_complex, # noqa: F401 select_compatible_dyads, ) @@ -228,7 +222,7 @@ from .motion_generation import motion_generation, motion_generation_3_poses from .multi_topology import synthesize as multi_topology_synthesize from .path_generation import path_generation, path_generation_with_timing, verify_path_generation -from .ranking import compute_metrics +from .ranking import compute_metrics # noqa: F401 from .six_bar import six_bar_path_generation # Topology-aware synthesis (Phase 3) diff --git a/src/pylinkage/visualizer/__init__.py b/src/pylinkage/visualizer/__init__.py index a26787d..72fdbfe 100644 --- a/src/pylinkage/visualizer/__init__.py +++ b/src/pylinkage/visualizer/__init__.py @@ -11,7 +11,6 @@ __all__ = [ # Matplotlib backend (default) - "COLOR_SWITCHER", "animate_dashboard", "animate_parallel_coordinates", "dashboard_layout", @@ -45,10 +44,6 @@ "LinkProfile", "JointProfile", # Symbol definitions - "LINK_COLORS", - "SYMBOL_SPECS", - "LinkStyle", - "SymbolType", ] import importlib as _importlib diff --git a/tests/cam/test_cam_follower.py b/tests/cam/test_cam_follower.py index de19304..6278ec6 100644 --- a/tests/cam/test_cam_follower.py +++ b/tests/cam/test_cam_follower.py @@ -18,11 +18,11 @@ ) from pylinkage.components import Ground from pylinkage.dyads import ( - Linkage, OscillatingCamFollower, RRRDyad, TranslatingCamFollower, ) +from pylinkage.simulation import Linkage class TestMotionLaws: diff --git a/tests/dyads/test_cam_dyads.py b/tests/dyads/test_cam_dyads.py index 0b8cef1..9bda33a 100644 --- a/tests/dyads/test_cam_dyads.py +++ b/tests/dyads/test_cam_dyads.py @@ -15,10 +15,10 @@ ) from pylinkage.components import Ground from pylinkage.dyads import ( - Linkage, OscillatingCamFollower, TranslatingCamFollower, ) +from pylinkage.simulation import Linkage class TestOscillatingCamFollowerExtended: diff --git a/tests/dyads/test_conversion.py b/tests/dyads/test_conversion.py index 17feb91..8119681 100644 --- a/tests/dyads/test_conversion.py +++ b/tests/dyads/test_conversion.py @@ -2,21 +2,21 @@ import pytest +from pylinkage.actuators import Crank +from pylinkage.components import Ground from pylinkage.dyads import ( - Crank, FixedDyad, - Ground, - Linkage, RRPDyad, RRRDyad, + to_mechanism, ) -from pylinkage.dyads._conversion import to_mechanism from pylinkage.mechanism import ( DriverLink, GroundJoint, GroundLink, Mechanism, ) +from pylinkage.simulation import Linkage def _make_fourbar() -> Linkage: diff --git a/tests/dyads/test_dyads.py b/tests/dyads/test_dyads.py index efcd5a0..529098d 100644 --- a/tests/dyads/test_dyads.py +++ b/tests/dyads/test_dyads.py @@ -8,18 +8,15 @@ import pytest +from pylinkage.actuators import ArcCrank, Crank, LinearActuator +from pylinkage.components import Ground, PointTracker from pylinkage.dyads import ( - ArcCrank, - Crank, FixedDyad, - Ground, - LinearActuator, - Linkage, - PointTracker, RRPDyad, RRRDyad, ) from pylinkage.exceptions import UnbuildableError +from pylinkage.simulation import Linkage class TestGround: diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..1343161 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,485 @@ +"""The public surface of pylinkage, pinned name by name. + +A name is public when it appears in the ``__all__`` of one of the packages +listed here. Everything else, including the modules inside those packages, +is implementation: it may keep working, but nothing promises it will. + +This file is the freeze. Changing the surface means changing this file, so a +name cannot slip in or out of the public API without a visible diff: + +- Adding a name to ``PUBLIC`` is an addition; document it in the changelog. +- Moving a name from ``PUBLIC`` to ``DEPRECATED`` is a deprecation. Serve it + through ``pylinkage._deprecation`` so that reading it warns, and add it to + the table in ``docs/source/deprecations.md``. +- Removing a name from ``DEPRECATED`` is a removal, which the policy allows no + earlier than the next major release. +""" + +from __future__ import annotations + +import importlib +import warnings + +import pytest + +PUBLIC: dict[str, set[str]] = { + "pylinkage": { + "actuators", + "assur", + "cam", + "components", + "dimensions", + "dyads", + "exceptions", + "geometry", + "hypergraph", + "linkage", + "mechanism", + "optimization", + "population", + "simulation", + "solver", + "symbolic", + "synthesis", + "topology", + "visualizer", + "Ground", + "PointTracker", + "Crank", + "ArcCrank", + "LinearActuator", + "RRRDyad", + "RRPDyad", + "PPDyad", + "FixedDyad", + "JointType", + "NodeRole", + "NodeId", + "EdgeId", + "HyperedgeId", + "ComponentId", + "PortId", + "NotCompletelyDefinedError", + "OptimizationError", + "UnbuildableError", + "UnderconstrainedError", + "circle_intersect", + "cyl_to_cart", + "intersection", + "norm", + "sqr_dist", + "Linkage", + "Simulation", + "bounding_box", + "extract_trajectories", + "extract_trajectory", + "kinematic_default_test", + "collections", + "generate_bounds", + "kinematic_maximization", + "kinematic_minimization", + "particle_swarm_optimization", + "trials_and_errors_optimization", + "Ensemble", + "Member", + "Population", + "plot_kinematic_linkage", + "plot_static_linkage", + "show_linkage", + "swarm_tiled_repr", + }, + "pylinkage.actuators": { + "ArcCrank", + "Crank", + "LinearActuator", + }, + "pylinkage.assur": { + "JointType", + "NodeRole", + "NodeId", + "EdgeId", + "AssurMechanism", + "LinkageGraph", + "Node", + "Edge", + "AssurGroup", + "Dyad", + "Triad", + "DyadRRR", + "DyadRRP", + "DyadRPR", + "DyadPRR", + "AssurGroupClass", + "AssurSignature", + "parse_signature", + "signature_to_hypergraph", + "signature_to_group_class", + "DecompositionResult", + "decompose_assur_groups", + "validate_decomposition", + "graph_to_mechanism", + "mechanism_to_graph", + "from_hypergraph", + "to_hypergraph", + "graph_to_dict", + "graph_from_dict", + "graph_to_json", + "graph_from_json", + }, + "pylinkage.cam": { + "CamProfile", + "FunctionProfile", + "PointArrayProfile", + "MotionLaw", + "HarmonicMotionLaw", + "CycloidalMotionLaw", + "ModifiedTrapezoidalMotionLaw", + "PolynomialMotionLaw", + "polynomial_345", + "polynomial_4567", + }, + "pylinkage.components": { + "Component", + "ConnectedComponent", + "Ground", + "PointTracker", + }, + "pylinkage.dimensions": { + "Dimensions", + "DriverAngle", + }, + "pylinkage.dyads": { + "RRRDyad", + "RRPDyad", + "PPDyad", + "FixedDyad", + "BinaryDyad", + "TranslatingCamFollower", + "OscillatingCamFollower", + "create_dyad", + "to_mechanism", + "get_isomer_geometry", + "get_required_anchors", + "get_required_constraints", + }, + "pylinkage.exceptions": { + "UnbuildableError", + "UnderconstrainedError", + "NotCompletelyDefinedError", + "OptimizationError", + }, + "pylinkage.geometry": { + "circle_intersect", + "circle_line_from_points_intersection", + "circle_line_intersection", + "cyl_to_cart", + "get_nearest_point", + "intersection", + "norm", + "sqr_dist", + }, + "pylinkage.hypergraph": { + "NodeId", + "EdgeId", + "HyperedgeId", + "PortId", + "JointType", + "NodeRole", + "Node", + "Edge", + "Hyperedge", + "HypergraphLinkage", + "ComponentInstance", + "Connection", + "HierarchicalLinkage", + "to_mechanism", + "from_mechanism", + "from_sim_linkage", + "graph_to_dict", + "graph_from_dict", + "graph_to_json", + "graph_from_json", + "hierarchical_to_dict", + "hierarchical_from_dict", + "hierarchical_to_json", + "hierarchical_from_json", + }, + "pylinkage.linkage": { + "bounding_box", + "extract_trajectories", + "extract_trajectory", + "kinematic_default_test", + "TransmissionAngleAnalysis", + "analyze_transmission", + "compute_transmission_angle", + "StrokeAnalysis", + "analyze_stroke", + "compute_slide_position", + "SensitivityAnalysis", + "ToleranceAnalysis", + "analyze_sensitivity", + "analyze_tolerance", + }, + "pylinkage.mechanism": { + "Joint", + "RevoluteJoint", + "PrismaticJoint", + "GroundJoint", + "TrackerJoint", + "JointType", + "Link", + "GroundLink", + "DriverLink", + "ArcDriverLink", + "LinkType", + "Mechanism", + "MechanismBuilder", + "fourbar", + "slider_crank", + "mechanism_to_dict", + "mechanism_from_dict", + "mechanism_to_json", + "mechanism_from_json", + }, + "pylinkage.optimization": { + "chain_optimizers", + "co_optimize", + "CoOptimizationConfig", + "CoOptimizationResult", + "CoOptSolution", + "collections", + "differential_evolution_optimization", + "differential_evolution_optimization_async", + "dual_annealing_optimization", + "generate_bounds", + "kinematic_maximization", + "kinematic_minimization", + "MixedChromosome", + "minimize_linkage", + "minimize_linkage_async", + "multi_objective_optimization", + "OptimizationProgress", + "ParetoFront", + "ParetoSolution", + "particle_swarm_optimization", + "particle_swarm_optimization_async", + "TopologyNeighbor", + "topology_neighbors", + "trials_and_errors_optimization", + "trials_and_errors_optimization_async", + "warm_start_co_optimization", + }, + "pylinkage.population": { + "Ensemble", + "Member", + "Population", + }, + "pylinkage.simulation": { + "Linkage", + }, + "pylinkage.solver": { + "SolverData", + "JOINT_STATIC", + "JOINT_CRANK", + "JOINT_REVOLUTE", + "JOINT_FIXED", + "JOINT_PRISMATIC", + "MAX_PARENTS", + "simulate", + "simulate_with_kinematics", + "linkage_to_solver_data", + "solver_data_to_linkage", + "update_solver_constraints", + "update_solver_positions", + }, + "pylinkage.symbolic": { + "theta", + "SymJoint", + "SymStatic", + "SymCrank", + "SymRevolute", + "SymbolicLinkage", + "symbolic_circle_intersect", + "symbolic_circle_line_intersect", + "symbolic_cyl_to_cart", + "symbolic_dist", + "symbolic_sqr_dist", + "solve_linkage_symbolically", + "eliminate_theta", + "compute_trajectory_numeric", + "create_trajectory_functions", + "check_buildability", + "SymbolicOptimizer", + "OptimizationResult", + "symbolic_gradient", + "symbolic_hessian", + "generate_symbolic_bounds", + "get_numeric_parameters", + "fourbar_symbolic", + }, + "pylinkage.synthesis": { + "PrecisionPoint", + "Pose", + "SynthesisType", + "FourBarSolution", + "DyadSolution", + "SynthesisProblem", + "SynthesisResult", + "BurmesterDyad", + "BurmesterCurves", + "function_generation", + "verify_function_generation", + "path_generation", + "path_generation_with_timing", + "verify_path_generation", + "motion_generation", + "motion_generation_3_poses", + "NBarSolution", + "GroupSynthesisResult", + "QualityMetrics", + "TopologySolution", + "six_bar_path_generation", + "generalized_synthesis", + "multi_topology_synthesize", + "nbar_solution_to_linkage", + "compute_pole", + "compute_all_poles", + "compute_circle_point_curve", + "select_compatible_dyads", + "solution_to_linkage", + "solutions_to_linkages", + "linkage_to_synthesis_params", + "fourbar_from_lengths", + "watt_from_lengths", + "stephenson_from_lengths", + "crank_angle_limits", + "grashof_check", + "is_grashof", + "is_crank_rocker", + "GrashofType", + "validate_fourbar", + }, + "pylinkage.topology": { + "compute_dof", + "compute_mobility", + "MobilityInfo", + "are_isomorphic", + "canonical_form", + "canonical_hash", + "enumerate_topologies", + "enumerate_all", + "TopologyCatalog", + "CatalogEntry", + "load_catalog", + }, + "pylinkage.visualizer": { + "animate_dashboard", + "animate_parallel_coordinates", + "dashboard_layout", + "parallel_coordinates_plot", + "plot_kinematic_linkage", + "plot_static_linkage", + "show_linkage", + "swarm_tiled_repr", + "animate_kinematics", + "plot_acceleration_vectors", + "plot_kinematics_frame", + "plot_velocity_vectors", + "show_kinematics", + "animate_linkage_plotly", + "interactive_linkage_plotly", + "plot_linkage_plotly", + "plot_linkage_plotly_with_velocity", + "plot_linkage_svg", + "plot_linkage_svg_with_velocity", + "save_linkage_svg", + "save_linkage_svg_with_velocity", + "plot_linkage_dxf", + "save_linkage_dxf", + "build_linkage_3d", + "save_linkage_step", + "LinkProfile", + "JointProfile", + }, + "pylinkage.optimization.collections": { + "Agent", + "ParetoFront", + "ParetoSolution", + }, +} + +DEPRECATED: dict[str, set[str]] = { + "pylinkage.assur": { + "MobilityResult", + "StructuralAnalysis", + }, + "pylinkage.components": { + "Dyad", + "ConnectedDyad", + }, + "pylinkage.dyads": { + "Ground", + "PointTracker", + "Crank", + "ArcCrank", + "LinearActuator", + "Linkage", + "Component", + "ConnectedComponent", + "Dyad", + "ConnectedDyad", + }, + "pylinkage.optimization": { + "Ensemble", + }, + "pylinkage.synthesis": { + "Dyad", + }, +} + +FROZEN_PACKAGES = sorted(PUBLIC) + + +@pytest.mark.parametrize("package", FROZEN_PACKAGES) +def test_all_matches_frozen_surface(package: str) -> None: + """``__all__`` lists exactly the public and deprecated names, nothing else.""" + module = importlib.import_module(package) + expected = PUBLIC[package] | DEPRECATED.get(package, set()) + actual = set(module.__all__) + assert actual == expected, ( + f"{package}.__all__ drifted from the frozen surface.\n" + f" unexpected: {sorted(actual - expected)}\n" + f" missing: {sorted(expected - actual)}" + ) + + +@pytest.mark.parametrize("package", FROZEN_PACKAGES) +def test_public_names_resolve_silently(package: str) -> None: + """Every public name is reachable and reading it warns about nothing.""" + module = importlib.import_module(package) + with warnings.catch_warnings(): + warnings.simplefilter("error") + for name in PUBLIC[package]: + getattr(module, name) + + +@pytest.mark.parametrize( + ("package", "name"), + [(package, name) for package, names in DEPRECATED.items() for name in sorted(names)], +) +def test_deprecated_names_warn(package: str, name: str) -> None: + """Every deprecated name still resolves, and says so.""" + module = importlib.import_module(package) + with pytest.warns(DeprecationWarning, match=f"{package}.{name} is deprecated"): + getattr(module, name) + + +def test_no_underscore_names_are_public() -> None: + """A leading underscore and a place in ``__all__`` contradict each other.""" + leaked = [ + f"{package}.{name}" + for package, names in PUBLIC.items() + for name in names + if name.startswith("_") + ] + assert not leaked From dd044a12caed469d591b1265af434fca797e9ad8 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Sat, 12 Sep 2026 12:14:29 +0200 Subject: [PATCH 2/2] fix(typing): let type checkers see lazily loaded package names optimization, visualizer, solver and the top-level package load some names on first access through a module __getattr__ that returns object. That is invisible to a type checker: leggedsnake importing CoOptimizationConfig from pylinkage.optimization, as the public-surface rule now asks it to, got "object not callable" and "not valid as a type" from mypy. Each of those packages now carries a TYPE_CHECKING block importing the same names eagerly. Runtime behaviour is unchanged; the block never executes. --- src/pylinkage/__init__.py | 20 ++++++++++++++++ src/pylinkage/optimization/__init__.py | 29 ++++++++++++++++++++++ src/pylinkage/solver/__init__.py | 11 +++++++++ src/pylinkage/visualizer/__init__.py | 33 ++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/src/pylinkage/__init__.py b/src/pylinkage/__init__.py index da72c2b..7d16a7b 100644 --- a/src/pylinkage/__init__.py +++ b/src/pylinkage/__init__.py @@ -87,6 +87,7 @@ ] import importlib as _importlib +from typing import TYPE_CHECKING # --- Eager imports (lightweight, always available) --- from . import actuators as actuators @@ -182,6 +183,25 @@ # --- Lazy imports (heavy optional dependencies) --- +if TYPE_CHECKING: + # Eager imports for type checkers only; at runtime these load on first access. + from . import optimization, population, solver, symbolic, synthesis, visualizer + from .optimization import ( + collections, + generate_bounds, + kinematic_maximization, + kinematic_minimization, + particle_swarm_optimization, + trials_and_errors_optimization, + ) + from .population import Ensemble, Member, Population + from .visualizer import ( + plot_kinematic_linkage, + plot_static_linkage, + show_linkage, + swarm_tiled_repr, + ) + _LAZY_SUBMODULES = { "optimization", "population", diff --git a/src/pylinkage/optimization/__init__.py b/src/pylinkage/optimization/__init__.py index ddd6a2c..7725a7a 100644 --- a/src/pylinkage/optimization/__init__.py +++ b/src/pylinkage/optimization/__init__.py @@ -32,6 +32,7 @@ ] import importlib as _importlib +from typing import TYPE_CHECKING # Eagerly import lightweight submodules from . import collections as collections @@ -52,6 +53,34 @@ ) # Lazy-loaded attributes (require scipy / pyswarms) +if TYPE_CHECKING: + # Eager imports for type checkers only; at runtime these load on first access. + from .async_optimization import ( + OptimizationProgress, + differential_evolution_optimization_async, + minimize_linkage_async, + particle_swarm_optimization_async, + trials_and_errors_optimization_async, + ) + from .co_optimization_types import ( + CoOptimizationConfig, + CoOptimizationResult, + CoOptSolution, + MixedChromosome, + ) + from .grid_search import trials_and_errors_optimization + from .mixed_variable import co_optimize + from .multi_objective import multi_objective_optimization + from .particle_swarm import particle_swarm_optimization + from .scipy_optimize import ( + chain_optimizers, + differential_evolution_optimization, + dual_annealing_optimization, + minimize_linkage, + ) + from .topology_neighborhood import TopologyNeighbor, topology_neighbors + from .warm_start import warm_start_co_optimization + _LAZY_ATTRS: dict[str, tuple[str, str]] = { "chain_optimizers": (".scipy_optimize", "chain_optimizers"), "differential_evolution_optimization": ( diff --git a/src/pylinkage/solver/__init__.py b/src/pylinkage/solver/__init__.py index 7684fbb..82f03eb 100644 --- a/src/pylinkage/solver/__init__.py +++ b/src/pylinkage/solver/__init__.py @@ -23,6 +23,8 @@ ... ) """ +from typing import TYPE_CHECKING + # Pure numba components (no Python object dependencies). The kernels stay # importable from here but are not public: see tests/test_public_api.py. from .acceleration import ( @@ -79,6 +81,15 @@ # Conversion functions are loaded lazily to avoid circular imports. They are # implemented in pylinkage.bridge (which keeps this package free of Python # object dependencies) but this package is their public home. +if TYPE_CHECKING: + # Eager imports for type checkers only; at runtime these load on first access. + from ..bridge.solver_conversion import ( + linkage_to_solver_data, + solver_data_to_linkage, + update_solver_constraints, + update_solver_positions, + ) + _conversion_attrs = { "linkage_to_solver_data", "solver_data_to_linkage", diff --git a/src/pylinkage/visualizer/__init__.py b/src/pylinkage/visualizer/__init__.py index 72fdbfe..e84c271 100644 --- a/src/pylinkage/visualizer/__init__.py +++ b/src/pylinkage/visualizer/__init__.py @@ -47,8 +47,41 @@ ] import importlib as _importlib +from typing import TYPE_CHECKING # Mapping from public name to (module, attribute) within this package. +if TYPE_CHECKING: + # Eager imports for type checkers only; at runtime these load on first access. + from .animated import plot_kinematic_linkage, show_linkage, swarm_tiled_repr + from .drawsvg_viz import ( + plot_linkage_svg, + plot_linkage_svg_with_velocity, + save_linkage_svg, + save_linkage_svg_with_velocity, + ) + from .dxf_export import plot_linkage_dxf, save_linkage_dxf + from .kinematics import ( + animate_kinematics, + plot_acceleration_vectors, + plot_kinematics_frame, + plot_velocity_vectors, + show_kinematics, + ) + from .plotly_viz import ( + animate_linkage_plotly, + interactive_linkage_plotly, + plot_linkage_plotly, + plot_linkage_plotly_with_velocity, + ) + from .pso_plots import ( + animate_dashboard, + animate_parallel_coordinates, + dashboard_layout, + parallel_coordinates_plot, + ) + from .static import plot_static_linkage + from .step_export import JointProfile, LinkProfile, build_linkage_3d, save_linkage_step + _LAZY_ATTRS: dict[str, tuple[str, str]] = { # Matplotlib backend "plot_kinematic_linkage": (".animated", "plot_kinematic_linkage"),