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 / MaterialRegistry respectively — 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), so optiland.analysis loads the optiland.analyses group 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.