21. Plugin Packages
Extension Recipes shows how to add a new surface geometry, material, or analysis by editing Optiland’s own source tree. This page instead walks through shipping each of those as a separate, pip-installable package that Optiland discovers automatically at runtime, with no edit to Optiland itself, using Python entry points.
Optiland defines three entry-point groups: optiland.surfaces, optiland.materials, and
optiland.analyses. A plugin package declares one or more of them in its own
pyproject.toml and points each at a zero-argument callable. optiland/plugins.py resolves
and calls every registered callable, at most once per process:
import optiland.plugins as plugins
plugins.load_plugins(plugins.SURFACES_GROUP)
For surfaces and materials, this happens lazily, triggered by the first access to
GeometryFactory/MaterialRegistryrespectively — installing Optiland with no plugins present pays no import-time cost.For analyses, there is no factory to hang a lazy trigger off (analyses are instantiated directly, e.g.
SpotDiagram(optic), not looked up by string), sooptiland.analysisloads theoptiland.analysesgroup eagerly on package import instead.
A failing plugin only produces a UserWarning — it never breaks Optiland for everyone else
installed alongside it.
21.1. Walkthrough 1: A Surface Geometry Plugin
Package layout for a standalone optiland-my-surface distribution:
optiland-my-surface/
pyproject.toml
my_surface_plugin/
__init__.py
geometry.py
register.py
my_surface_plugin/geometry.py — the geometry itself, following Extension Recipes
Recipe 1 (subclass BaseGeometry or NewtonRaphsonGeometry):
from __future__ import annotations
from optiland.geometries.newton_raphson import NewtonRaphsonGeometry
class MyGeometry(NewtonRaphsonGeometry):
def sag(self, x, y):
...
def _surface_normal(self, x, y):
...
my_surface_plugin/register.py — the entry-point target. It registers a factory function and
config dataclass against Optiland’s GeometryFactory, exactly as an in-tree geometry would:
from dataclasses import dataclass
from optiland.surfaces.factories.geometry_factory import GeometryFactory
@dataclass
class MyGeometryConfig:
surface_type = "my_surface"
radius: float = float("inf")
def _create_my_geometry(cs, config):
from .geometry import MyGeometry
return MyGeometry(cs, radius=config.radius)
def register() -> None:
GeometryFactory.register("my_surface", _create_my_geometry, MyGeometryConfig)
pyproject.toml of the plugin package:
[project.entry-points."optiland.surfaces"]
my_surface = "my_surface_plugin.register:register"
Once pip install optiland-my-surface is run alongside Optiland,
optic.surfaces.add(surface_type="my_surface", ...) works with no changes to Optiland’s
source.
21.2. Walkthrough 2: A Material Catalog Plugin
Scenario: ship a proprietary or vendor glass catalog as an installable package rather than asking every user to load a CSV/YAML file by hand.
Package layout:
optiland-acme-glass/
pyproject.toml
acme_glass_plugin/
__init__.py
register.py
data/
acme_catalog.yml
acme_glass_plugin/register.py:
from importlib import resources
from optiland.materials.registry import MaterialRegistry
def register() -> None:
catalog_path = resources.files("acme_glass_plugin.data") / "acme_catalog.yml"
MaterialRegistry.instance().register_file(str(catalog_path))
pyproject.toml:
[project.entry-points."optiland.materials"]
acme_glass = "acme_glass_plugin.register:register"
After installing optiland-acme-glass, materials from acme_catalog.yml resolve through
the normal Material("...") lookup path the first time any material is resolved — the
catalog file never has to live inside the user’s own project.
21.3. Walkthrough 3: An Analysis Plugin
Scenario: ship a custom performance metric (e.g. a proprietary stray-light or ghost-image analysis) as an installable package.
Package layout:
optiland-ghost-analysis/
pyproject.toml
ghost_analysis_plugin/
__init__.py
analysis.py
register.py
ghost_analysis_plugin/analysis.py — following Extension Recipes Recipe 4:
from __future__ import annotations
from optiland.analysis.base import BaseAnalysis
class GhostImageAnalysis(BaseAnalysis):
def _generate_data(self):
...
def view(self, show: bool = True):
...
Analyses have no central factory, so the plugin’s register() makes the class importable
from Optiland’s own namespace, matching how in-tree analyses are exposed:
import sys
def register() -> None:
from .analysis import GhostImageAnalysis
setattr(sys.modules["optiland.analysis"], "GhostImageAnalysis", GhostImageAnalysis)
pyproject.toml:
[project.entry-points."optiland.analyses"]
ghost_analysis = "ghost_analysis_plugin.register:register"
After installing optiland-ghost-analysis, from optiland.analysis import
GhostImageAnalysis works without Optiland ever having imported the plugin package by name.
(A plugin analysis is also perfectly usable without registering into optiland.analysis at
all — nothing stops a caller from doing from ghost_analysis_plugin.analysis import
GhostImageAnalysis directly. The entry point only buys the convenience of the familiar
optiland.analysis import path.)
See Extension Recipes for the in-tree-edit versions of these same three recipes, and
CONTRIBUTING.md for the dead-code-audit and tooling conventions that apply to plugin
development the same as to the core package.