diff --git a/src/dodal/beamlines/configs/i10_2/__init__.py b/src/dodal/beamlines/configs/i10_2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/dodal/beamlines/configs/i10_2/config.yaml b/src/dodal/beamlines/configs/i10_2/config.yaml new file mode 100644 index 0000000000..c86f744ce5 --- /dev/null +++ b/src/dodal/beamlines/configs/i10_2/config.yaml @@ -0,0 +1,19 @@ +beamline: "i10" + +base_imports: + - "from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline" + - "from dodal.device_manager import DeviceManager" + - "from dodal.log import set_beamline as set_log_beamline" + - "from dodal.utils import BeamlinePrefix, get_beamline_name" + +setup_script: | + devices = DeviceManager() + BL = get_beamline_name('{beamline}') + set_log_beamline(BL) + set_utils_beamline(BL) + PREFIX = BeamlinePrefix(BL) + +# Only these files will be processed +device_files: + - "devices/mirrors.yaml" + - "devices/detectors.yaml" diff --git a/src/dodal/beamlines/configs/i10_2/devices/detectors.yaml b/src/dodal/beamlines/configs/i10_2/devices/detectors.yaml new file mode 100644 index 0000000000..96c72adb97 --- /dev/null +++ b/src/dodal/beamlines/configs/i10_2/devices/detectors.yaml @@ -0,0 +1,25 @@ +- section: "Rasor Detectors" + device: rasor_det_scalers + type: RasorScalerCard1 + import_from: "dodal.devices.i10.rasor.rasor_scaler_cards" + params: + prefix: "ME01D-EA-SCLR-01:SCALER1" + +- section: "Rasor Detectors" + device: rasor_sr570 + type: RasorSR570 + import_from: "dodal.devices.i10.rasor.rasor_current_amp" + params: + prefix: "ME01D-EA-IAMP" + +- section: "Rasor Detectors" + device: rasor_sr570_pa_scaler_det + type: CurrentAmpDet + import_from: "dodal.devices.current_amplifiers" + params: + current_amp: "rasor_sr570().ca1" + counter: "rasor_det_scalers().det" + decorators: + - name: "devices.factory" + args: + mock: true diff --git a/src/dodal/beamlines/configs/i10_2/devices/mirrors.yaml b/src/dodal/beamlines/configs/i10_2/devices/mirrors.yaml new file mode 100644 index 0000000000..3dcc478cbb --- /dev/null +++ b/src/dodal/beamlines/configs/i10_2/devices/mirrors.yaml @@ -0,0 +1,25 @@ +- section: "Mirrors" + device: focusing_mirror + type: PiezoMirror + import_from: "dodal.devices.i10" + params: + prefix: 'f"{PREFIX.beamline_prefix}-OP-FOCS-01:"' + +- section: "Mirrors" + device: switching_mirror + type: PiezoMirror + import_from: "dodal.devices.i10" + params: + prefix: "f'{PREFIX.beamline_prefix}-OP-SWTCH-01:'" + decorators: + - name: "devices.factory" + args: + skip: false + +- section: "Mirrors" + device: switching_mirror_backup + type: PiezoMirror + import_from: "dodal.devices.i10" + params: + prefix: "tbsjkfbnskfbnsk" + decorators: [] #no decorators diff --git a/src/dodal/beamlines/configs/i10_recovered/__init__.py b/src/dodal/beamlines/configs/i10_recovered/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/dodal/beamlines/configs/i10_recovered/config.yaml b/src/dodal/beamlines/configs/i10_recovered/config.yaml new file mode 100644 index 0000000000..7219685b26 --- /dev/null +++ b/src/dodal/beamlines/configs/i10_recovered/config.yaml @@ -0,0 +1,17 @@ +beamline: i10 +base_imports: +- from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline +- from dodal.device_manager import DeviceManager +- from dodal.log import set_beamline as set_log_beamline +- from dodal.utils import BeamlinePrefix, get_beamline_name +setup_script: 'devices = DeviceManager() + + BL = get_beamline_name(''{beamline}'') + + set_log_beamline(BL) + + set_utils_beamline(BL) + + PREFIX = BeamlinePrefix(BL)' +device_files: +- devices.yaml diff --git a/src/dodal/beamlines/configs/i10_recovered/devices.yaml b/src/dodal/beamlines/configs/i10_recovered/devices.yaml new file mode 100644 index 0000000000..005b390bff --- /dev/null +++ b/src/dodal/beamlines/configs/i10_recovered/devices.yaml @@ -0,0 +1,166 @@ +- device: focusing_mirror + type: PiezoMirror + import_from: dodal.devices.i10 + decorators: + - name: devices.factory + args: {} + params: + prefix: f'{PREFIX.beamline_prefix}-OP-FOCS-01:' +- device: slits + type: I10Slits + import_from: dodal.devices.i10 + decorators: + - name: devices.factory + args: {} + params: + prefix: f'{PREFIX.beamline_prefix}-AL-SLITS-' +- device: slits_current + type: I10SlitsDrainCurrent + import_from: dodal.devices.i10 + decorators: + - name: devices.factory + args: {} + params: + prefix: f'{PREFIX.beamline_prefix}-' +- device: diagnostics + type: I10Diagnostic + import_from: dodal.devices.i10.diagnostics + decorators: + - name: devices.factory + args: {} + params: + prefix: f'{PREFIX.beamline_prefix}-DI-' +- device: d5a_det + type: I10Diagnostic5ADet + import_from: dodal.devices.i10.diagnostics + decorators: + - name: devices.factory + args: {} + params: + prefix: f'{PREFIX.beamline_prefix}-DI-' +- device: pin_hole + type: XYStage + import_from: dodal.devices.motors + decorators: + - name: devices.factory + args: {} + params: + prefix: 'ME01D-EA-PINH-01:' +- device: det_slits + type: DetSlits + import_from: dodal.devices.i10.rasor.rasor_motors + decorators: + - name: devices.factory + args: {} + params: + prefix: ME01D-MO-APTR-0 +- device: diffractometer + type: Diffractometer + import_from: dodal.devices.i10.rasor.rasor_motors + decorators: + - name: devices.factory + args: {} + params: + prefix: 'ME01D-MO-DIFF-01:' +- device: pa_stage + type: PaStage + import_from: dodal.devices.i10.rasor.rasor_motors + decorators: + - name: devices.factory + args: {} + params: + prefix: 'ME01D-MO-POLAN-01:' +- device: sample_stage + type: XYZStage + import_from: dodal.devices.motors + decorators: + - name: devices.factory + args: {} + params: + prefix: 'ME01D-MO-CRYO-01:' +- device: rasor_temperature_controller + type: Lakeshore340 + import_from: dodal.devices.temperture_controller + decorators: + - name: devices.factory + args: {} + params: + prefix: 'ME01D-EA-TCTRL-01:' +- device: rasor_femto + type: RasorFemto + import_from: dodal.devices.i10.rasor.rasor_current_amp + decorators: + - name: devices.factory + args: {} + params: + prefix: ME01D-EA-IAMP +- device: rasor_det_scalers + type: RasorScalerCard1 + import_from: dodal.devices.i10.rasor.rasor_scaler_cards + decorators: + - name: devices.factory + args: {} + params: + prefix: ME01D-EA-SCLR-01:SCALER1 +- device: rasor_sr570 + type: RasorSR570 + import_from: dodal.devices.i10.rasor.rasor_current_amp + decorators: + - name: devices.factory + args: {} + params: + prefix: ME01D-EA-IAMP +- device: rasor_sr570_pa_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_sr570().ca1 + counter: rasor_det_scalers().det +- device: rasor_femto_pa_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_femto().ca1 + counter: rasor_det_scalers().det +- device: rasor_sr570_fluo_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_sr570().ca2 + counter: rasor_det_scalers().fluo +- device: rasor_femto_fluo_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_femto().ca2 + counter: rasor_det_scalers().fluo +- device: rasor_sr570_drain_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_sr570().ca3 + counter: rasor_det_scalers().drain +- device: rasor_femto_drain_scaler_det + type: CurrentAmpDet + import_from: dodal.devices.current_amplifiers + decorators: + - name: devices.factory + args: {} + params: + current_amp: rasor_femto().ca3 + counter: rasor_det_scalers().drain diff --git a/src/dodal/beamlines/configs/i10_recovered/generate_i10.py b/src/dodal/beamlines/configs/i10_recovered/generate_i10.py new file mode 100644 index 0000000000..c6221bb0b9 --- /dev/null +++ b/src/dodal/beamlines/configs/i10_recovered/generate_i10.py @@ -0,0 +1,6 @@ +from dodal.common.beamlines.config_generator import translate_beamline_py_config_to_yaml + +translate_beamline_py_config_to_yaml( + py_file_path="/workspaces/dodal/src/dodal/beamlines/i10.py", + output_dir="/workspaces/dodal/src/dodal/beamlines/configs/i10_recovered", +) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index 96ff4c2589..df3d58229e 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -6,8 +6,8 @@ idd == id1, idu == id2. """ -from dodal.common.beamlines.beamline_utils import device_factory from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.device_manager import DeviceManager from dodal.devices.current_amplifiers import CurrentAmpDet from dodal.devices.i10 import ( I10Diagnostic, @@ -31,6 +31,7 @@ from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name +devices = DeviceManager() BL = get_beamline_name("i10") set_log_beamline(BL) set_utils_beamline(BL) @@ -39,7 +40,7 @@ """Mirrors""" -@device_factory() +@devices.factory() def focusing_mirror() -> PiezoMirror: return PiezoMirror(prefix=f"{PREFIX.beamline_prefix}-OP-FOCS-01:") @@ -47,12 +48,12 @@ def focusing_mirror() -> PiezoMirror: """Optic slits""" -@device_factory() +@devices.factory() def slits() -> I10Slits: return I10Slits(prefix=f"{PREFIX.beamline_prefix}-AL-SLITS-") -@device_factory() +@devices.factory() def slits_current() -> I10SlitsDrainCurrent: return I10SlitsDrainCurrent(prefix=f"{PREFIX.beamline_prefix}-") @@ -60,14 +61,14 @@ def slits_current() -> I10SlitsDrainCurrent: """Diagnostics""" -@device_factory() +@devices.factory() def diagnostics() -> I10Diagnostic: return I10Diagnostic( prefix=f"{PREFIX.beamline_prefix}-DI-", ) -@device_factory() +@devices.factory() def d5a_det() -> I10Diagnostic5ADet: return I10Diagnostic5ADet(prefix=f"{PREFIX.beamline_prefix}-DI-") @@ -75,58 +76,58 @@ def d5a_det() -> I10Diagnostic5ADet: """Rasor devices""" -@device_factory() +@devices.factory() def pin_hole() -> XYStage: return XYStage(prefix="ME01D-EA-PINH-01:") -@device_factory() +@devices.factory() def det_slits() -> DetSlits: return DetSlits(prefix="ME01D-MO-APTR-0") -@device_factory() +@devices.factory() def diffractometer() -> Diffractometer: return Diffractometer(prefix="ME01D-MO-DIFF-01:") -@device_factory() +@devices.factory() def pa_stage() -> PaStage: return PaStage(prefix="ME01D-MO-POLAN-01:") -@device_factory() +@devices.factory() def sample_stage() -> XYZStage: return XYZStage(prefix="ME01D-MO-CRYO-01:") -@device_factory() +@devices.factory() def rasor_temperature_controller() -> Lakeshore340: return Lakeshore340( prefix="ME01D-EA-TCTRL-01:", ) -@device_factory() +@devices.factory() def rasor_femto() -> RasorFemto: return RasorFemto( prefix="ME01D-EA-IAMP", ) -@device_factory() +@devices.factory() def rasor_det_scalers() -> RasorScalerCard1: return RasorScalerCard1(prefix="ME01D-EA-SCLR-01:SCALER1") -@device_factory() +@devices.factory() def rasor_sr570() -> RasorSR570: return RasorSR570( prefix="ME01D-EA-IAMP", ) -@device_factory() +@devices.factory() def rasor_sr570_pa_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_sr570().ca1, @@ -134,7 +135,7 @@ def rasor_sr570_pa_scaler_det() -> CurrentAmpDet: ) -@device_factory() +@devices.factory() def rasor_femto_pa_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_femto().ca1, @@ -142,7 +143,7 @@ def rasor_femto_pa_scaler_det() -> CurrentAmpDet: ) -@device_factory() +@devices.factory() def rasor_sr570_fluo_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_sr570().ca2, @@ -150,7 +151,7 @@ def rasor_sr570_fluo_scaler_det() -> CurrentAmpDet: ) -@device_factory() +@devices.factory() def rasor_femto_fluo_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_femto().ca2, @@ -158,7 +159,7 @@ def rasor_femto_fluo_scaler_det() -> CurrentAmpDet: ) -@device_factory() +@devices.factory() def rasor_sr570_drain_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_sr570().ca3, @@ -166,7 +167,7 @@ def rasor_sr570_drain_scaler_det() -> CurrentAmpDet: ) -@device_factory() +@devices.factory() def rasor_femto_drain_scaler_det() -> CurrentAmpDet: return CurrentAmpDet( current_amp=rasor_femto().ca3, diff --git a/src/dodal/beamlines/i10_2.py b/src/dodal/beamlines/i10_2.py new file mode 100644 index 0000000000..aa599588f9 --- /dev/null +++ b/src/dodal/beamlines/i10_2.py @@ -0,0 +1,11 @@ +import os + +from dodal.common.beamlines.config_generator import beamline_config_generator + +current_dir = os.path.dirname(__file__) +beamline_name = os.path.splitext(os.path.basename(__file__))[0] +CONFIG_DIR = os.path.join(current_dir, "configs", beamline_name) + +# Generate and inject +code = beamline_config_generator(CONFIG_DIR) +exec(code, globals()) diff --git a/src/dodal/beamlines/i10_recovered.py b/src/dodal/beamlines/i10_recovered.py new file mode 100644 index 0000000000..aa599588f9 --- /dev/null +++ b/src/dodal/beamlines/i10_recovered.py @@ -0,0 +1,11 @@ +import os + +from dodal.common.beamlines.config_generator import beamline_config_generator + +current_dir = os.path.dirname(__file__) +beamline_name = os.path.splitext(os.path.basename(__file__))[0] +CONFIG_DIR = os.path.join(current_dir, "configs", beamline_name) + +# Generate and inject +code = beamline_config_generator(CONFIG_DIR) +exec(code, globals()) diff --git a/src/dodal/common/beamlines/config_generator.py b/src/dodal/common/beamlines/config_generator.py new file mode 100644 index 0000000000..382da8c28c --- /dev/null +++ b/src/dodal/common/beamlines/config_generator.py @@ -0,0 +1,186 @@ +import ast +import os +from collections import defaultdict +from datetime import datetime +from typing import Any + +import yaml +from pydantic import BaseModel, Field + +from dodal.log import LOGGER + + +class DecoratorModel(BaseModel): + name: str = Field(..., description="The decorator name, e.g., 'devices.factory'") + args: dict[str, Any] = Field( + default_factory=dict, description="Keyword arguments for the decorator" + ) + + +class DeviceModel(BaseModel): + device: str + type: str + import_from: str | None = "" + section: str = "Other" + params: dict[str, Any] = Field(default_factory=dict) + # Default to devices.factory() if none provided + decorators: list[DecoratorModel] = Field( + default_factory=lambda: [DecoratorModel(name="devices.factory")] + ) + + +class MasterConfigModel(BaseModel): + beamline: str + base_imports: list[str] + setup_script: str + device_files: list[str] + note: str | None = "" + + +def format_value(v: Any) -> str: + if isinstance(v, str): + if "(" in v or ")." in v or v.startswith('f"') or v.startswith("f'"): + return v + return f"'{v}'" + return repr(v) + + +def beamline_config_generator(config_dir: str) -> str: + config_file = os.path.join(config_dir, "config.yaml") + with open(config_file) as f: + master_raw: dict = yaml.safe_load(f) + master = MasterConfigModel(**master_raw) + + beamline = master.beamline + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + sections = defaultdict(list) + needed_imports = defaultdict(set) + seen_functions = set() + + for y_path in master.device_files: + full_device_path = os.path.join(config_dir, y_path) + if not os.path.exists(full_device_path): + LOGGER.warning(f"Device file not found: {full_device_path}") + continue + + with open(full_device_path) as f: + content = yaml.safe_load(f) + device_list_raw = content if isinstance(content, list) else [content] + for dev_dict in device_list_raw: + dev = DeviceModel(**dev_dict) + if dev.device in seen_functions: + raise ValueError(f"Duplicate function: {dev.device}") + seen_functions.add(dev.device) + sections[dev.section].append(dev) + needed_imports[dev.import_from].add(dev.type) + + code = f'"""\nGenerated on: {timestamp}\nBeamline: {beamline}\n\nnote:\n{master.note}\n"""\n\n' + code_lines = master.base_imports[:] + for module, classes in sorted(needed_imports.items()): + cls_str = ", ".join(sorted(classes)) + code_lines.append(f"from {module} import {cls_str}") + code += "\n".join(code_lines) + "\n\n" + code += master.setup_script.format(beamline=beamline) + "\n" + + for section_name in sorted(sections.keys()): + code += f'\n\n""" {section_name} """\n' + + for dev in sections[section_name]: + for dec in dev.decorators: + arg_str = ", ".join([f"{k}={repr(v)}" for k, v in dec.args.items()]) + code += f"\n@{dec.name}({arg_str})" + + if dev.params: + args = ",\n ".join( + [f"{k}={format_value(v)}" for k, v in dev.params.items()] + ) + body = f"{dev.type}(\n {args}\n )" + else: + body = f"{dev.type}()" + + code += f"\ndef {dev.device}() -> {dev.type}:\n return {body}\n" + + return code + + +def translate_beamline_py_config_to_yaml(py_file_path: str, output_dir: str): + with open(py_file_path) as f: + source = f.read() + tree = ast.parse(source) + + devices = [] + base_imports = [] + setup_nodes = [] + import_map = {} + + for node in tree.body: + if isinstance(node, ast.ImportFrom): + module = node.module + for alias in node.names: + import_map[alias.name] = module + + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + base_imports.append(ast.unparse(node)) + + elif isinstance(node, ast.FunctionDef): + ret_type = node.returns.id if isinstance(node.returns, ast.Name) else None + + recovered_decs = [] + for dec_node in node.decorator_list: + if isinstance(dec_node, ast.Call): + name = ast.unparse(dec_node.func) + args = { + kw.arg: ast.literal_eval(kw.value) for kw in dec_node.keywords + } + recovered_decs.append({"name": name, "args": args}) + else: + # Handle decorators without parentheses, e.g., @devices.factory + recovered_decs.append({"name": ast.unparse(dec_node), "args": {}}) + + device_meta = { + "device": node.name, + "type": ret_type, + "import_from": import_map.get(ret_type, "unknown.module"), + "decorators": recovered_decs, + } + if node.body and isinstance(node.body[0], ast.Return): + ret_val = node.body[0].value + if isinstance(ret_val, ast.Call): + params = {} + for kw in ret_val.keywords: + try: + params[kw.arg] = ast.literal_eval(kw.value) + except (ValueError, TypeError): + params[kw.arg] = ast.unparse(kw.value) + if params: + device_meta["params"] = params + + devices.append(device_meta) + + elif not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)): + setup_nodes.append(ast.unparse(node)) + + beamline_name = os.path.splitext(os.path.basename(py_file_path))[0] + setup_raw = "\n".join(setup_nodes) + setup_script = setup_raw.replace(f"'{beamline_name}'", "'{beamline}'") + device_types = {d["type"] for d in devices} + filtered_base_imports = [] + for imp in base_imports: + if not any(f"import {t}" in imp or f", {t}" in imp for t in device_types): + filtered_base_imports.append(imp) + + os.makedirs(output_dir, exist_ok=True) + + master_config = { + "beamline": beamline_name, + "base_imports": filtered_base_imports, + "setup_script": setup_script, + "device_files": ["devices.yaml"], + } + + with open(os.path.join(output_dir, "config.yaml"), "w") as f: + yaml.dump(master_config, f, sort_keys=False, default_flow_style=False) + + with open(os.path.join(output_dir, "devices.yaml"), "w") as f: + yaml.dump(devices, f, sort_keys=False, default_flow_style=False) diff --git a/tests/common/beamlines/test_config_generator.py b/tests/common/beamlines/test_config_generator.py new file mode 100644 index 0000000000..f9e5d55c3f --- /dev/null +++ b/tests/common/beamlines/test_config_generator.py @@ -0,0 +1,95 @@ +import pytest +import yaml + +from dodal.common.beamlines.config_generator import ( + beamline_config_generator, + translate_beamline_py_config_to_yaml, +) + + +@pytest.fixture +def mock_config_dir(tmp_path): + config_dir = tmp_path / "i10" + config_dir.mkdir() + + config_data = { + "beamline": "i10", + "base_imports": ["import os"], + "setup_script": "PREFIX = '{beamline}'", + "device_files": ["devices.yaml"], + "note": "Test Beamline", + } + + with open(config_dir / "config.yaml", "w") as f: + yaml.dump(config_data, f) + + return config_dir + + +def test_generator_output_contains_expected_code(tmp_path): + # Setup temporary YAML files + conf_dir = tmp_path / "i10" + conf_dir.mkdir() + + config_yaml = conf_dir / "config.yaml" + config_yaml.write_text(""" +beamline: i10 +output_file: i10.py +base_imports: ["import os"] +setup_script: "PREFIX = '{beamline}'" +device_files: ["devices.yaml"] +""") + + devices_yaml = conf_dir / "devices.yaml" + devices_yaml.write_text(""" +- device: my_motor + type: MotorClass + import_from: dodal.devices + params: + prefix: 'f"{PREFIX}-MOT-01"' +""") + + code = beamline_config_generator(str(conf_dir)) + + assert "def my_motor() -> MotorClass:" in code + assert "from dodal.devices import MotorClass" in code + assert 'prefix=f"{PREFIX}-MOT-01"' in code + + +def test_decorators_with_args(tmp_path, mock_config_dir): + # YAML representation + devices_file = mock_config_dir / "devices.yaml" + devices_file.write_text(""" +- device: fancy_motor + type: Motor + import_from: dodal.devices + decorators: + - name: "devices.factory" + args: + skip: True + reason: "Testing" + - name: "other.wrapper" + args: + priority: 10 +""") + + code = beamline_config_generator(str(mock_config_dir)) + + assert "@devices.factory(skip=True, reason='Testing')" in code + assert "@other.wrapper(priority=10)" in code + + +def test_reverse_translator_recovers_decorator_args(tmp_path): + py_file = tmp_path / "i10.py" + py_file.write_text(""" +@devices.factory(skip=False) +def my_dev() -> Motor: + return Motor() +""") + output_dir = tmp_path / "recovered" + translate_beamline_py_config_to_yaml(str(py_file), str(output_dir)) + + with open(output_dir / "devices.yaml") as f: + devs = yaml.safe_load(f) + assert devs[0]["decorators"][0]["name"] == "devices.factory" + assert devs[0]["decorators"][0]["args"]["skip"] is False