Metadata-Version: 2.5
Name: koios-component-builder
Version: 1.3.0
Summary: Simplified component builder for the Koios platform
Project-URL: Homepage, https://www.ai-op.com
Project-URL: Documentation, https://docs.ai-op.com
Author-email: "Ai-OPs, Inc." <support@ai-op.com>
License: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: click>=8.0.0
Requires-Dist: packaging>=23.0
Requires-Dist: pandas>=2.0.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: basedpyright>=1.29.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: pyyaml>=6.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# Koios Component Builder

SDK and CLI for building component libraries for the [Koios](https://ai-op.com) platform. Components are reusable logic blocks with typed inputs and outputs that get wired together and executed by the Koios Component Engine at configurable scan rates.

## Installation

```bash
pip install koios-component-builder
```

Requires Python 3.12+.

## Quick Start

### 1. Write a component

```python
# my_library/math_ops.py
from koios_component_builder import (
    Component,
    ComponentCategory,
    ComponentIcon,
    Input,
    Output,
)


class SimpleAdder(Component):
    """A component that adds two numbers."""

    class Meta:
        icon = ComponentIcon.SUM
        category = ComponentCategory.MATH

    a: Input[float] = Input(default=0.0, description="First number")
    b: Input[float] = Input(default=0.0, description="Second number")
    result: Output[float] = Output(default=0.0, description="Sum of a and b")

    def execute(self) -> None:
        self.result = self.a + self.b
```

### 2. Define a library

```python
# my_library/__init__.py
from koios_component_builder import ComponentLibrary
from .math_ops import SimpleAdder, Multiplier


class MyLibrary(ComponentLibrary):
    """My custom component library."""

    name = "my-library"
    major = 1
    minor = 0
    patch = 0
    description = "Custom math components"

    components = [SimpleAdder, Multiplier]


__all__ = ["MyLibrary", "SimpleAdder", "Multiplier"]
```

### 3. Export

```bash
koios-component-builder export my_library/
```

This creates a `.kcl` (Koios Component Library) package in `dist/` that can be uploaded to Koios.

## Component Lifecycle

When deployed to Koios, each execution cycle:

1. **Field Injection** — Input and config values are set on the component
2. **Setup** — `setup()` runs once before the first `execute()` (if overridden)
3. **Execute** — `execute()` runs with current input values
4. **Output Propagation** — Output values are sent to wired destinations

Your component implements `setup()` (optional) and `execute()` — the engine handles all wiring and data flow.

### `setup()` — One-Time Initialization

Override `setup()` for expensive work that should only happen once, such as loading models, creating registries, or parsing configuration. All field values are available when `setup()` runs.

If `setup()` raises an exception, the component is marked FAILED and retried on the next cycle.

```python
class UnitConverter(Component):
    """Converts Fahrenheit to Celsius using pint."""

    class Meta:
        icon = ComponentIcon.TRANSFORM
        category = ComponentCategory.TRANSFORM

    value: Input[float] = Input(default=0.0, description="Temperature in °F")
    decimals: NumberConfig = NumberConfig(
        default=2, min_value=0, max_value=6, description="Decimal places"
    )
    result: Output[float] = Output(default=0.0, description="Temperature in °C")

    def setup(self) -> None:
        from pint import UnitRegistry
        self._ureg = UnitRegistry()  # ~80ms — only runs once

    def execute(self) -> None:
        temp = self._ureg.Quantity(self.value, self._ureg.degF)
        self.result = round(temp.to(self._ureg.degC).magnitude, int(self.decimals))
```

For resources shared across **all instances** of the same component class, use a class-level guard instead:

```python
class MyComponent(Component):
    def execute(self) -> None:
        if not hasattr(MyComponent, "_shared_model"):
            MyComponent._shared_model = load_model()
        self.result = MyComponent._shared_model.predict(self.input)
```

## Field Types

### Input / Output

```python
from koios_component_builder import Component, Input, Output

class ExampleComponent(Component):
    # Supported types: float, int, bool, str, list, dict
    temperature: Input[float] = Input(default=0.0, description="Temperature in Celsius")
    count: Input[int] = Input(default=0, description="Item count")
    enabled: Input[bool] = Input(default=True, description="Enable processing")
    mode: Input[str] = Input(default="auto", description="Operating mode")

    alarm: Output[bool] = Output(default=False, description="High temperature alarm")
    status: Output[str] = Output(default="ok", description="Current status")

    def execute(self) -> None:
        if self.enabled and self.temperature > 100:
            self.alarm = True
            self.status = "overtemp"
        else:
            self.alarm = False
            self.status = "ok"
```

### Config Fields

Config fields are set when the component instance is created in the Koios UI and remain constant during execution. They appear as configuration controls on the component node.

```python
from koios_component_builder import (
    Component, Input, Output,
    NumberConfig, StringConfig, ChoiceConfig, BoolConfig,
)

class ConfigurableComponent(Component):
    # Numeric with min/max validation
    threshold: NumberConfig = NumberConfig(
        default=75.0, min_value=0.0, max_value=100.0,
        description="Alert threshold"
    )

    # Dropdown with predefined options
    mode: ChoiceConfig = ChoiceConfig(
        default="average", choices=["average", "median", "max"],
        description="Calculation mode"
    )

    # Text with optional regex validation
    label: StringConfig = StringConfig(
        default="Sensor", description="Display label"
    )

    # Boolean toggle
    verbose: BoolConfig = BoolConfig(
        default=False, description="Enable verbose output"
    )

    value: Input[float] = Input(default=0.0)
    alert: Output[bool] = Output(default=False)

    def execute(self) -> None:
        self.alert = self.value > self.threshold
```

### File Fields

`FileConfig` renders as an upload control in the Koios UI. At runtime the
component receives a `ComponentFile` handle — not a raw path — pointing at the
file the operator uploaded for that instance. Each instance has its own file,
and uploads are versioned, so an operator can swap a model and revert.

```python
from koios_component_builder import Component, FileConfig, Input, Output

class Scorer(Component):
    reading: Input[float] = Input(default=0.0)
    score: Output[float] = Output(default=0.0)

    model_file: FileConfig = FileConfig(
        description="Trained ONNX model",
        extensions=[".onnx", ".tflite"],   # leading dot optional, case ignored
        mime_types=["application/octet-stream"],
        max_bytes=200 * 1024 * 1024,
        required=True,
    )

    def setup(self) -> None:
        # required=True, so Koios guarantees the file is here before setup runs
        import onnxruntime
        self.session = onnxruntime.InferenceSession(str(self.model_file.path))

    def execute(self) -> None:
        self.score = float(self.session.run(None, {"x": [[self.reading]]})[0])
```

Load the file in `setup()`, not `execute()` — `setup()` runs once, and Koios
re-runs it after an operator replaces the file, so a swap takes effect without
a restart.

**`required` decides what happens when no file is uploaded**, so you never
write that check yourself:

| | Behavior |
|---|---|
| `required=True` | Koios fails the instance with a specific message *before* `setup()` runs. Component code can assume the file is present. |
| `required=False` | The field is `None`. Guard with `if self.field:`. |

**Handle API** — `path`, `name`, `suffix`, `size_bytes`, `content_type`,
`sha256`, `uploaded_at`, `version`, plus `read_bytes()`, `read_text()`,
`open()` and `exists()`. Reading through the handle rather than calling the
`open` builtin keeps your component clear of the security audit's file-I/O
review tier.

```python
class Lookup(Component):
    table: FileConfig = FileConfig(extensions=[".csv"])  # optional

    def setup(self) -> None:
        self.rows = self.table.read_text().splitlines() if self.table else []
```

**Koios enforces the constraints server-side**, so your declaration holds no
matter what uploads the file. Extension is the deterministic gate; MIME only
filters the file dialog, since a browser can claim anything and `.onnx` is in
no MIME registry. `max_bytes` is optional — omit it and a platform ceiling
applies — and a value above that ceiling is clamped down to it, so a field can
lower the cap but never raise it. Files are never executed by Koios; they are
handed to the component that asked for them.

### HistoryInput

HistoryInput fields provide access to historical tag data via InfluxDB. They must be wired to a HISTORY connector on the environment canvas.

```python
from koios_component_builder import Component, HistoryInput, Output

class TrendAnalyzer(Component):
    """Calculates trend from historical data."""

    sensor_history: HistoryInput = HistoryInput(
        description="Historical sensor readings"
    )
    trend: Output[float] = Output(default=0.0, description="Trend slope")

    def execute(self) -> None:
        if self.sensor_history is None:
            return  # Not wired to a history connector

        # Fetch last hour of data, max 200 samples
        df = self.sensor_history.get_history(
            period_seconds=3600,
            num_samples=200,
        )
        # df has columns: timestamp, value
        if not df.empty:
            values = df["value"].tolist()
            self.trend = values[-1] - values[0]
```

## Component Metadata

Customize how components appear in the Koios UI:

```python
class MyComponent(Component):
    """Component description shown in the UI."""

    class Meta:
        icon = ComponentIcon.CHART_LINE       # Tabler icon name
        category = ComponentCategory.ANALYSIS  # UI grouping
        canvas_width = 8                       # Node width (4–15 grid units, default: 6)
        canvas_minimal = False                 # Compact mode (no header/footer)

        # Optional: component-specific version (overrides library version)
        major = 2
        minor = 1
        patch = 0
        prerelease = "beta"
```

**Icons** — Any [Tabler icon](https://tabler.io/icons) name in kebab-case. Common constants: `SUM`, `CALCULATOR`, `CHART_LINE`, `GAUGE`, `THERMOMETER`, `FILTER`, `WAVE_SINE`, `TOGGLE_LEFT`, `ALERT_TRIANGLE`, `TRANSFORM`.

**Categories** — Standard constants: `MATH`, `STATISTICS`, `LOGIC`, `ANALYSIS`, `TRANSFORM`, `FILTER`, `CONTROL`, `MONITORING`. Custom strings are also accepted.

## Pin Layout

By default pins are laid out on the canvas in the order they appear in your class body. For control-systems blocks where grouping matters — setpoints together, tuning constants together, status outputs apart — you can declare an explicit arrangement in `Meta` and insert gaps between pins. The arrangement ships in the `.kcl` manifest so every instance of the component starts with the same visual default. Users can further tweak the layout per-instance from the Koios UI.

```python
from koios_component_builder import (
    Component,
    ComponentCategory,
    ComponentIcon,
    Gap,
    Input,
    NumberConfig,
    Output,
)


class PIDController(Component):
    """Discrete PID controller with anti-windup."""

    class Meta:
        icon = ComponentIcon.GAUGE
        category = ComponentCategory.CONTROL
        canvas_width = 8

        # Group setpoint + process variable, give the tuning constants their
        # own visual block, separate the enable/reset inputs at the bottom,
        # and keep the diagnostic outputs distinct from the control output.
        inputs_layout = [
            "setpoint",
            "process_variable",
            Gap(),
            "kp",
            "ki",
            "kd",
            Gap(size=2),
            "enable",
            "reset",
        ]
        outputs_layout = [
            "control_output",
            Gap(),
            "saturated",
            "integral",
        ]

    setpoint: Input[float] = Input(default=0.0, description="Target value")
    process_variable: Input[float] = Input(default=0.0, description="Measured value")
    kp: Input[float] = Input(default=1.0, description="Proportional gain")
    ki: Input[float] = Input(default=0.0, description="Integral gain")
    kd: Input[float] = Input(default=0.0, description="Derivative gain")
    enable: Input[bool] = Input(default=True, description="Enable control")
    reset: Input[bool] = Input(default=False, description="Reset integrator")

    output_min: NumberConfig = NumberConfig(default=-100.0, description="Output lower bound")
    output_max: NumberConfig = NumberConfig(default=100.0, description="Output upper bound")

    control_output: Output[float] = Output(default=0.0, description="Manipulated variable")
    saturated: Output[bool] = Output(default=False, description="Output is at a limit")
    integral: Output[float] = Output(default=0.0, description="Current integrator state")

    def execute(self) -> None:
        ...  # PID math
```

**`Gap(size=1)`** — Insert a vertical spacer between pins. `size` is in grid units (1 row = the height of one pin); defaults to 1.

**Validation** — Strings in `inputs_layout` / `outputs_layout` are checked against your declared `Input` / `Output` / `HistoryInput` fields at class-creation time. A typo raises `ComponentDefinitionError` on import, not at runtime.

**Partial layouts** — Pins declared on the class but absent from the layout list are appended in declaration order, so you can add new pins to a component without touching the layout.

**Empty layouts** — If neither list is declared, pins use class-body order with no gaps — the same behavior components had before this feature.

> **Note on `Input(order=...)`** — The old per-field `order=` parameter is deprecated for `Input`, `Output`, and `HistoryInput`. Use `Meta.inputs_layout` / `Meta.outputs_layout` instead — it gives finer control (gaps, explicit grouping) and the contract is clearer to read at the top of the class. The `order=` parameter on `Config` family fields is unaffected.

## Dependencies

Libraries can declare third-party Python package dependencies. The builder resolves them against the Koios platform manifest to determine what's pre-installed in the container vs. what needs bundling.

```python
class MyProtocolLibrary(ComponentLibrary):
    name = "my-protocol-library"
    major = 1
    minor = 0
    dependencies = ["crcmod", "minimalmodbus>=2.0"]
    components = [MyDevice]
```

**Three tiers:**

| Tier | Description | Example |
|------|-------------|---------|
| Platform | Pre-installed in the Koios container | `numpy`, `pandas`, `scipy` |
| Bundled | Downloaded and included in the `.kcl` | `crcmod`, `pint` |
| SDK | Always available (koios-component-builder itself) | `pydantic`, `click` |

```bash
# Export without bundling (warns about non-platform deps)
koios-component-builder export my_library/

# Bundle non-platform dependencies into the .kcl (deprecated — prefer a
# package stack attached to the component environment in Koios)
koios-component-builder export my_library/ --include-deps

# Target specific platforms
koios-component-builder export my_library/ --include-deps \
    --platform manylinux2014_x86_64 --platform manylinux2014_aarch64

# List available platform packages
koios-component-builder platform-packages
```

### System binaries

`platform-packages` lists Python distributions only. Some libraries shell out to
a solver or CLI tool instead, and those are installed as system packages in the
Koios image — they will never appear in that listing, and declaring them as a
dependency will not work.

| Binary | Provides | Available from |
|--------|----------|----------------|
| `glpsol` | GLPK — LP and MIP solver | Koios 1.2.0 |

A `pyomo` model can call it directly:

```python
import pyomo.environ as pyo

results = pyo.SolverFactory("glpk").solve(model)
```

`pyomo` itself is not pre-installed — declare it as a dependency and export with
`--include-deps`.

For a solver that ships as a Python wheel and needs no system package, use
[HiGHS](https://highs.dev) via `highspy`: add it to `dependencies` and it bundles
like any other third-party package. HiGHS is generally faster than GLPK on larger
LP/MIP problems.

## Exporting a stack bundle (.kps)

A **component stack** is a named, isolated set of packages inside Koios that
component environments can be attached to. It exists for dependencies that
conflict with the platform's own — a library capping `pandas<3` when the
container ships pandas 3, for example.

Wheels can be uploaded one at a time in the Koios UI, or exported here as a
single `.kps` bundle:

```bash
# Bundle a requirements file (repeatable; --name defaults to the file's name)
koios-component-builder export-stack -r requirements.txt

# Bundle everything installed in the environment you build in
koios-component-builder export-stack --from-env --name ml-tools

# Target a specific platform / Python version, and allow a longer download
koios-component-builder export-stack -r requirements.txt \
    --platform manylinux2014_x86_64 --python-version 3.12 --timeout 1800
```

Wheels are **downloaded for the target platforms** — linux amd64 + arm64 by
default — never copied out of your local site-packages, so a bundle built on a
Mac installs on the on-prem Linux container. Packages the Koios container
already provides are pinned during resolution (a hard conflict fails the
download rather than shipping a mismatched transitive dependency) and then
excluded from the bundle, exactly as `--include-deps` treats them. Each target
platform is resolved in its own download, so a package with per-architecture
wheels ships one for each.

**Every target platform must resolve.** A package with no `aarch64` wheel fails
the whole export rather than producing a bundle that installs on one
architecture and silently claims both. Two ways forward: pass `--platform
manylinux2014_x86_64` (or whichever architecture you deploy on) to bundle for
just that one, or pin the package to a version that publishes wheels for both.
The same applies when the platforms resolve a package to *different* versions —
the manifest records one version per package, so the export names the package
and the versions and stops.

Requirements files may include other files with `-r other.txt`; other pip
option lines (`--index-url`, `-e`, `-f`) are rejected. `--from-env` additionally
leaves out the SDK's own dependencies — `pydantic`, `click`, `packaging` and
what they pull in are always installed alongside the builder, and the container
already provides them. Every export ends by listing what it withheld and why:
`--from-env` accounts for every installed name, and a requirements export names
the requirements you gave it that the container already provides. Dependencies
pulled in transitively are resolved by pip and reported only in the log.

**No Koios distribution is ever bundled.** `koios-component-builder`,
`koios-component-engine`, `koiosutility` and `koioslicense` are the packages the
engine loads your components *with*; a stack carrying its own copy replaces them
at import time and the worker cannot start. `--from-env` leaves every `koios*`
distribution out — the machine you build on always has the SDK installed — and
naming one of the four in a requirements file stops the export.

Import the resulting file in the Koios UI under **Components > Stacks**.

```
ml-tools.kps
├── manifest.json                              # Bundle metadata
└── wheels/
    ├── mlflow-3.13.0-py3-none-any.whl
    └── pandas-2.3.3-cp312-cp312-manylinux2014_x86_64.whl
```

`manifest.json` records the bundle format version, stack name, build time,
target Python version and platforms, the requirements it was resolved from, and
every bundled package with its wheel filenames.

> **Trust** — code installed into a stack runs with full platform privileges on
> the Koios server. Only bundle wheels from sources you trust.

Bundling a library's dependencies into its `.kcl` with `--include-deps` is
**deprecated** and will be removed in a future major release. Provide
third-party packages by attaching a package stack to the component environment
in Koios instead: stacks are curated per environment, isolated from the
platform's versions, and shared across every library that runs there, where
bundled wheels are injected into whichever worker runs the component.
`--include-deps` continues to work in the meantime and prints a warning.

## Package Format

The `export` command creates a `.kcl` (Koios Component Library) package — a ZIP archive:

```
my-library-1.0.0.kcl
├── manifest.json                              # Library metadata
├── my_library-1.0.0-py3-none-any.whl         # Python wheel
└── deps/                                      # Bundled dependency wheels (if any)
    ├── crcmod-1.7-cp312-...-x86_64.whl
    └── crcmod-1.7-cp312-...-aarch64.whl
```

## Security Audit

Every `.kcl` export automatically runs a static security analysis on your source code. The audit classifies patterns into three tiers:

| Tier | Meaning | Effect |
|------|---------|--------|
| ✅ ALLOW | Safe — no friction | Not reported |
| ⚠️ REVIEW | Flagged for attention, but build continues | Logged as warning, yellow badge in Koios UI |
| ❌ DENY | Blocked | Export fails unless `--allow-unsafe` is used |

### What gets flagged

**DENY** — patterns that have no legitimate use in a component:
- Dangerous imports: `os`, `subprocess`, `socket`, `threading`, `pickle`, `ctypes`, `sys`, etc.
- Unsafe builtins: `eval()`, `exec()`, `compile()`, `__import__()`
- Sandbox escape patterns: `__subclasses__`, `__builtins__`, `__code__`, `__globals__`

**REVIEW** — patterns that are often legitimate but worth being aware of:
- Filesystem imports: `pathlib`, `io`, `csv` (common for loading AI model files in `setup()`)
- `open()` calls (useful for reading model weights or config files)
- Unknown third-party packages not in the platform allow-list

### Audit command

Run the audit without building a `.kcl`:

```bash
# Audit and print results
koios-component-builder audit my_library/

# Output as JSON (for CI integration)
koios-component-builder audit my_library/ --json

# Audit with a custom policy
koios-component-builder audit my_library/ --policy security_policy.yaml
```

Exit code is `1` if any DENY findings are present, `0` otherwise — suitable for CI pipelines.

### Custom policies

Override the defaults by providing a YAML policy file:

```yaml
# security_policy.yaml
policy_version: "1.0"

imports:
  allow:
    - my_internal_sdk      # Trust your own internal package
    - requests             # Allow HTTP if your components genuinely need it
  deny:
    - pandas               # Tighten if you want to forbid heavy deps

builtins:
  review:
    - open                 # Already REVIEW by default; shown here for reference

attributes:
  deny:
    - __dict__             # Add to deny if you want stricter introspection rules
```

Policy overrides work bidirectionally — you can move items to a less restrictive tier (e.g. DENY → ALLOW) or a more restrictive one (e.g. ALLOW → DENY). Items are removed from conflicting tiers automatically.

```bash
koios-component-builder export my_library/ --policy security_policy.yaml
```

### Overriding for power users

If your library has a legitimate reason for a flagged pattern (e.g. loading an ONNX model from disk in `setup()`), the recommended approach is to use `pathlib` / `io` which are REVIEW-tier rather than DENY. For edge cases that genuinely need a denied import:

```bash
koios-component-builder export my_library/ --allow-unsafe
```

The `.kcl` is still created, but the `security_audit` in `manifest.json` records `passed: false` — the Koios UI will display a warning badge when the library is uploaded.

## CLI Reference

```bash
# Export a library to a .kcl package
koios-component-builder export <source_path> [OPTIONS]

Options:
  -o, --output PATH        Output directory (default: dist/)
  --include-deps           Bundle non-platform dependencies (deprecated)
  --platform TEXT          Target platform tag (repeatable)
  --allow-unsafe           Create .kcl even with DENY findings
  --policy PATH            Custom security policy YAML file

# Export a stack bundle to a .kps package
koios-component-builder export-stack [OPTIONS]

Options:
  -r, --requirements PATH  Requirements file to bundle (repeatable)
  --from-env               Bundle the current environment instead
  --name TEXT              Stack name (required with --from-env)
  -o, --output PATH        Output directory (default: dist/)
  --platform TEXT          Target platform tag (repeatable)
  --python-version TEXT    Python version to resolve for (default: container's)
  --timeout INTEGER        Seconds to allow the download (default: 900)

# Audit a library without building
koios-component-builder audit <source_path> [OPTIONS]

Options:
  --policy PATH            Custom security policy YAML file
  --json                   Output results as JSON

# List pre-installed platform packages
koios-component-builder platform-packages
```

## Local Testing

Test components locally before deploying:

```python
from my_library.math_ops import SimpleAdder

adder = SimpleAdder("test-instance")
adder.a = 5.0
adder.b = 3.0
adder.execute()

print(f"Result: {adder.result}")  # Result: 8.0
```

## License

Copyright Ai-OPs, Inc. All rights reserved.
