# Create a builder2ibek converter for a support module
When `builder2ibek xml2yaml` converts a builder XML file it applies a
**converter** — a small Python module — for each XML component prefix it
encounters. If no converter exists, the entity is passed through unchanged
with whatever attributes the XML had.
This how-to explains when and how to write a new converter.
---
## Why fix builder2ibek rather than editing ioc.yaml by hand
It is tempting to run `xml2yaml`, fix up the resulting `ioc.yaml` manually,
and move on. Resist this. Writing a converter instead has important
advantages:
- **Others benefit immediately.** Anyone converting the same module gets
correct output without knowing about the quirk.
- **Re-conversion is safe.** If the original builder XML is modified (a
device address changes, a new instance is added) you can re-run `xml2yaml`
and get a correct result. Manual edits to `ioc.yaml` would have to be
re-applied by hand.
- **The fix is tested.** Once you add a sample XML/YAML pair to
`tests/samples/` the CI suite catches any future regressions.
- **The knowledge is captured.** The converter documents exactly what
builder.py was doing implicitly, in executable form.
The rule of thumb: if you find yourself editing `ioc.yaml` to fix something
that came from `xml2yaml`, ask yourself whether that fix belongs in a
converter instead.
---
## When a converter is needed
A converter is needed when the ibek entity requires different field names,
values, or structure from the raw XML attributes. Common cases:
| Situation | Converter action |
|---|---|
| An XML attribute has been renamed in ibek | `entity.rename("old", "new")` |
| An XML attribute is GUI-only or builder-only and absent from ibek | `entity.remove("attr")` |
| An XML string `"True"` / `"False"` should be a YAML boolean | Already handled by `@globalHandler`; check first |
| A numeric value must be transformed (e.g. `valve × 10 = addr`) | Arithmetic on `entity.addr` |
| The entity type itself must change (module renamed in ibek) | `entity.type = "newModule.NewClass"` |
| An XML element maps to a different ibek module entirely | `entity.type = "dlsPLC.something"` |
| One XML element should be deleted (e.g. replaced by an ibek default) | `entity.delete_me()` |
| One XML element should expand to two ibek entities | `entity.add_entity(Entity({...}))` |
---
## Converter anatomy
Every converter is a `.py` file in `src/builder2ibek/converters/`.
`moduleinfos.py` discovers them automatically at import time — no
registration step is needed.
The minimal structure is:
```python
from builder2ibek.converters.globalHandler import globalHandler
from builder2ibek.types import Entity, Generic_IOC
# Must match the XML element prefix:
xml_component = "myModule"
@globalHandler
def handler(entity: Entity, entity_type: str, ioc: Generic_IOC):
"""Convert myModule XML entities to ibek YAML."""
if entity_type == "SomeClass":
entity.rename("oldParam", "newParam")
entity.remove("builderOnlyParam")
```
### Required module-level names
| Name | Type | Purpose |
|---|---|---|
| `xml_component` | `str` or `list[str]` | XML prefix(es) this converter handles |
| `handler` | callable | Called once per matching entity during conversion |
### Optional module-level names
| Name | Type | Purpose |
|---|---|---|
| `yaml_component` | `str` | Override the ibek module prefix in the output (if different from `xml_component`) |
| `defaults` | `dict` | Default parameter values injected for specific entity types |
| `schema` | `str` | URL of the ibek IOC schema for the Generic IOC (informational) |
### The `@globalHandler` decorator
`@globalHandler` wraps your `handler` function so that the global
pre-processing runs first. The global handler:
- Removes `gda_name` and `gda_desc` from every entity
- Converts string values `"True"` / `"False"` / `""` to Python
`True` / `False` / `None`
Always use `@globalHandler` unless you have a specific reason not to.
---
## The `Entity` API
`Entity` is a `dict` subclass that also supports attribute-style access.
All XML attributes arrive as string values; the global handler has already
converted booleans and empty strings.
```python
entity.rename("old", "new") # rename a key; no-op if absent
entity.remove("key") # delete a key; no-op if absent
entity.type = "mod.Class" # change the ibek entity type
entity.delete_me() # mark this entity for removal from output
entity.add_entity(new) # insert an extra entity after this one
```
Read-only access to the IOC-level context:
```python
ioc.ioc_name # IOC name string
ioc.entities # list of all entity dicts already processed
```
---
## Example 1: simple field rename and removal
The `autosave` XML element has several DLS-specific attributes that do not
exist in the ibek entity model:
```xml
```
The ibek model uses `P` for the PV prefix (derived from `iocName` with a
trailing colon added):
```python
# src/builder2ibek/converters/autosave.py
xml_component = "autosave"
@globalHandler
def handler(entity: Entity, entity_type: str, ioc: Generic_IOC):
if entity_type == "Autosave":
entity.rename("iocName", "P") # rename the field
entity.P += ":" # append the colon
entity.remove("bl") # DLS-only flags not in ibek model
entity.remove("name")
entity.remove("path")
entity.remove("skip_1")
entity.debug = bool(entity.debug)
```
---
## Example 2: type change and value transformation
`vacuumValve.vacuumValve` maps to `dlsPLC.vacValve` with the `valve` number
multiplied by 10 to get `addr`, and the `crate` short name looked up to
obtain the full `vlvcc` PV device name:
```python
# src/builder2ibek/converters/vacuumValve.py
xml_component = "vacuumValve"
read100Objects = {} # shared state to resolve crate name → port
@globalHandler
def handler(entity: Entity, entity_type: str, ioc: Generic_IOC):
if entity_type == "vacuumValveRead":
read100Objects[entity.name] = entity.port
entity.type = "dlsPLC.read100"
entity.century = 0
entity.remove("name")
elif entity_type in ["vacuumValve", "vacuumValve_callback"]:
entity.type = "dlsPLC.vacValve"
entity.rename("crate", "vlvcc")
entity.addr = int(entity.valve) * 10
entity.remove("valve")
entity.port = read100Objects[entity.vlvcc]
```
Note the module-level `read100Objects` dict: entities are processed in XML
document order, so a `vacuumValveRead` entity always appears before any
`vacuumValve` that references it.
---
## Example 3: deleting an entity
`devIocStats.devIocStatsHelper` is superseded by an ibek default that
auto-generates the equivalent entity. The converter simply removes it:
```python
@globalHandler
def handler(entity: Entity, entity_type: str, ioc: Generic_IOC):
if entity_type == "devIocStatsHelper":
entity.delete_me()
return
```
---
## Example 4: handling multiple XML prefixes
One converter can handle several XML prefixes by setting `xml_component` to
a list. This is useful for support modules that were renamed:
```python
xml_component = ["vacuumValve", "oldVacuum"]
@globalHandler
def handler(entity: Entity, entity_type: str, ioc: Generic_IOC):
...
```
---
## Development iteration loop
Open the `builder2ibek` repository in the devcontainer (first run
`git submodule update --init` to check out `ibek-support` and
`ibek-support-dls`). The typical inner loop is:
1. Edit `src/builder2ibek/converters/.py`
2. Optionally edit the ibek support YAML in `ibek-support-dls/` or
`ibek-support/` if the entity model also needs updating
3. Re-convert all sample XML files and update expected outputs:
```bash
cd tests/samples && ./make_samples.sh
```
4. Rebuild the global IOC YAML schema so VSCode validation reflects any
support YAML changes:
```bash
./update-schema
```
5. Open the generated `.yaml` files in `tests/samples/` in VSCode and check
for schema validation errors (requires the Red Hat YAML extension).
If the extension does not pick up schema changes immediately, toggle
**Yaml: Validate** off and on in the extension settings.
6. Once the output looks correct, commit both the converter and the updated
sample files.
## Adding a test
Add a sample XML and the expected YAML to `tests/samples/` so the CI will
catch regressions.
```bash
cp MY-IOC.xml tests/samples/
cd tests/samples && ./make_samples.sh
```
Review the diff carefully — `make_samples.sh` overwrites the `.yaml` files
from the current converter output, so only commit when the output is correct.
The test suite runs all sample conversions automatically:
```bash
uv run pytest tests/test_file_conversion.py
```
---
## Submitting the converter
Converters are part of the `builder2ibek` source tree, not the
`ibek-support*` repositories. Open a pull request against
[epics-containers/builder2ibek](https://github.com/epics-containers/builder2ibek)
including:
1. `src/builder2ibek/converters/.py`
2. `tests/samples/.xml` and `tests/samples/.yaml`