From 4792285248f8c8dbbfbac9679b736063264b6c41 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 11 Nov 2024 23:51:07 -0800 Subject: [PATCH] prepare rm sd formats --- miniscope_io/__init__.py | 2 - .../data/config/wirefree/sd_layout.yaml | 40 ++++ .../config/wirefree/sd_layout_battery.yaml | 41 ++++ miniscope_io/formats/sdcard.py | 29 --- miniscope_io/io.py | 11 +- miniscope_io/models/config.py | 2 + miniscope_io/models/mixins.py | 177 +++++++++++++++++- miniscope_io/models/sdcard.py | 9 +- notebooks/grab_frames.ipynb | 8 +- notebooks/plot_headers.ipynb | 3 +- notebooks/save_video.ipynb | 7 +- tests/fixtures.py | 7 +- tests/test_formats.py | 19 +- tests/test_io.py | 38 ++-- 14 files changed, 315 insertions(+), 78 deletions(-) create mode 100644 miniscope_io/data/config/wirefree/sd_layout.yaml create mode 100644 miniscope_io/data/config/wirefree/sd_layout_battery.yaml diff --git a/miniscope_io/__init__.py b/miniscope_io/__init__.py index 7087c536..6e846815 100644 --- a/miniscope_io/__init__.py +++ b/miniscope_io/__init__.py @@ -4,7 +4,6 @@ from pathlib import Path -from miniscope_io.io import SDCard from miniscope_io.logging import init_logger from miniscope_io.models.config import Config @@ -18,6 +17,5 @@ "DATA_DIR", "CONFIG_DIR", "Config", - "SDCard", "init_logger", ] diff --git a/miniscope_io/data/config/wirefree/sd_layout.yaml b/miniscope_io/data/config/wirefree/sd_layout.yaml new file mode 100644 index 00000000..68d296a9 --- /dev/null +++ b/miniscope_io/data/config/wirefree/sd_layout.yaml @@ -0,0 +1,40 @@ +id: wirefree-sd-layout +model: miniscope_io.models.sdcard.SDLayout +mio_version: v5.0.0 +sectors: + header: 1022 + config: 1023 + data: 1024 + size: 512 +write_key0: 226277911 +write_key1: 226277911 +write_key2: 226277911 +write_key3: 226277911 +word_size: 4 +header: + gain: 4 + led: 5 + ewl: 6 + record_length: 7 + fs: 8 + delay_start: 9 + battery_cutoff: 10 +config: + width: 0 + height: 1 + fs: 2 + buffer_size: 3 + n_buffers_recorded: 4 + n_buffers_dropped: 5 +buffer: + linked_list: 1 + frame_num: 2 + buffer_count: 3 + frame_buffer_count: 4 + write_buffer_count: 5 + dropped_buffer_count: 6 + timestamp: 7 + write_timestamp: null + length: 0 + data_length: 8 + battery_voltage: null diff --git a/miniscope_io/data/config/wirefree/sd_layout_battery.yaml b/miniscope_io/data/config/wirefree/sd_layout_battery.yaml new file mode 100644 index 00000000..5346804e --- /dev/null +++ b/miniscope_io/data/config/wirefree/sd_layout_battery.yaml @@ -0,0 +1,41 @@ +id: wirefree-sd-layout-battery +model: miniscope_io.models.sdcard.SDLayout +mio_version: v5.0.0 +sectors: + header: 1022 + config: 1023 + data: 1024 + size: 512 +write_key0: 226277911 +write_key1: 226277911 +write_key2: 226277911 +write_key3: 226277911 +word_size: 4 +header: + gain: 4 + led: 5 + ewl: 6 + record_length: 7 + fs: 8 + delay_start: 9 + battery_cutoff: 10 +config: + width: 0 + height: 1 + fs: 2 + buffer_size: 3 + n_buffers_recorded: 4 + n_buffers_dropped: 5 +buffer: + linked_list: 1 + frame_num: 2 + buffer_count: 3 + frame_buffer_count: 4 + write_buffer_count: 5 + dropped_buffer_count: 6 + timestamp: 7 + write_timestamp: 9 + length: 0 + data_length: 8 + battery_voltage: 10 +version: 0.1.1 diff --git a/miniscope_io/formats/sdcard.py b/miniscope_io/formats/sdcard.py index 04dffc14..006363e3 100644 --- a/miniscope_io/formats/sdcard.py +++ b/miniscope_io/formats/sdcard.py @@ -49,32 +49,3 @@ """ WireFreeSDLayout_Battery.buffer.write_timestamp = 9 WireFreeSDLayout_Battery.buffer.battery_voltage = 10 - - -WireFreeSDLayout_Old = SDLayout( - sectors=SectorConfig(header=1023, config=1024, data=1025, size=512), - write_key0=0x0D7CBA17, - write_key1=0x0D7CBA17, - write_key2=0x0D7CBA17, - write_key3=0x0D7CBA17, - header=SDHeaderPositions(gain=4, led=5, ewl=6, record_length=7, fs=8), - config=ConfigPositions( - width=0, - height=1, - fs=2, - buffer_size=3, - n_buffers_recorded=4, - n_buffers_dropped=5, - ), - buffer=SDBufferHeaderFormat( - length=0, - linked_list=1, - frame_num=2, - buffer_count=3, - frame_buffer_count=4, - write_buffer_count=5, - dropped_buffer_count=6, - timestamp=7, - data_length=8, - ), -) diff --git a/miniscope_io/io.py b/miniscope_io/io.py index 3b902e94..ce806542 100644 --- a/miniscope_io/io.py +++ b/miniscope_io/io.py @@ -104,9 +104,16 @@ class SDCard: """ - def __init__(self, drive: Union[str, Path], layout: SDLayout): + def __init__( + self, drive: Union[str, Path], layout: Union[SDLayout, str] = "wirefree-sd-layout" + ): self.drive = drive - self.layout = layout + if isinstance(layout, str): + self.layout = SDLayout.from_id(layout) + elif isinstance(layout, SDLayout): + self.layout = layout + else: + raise TypeError("layout must be either a layout config id or a SDLayout") self.logger = init_logger("SDCard") # Private attributes used when the file reading context is entered diff --git a/miniscope_io/models/config.py b/miniscope_io/models/config.py index 51f096e8..1c2067aa 100644 --- a/miniscope_io/models/config.py +++ b/miniscope_io/models/config.py @@ -84,7 +84,9 @@ class Config(BaseSettings): description="Base directory to store configuration and other temporary files, " "other paths are relative to this by default", ) + config_dir: Path = Field(Path("config"), description="Location to store user configs") log_dir: Path = Field(Path("logs"), description="Location to store logs") + logs: LogConfig = Field(LogConfig(), description="Additional settings for logs") @field_validator("base_dir", mode="before") diff --git a/miniscope_io/models/mixins.py b/miniscope_io/models/mixins.py index 18723ce3..50020af4 100644 --- a/miniscope_io/models/mixins.py +++ b/miniscope_io/models/mixins.py @@ -3,14 +3,32 @@ to use composition for functionality and inheritance for semantics. """ +import re +from importlib.metadata import version +from itertools import chain from pathlib import Path -from typing import Type, TypeVar, Union +from typing import Any, List, Literal, Optional, Type, TypeVar, Union, overload import yaml +from pydantic import BaseModel + +from miniscope_io import CONFIG_DIR, Config +from miniscope_io.logging import init_logger T = TypeVar("T") +class YamlDumper(yaml.SafeDumper): + """Dumper that can represent extra types like Paths""" + + def represent_path(self, data: Path) -> yaml.ScalarNode: + """Represent a path as a string""" + return self.represent_scalar("tag:yaml.org,2002:str", str(data)) + + +YamlDumper.add_representer(type(Path()), YamlDumper.represent_path) + + class YAMLMixin: """ Mixin class that provides :meth:`.from_yaml` and :meth:`.to_yaml` @@ -23,3 +41,160 @@ def from_yaml(cls: Type[T], file_path: Union[str, Path]) -> T: with open(file_path) as file: config_data = yaml.safe_load(file) return cls(**config_data) + + def to_yaml(self, path: Optional[Path] = None, **kwargs: Any) -> str: + """ + Dump the contents of this class to a yaml file, returning the + contents of the dumped string + """ + data = self._dump_data(**kwargs) + data_str = yaml.dump(data, Dumper=YamlDumper, sort_keys=False) + + if path: + with open(path, "w") as file: + file.write(data_str) + + return data_str + + def _dump_data(self, **kwargs: Any) -> dict: + data = self.model_dump(**kwargs) if isinstance(self, BaseModel) else self.__dict__ + return data + + +class ConfigYAMLMixin(YAMLMixin): + """ + Yaml Mixin class that always puts a header consisting of + + * `id` - unique identifier for this config + * `model` - fully-qualified module path to model class + * `mio_version` - version of miniscope-io when this model was created + """ + + HEADER_FIELDS = {"id", "model", "mio_version"} + + @classmethod + def from_yaml(cls: Type[T], file_path: Union[str, Path]) -> T: + """Instantiate this class by passing the contents of a yaml file as kwargs""" + with open(file_path) as file: + config_data = yaml.safe_load(file) + instance = cls(**config_data) + + # fill in any missing fields in the source file needed for a header + cls._complete_header(instance, config_data, file_path) + + return instance + + @classmethod + def from_id(cls, id: str) -> T: + """ + Instantiate a model from a config `id` specified in one of the .yaml configs in + either the user :attr:`.Config.config_dir` or the packaged ``config`` dir. + + .. note:: + + this method does not yet validate that the config matches the model loading it + + """ + for config_file in chain(Config().config_dir.rglob("*.y*ml"), CONFIG_DIR.rglob("*.y*ml")): + try: + file_id = yaml_peek("id", config_file) + if file_id == id: + return cls.from_yaml(config_file) + except KeyError: + continue + raise KeyError(f"No config with id {id} found in {Config().config_dir}") + + def _dump_data(self, **kwargs: Any) -> dict: + """Ensure that header is prepended to model data""" + return {**self._yaml_header(self), **super()._dump_data(**kwargs)} + + @classmethod + def _yaml_header(cls, instance: T) -> dict: + return { + "id": instance.id, + "model": f"{cls.__module__}.{cls.__name__}", + "mio_version": version("miniscope_io"), + } + + @classmethod + def _complete_header( + cls: Type[T], instance: T, data: dict, file_path: Union[str, Path] + ) -> None: + """fill in any missing fields in the source file needed for a header""" + + missing_fields = cls.HEADER_FIELDS - set(data.keys()) + if missing_fields: + logger = init_logger(cls.__name__) + logger.warning( + f"Missing required header fields {missing_fields} in config model " + f"{str(file_path)}. Updating file..." + ) + header = cls._yaml_header(instance) + data = {**header, **data} + with open(file_path, "w") as yfile: + yaml.safe_dump(data, yfile, sort_keys=False) + + +@overload +def yaml_peek( + key: str, path: Union[str, Path], root: bool = True, first: Literal[True] = True +) -> str: ... + + +@overload +def yaml_peek( + key: str, path: Union[str, Path], root: bool = True, first: Literal[False] = False +) -> List[str]: ... + + +@overload +def yaml_peek( + key: str, path: Union[str, Path], root: bool = True, first: bool = True +) -> Union[str, List[str]]: ... + + +def yaml_peek( + key: str, path: Union[str, Path], root: bool = True, first: bool = True +) -> Union[str, List[str]]: + """ + Peek into a yaml file without parsing the whole file to retrieve the value of a single key. + + This function is _not_ designed for robustness to the yaml spec, it is for simple key: value + pairs, not fancy shit like multiline strings, tagged values, etc. If you want it to be, + then i'm afraid you'll have to make a PR about it. + + Returns a string no matter what the yaml type is so ya have to do your own casting if you want + + Args: + key (str): The key to peek for + path (:class:`pathlib.Path` , str): The yaml file to peek into + root (bool): Only find keys at the root of the document (default ``True`` ), otherwise + find keys at any level of nesting. + first (bool): Only return the first appearance of the key (default). Otherwise return a + list of values (not implemented lol) + + Returns: + str + """ + if root: + pattern = re.compile(rf"^(?P{key}):\s*(?P\S.*)") + else: + pattern = re.compile(rf"^\s*(?P{key}):\s*(?P\S.*)") + + res = None + if first: + with open(path) as yfile: + for line in yfile: + res = pattern.match(line) + if res: + break + if res: + return res.groupdict()["value"] + else: + with open(path) as yfile: + text = yfile.read() + res = [match.groupdict()["value"] for match in pattern.finditer(text)] + if res: + return res + + raise KeyError(f"Key {key} not found in {path}") diff --git a/miniscope_io/models/sdcard.py b/miniscope_io/models/sdcard.py index c6ad9395..096966d8 100644 --- a/miniscope_io/models/sdcard.py +++ b/miniscope_io/models/sdcard.py @@ -8,6 +8,7 @@ from miniscope_io.models import MiniscopeConfig from miniscope_io.models.buffer import BufferHeader, BufferHeaderFormat +from miniscope_io.models.mixins import ConfigYAMLMixin class SectorConfig(MiniscopeConfig): @@ -104,7 +105,7 @@ class SDBufferHeaderFormat(BufferHeaderFormat): battery_voltage: Optional[int] = None -class SDLayout(MiniscopeConfig): +class SDLayout(MiniscopeConfig, ConfigYAMLMixin): """ Data layout of an SD Card. @@ -131,12 +132,6 @@ class SDLayout(MiniscopeConfig): config: ConfigPositions = ConfigPositions() buffer: SDBufferHeaderFormat = SDBufferHeaderFormat() - version: Optional[str] = None - """ - Not Implemented: version stored in the SD card header that indicates - when this layout should be used - """ - class SDConfig(MiniscopeConfig): """ diff --git a/notebooks/grab_frames.ipynb b/notebooks/grab_frames.ipynb index e125dde2..06c22a89 100644 --- a/notebooks/grab_frames.ipynb +++ b/notebooks/grab_frames.ipynb @@ -118,7 +118,9 @@ "In the future once I get an image we would want to make some jupyter widget to select possible drives,\n", "but for now i'll just hardcode it as a string for the sake of an example. The `SDCard` class has a\n", "`check_valid` method that looks for the `WRITE_KEY`s as in the prior notebook, so we could also\n", - "make that into a classmethod and just automatically find the right drive that way." + "make that into a classmethod and just automatically find the right drive that way.\n", + "\n", + "Rather than directly referencing the layout object, we can instead just use its `id` field" ] }, { @@ -157,7 +159,7 @@ } ], "source": [ - "sd = SDCard(drive=drive, layout = WireFreeSDLayout)\n", + "sd = SDCard(drive=drive, layout = \"wirefree-sd-layout\")\n", "\n", "pprint(sd.config.dict())" ] @@ -184984,4 +184986,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} diff --git a/notebooks/plot_headers.ipynb b/notebooks/plot_headers.ipynb index ea600839..a3d98e4b 100644 --- a/notebooks/plot_headers.ipynb +++ b/notebooks/plot_headers.ipynb @@ -23,7 +23,6 @@ "warnings.filterwarnings(\"ignore\")\n", "\n", "from miniscope_io.io import SDCard\n", - "from miniscope_io.formats import WireFreeSDLayout_Battery\n", "from miniscope_io.models.data import Frames\n", "from miniscope_io.plots.headers import plot_headers, battery_voltage" ], @@ -66,7 +65,7 @@ "source": [ "# Recall that you have to use an SDCard layout that matches the data you have!\n", "# Here we are using an updated layout that includes the battery level\n", - "sd = SDCard(drive=drive, layout = WireFreeSDLayout_Battery)" + "sd = SDCard(drive=drive, layout = \"wirefree-sd-layout-battery\")" ], "metadata": { "collapsed": false, diff --git a/notebooks/save_video.ipynb b/notebooks/save_video.ipynb index 7eb08952..a04ddf66 100644 --- a/notebooks/save_video.ipynb +++ b/notebooks/save_video.ipynb @@ -21,8 +21,7 @@ "outputs": [], "source": [ "from pathlib import Path\n", - "from miniscope_io.io import SDCard\n", - "from miniscope_io.formats import WireFreeSDLayout" + "from miniscope_io.io import SDCard" ] }, { @@ -43,7 +42,7 @@ "outputs": [], "source": [ "drive = Path('..') / 'data' / 'wirefree_example.img'\n", - "sd = SDCard(drive=drive, layout = WireFreeSDLayout)" + "sd = SDCard(drive=drive, layout = \"wirefree-sd-layout\")" ], "metadata": { "collapsed": false, @@ -130,4 +129,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} \ No newline at end of file +} diff --git a/tests/fixtures.py b/tests/fixtures.py index 508d2d3b..0df21e6a 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -2,8 +2,7 @@ import pytest -from miniscope_io import SDCard -from miniscope_io.formats import WireFreeSDLayout, WireFreeSDLayout_Battery +from miniscope_io.io import SDCard from miniscope_io.models.data import Frames @@ -14,14 +13,14 @@ def wirefree() -> SDCard: """ sd_path = Path(__file__).parent.parent / "data" / "wirefree_example.img" - sdcard = SDCard(drive=sd_path, layout=WireFreeSDLayout) + sdcard = SDCard(drive=sd_path, layout="wirefree-sd-layout") return sdcard @pytest.fixture def wirefree_battery() -> SDCard: sd_path = Path(__file__).parent.parent / "data" / "wirefree_battery_sample.img" - sdcard = SDCard(drive=sd_path, layout=WireFreeSDLayout_Battery) + sdcard = SDCard(drive=sd_path, layout="wirefree-sd-layout-battery") return sdcard diff --git a/tests/test_formats.py b/tests/test_formats.py index 53b481d3..9ac437a6 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -5,10 +5,11 @@ import importlib from miniscope_io.formats import WireFreeSDLayout +from miniscope_io.models.sdcard import SDLayout # More formats can be added here as needed. -@pytest.mark.parametrize('format', [WireFreeSDLayout]) +@pytest.mark.parametrize("format", [WireFreeSDLayout]) def test_to_from_json(format): """ A format can be exported and re-imported from JSON and remain equivalent @@ -20,8 +21,8 @@ def test_to_from_json(format): # Get the parent class parent_class = type(format) - #parent_class_name = parent_module_str.split('.')[-1] - #parent_class = getattr(importlib.import_module(parent_module_str), parent_class_name) + # parent_class_name = parent_module_str.split('.')[-1] + # parent_class = getattr(importlib.import_module(parent_module_str), parent_class_name) new_format = parent_class(**fmt_dict) @@ -29,10 +30,13 @@ def test_to_from_json(format): @pytest.mark.parametrize( - ['format', 'format_json'], + ["format", "format_json"], [ - (WireFreeSDLayout, '{"sectors": {"header": 1022, "config": 1023, "data": 1024, "size": 512}, "write_key0": 226277911, "write_key1": 226277911, "write_key2": 226277911, "write_key3": 226277911, "word_size": 4, "header": {"gain": 4, "led": 5, "ewl": 6, "record_length": 7, "fs": 8, "delay_start": 9, "battery_cutoff": 10}, "config": {"width": 0, "height": 1, "fs": 2, "buffer_size": 3, "n_buffers_recorded": 4, "n_buffers_dropped": 5}, "buffer": {"length": 0, "linked_list": 1, "frame_num": 2, "buffer_count": 3, "frame_buffer_count": 4, "write_buffer_count": 5, "dropped_buffer_count": 6, "timestamp": 7, "data_length": 8, "write_timestamp": null, "battery_voltage": null}, "version": "0.1.1"}') - ] + ( + "wirefree-sd-layout", + '{"sectors": {"header": 1022, "config": 1023, "data": 1024, "size": 512}, "write_key0": 226277911, "write_key1": 226277911, "write_key2": 226277911, "write_key3": 226277911, "word_size": 4, "header": {"gain": 4, "led": 5, "ewl": 6, "record_length": 7, "fs": 8, "delay_start": 9, "battery_cutoff": 10}, "config": {"width": 0, "height": 1, "fs": 2, "buffer_size": 3, "n_buffers_recorded": 4, "n_buffers_dropped": 5}, "buffer": {"length": 0, "linked_list": 1, "frame_num": 2, "buffer_count": 3, "frame_buffer_count": 4, "write_buffer_count": 5, "dropped_buffer_count": 6, "timestamp": 7, "data_length": 8, "write_timestamp": null, "battery_voltage": null}}', + ) + ], ) def test_format_unchanged(format, format_json): """ @@ -41,7 +45,8 @@ def test_format_unchanged(format, format_json): This protects against changes in the parent classes breaking the formats, and also breaking the formats themselves """ - parent_class = type(format) + format = SDLayout.from_id(format) + parent_class = SDLayout format_dict = json.loads(format_json) new_format = parent_class(**format_dict) diff --git a/tests/test_io.py b/tests/test_io.py index a2c579c8..f4be5815 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -7,9 +7,7 @@ import numpy as np import warnings -from miniscope_io.models.data import Frame from miniscope_io.models.sdcard import SDBufferHeader -from miniscope_io.formats import WireFreeSDLayout, WireFreeSDLayout_Battery from miniscope_io.io import SDCard from miniscope_io.io import BufferedCSVWriter from miniscope_io.exceptions import EndOfRecordingException @@ -18,6 +16,7 @@ from .fixtures import wirefree, wirefree_battery + @pytest.fixture def tmp_csvfile(tmp_path): """ @@ -25,6 +24,7 @@ def tmp_csvfile(tmp_path): """ return tmp_path / "test.csv" + def test_csvwriter_initialization(tmp_csvfile): """ Test that the BufferedCSVWriter initializes correctly. @@ -34,6 +34,7 @@ def test_csvwriter_initialization(tmp_csvfile): assert writer.buffer_size == 10 assert writer.buffer == [] + def test_csvwriter_append_and_flush(tmp_csvfile): """ Test that the BufferedCSVWriter appends to the buffer and flushes it when full. @@ -41,16 +42,17 @@ def test_csvwriter_append_and_flush(tmp_csvfile): writer = BufferedCSVWriter(tmp_csvfile, buffer_size=2) writer.append([1, 2, 3]) assert len(writer.buffer) == 1 - + writer.append([4, 5, 6]) assert len(writer.buffer) == 0 assert tmp_csvfile.exists() - - with tmp_csvfile.open('r', newline='') as f: + + with tmp_csvfile.open("r", newline="") as f: reader = csv.reader(f) rows = list(reader) assert len(rows) == 2 - assert rows == [['1', '2', '3'], ['4', '5', '6']] + assert rows == [["1", "2", "3"], ["4", "5", "6"]] + def test_csvwriter_flush_buffer(tmp_csvfile): """ @@ -59,15 +61,16 @@ def test_csvwriter_flush_buffer(tmp_csvfile): writer = BufferedCSVWriter(tmp_csvfile, buffer_size=2) writer.append([1, 2, 3]) writer.flush_buffer() - + assert len(writer.buffer) == 0 assert tmp_csvfile.exists() - - with tmp_csvfile.open('r', newline='') as f: + + with tmp_csvfile.open("r", newline="") as f: reader = csv.reader(f) rows = list(reader) assert len(rows) == 1 - assert rows == [['1', '2', '3']] + assert rows == [["1", "2", "3"]] + def test_csvwriter_close(tmp_csvfile): """ @@ -76,15 +79,16 @@ def test_csvwriter_close(tmp_csvfile): writer = BufferedCSVWriter(tmp_csvfile, buffer_size=2) writer.append([1, 2, 3]) writer.close() - + assert len(writer.buffer) == 0 assert tmp_csvfile.exists() - - with tmp_csvfile.open('r', newline='') as f: + + with tmp_csvfile.open("r", newline="") as f: reader = csv.reader(f) rows = list(reader) assert len(rows) == 1 - assert rows == [['1', '2', '3']] + assert rows == [["1", "2", "3"]] + def test_read(wirefree): """ @@ -169,7 +173,7 @@ def test_relative_path(): rel_path = abs_child.relative_to(abs_cwd) assert not rel_path.is_absolute() - sdcard = SDCard(drive=rel_path, layout=WireFreeSDLayout) + sdcard = SDCard(drive=rel_path, layout="wirefree-sd-layout") # check we can do something basic like read config assert sdcard.config is not None @@ -180,7 +184,7 @@ def test_relative_path(): # now try with an absolute path abs_path = rel_path.resolve() assert abs_path.is_absolute() - sdcard_abs = SDCard(drive=abs_path, layout=WireFreeSDLayout) + sdcard_abs = SDCard(drive=abs_path, layout="wirefree-sd-layout") assert sdcard_abs.config is not None assert sdcard_abs.drive.is_absolute() @@ -212,7 +216,7 @@ def test_to_img(wirefree_battery, n_frames, hash, tmp_path): assert out_hash == hash - sd = SDCard(out_file, WireFreeSDLayout_Battery) + sd = SDCard(out_file, "wirefree-sd-layout-battery") # we should be able to read all the frames! frames = []