Package architecture
A SciStudio package is an ordinary Python distribution with three jobs: declare some types, declare some blocks that produce and consume them, and (optionally) declare previewers that show them. This page explains how those pieces relate, how core finds them, and — most importantly — the boundary between what your package exposes and what it keeps to itself.
Two surfaces, two audiences
The single idea that keeps a package clean is that it faces two different audiences, and they need different things:
-
A registration surface — to core. The block, type, and previewer classes core needs to populate the palette, the type system, and the preview router. This is a handshake with the runtime, wired through entry points. It is not something a human imports.
-
A reuse surface — to other authors. The symbols a user (or the in-app AI on their behalf) imports when writing their own block, plot, or script against your package: your types, the constructors and readers on them, and any helpers worth sharing.
Conflating these is what makes packages hard to build on. The registration surface is for the engine; the reuse surface is for people. Design them separately.
Layout
The spectroscopy package is a good template to copy. Its shape:
scistudio-blocks-spectroscopy/
pyproject.toml # deps + the three entry points
src/scistudio_blocks_spectroscopy/
__init__.py # the package's PUBLIC surface (__all__) + entry callables
types.py # Spectrum, SpectralDataset
_support.py # INTERNAL helpers — not public
blocks/
__init__.py # aggregates BLOCKS
preprocessing.py # BaselineCorrection, SmoothSpectrum, ...
feature_extraction.py # ExtractIntensity, FindPeaks, ...
...
previewers/
__init__.py # get_previewers() -> [PreviewerSpec, ...]
providers.py # the backend provider functions
assets/ # viewer.js + CSS the frontend loads
tests/
Two conventions carry the public/private boundary:
-
The package top level (
__init__.py) is the public front door. Everything an author may import is re-exported there and listed in__all__. An author writesfrom scistudio_blocks_spectroscopy import Spectrum, neverfrom scistudio_blocks_spectroscopy.types import Spectrum. The deep path is an internal detail you must be free to move. -
A leading underscore means internal.
_support.pyholds genuinely package-internal code — Arrow plumbing, coercion helpers, ID generation — that no outside author should call. It is not in__all__and carries no stability promise.
How core discovers a package
Core finds your package only through three entry-point callables declared in
pyproject.toml. There is no import-time magic and no scanning of your modules:
[project.entry-points."scistudio.blocks"]
scistudio_blocks_spectroscopy = "scistudio_blocks_spectroscopy:get_block_package"
[project.entry-points."scistudio.types"]
scistudio_blocks_spectroscopy = "scistudio_blocks_spectroscopy:get_types"
[project.entry-points."scistudio.previewers"]
scistudio_blocks_spectroscopy = "scistudio_blocks_spectroscopy.previewers:get_previewers"
Each points at a plain function:
get_block_package()→(PackageInfo, [BlockClass, ...])— your blocks plus package metadata (name, version, update channel).get_types()→[TypeClass, ...]— your data types (here[Spectrum, SpectralDataset]).get_previewers()→[PreviewerSpec, ...]— your previewer specs.
That is the whole registration contract. Everything else about your package is
your own business. The shared scripts/validate_contract.py checks these
callables exist and return the right shapes.
How the pieces relate at runtime
┌─────────── your package ───────────┐
│ │
types ───┤ Spectrum (a Series subclass) ──────┼──► type system: ports that
│ ▲ ▲ │ accept_types=[Spectrum]
│ │ produces/consumes│ │
blocks ───┤ BaselineCorrection (ProcessBlock) ──┼──► palette + engine
│ │ │
previewers┤ PreviewerSpec(target_type=Spectrum)─┼──► preview router shows a
│ │ Spectrum on its port
└─────────────────────────────────────┘
A block names your types on its ports; the type system uses those names to type-check connections on the canvas; and a previewer registered for a type is what the user sees when they click a port carrying it. The three contributions are independent entry points but cohere around your types — which is why the types are where you start.
The reuse surface in practice
When another author writes a block that consumes your Spectrum, they need to:
- name it on a port — so
Spectrummust be public (it is, via__all__); - construct it — so your type needs a public constructor that takes domain-native inputs (Section types.md);
- read it — solved for free by the inherited
to_memory()/to_pandas()/to_numpy(); a package must not redefine those.
Everything they need is on the type or in a public helper. Nothing forces them
into _support. When you find yourself wishing an outside author could call
something private, that is the signal to give it a public home — that gap is the
exact problem this contract exists to close.
Next
Start with types.md: your types anchor everything else.