From c49118ba0708c0e813e1e75dacb8139cc7c26a13 Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Wed, 11 Dec 2024 17:59:20 -0500 Subject: [PATCH 01/14] feat: implement base writer classes and NIFTI writer for customizable file output --- pixi.lock | 4 +- src/readii/io/__init__.py | 1 + src/readii/io/writers/__init__.py | 1 + src/readii/io/writers/base_writer.py | 230 +++++++++++++++++++++++++++ src/readii/io/writers/writers.py | 46 ++++++ 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/readii/io/__init__.py create mode 100644 src/readii/io/writers/__init__.py create mode 100644 src/readii/io/writers/base_writer.py create mode 100644 src/readii/io/writers/writers.py diff --git a/pixi.lock b/pixi.lock index 0abb6f1..ad9f8ac 100644 --- a/pixi.lock +++ b/pixi.lock @@ -6040,8 +6040,8 @@ packages: timestamp: 1728642457661 - pypi: . name: readii - version: 1.18.0 - sha256: 4c0d9f950a9aa12b40de952a4637380312baf5ceb1c17922322d0b3b84588f52 + version: 1.19.0 + sha256: 9ae30b80b97e4d9f1a19ebc753a55226e088367851d842d1da8d2f104cf3d998 requires_dist: - simpleitk>=2.3.1 - matplotlib>=3.9.2,<4 diff --git a/src/readii/io/__init__.py b/src/readii/io/__init__.py new file mode 100644 index 0000000..2938c56 --- /dev/null +++ b/src/readii/io/__init__.py @@ -0,0 +1 @@ +"""Tools for reading and writing data.""" \ No newline at end of file diff --git a/src/readii/io/writers/__init__.py b/src/readii/io/writers/__init__.py new file mode 100644 index 0000000..f413ebf --- /dev/null +++ b/src/readii/io/writers/__init__.py @@ -0,0 +1 @@ +"""Tools for writing data.""" \ No newline at end of file diff --git a/src/readii/io/writers/base_writer.py b/src/readii/io/writers/base_writer.py new file mode 100644 index 0000000..731c7be --- /dev/null +++ b/src/readii/io/writers/base_writer.py @@ -0,0 +1,230 @@ +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Tuple + +from imgtools.dicom.sort.exceptions import InvalidPatternError +from imgtools.dicom.sort.parser import PatternParser + +from readii.utils import logger + + +@dataclass +class PatternResolver: + """Handles parsing and validating filename patterns.""" + + DEFAULT_PATTERN: re.Pattern = field(default=re.compile(r"%(\w+)|\{(\w+)\}"), init=False) + filename_format: str = field(init=True) + + def __init__(self, filename_format: str) -> None: + self.filename_format = filename_format + + try: + self.pattern_parser = PatternParser( + self.filename_format, pattern_parser=self.DEFAULT_PATTERN + ) + self.formatted_pattern, self.keys = self.parse() # Validate the pattern by parsing it + except InvalidPatternError as e: + msg = f"Invalid filename format: {e}" + raise ValueError(msg) from e + else: + logger.debug("All keys are valid.", pattern=self.formatted_pattern, keys=self.keys) + + def parse(self) -> Tuple[str, list[str]]: + """ + Parse and validate the pattern. + + Returns + ------- + Tuple[str, List[str]] + The formatted pattern string and a list of extracted keys. + + Raises + ------ + InvalidPatternError + If the pattern contains no valid placeholders or is invalid. + """ + return self.pattern_parser.parse() + + def resolve(self, context: Dict[str, Any]) -> str: + """Resolve the pattern using the provided context dictionary. + + Parameters + ---------- + context : Dict[str, Any] + Dictionary containing key-value pairs to substitute in the pattern. + + Returns + ------- + str + The resolved pattern string with placeholders replaced by values. + + Raises + ------ + ValueError + If a required key is missing from the context dictionary. + """ + try: + return self.formatted_pattern % context + except KeyError as e: + missing_key = e.args[0] + valid_keys = ", ".join(context.keys()) + msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" + msg += "\nPlease provide a value for this key in the `kwargs` argument," + msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." + raise ValueError(msg) from e + +@dataclass +class BaseWriter(ABC): + """Abstract base class for managing file writing with customizable paths and filenames.""" + + # Any subclass has to be initialized with a root directory and a filename format + root_directory: Path + filename_format: str + + # optionally, you can set create_dirs to False if you want to handle the directory creation yourself + create_dirs: bool = field(default=True) + + # subclasses dont need to worry about the pattern_resolver + pattern_resolver: PatternResolver = field(init=False) + + def __post_init__(self) -> None: + """Initialize the writer with the given root directory and filename format.""" + self.root_directory = Path(self.root_directory) + if self.create_dirs: + self.root_directory.mkdir(parents=True, exist_ok=True) + elif not self.root_directory.exists(): + msg = f"Root directory {self.root_directory} does not exist." + raise FileNotFoundError(msg) + self.pattern_resolver = PatternResolver(self.filename_format) + + @abstractmethod + def save(self, *args: Any, **kwargs: Any) -> Path: # noqa + """Abstract method for writing data. Must be implemented by subclasses.""" + pass + + def _generate_datetime_strings(self) -> dict[str, str]: + now = datetime.now(timezone.utc) + return { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H%M%S"), + "date_time": now.strftime("%Y-%m-%d_%H%M%S"), + } + + def resolve_path(self, **kwargs: str) -> Path: + """Generate a file path based on the filename format, subject ID, and additional parameters.""" + context = {**self._generate_datetime_strings(), **kwargs} + filename = self.pattern_resolver.resolve(context) + out_path = self.root_directory / filename + if self.create_dirs: + out_path.parent.mkdir(parents=True, exist_ok=True) + return out_path + + +if __name__ == "__main__": + from pathlib import Path + + from rich import print # noqa + print("-" * 80) + print("[bold]Example usage[/bold]") + print("-" * 80) + print("TEXT WRITER EXAMPLE\n\n") + + # Example subclass for writing text files + # Define a concrete subclass of BaseWriter that will handle the saving of a specific file type + # this is a simple example with no validation or error handling + class TextWriter(BaseWriter): # noqa + def save(self, content: str, **kwargs: Any) -> Path: # noqa + output_path = self.resolve_path(**kwargs) + with output_path.open('w') as f: # noqa + f.write(content) + return output_path + + # Create text writers with different filename patterns + text_writers = [ + TextWriter( + root_directory="TRASH/output/text_data", + filename_format=fmt + ) for fmt in [ + # a placeholder can be of the format {key} or %key + "notes_%SubjectID.txt", + + # You define the placeholder that you will later pass in as a keyword argument in the save method + # By default, the writer automatically has data for the current "date", "time", and "date_time" + # so those can be used as placeholders + "important-file-name_{SubjectID}_{date}.txt", + "subjects/{SubjectID}/{time}_result.txt", + "subjects/{SubjectID}_Birthday-{SubjectBirthDate}/data_{date_time}.txt", + ] + ] + + # Define some example data to pass to the writers + # this could be extracted from some data source and used to generate the file names + SubjectID="SUBJ001" + SubjectBirthDate="2022-01-01" + + # Test text writers + for writer in text_writers: + path = writer.save( + content = "Sample text content", # this is the data that will be written to the file + + # They key-value pairs can be passed in as keyword arguments, and matched to placeholders in the filename format + SubjectID=SubjectID, + SubjectBirthDate=SubjectBirthDate, + + # If you pass in a key that is not in the filename format, it will be ignored + # this can also be seen as `SubjectBirthDate` is only used in one of the above filename formats + RandomKey="This will be ignored", + RandomKey2="This will also be ignored" + ) + print(f"{writer.__class__.__name__} with format [magenta]'{writer.pattern_resolver.formatted_pattern}':") + print(f"File written to: [green]{path}\n") + + print("-" * 80) + + subject_data_examples = [ + { + "PatientID": f"PAT{i:03d}", + "Modality": f"{modality}", + "Study": f"Study{j:03d}", + "DataType": f"{data_type}", + } + for i in range(1, 4) + for j in range(1, 3) + for modality in ["CT", "RTSTRUCT"] + for data_type in ["raw", "processed", "segmented", "labeled"] + ] + + print("CSV WRITER EXAMPLE\n\n") + # Example subclass for writing CSV files + import pandas as pd + class CSVWriter(BaseWriter): # noqa + def save(self, data: list, **kwargs: Any) -> Path: # noqa + output_path = self.resolve_path(**kwargs) + with output_path.open('w') as f: # noqa + pd.DataFrame(data).to_csv(f, index=False) + return output_path + + # Create CSV writers with different filename patterns + csv_writer = CSVWriter( + root_directory="TRASH/output/patient_data", + filename_format="PatientID-{PatientID}/Study-{Study}/{Modality}/{DataType}-data.csv" + ) + + # Test CSV writers + for patient in subject_data_examples: + path = csv_writer.save( + data = pd.DataFrame(patient, index=[0]), # just assume that this dataframe is some real data + PatientID=patient["PatientID"], + Study=patient["Study"], + Modality=patient["Modality"], + DataType=patient["DataType"] + ) + + # run the tree command and capture the output + import subprocess + output = subprocess.check_output(["tree", "-F", "TRASH/output/patient_data"]) + print(output.decode("utf-8")) + diff --git a/src/readii/io/writers/writers.py b/src/readii/io/writers/writers.py new file mode 100644 index 0000000..5c9983c --- /dev/null +++ b/src/readii/io/writers/writers.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import SimpleITK as sitk + +from readii.io.writers.base_writer import BaseWriter +from readii.utils import logger + + +class NIFTIWriter(BaseWriter): + """Class for managing file writing with customizable paths and filenames for NIFTI files.""" + + def save(self, image: sitk.Image, SubjectID: str, **kwargs: str | int) -> Path: + """Write the given data to the file resolved by the given kwargs.""" + out_path = self.resolve_path(SubjectID=SubjectID, **kwargs) + logger.debug("Writing image to file", out_path=out_path) + sitk.WriteImage(image, str(out_path), useCompression=True, compressionLevel=9) + return out_path + + +if __name__ == "__main__": # noqa + from pathlib import Path # noqa + + # Example usage + nifti_writer = NIFTIWriter( + root_directory=Path("TRASH", "negative_controls"), + filename_format="{NegativeControl}_{Region}/{date}-{SubjectID}_{Modality}.nii.gz", + ) + + # This would create a directory structure like: + # TRASH/ + # negative_controls/ + # Randomized_ROI/ + # 2022-01-01-JohnAdams_CT.nii.gz + # Sampled_NonROI/ + # 2022-01-01-JohnAdams_CT.nii.gz + # note: this file structure is probably confusing, but just showing how the file names are generated + + # The keyword arguments passed here MUST match the placeholders in the filename format + nifti_writer.save( + image=sitk.Image(10, 10, 10, sitk.sitkInt16), + SubjectID="JohnAdams", + NegativeControl="Randomized", + Region="Brain", + Modality="CT", + # note, the date and time are generated automatically! + ) From 5ec562ad828c48cc2c5c76236d80d514e1fc83dd Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Thu, 12 Dec 2024 10:14:56 -0500 Subject: [PATCH 02/14] feat: add NIFTIWriter class for customizable NIFTI file output and update Ruff configuration --- config/ruff.toml | 5 +- src/readii/io/writers/__init__.py | 2 +- src/readii/io/writers/base_writer.py | 365 +++++++++++--------------- src/readii/io/writers/nifti_writer.py | 51 ++++ src/readii/io/writers/writers.py | 46 ---- 5 files changed, 208 insertions(+), 261 deletions(-) create mode 100644 src/readii/io/writers/nifti_writer.py delete mode 100644 src/readii/io/writers/writers.py diff --git a/config/ruff.toml b/config/ruff.toml index 93eb596..9107c8d 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -5,6 +5,7 @@ include = [ "src/readii/loaders.py", "src/readii/feature_extraction.py", "src/readii/negative_controls_refactor/**.py", + "src/readii/io/writers/**.py", ] # extend-exclude is used to exclude directories from the flake8 checks @@ -15,7 +16,9 @@ extend-exclude = [ "src/readii/image_processing.py", "src/readii/metadata.py", "src/readii/negative_controls.py", - "src/readii/pipeline.py",] + "src/readii/pipeline.py", + "notebooks/*", +] # Same as Black. line-length = 100 diff --git a/src/readii/io/writers/__init__.py b/src/readii/io/writers/__init__.py index f413ebf..9bb3553 100644 --- a/src/readii/io/writers/__init__.py +++ b/src/readii/io/writers/__init__.py @@ -1 +1 @@ -"""Tools for writing data.""" \ No newline at end of file +"""Tools for writing data.""" diff --git a/src/readii/io/writers/base_writer.py b/src/readii/io/writers/base_writer.py index 731c7be..d163814 100644 --- a/src/readii/io/writers/base_writer.py +++ b/src/readii/io/writers/base_writer.py @@ -3,7 +3,8 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Tuple +from types import TracebackType +from typing import Any, Dict, Optional, Tuple from imgtools.dicom.sort.exceptions import InvalidPatternError from imgtools.dicom.sort.parser import PatternParser @@ -13,218 +14,156 @@ @dataclass class PatternResolver: - """Handles parsing and validating filename patterns.""" - - DEFAULT_PATTERN: re.Pattern = field(default=re.compile(r"%(\w+)|\{(\w+)\}"), init=False) - filename_format: str = field(init=True) - - def __init__(self, filename_format: str) -> None: - self.filename_format = filename_format - - try: - self.pattern_parser = PatternParser( - self.filename_format, pattern_parser=self.DEFAULT_PATTERN - ) - self.formatted_pattern, self.keys = self.parse() # Validate the pattern by parsing it - except InvalidPatternError as e: - msg = f"Invalid filename format: {e}" - raise ValueError(msg) from e - else: - logger.debug("All keys are valid.", pattern=self.formatted_pattern, keys=self.keys) - - def parse(self) -> Tuple[str, list[str]]: - """ - Parse and validate the pattern. - - Returns - ------- - Tuple[str, List[str]] - The formatted pattern string and a list of extracted keys. - - Raises - ------ - InvalidPatternError - If the pattern contains no valid placeholders or is invalid. - """ - return self.pattern_parser.parse() - - def resolve(self, context: Dict[str, Any]) -> str: - """Resolve the pattern using the provided context dictionary. - - Parameters - ---------- - context : Dict[str, Any] - Dictionary containing key-value pairs to substitute in the pattern. - - Returns - ------- - str - The resolved pattern string with placeholders replaced by values. - - Raises - ------ - ValueError - If a required key is missing from the context dictionary. - """ - try: - return self.formatted_pattern % context - except KeyError as e: - missing_key = e.args[0] - valid_keys = ", ".join(context.keys()) - msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" - msg += "\nPlease provide a value for this key in the `kwargs` argument," - msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." - raise ValueError(msg) from e + """Handles parsing and validating filename patterns.""" + + DEFAULT_PATTERN: re.Pattern = field(default=re.compile(r"%(\w+)|\{(\w+)\}"), init=False) + filename_format: str = field(init=True) + + def __init__(self, filename_format: str) -> None: + self.filename_format = filename_format + + try: + self.pattern_parser = PatternParser( + self.filename_format, pattern_parser=self.DEFAULT_PATTERN + ) + self.formatted_pattern, self.keys = self.parse() # Validate the pattern by parsing it + except InvalidPatternError as e: + msg = f"Invalid filename format: {e}" + raise ValueError(msg) from e + else: + logger.debug("All keys are valid.", keys=self.keys) + logger.debug("Formatted Pattern valid.", formatted_pattern=self.formatted_pattern) + + def parse(self) -> Tuple[str, list[str]]: + """ + Parse and validate the pattern. + + Returns + ------- + Tuple[str, List[str]] + The formatted pattern string and a list of extracted keys. + + Raises + ------ + InvalidPatternError + If the pattern contains no valid placeholders or is invalid. + """ + return self.pattern_parser.parse() + + def resolve(self, context: Dict[str, Any]) -> str: + """Resolve the pattern using the provided context dictionary. + + Parameters + ---------- + context : Dict[str, Any] + Dictionary containing key-value pairs to substitute in the pattern. + + Returns + ------- + str + The resolved pattern string with placeholders replaced by values. + + Raises + ------ + ValueError + If a required key is missing from the context dictionary. + """ + try: + return self.formatted_pattern % context + except KeyError as e: + missing_key = e.args[0] + valid_keys = ", ".join(context.keys()) + msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" + msg += "\nPlease provide a value for this key in the `kwargs` argument," + msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." + raise ValueError(msg) from e + @dataclass class BaseWriter(ABC): - """Abstract base class for managing file writing with customizable paths and filenames.""" - - # Any subclass has to be initialized with a root directory and a filename format - root_directory: Path - filename_format: str - - # optionally, you can set create_dirs to False if you want to handle the directory creation yourself - create_dirs: bool = field(default=True) - - # subclasses dont need to worry about the pattern_resolver - pattern_resolver: PatternResolver = field(init=False) - - def __post_init__(self) -> None: - """Initialize the writer with the given root directory and filename format.""" - self.root_directory = Path(self.root_directory) - if self.create_dirs: - self.root_directory.mkdir(parents=True, exist_ok=True) - elif not self.root_directory.exists(): - msg = f"Root directory {self.root_directory} does not exist." - raise FileNotFoundError(msg) - self.pattern_resolver = PatternResolver(self.filename_format) - - @abstractmethod - def save(self, *args: Any, **kwargs: Any) -> Path: # noqa - """Abstract method for writing data. Must be implemented by subclasses.""" - pass - - def _generate_datetime_strings(self) -> dict[str, str]: - now = datetime.now(timezone.utc) - return { - "date": now.strftime("%Y-%m-%d"), - "time": now.strftime("%H%M%S"), - "date_time": now.strftime("%Y-%m-%d_%H%M%S"), - } - - def resolve_path(self, **kwargs: str) -> Path: - """Generate a file path based on the filename format, subject ID, and additional parameters.""" - context = {**self._generate_datetime_strings(), **kwargs} - filename = self.pattern_resolver.resolve(context) - out_path = self.root_directory / filename - if self.create_dirs: - out_path.parent.mkdir(parents=True, exist_ok=True) - return out_path - - -if __name__ == "__main__": - from pathlib import Path - - from rich import print # noqa - print("-" * 80) - print("[bold]Example usage[/bold]") - print("-" * 80) - print("TEXT WRITER EXAMPLE\n\n") - - # Example subclass for writing text files - # Define a concrete subclass of BaseWriter that will handle the saving of a specific file type - # this is a simple example with no validation or error handling - class TextWriter(BaseWriter): # noqa - def save(self, content: str, **kwargs: Any) -> Path: # noqa - output_path = self.resolve_path(**kwargs) - with output_path.open('w') as f: # noqa - f.write(content) - return output_path - - # Create text writers with different filename patterns - text_writers = [ - TextWriter( - root_directory="TRASH/output/text_data", - filename_format=fmt - ) for fmt in [ - # a placeholder can be of the format {key} or %key - "notes_%SubjectID.txt", - - # You define the placeholder that you will later pass in as a keyword argument in the save method - # By default, the writer automatically has data for the current "date", "time", and "date_time" - # so those can be used as placeholders - "important-file-name_{SubjectID}_{date}.txt", - "subjects/{SubjectID}/{time}_result.txt", - "subjects/{SubjectID}_Birthday-{SubjectBirthDate}/data_{date_time}.txt", - ] - ] - - # Define some example data to pass to the writers - # this could be extracted from some data source and used to generate the file names - SubjectID="SUBJ001" - SubjectBirthDate="2022-01-01" - - # Test text writers - for writer in text_writers: - path = writer.save( - content = "Sample text content", # this is the data that will be written to the file - - # They key-value pairs can be passed in as keyword arguments, and matched to placeholders in the filename format - SubjectID=SubjectID, - SubjectBirthDate=SubjectBirthDate, - - # If you pass in a key that is not in the filename format, it will be ignored - # this can also be seen as `SubjectBirthDate` is only used in one of the above filename formats - RandomKey="This will be ignored", - RandomKey2="This will also be ignored" - ) - print(f"{writer.__class__.__name__} with format [magenta]'{writer.pattern_resolver.formatted_pattern}':") - print(f"File written to: [green]{path}\n") - - print("-" * 80) - - subject_data_examples = [ - { - "PatientID": f"PAT{i:03d}", - "Modality": f"{modality}", - "Study": f"Study{j:03d}", - "DataType": f"{data_type}", - } - for i in range(1, 4) - for j in range(1, 3) - for modality in ["CT", "RTSTRUCT"] - for data_type in ["raw", "processed", "segmented", "labeled"] - ] - - print("CSV WRITER EXAMPLE\n\n") - # Example subclass for writing CSV files - import pandas as pd - class CSVWriter(BaseWriter): # noqa - def save(self, data: list, **kwargs: Any) -> Path: # noqa - output_path = self.resolve_path(**kwargs) - with output_path.open('w') as f: # noqa - pd.DataFrame(data).to_csv(f, index=False) - return output_path - - # Create CSV writers with different filename patterns - csv_writer = CSVWriter( - root_directory="TRASH/output/patient_data", - filename_format="PatientID-{PatientID}/Study-{Study}/{Modality}/{DataType}-data.csv" - ) - - # Test CSV writers - for patient in subject_data_examples: - path = csv_writer.save( - data = pd.DataFrame(patient, index=[0]), # just assume that this dataframe is some real data - PatientID=patient["PatientID"], - Study=patient["Study"], - Modality=patient["Modality"], - DataType=patient["DataType"] - ) - - # run the tree command and capture the output - import subprocess - output = subprocess.check_output(["tree", "-F", "TRASH/output/patient_data"]) - print(output.decode("utf-8")) - + """Abstract base class for managing file writing with customizable paths and filenames.""" + + # Any subclass has to be initialized with a root directory and a filename format + root_directory: Path + filename_format: str + + # optionally, you can set create_dirs to False if you want to handle the directory creation yourself + create_dirs: bool = field(default=True) + + # subclasses dont need to worry about the pattern_resolver + pattern_resolver: PatternResolver = field(init=False) + + def __post_init__(self) -> None: + """Initialize the writer with the given root directory and filename format.""" + self.root_directory = Path(self.root_directory) + if self.create_dirs: + self.root_directory.mkdir(parents=True, exist_ok=True) + elif not self.root_directory.exists(): + msg = f"Root directory {self.root_directory} does not exist." + raise FileNotFoundError(msg) + self.pattern_resolver = PatternResolver(self.filename_format) + + @abstractmethod + def save(self, *args: Any, **kwargs: Any) -> Path: # noqa + """Abstract method for writing data. Must be implemented by subclasses.""" + pass + + def _generate_datetime_strings(self) -> dict[str, str]: + now = datetime.now(timezone.utc) + return { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H%M%S"), + "date_time": now.strftime("%Y-%m-%d_%H%M%S"), + } + + def resolve_path(self, **kwargs: str) -> Path: + """Generate a file path based on the filename format, subject ID, and additional parameters.""" + context = {**self._generate_datetime_strings(), **kwargs} + filename = self.pattern_resolver.resolve(context) + out_path = self.root_directory / filename + if self.create_dirs: + out_path.parent.mkdir(parents=True, exist_ok=True) + return out_path + + # Context Manager Implementation + def __enter__(self) -> "BaseWriter": + """ + Enter the runtime context related to this writer. + + Useful if the writer needs to perform setup actions, such as + opening connections or preparing resources. + """ + logger.debug(f"Entering context manager for {self.__class__.__name__}") + return self + + def __exit__( + self: "BaseWriter", + exc_type: Optional[type], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """ + Exit the runtime context related to this writer. + + Parameters + ---------- + exc_type : Optional[type] + The exception type, if an exception was raised, otherwise None. + exc_value : Optional[BaseException] + The exception instance, if an exception was raised, otherwise None. + traceback : Optional[Any] + The traceback object, if an exception was raised, otherwise None. + """ + if exc_type: + logger.error( + f"Exception raised in {self.__class__.__name__} while in context manager: {exc_value}" + ) + logger.debug(f"Exiting context manager for {self.__class__.__name__}") + + # if the root directory is empty, aka we created it but didn't write anything, delete it + if ( + self.create_dirs + and self.root_directory.exists() + and not (self.root_directory.iterdir()) + ): + logger.debug(f"Deleting empty directory {self.root_directory}") diff --git a/src/readii/io/writers/nifti_writer.py b/src/readii/io/writers/nifti_writer.py new file mode 100644 index 0000000..ed10def --- /dev/null +++ b/src/readii/io/writers/nifti_writer.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass, field +from pathlib import Path + +import SimpleITK as sitk + +from readii.io.writers.base_writer import BaseWriter +from readii.utils import logger + + +@dataclass +class NIFTIWriter(BaseWriter): + """Class for managing file writing with customizable paths and filenames for NIFTI files.""" + + # The default compression level for NIFTI files + # compression_level: int = 9 + # overwrite: bool = True + compression_level: int = field(default=9) + overwrite: bool = field(default=False) + + # You can enforce some required keys by explicitly defining them in the + # signnature of the save method. I.e force including the PatientID key + def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: + """Write the given data to the file resolved by the given kwargs.""" + # iterate over all the class attributes and log themn + logger.debug("Saving.", kwargs=kwargs) + + out_path = self.resolve_path(PatientID=PatientID, **kwargs) + if out_path.exists(): + if not self.overwrite: + msg = f"File {out_path} already exists. \nSet {self.__class__.__name__}.overwrite to True to overwrite." + raise FileExistsError(msg) + else: + logger.warning(f"File {out_path} already exists. Overwriting.") + + logger.debug("Writing image to file", out_path=out_path) + sitk.WriteImage(image, str(out_path), useCompression=True, compressionLevel=9) + return out_path + + +@dataclass +class NRRDWriter(BaseWriter): + """Class for managing file writing with customizable paths and filenames for NRRD files.""" + + # The default compression level for NRRD files + compression_level: int = 9 + overwrite: bool = True + + def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: + """Write the given data to the file resolved by the given kwargs.""" + # iterate over all the class attributes and log themn + logger.debug("Saving.", kwargs=kwargs) diff --git a/src/readii/io/writers/writers.py b/src/readii/io/writers/writers.py deleted file mode 100644 index 5c9983c..0000000 --- a/src/readii/io/writers/writers.py +++ /dev/null @@ -1,46 +0,0 @@ -from pathlib import Path - -import SimpleITK as sitk - -from readii.io.writers.base_writer import BaseWriter -from readii.utils import logger - - -class NIFTIWriter(BaseWriter): - """Class for managing file writing with customizable paths and filenames for NIFTI files.""" - - def save(self, image: sitk.Image, SubjectID: str, **kwargs: str | int) -> Path: - """Write the given data to the file resolved by the given kwargs.""" - out_path = self.resolve_path(SubjectID=SubjectID, **kwargs) - logger.debug("Writing image to file", out_path=out_path) - sitk.WriteImage(image, str(out_path), useCompression=True, compressionLevel=9) - return out_path - - -if __name__ == "__main__": # noqa - from pathlib import Path # noqa - - # Example usage - nifti_writer = NIFTIWriter( - root_directory=Path("TRASH", "negative_controls"), - filename_format="{NegativeControl}_{Region}/{date}-{SubjectID}_{Modality}.nii.gz", - ) - - # This would create a directory structure like: - # TRASH/ - # negative_controls/ - # Randomized_ROI/ - # 2022-01-01-JohnAdams_CT.nii.gz - # Sampled_NonROI/ - # 2022-01-01-JohnAdams_CT.nii.gz - # note: this file structure is probably confusing, but just showing how the file names are generated - - # The keyword arguments passed here MUST match the placeholders in the filename format - nifti_writer.save( - image=sitk.Image(10, 10, 10, sitk.sitkInt16), - SubjectID="JohnAdams", - NegativeControl="Randomized", - Region="Brain", - Modality="CT", - # note, the date and time are generated automatically! - ) From 01d4b72ec4fe2de6e0a157cb50edc3174910ba34 Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Thu, 12 Dec 2024 10:43:38 -0500 Subject: [PATCH 03/14] feat: add notebooks, improve handling of NIFTIWriter --- notebooks/nifti_writer_example.ipynb | 178 ++++++++++++++++++ notebooks/nifti_writer_example.pdf | Bin 0 -> 33050 bytes notebooks/writer_examples.ipynb | 258 ++++++++++++++++++++++++++ notebooks/writer_examples.pdf | Bin 0 -> 41356 bytes src/readii/io/writers/base_writer.py | 27 +-- src/readii/io/writers/nifti_writer.py | 127 ++++++++++--- 6 files changed, 553 insertions(+), 37 deletions(-) create mode 100644 notebooks/nifti_writer_example.ipynb create mode 100644 notebooks/nifti_writer_example.pdf create mode 100644 notebooks/writer_examples.ipynb create mode 100644 notebooks/writer_examples.pdf diff --git a/notebooks/nifti_writer_example.ipynb b/notebooks/nifti_writer_example.ipynb new file mode 100644 index 0000000..3ff0eea --- /dev/null +++ b/notebooks/nifti_writer_example.ipynb @@ -0,0 +1,178 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from readii.io.writers.nifti_writer import NIFTIWriter\n", + "from readii.io.writers.base_writer import BaseWriter\n", + "from pathlib import Path\n", + "import subprocess\n", + "import SimpleITK as sitk\n", + "import pandas as pd\n", + "import uuid\n", + "import random\n", + "import sys\n", + "from readii.utils import logger\n", + "\n", + "# copy this writer from the other notebook:\n", + "class CSVWriter(BaseWriter): # noqa\n", + "\n", + " # The save method is the only method that needs to be implemented for the subclasses of BaseWriter\n", + " def save(self, data: list, **kwargs) -> Path: # noqa\n", + " output_path = self.resolve_path(**kwargs)\n", + " with output_path.open('w') as f: # noqa\n", + " pd.DataFrame(data).to_csv(f, index=False)\n", + " return output_path\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TRASH/writer_examples/nifti_writer_examples/\n", + "├── PatientID-AliceSmith/\n", + "│   └── Study-Study003/\n", + "│   ├── CT_SeriesUID-13278.nii.gz\n", + "│   ├── CT_SeriesUID-13278_metadata.csv\n", + "│   ├── RTSTRUCT_SeriesUID-39256.nii.gz\n", + "│   └── RTSTRUCT_SeriesUID-39256_metadata.csv\n", + "├── PatientID-JaneDoe/\n", + "│   └── Study-Study002/\n", + "│   ├── CT_SeriesUID-24592.nii.gz\n", + "│   ├── CT_SeriesUID-24592_metadata.csv\n", + "│   ├── RTSTRUCT_SeriesUID-42098.nii.gz\n", + "│   └── RTSTRUCT_SeriesUID-42098_metadata.csv\n", + "└── PatientID-JohnAdams/\n", + " └── Study-Study001/\n", + " ├── CT_SeriesUID-93810.nii.gz\n", + " ├── CT_SeriesUID-93810_metadata.csv\n", + " ├── RTSTRUCT_SeriesUID-46048.nii.gz\n", + " └── RTSTRUCT_SeriesUID-46048_metadata.csv\n", + "\n", + "7 directories, 12 files\n", + "\n" + ] + } + ], + "source": [ + "ROOT_DIRECTORY = Path(\"TRASH\", \"writer_examples\", \"nifti_writer_examples\")\n", + "FILENAME_FORMAT = \"PatientID-{PatientID}/Study-{Study}/{Modality}_SeriesUID-{SeriesUID}\"\n", + "\n", + "data_sets = []\n", + "random.seed(42) # Set random seed for reproducibility\n", + "\n", + "random_5d = lambda: random.randint(10000, 99999)\n", + "\n", + "for MODALITY in [\"CT\", \"RTSTRUCT\"]:\n", + " data_sets.extend([\n", + " {\n", + " \"image\": sitk.Image(10, 10, 10, sitk.sitkInt16),\n", + " \"metadata\": pd.DataFrame({\"PatientID\": [\"JohnAdams\"], \"Study\": [\"Study001\"]}),\n", + " \"PatientID\": \"JohnAdams\",\n", + " \"Study\": \"Study001\",\n", + " \"Modality\": MODALITY,\n", + " \"SeriesUID\": random_5d(),\n", + " },\n", + " {\n", + " \"image\": sitk.Image(20, 20, 20, sitk.sitkInt16),\n", + " \"metadata\": pd.DataFrame({\"PatientID\": [\"JaneDoe\"], \"Study\": [\"Study002\"]}),\n", + " \"PatientID\": \"JaneDoe\",\n", + " \"Study\": \"Study002\",\n", + " \"Modality\": MODALITY,\n", + " \"SeriesUID\": random_5d(),\n", + " },\n", + " {\n", + " \"image\": sitk.Image(30, 30, 30, sitk.sitkInt16),\n", + " \"metadata\": pd.DataFrame({\"PatientID\": [\"AliceSmith\"], \"Study\": [\"Study003\"]}),\n", + " \"PatientID\": \"AliceSmith\",\n", + " \"Study\": \"Study003\",\n", + " \"Modality\": MODALITY,\n", + " \"SeriesUID\": random_5d(),\n", + " }\n", + " ])\n", + "\n", + "# Create a writer with the specified root directory and filename format\n", + "with (\n", + " NIFTIWriter(\n", + " root_directory=ROOT_DIRECTORY, \n", + " filename_format=f\"{FILENAME_FORMAT}.nii.gz\",\n", + " overwrite=True\n", + " ) as nifti_writer,\n", + " CSVWriter(\n", + " root_directory=ROOT_DIRECTORY, \n", + " filename_format=f\"{FILENAME_FORMAT}_metadata.csv\"\n", + " ) as metadata_writer\n", + "):\n", + " # Iterate over the data sets and save them\n", + " for data_set in data_sets:\n", + "\n", + " # The actual data being saved is image or data, but the rest of the kwargs are \n", + " # only for resolving the filename\n", + " try:\n", + " nifti_writer.save(\n", + " image=data_set[\"image\"],\n", + " PatientID=data_set[\"PatientID\"],\n", + " Study=data_set[\"Study\"],\n", + " Modality=data_set[\"Modality\"],\n", + " SeriesUID=data_set[\"SeriesUID\"]\n", + " )\n", + " metadata_writer.save(\n", + " data=data_set[\"metadata\"],\n", + " PatientID=data_set[\"PatientID\"],\n", + " Study=data_set[\"Study\"],\n", + " Modality=data_set[\"Modality\"],\n", + " SeriesUID=data_set[\"SeriesUID\"]\n", + " )\n", + " except FileExistsError as e:\n", + " logger.exception(f\"Error saving data set: {e}\")\n", + " sys.exit(1)\n", + "\n", + "output = subprocess.check_output([\"tree\", \"-nF\", ROOT_DIRECTORY])\n", + "print(output.decode(\"utf-8\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/nifti_writer_example.pdf b/notebooks/nifti_writer_example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..876a60ab82631472c8094713e45545ec54b8245d GIT binary patch literal 33050 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 literal 0 HcmV?d00001 diff --git a/notebooks/writer_examples.ipynb b/notebooks/writer_examples.ipynb new file mode 100644 index 0000000..b4cae78 --- /dev/null +++ b/notebooks/writer_examples.ipynb @@ -0,0 +1,258 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from readii.io.writers.base_writer import BaseWriter\n", + "from readii.utils import logger\n", + "import SimpleITK as sitk\n", + "from pathlib import Path\n", + "import json\n", + "from typing import Any" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Example subclass for writing text files\n", + "# Define a concrete subclass of BaseWriter that will handle the saving of a specific file type\n", + "# this is a simple example with no validation or error handling\n", + "class TextWriter(BaseWriter):\n", + " def save(self, content: str, **kwargs: Any) -> Path:\n", + " output_path = self.resolve_path(**kwargs)\n", + " with open(output_path, 'w') as f:\n", + " f.write(content)\n", + " return output_path" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "example_file_formats = [\n", + " # a placeholder can be of the format {key} or %key\n", + " # {key} is useful for python code, whereas %key is useful for use in CLI or bash scripts where using {} would be problematic\n", + " \"notes_%SubjectID.txt\",\n", + " \"notes_{SubjectID}.txt\",\n", + "\n", + " # You define the placeholder that you will later pass in as a keyword argument in the save method\n", + " # By default, the writer automatically generates data for the current \"date\", \"time\", and \"date_time\" \n", + " # so those can be used as placeholders\n", + " # Every other placeholder needs to be passed in as a keyword argument in the save method\n", + " \"important-file-name_{SubjectID}_{date}.txt\",\n", + " \"subjects/{SubjectID}/{time}_result.txt\",\n", + " \"subjects/{SubjectID}_Birthday-{SubjectBirthDate}/data_{date_time}.txt\",\n", + "]\n", + "\n", + "# Create text writers with different filename patterns\n", + "text_writers = [\n", + " TextWriter(\n", + " root_directory=\"TRASH/writer_examples/text_data\",\n", + " filename_format=fmt\n", + " ) for fmt in example_file_formats\n", + "]\n", + "\n", + "# Define some example data to pass to the writers\n", + "# this could be extracted from some data source and used to generate the file names\n", + "SubjectID=\"SUBJ001\"\n", + "SubjectBirthDate=\"2022-01-01\"\n", + "\n", + "# Test text writers\n", + "for writer in text_writers:\n", + " path = writer.save(\n", + " content = \"Sample text content\", # this is the data that will be written to the file\n", + "\n", + " # They key-value pairs can be passed in as keyword arguments, and matched to placeholders in the filename format\n", + " SubjectID=SubjectID, \n", + " SubjectBirthDate=SubjectBirthDate,\n", + "\n", + " # If you pass in a key that is not in the filename format, it will be ignored\n", + " # this can also be seen as `SubjectBirthDate` is only used in one of the above filename formats\n", + " RandomKey=\"This will be ignored\",\n", + " RandomKey2=\"This will also be ignored\"\n", + " )\n", + " print(f\"{writer.__class__.__name__} with format [magenta]'{writer.pattern_resolver.formatted_pattern}':\")\n", + " print(f\"File written to: [green]{path}\\n\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# More detailed example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "import pandas as pd\n", + "\n", + "# Any subclass has to be initialized with a root directory and a filename format\n", + "# which might not be obvious at first\n", + "\n", + "class CSVWriter(BaseWriter): # noqa\n", + "\n", + " # The save method is the only method that needs to be implemented for the subclasses of BaseWriter\n", + " def save(self, data: list, **kwargs: Any) -> Path: # noqa\n", + " output_path = self.resolve_path(**kwargs)\n", + " with output_path.open('w') as f: # noqa\n", + " pd.DataFrame(data).to_csv(f, index=False)\n", + " return output_path\n", + "\n", + "# Make some fake data\n", + "subject_data_examples = [\n", + " {\n", + " \"PatientID\": f\"PAT{i:03d}\",\n", + " \"Modality\": f\"{MODALITY}\",\n", + " \"Study\": f\"Study{j:03d}\",\n", + " \"DataType\": f\"{DATA_TYPE}\",\n", + " }\n", + " for i in range(1, 4)\n", + " for j in range(1, 3)\n", + " for MODALITY in [\"CT\", \"RTSTRUCT\"]\n", + " for DATA_TYPE in [\"raw\", \"processed\", \"segmented\", \"labeled\"]\n", + "]\n", + "ROOT_DIRECTORY = Path(\"TRASH/writer_examples/csv_examples/patient_data\")\n", + "with CSVWriter(\n", + " root_directory=ROOT_DIRECTORY,\n", + " filename_format=\"PatientID-{PatientID}/Study-{Study}/{Modality}/{DataType}-data.csv\"\n", + ") as csv_writer:\n", + " # Test CSV writers\n", + " for patient in subject_data_examples:\n", + " path = csv_writer.save(\n", + " data = pd.DataFrame(patient, index=[0]), # just assume that this dataframe is some real data\n", + " PatientID=patient[\"PatientID\"],\n", + " Study=patient[\"Study\"],\n", + " Modality=patient[\"Modality\"],\n", + " DataType=patient[\"DataType\"]\n", + " )\n", + "\n", + "# run the tree command and capture the output\n", + "output = subprocess.check_output([\"tree\", \"-nF\", ROOT_DIRECTORY])\n", + "# print(output.decode(\"utf-8\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Output would look like:\n", + "\n", + "```console\n", + "TRASH/writer_examples/csv_examples/patient_data/\n", + "├── PatientID-PAT001/\n", + "│   ├── Study-Study001/\n", + "│   │   ├── CT/\n", + "│   │   │   ├── labeled-data.csv\n", + "│   │   │   ├── processed-data.csv\n", + "│   │   │   ├── raw-data.csv\n", + "│   │   │   └── segmented-data.csv\n", + "│   │   └── RTSTRUCT/\n", + "│   │   ├── labeled-data.csv\n", + "│   │   ├── processed-data.csv\n", + "│   │   ├── raw-data.csv\n", + "│   │   └── segmented-data.csv\n", + "│   └── Study-Study002/\n", + "│   ├── CT/\n", + "│   │   ├── labeled-data.csv\n", + "│   │   ├── processed-data.csv\n", + "│   │   ├── raw-data.csv\n", + "│   │   └── segmented-data.csv\n", + "│   └── RTSTRUCT/\n", + "│   ├── labeled-data.csv\n", + "│   ├── processed-data.csv\n", + "│   ├── raw-data.csv\n", + "│   └── segmented-data.csv\n", + "├── PatientID-PAT002/\n", + "│   ├── Study-Study001/\n", + "│   │   ├── CT/\n", + "│   │   │   ├── labeled-data.csv\n", + "│   │   │   ├── processed-data.csv\n", + "│   │   │   ├── raw-data.csv\n", + "│   │   │   └── segmented-data.csv\n", + "│   │   └── RTSTRUCT/\n", + "│   │   ├── labeled-data.csv\n", + "│   │   ├── processed-data.csv\n", + "│   │   ├── raw-data.csv\n", + "│   │   └── segmented-data.csv\n", + "│   └── Study-Study002/\n", + "│   ├── CT/\n", + "│   │   ├── labeled-data.csv\n", + "│   │   ├── processed-data.csv\n", + "│   │   ├── raw-data.csv\n", + "│   │   └── segmented-data.csv\n", + "│   └── RTSTRUCT/\n", + "│   ├── labeled-data.csv\n", + "│   ├── processed-data.csv\n", + "│   ├── raw-data.csv\n", + "│   └── segmented-data.csv\n", + "└── PatientID-PAT003/\n", + " ├── Study-Study001/\n", + " │   ├── CT/\n", + " │   │   ├── labeled-data.csv\n", + " │   │   ├── processed-data.csv\n", + " │   │   ├── raw-data.csv\n", + " │   │   └── segmented-data.csv\n", + " │   └── RTSTRUCT/\n", + " │   ├── labeled-data.csv\n", + " │   ├── processed-data.csv\n", + " │   ├── raw-data.csv\n", + " │   └── segmented-data.csv\n", + " └── Study-Study002/\n", + " ├── CT/\n", + " │   ├── labeled-data.csv\n", + " │   ├── processed-data.csv\n", + " │   ├── raw-data.csv\n", + " │   └── segmented-data.csv\n", + " └── RTSTRUCT/\n", + " ├── labeled-data.csv\n", + " ├── processed-data.csv\n", + " ├── raw-data.csv\n", + " └── segmented-data.csv\n", + "\n", + "22 directories, 48 files\n", + "\n", + "\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/writer_examples.pdf b/notebooks/writer_examples.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b0ec9fca946ca6107c875b65523f84b9f5edadc3 GIT binary patch literal 41356 zcmb5WQ;=xevMpNnEZep*%eHOXwr$(CZO*c7+qUb?^WOc~JI;z1>;8=xF`|ED>pfd* znb`-4oRA0&11$>_$>HtQD-mZoGZ7As=UP3!em zcuTA-ycx(ibLVxztnW9Hhtwkb@Y+lro11|HscQ~O423m{6&Ex$JNrLJ~d-K4_ z&FwDS&zsBp$AfB5zol=GsQ=x0_pHYo%~ty9&Fog=iYA_ z>TVR91=;Qq(Yx|=Hhtbxd13E$%ica%_siD&`O|m)$edNZ89WAwVl@iU z{P}4v@W~yD)NPl>{=gNKNVcbXBETk(Y9(RFW{;QKG`k075a5P;fTGF2V4vp|erY|C z^AuGqO`!0AUMSK+zcGkRTS6VI8O_YS)5jFl25&P&5o|=#a?2XX*t9V?QmwL@qZ%jq zK=Y}kCSM-na^pqRbHJM6yTRN-8&EP;)$3bm^{@@blwmz~N%C$9elI?5-%Nk&yMGQfR6$;h?1&(1O zKRq~3{>9pzAmT!XW3@d^`pn8$)Ae`OPw>x=&iqijNak%E7-4{yS>c2rLmmpmdjv=i z<}I@BI)4pFTMR=Q?;X2gA}r5h&=QG9I{MR^?@3`ljlQ*=T6 zJO$=h(N*( z{3o_Tm~7A@()r5lJ%}HjS0qg!g>0}@0wIIKyGz*^=;)ei5n_Ss0OZ9XliQIAiCH)1 z5I70Gk9r7C3)Jq+Q9H27o>TOdXvU~DROC@JO;+v2fW}aEU6?saOoZZ4%xydQgSpz~ zWVi%gPODXmk&*?^s;NU(F7Huf`56`cms)cC5&Zi_inEWQ9)zn$aGVL@ZJh(kILd)) zj=a;yQ(dexfALs)$)w^^3FYq^Of00(_CKvufyPOjbD&vTG|1N7&`F3n=oH6)Fca8yhpj-w6tVphHy%N77f>Gt$0xF|? zdh`2ID4nyDTJ5b@y|5bDRbf1HCS3b*EW)V@5x6YPVmc7}cgsft7aKX$pW|fE18WzP za?B&{tqj*r1&PppMbtH%{0%bGB6k|I1mWDs)kXro$oknS+k{XXp_}(ogcEsO<}cHG z2VFxcE~SusR~+JDRaIplgSLuR$Mh;`^#VgSpo&(;5C}GPWBV!Mtvn)Q1?I@>enbt_ zM^cHZswrc;wCZzsk$@E#)S7D*Q7JV*z@n(5J1D8OdSreM-*{w{@n?v$Z4)b{{&9nt zs6BQ~8!==z9C+slI%{Q|VRj09v9^Hla;$*v9D%x|J!7=qHHjhoI|wmZoq?+encvh( z47MCF5lq5~ajuo@42e_W5O%#A5q2;H!)2`KGdj&o&}=6i(TO^pszeQD^EEmVCZd%T z4!<=rkJ(`mxAhU~8_iXL=IL5x9hY( zoi6IoJf-Yy#dsx6xb|YUDp@r(q;-Pkyod~rv#m_P^MU`6YnxycIBoTAV8X%Ar|6B0 zhhqt(&#N0BPOXtzQmfM;3%Rm6fhBg3pEqXKU1ZUv1O_dH6t(_}vze`%@@tBpW#dJS zLj?nBWL_r1r{S|95hN9CQ}NT5*ej;JQGxm+PL&QYpR5IV#33OmDF40b$L#al&#ClC z!fCnzNDRDKj3V6{IXX#ZPK?ygiA0sJ9ag~n@9Emi8>NxwZz&0T+=!sHW3p^YK!4I$ zM+vaOHlc%9_ltfF)t{XoXbjN}=Z}q2o7vxgTS$c*{+K5mJ;RCem)7ZQ4>4g^SJ+#m zadxWBAe~EYpRdwY%A^QT57d>t+~qHlrytK$d4b1LDQ9dMqf7Ku{WTNC`V$uH?$F zo|Mtx_R?}$Yqi@;X=>0dN__1?<+dMkQAT|vo07&9zaX|ag=SV2vaD1x6ks^u>>2Tw zE9YCN{>Ap={3PqcM9RJ$ab6zSz_+#ZSJP|RkclQ+a?pT5!>h5jl1wm8*fHjZSSd5DIVV0iQEqm(P`L6d zLuk=f)3#56a`<)(ZS%2pq^jvgbA(`vcn;$X+edZ=700ZHvxOW#7tiQ<-4ef~wR1*s z#Rei7NBiW6UxLOYI1KKIXF&#{=hJpJ<_2qPsh_lVtN4>=C+Q73NtAA}=i(cC|JU@S zn=Dm9w$^~jA43gnx_PdA_jFCsoKkTNU@?iK%)J$QE7R#>+Y_n-lVKReDatGK z0>u1%dfNAOSk%pNN`#jHiB~W=JDXR`msFGB+B4Z8A1I{DL1~)WLS%f;9v|1$o9-xfMKypi2OaIhvnaqv-Lq!4~sBJ0xN%lG}Mdq zc4j%E;XO+8S0Dj*#Yhk_bn<;f_we>^C@92yA}j8mYTh@q21{~Fb$tU{Cj@AyxY)sy zf!`MNIliZl%gD!zeSO~MWBsx|uQnbRcs~dAYbPJhHdycp`9h;}d$fJN2Gcrxc+B(n z65XKgr}%!MAI#jYh0eTMz7MA0W58FjHFlgm<)bG*JcV z4kV5*=>og_6_BOML+wLE{fN1~AI}euFSfxB@su5f5g?R~spSH$(6ymtyy7j#T^|?nc?``@dP0FoSX#< z@rw{X46=WfFnL)%BZe}uhsKftQ;tqiIPX7AiQO+e84;K(qp(m8Vi@4l}rUdcrHCYpc1{_0e{yW1Qd=b_B zSln+=#2$9OBz}(di3e#!g&+FBeBc9=HWDPSFxs6D2fFi2Dzk1JJv)KVm7}=Q2f$>v zo3P@IsK6YOMwl?g5#GCLQ6i}=wtC(?6B^)GL%i28ss3)b*V6?&U05u2n+Mcyhuem& zJozk}vF`~5p=IF$^fM${p)~vq>6^!Lt-j}IHIAjK0nim_`wBvR$?UcF9?h3EGuMLx z7Tq?Xxemj3DTi^I!(TGj)qCKo1}R?RltLm1L4ktdFx|w2VG)3AU#NnA?{P@-Ueo` zDTX*pAikPHH(XJVynv1x;|xKAbK4o~s=y45+%%SXvSSe#=|`{)jG?^G_*;g8H~R&u zUT-I#@x3JG(nRm_ZalYQkHINfz6N6bUT6BFE9JJPI>7->%-Qiq7oWBX;2J#0x!6Lx);}-;?4_1`ls> zbs`8BHfF%{^;Va0uB8(RAc5#Q9u=)$uUt1!xp;GVAEbW*fZ~*J^`-QLs*P zdTdosp6!2f$G4-F!-$IA+RwGqdxRA4P;ijV;!Rt*K%sE((V3P%uD0qn0%Z^?f{8j{ zCptFzrME|&&sxZXqnS>^j5r7_Jn^V12^Gvc5?xl69mR5R^2{j2U_6{UTJkk)bXA?y zS`&~QcdP{LY}=QaI%Pfk{;Hv6 zyJR^O!8iE2W+g#!-HoxW=II&N+vaUTwR<`1c)jegiRV|w`xhd6*fQ^|82}N0`@qe_ zN$W+67w!dc2`1o-NFwcvAyO6vWL3pZH0f2mX?bI~W%{lCeN|eJ(Rnwa0T8z^6yqG- z(e}t{4vn*I(vu!d32b_ufihNS#Y7Lp=wVK^Jk zQL%6D+zwGC83-f{QBcLJ#@Nof76Q#~uFcTyRBxAqb0eg|tsw9634u?)p}r8t*7ha0 zwR7;-qke(DgfT<8G!j#C2Qv8~J-$h1kf` zOCUIK+I<8?0>OpC)pwt3HM&==7VV@&@}~WmCr9^tUP8f54R>NbsYSaqiAGgi=mD2Os?(Nuc_>{-AG@-V8gtg?__>Ed6tKlECv}xWZ4! zA_dQLBtJLy>>O9QrJCC{CN_v(ur0uMFrqzrutRu|cd&QzeV65EBGhnD3)@Q8!S1b! z$vumzzR3&#&Y=W5DLDz};o@q`XVHy38#}jQIXr^Kh;!R5q8HcNo7}C2dh^`LTFaYX zjy|8-vU8SXuI`&#(00UC%V!SLUJx3&m>_rhyA4jG+NJl=&OLVQjzYQdCYa}oaMN*G z1C&jXB4A6i5WUTtoEa?~6?!n4e{mpoZ$Gm9K>Gf$6vH)_hxd?;R0r}VQ#whG_>lgp z#}c9n!y4rbqlZqvP|U3NfqmMRof1#4HLX^IgjODVT#P0??{aP+02UDv`^Z_r34BSO z^cU;i2Us=MOQKE%WCvF_)(T^6jfvBAb57H8ts0oZ8E%l^3@Q8c)pZ?wIqonNJ>ocR zdT@K9n-yX_auzhjK0A#XIBYS*v9Y1r$8;(qV1i|3Mc&{q7Ff{q+_>XOpHLQAo+So6 zH*b3JIFS3na&Bio^S_ARJKaqDh-R5{rd^%`PL3MYci?4X&L*pyp7#G*;4o__Q$3pH zeb=vQ>BdL|ifJL&#A?zCM6GVS0`aPjMieB0;$6nLcZUPho7>CH%vo%GObIxh(&;{M zf%u%Kbu`CpbSaE2*6SFioa|&X^fnKFkQa^c-^Eq3TQiz;{L{kjWvSLn&6_+w+{{Z_ zWKBLXZ^v~!ajg`tp#Ym(eF66|44PaLAG3*YRr|5sM$)nFrKFFycG-}ryNhUgmC{;T zU8el8NVMc4tN)9KYwi)9x&yW&hdxKd3if>>o5*3s%v}{zGe5MVV+j#m3{Tp81zl_t zHadaQTnYSx0?$Zy<(kB{mrsl!@Gfd(H_RCZYQ=^JYaj9%=Mc;l$95EvIU%Vm+#|Em$SeG#D%)cJ>)2RJR2g;O$=5^u+fV$YA_8(*~`@fOB zOpL6I|1EnZXRuoDwsq@$`LKG%Km+n1X8%v=IhRNM`VZv!^K7p!kBb;=>c_qUn1N=B z&=pMRfV(~0;jMKZaJ}zYUiz14$P4e~3EcD3P>WApE!~}`M+yvU`Qd1~(~K|HXW{pn zf0P%a!|Tp&rl?@~a^%a;D!A|KOX|ta&d#aXpYIFY=CBcS0|i*$pjw5ycu8QN^Ud@ErCht>PnJ!`t9w_Qa+b*7(7UkUV5Br-2eZmX$R(v?9ErC49~ z&~!~49*f9(xPZgmtBsfH3a`U#n1IlQafGvf)G?C>PCpFXZ?wQYkjtYkUu1h>eqNK- zlvtg6ebRXQlt#TMK{7=sBHH!ts|THp3S8fdo#G0YD30D)h)^ur?5R{1gLIxYx{FsL z+`{8E7=%Z=A<9M509@cMpA|>@BZ=(osgF5iFePU?PMUK4)CZ3#8jutaZN-!P5pEGi zmsqUfO^_uf4;xOXWjg|v|5~+Xyo$#0YX8PE`f-79s3u8bg;=QvpztI}J7+hD=zSE9 zF2Bt6+Z2c>Cp^h-uKhzHZJ3+OFY4f=1$idz$K6q+Mmsg`9g;)|2FjWgC!j;}{RwD? zca73zE69F4T*U_!Ov?Ay@7#PW&=o~# zk=lM{nAC-?N`&4jxdSiEdCTRQX0tGhG-zZnq$z^^fhPD~8zx6hDRl`t=CEcEYKT%8 zfG>=qIABqB(PN{-ImxC40ZIkmwHq)d-ir!$F{>~l&c@d(Rw5=iupF{+W;E7b(q6?V04r8BlV90D@hTmA%a{L{8aLgoHO=Q=e$WA zN6GrDEvSW>34W$)_tR-$q6m({=j$aMw-FNYJ!61}X@M3V@C+D8jvM2z;a14i2#Wc7 z04Uw+^&uFk34kpOm?)lMhZU|z4ibTZG@cLK^GBMf8lZ*f{DLPb&HDX&QAH*t@%*@d zCJ+$ZhxlH6aUx?G(Cm#`CES~q&7cazQ9;u=a*f3uGKXFMJj)W=A@h+*HpZx=ktA-hzNP#ZZ(?kIg| z`!OLeUy>kx;gVkIMImP`q##Q!(3i+LOVjFYIU&dBQur*?!c!7A_&+%-jcM4Facb1Yp!q&F2e^sEy3mQI-S9grgH+(aUUe6M4dp?VOR)5(rZy zCSwSONYZ->K%-p%cI;v}o9P?6^Y;Y=0ZW$r)z@8K+;r;V9}LFbN~;q7#B_@KLE3Z3 zVVo@2#VvJ>^{gq$^mzQ*%4=o<@XI=sSW}adCNv_(rKZ*5NBG(*QhprqbOU(;TFgLF z)?ouI!0-oTK|P+tKomU&$Q_O`H(ezp&|wr8M#EaDlQq=3M#J>ajDSa9u>o4vJ7Pw* zS{I}RdF_hC0rk%@LeHjm3d9O^hf>4}@qV6Z?myoc1#xwH2ag2=K*kguSKWDRblUd( z@^a5<4`exZI>&O4XGWY#$o5PM>7^4V2I&LlR#AY}s?K@{*`_2u)e0c&mx#JF*={9C zKnIcTm!hjeoC&WRDT# zgfQf4=19U(Hyz}JUa3L|`ser&=sOhhLV4Um2##l0QRMsjO_2o&g+tE5$Os_=&=-mZ zP?g~YbS_rFq|*36rpNoZZce8V)~&=gNr+NUn%w>yi@};jm-4;!02Jh&TGbS3OjUII zWL2h6k2CmL7N`n8U)@Z(`Xk?5wbfxO$jQ=EP_@;II`S_t-%6Jr5_^SDGaj5Z*LkVz z<*Lct7KR~BnPwuDIRxOPYt@&a&!R1&b;YUdytR~^)IYlYGi}FX@sWLsxv#yY4V);F z51cfQ`C-S@xrW?YvOFUi(W7h+-O0ev*@%=CjANp&T=gW9HTUUA-y9$C zmfrE~S+W#ChD7DC1!%tQY3r{O#Z|YgY*m_{2({+}wMAwwJ*%LJ*GJxuC%cYXd}lrf z$e)j&n@Hg4C;bi(jO_K11!qzu5LGnvM(TIo?Z@-NmcnSU(-g*0xcdY*8(Z~gCp#za zn@0qxL?j%5JakH&P>E1py3ne6nKuz61jo&UBvCz{@e2Mvt-Dn| z9i=p^H5Noh=ja&S4yOzv14e9>t(){$tnY3z=Irkpt=%G6f$0uS-XcTgRncahhouLn z%$&bFn8@_6973cV4#+QgidA0%P7E8_Npfm8u+qL>E6zrnDJbBJ6f$k^0SZ@uwmjfS z$FE~SfTn#d8A=@erph&fj!*UDPrn>@8NW3mPik_=!jrJ5#;8Ky78o?hsbk|Io2o)G zFYnte3=VX7w8cX8VJ`SB9FIM>14h2ErGxqX{PI6kw?*aKhcZZ-VlEs=VP$S1C`C=C zt7bmg9YLI>fY;%WC*Qa>&*~36D?kQ99~9MasW|2&I(lF@*a2qfEjeI#kt_!`4M#0c zy7wV{qZY7t_y*%dWxcFvSQkKn>DUyVbFLVmRihS}7YIkI%pvl5>XfIg7LkbY7WP&A zp-k5@V9AFIpCG(l&T(QC2~rbukN13rPBQ1KY4&_lLBM+`^XcrKI zV71HBbqIs%>$d$mk*T++-t1N6b_NpLz6<1wpM3pOCtw1bBg|nLs<);$DE118Ai+v$>{r_? za2HbCH6nTQjtp;J5B4jM1u}HZZj(CY$zC*NB7}YJ#xtsty`STPTc_J z?V5rsh$gY_cznQfv;X+SYf98b8+fPv(cYbEARi>xX(-L(!z1(-)6^cCyRM$2s>}>q{ z5g8Gkd-Bzb*Uo(Sk3ra^^M}(~#_x7vvxFP2M?g3n>Psj|=!*@NPeEWwyty_*Do&!S0wGkYJurbpd4f_A|*46y7RtU%# zbXSs4m{8w%l44z0B1g~_fZA1riu?qu|2jMk-xyK%)eYMaMMPu-mx(Z#DWt-SM;f|* zg8d}Oezfi;ESeDAzhf$J)M*0c1I?o!>>1(K6+Hdwvd<^W8A&kIG1UK3;le8`k8-lG z7<>&eOaPo;V62}CDkwc6plK-$i%{U7rYE^2|p+S zM$c#;ZHpf>s<1X%)Gq3dPmH|Z>6OOO4#AnC#aBa`{l;viWmeQFnjF^M6xbT!E}@;L zNsB7FMva`sAKwuhI3Iq=d@EQxb(n#}kf* z*A)e4IBb5m|M0Ni9bY>hwXi>AdOXw#tecLp^J#%F$E(GIbO`e73E#+_d9pd6d=*;o z-p^ODzzSAo6rxi9w-HBhYjOBX65c&_uv-}(6nV6aW(*_TzNXrh;!*oAT zeS%{ml1+yOx$prI+lX}&z&b6t?Dzfj>aAF}CZk9fY^{rgZyj=z`Gf=Q?DpMX;1l?w zw&+@Q+pYan;90i$lG-gJYho{Q@+FZf?H2$!e|+m5*$;`YDFrc~oaO#ODqdLDi1`C% zb0Bl2)>Jat6Jq_HdwE>wbCgDT@+;UEktr{=e>Z>5#VlEE9dR@PQ2I1npGZ)+)Y+7Y z%jfyq4My5Dj$xWFv?{*f#xxl%dybk8RGN%^2x&SjG+8IpSD#|)p{gQ_B3;pMl~OMP z%4a$^B4st|9mDl}hdQ+~#)gQaqx!hU+ha??9xX>|WV}GV&e_Ob8KElS{s~cQ$O@6P zj2=T1Z2VfxZ1{@SuFUal7b`dk6j`@#fY5+5>K-u)ue#_fBueD+H1~YLGw^{74g3a% zbcVmg$YI*siAQ#%XX^{V=6gx_e`CY)ujb>J=;{9x8|^ts%fHwF?0WhH$KVtm&WUP3 z_|s~O>TQWE%r{z6rRUd2Ct|s-qC4DH2;r8QIx(9PFDnLeN<5G*Z z=@Ti#li?HJkj3hLR5s#f|nNh>T&wxl7T8SO{4e=Or5y~yJ4X19w7Pk#n&o&-iGDJcsY1v2pw1Wg3VQfSchufVSd^2 zK-sO^{Yt!&gd$q(VwCFcUU@!vW!duVNk#)l>ET0ez9ZOO)FUu8CjuZe zw*ss>@tU_hqYAOg#KSyJdv&wL;5g&@61$kvyADi+b;#NMevF&R?fpb5)(vV!so*KC zith!$nvn+tnj&McW@cnIy>4Ou?DYEnT*l6fEU3F`1zUL!yL8!i_UORs!?668@ovWg zX|an>eevn5k&6x?z61EAkbVyA9yo7aM&X$<&215(QiiUyRGAlN$8;QkT>|h=Em5L% zx`Na;v~A;?P>$3=l!_N{NF#ibTl%>kAgQ((q-$-=5>DiTFi1hX!V!*fAWUKRAX>Ky zBRo#h6r~f2@7$nPb||P;6d{nuhQHeMtXC9?agj{+)6n(E;$`Ukq1TOCmQk4v8$Dcu zE5BtBE}Y5|zJMd7kCFI?X#XqHVPX3HU)wuL+j5@|IppSn>bSv}g7iV1A0!$jsJT<= z1K5DE!=D4vu>bP&RLYViO}B7-ip_I_f1q4=I^Mn z5z(7q;+x;0Bi6So+H9@{^`nDrQ*AHSVQ_a%79EMcIon_1(XrNTVWl(oaogC}-akjR zJTQbaDHBcFfSnzdpVhS%c$Ic{YlL{Y#cEa~rs;39pXjcL6EY|7x+uOG#u$Ki)i#0-i7$R-I^gsougvtW-IqjpxX`>Rl%l<->AkhBqVtacRqwrZvPnpME% zK!#u-mXZO@9-!v%TF4Hws6p7&kjoJWr)PZhinjO}3_w$S8Bao7(lhK~ImB)jhc$cV z;$Vhlr6J8Ee>4guFq2z}mow(lfg#z|4)u1+?FC`DXdrW-SO@zYwc;RhVnAfWH-B-F z+%w%Kv7rFVj-7IuZ&C+(NGz`B<$xKY{?7-28-{ylVo@>J)V9Pj3;R|$kje^Lp_CLN zebLNyEw@g@ZkQr@WHzydWZ^lZqjQOaPz5HiD9mN)2dpCd(NfK{$E%LXVi^?Rs)Rm>z0VLHtSU)j$Ip9vUa7Moc(8Nn=sQfd}Unjcz7 zViqwz55Z#&3WU3ye|ABtN{>(Lh$K!bo)yeqnpCQ2E>bCMPzcc@;|w%tjbtX&oTQRz zia(MM>f8e|AeR!5e~X@`D z&OZRn{I7ui{a*q`J83Q^lOA4p=M9S2M!6aYwxv}eD2YT|c*&{K+KxUETjIgfbAwoE;cIgBQTQSOjg~oZ@cZ3z zE{C&OL)e1DnB)C7EZkF5O=sc{W3MNc1uyfJFgMPFpqpdc%U5XcyNi;2lJQ;}h@-OV zho-KI5BWx9tjgV^kwtT64)wkDm+q!nOZN2Ajzd~1S?$6-n@l3L9PX%q2GYW8LR#Ff zLmdKe>pYf^6nH2VGbFK-*_338CPR`YsW1!(Iu!`I2{Z_2OM*GaI3r}ig1#Q+Vdxsg zxeu`L966|J0*BVaoPwML`F8;+F-iiceW@^sRhl3s>0}t4OIU%e_&|m#V(>t;XxTut z|9Qs@1?DVj8~x~Sqd~bBB**Gjuhb7|Uy?7X=q?}?#)Na8vW@9bcW8PVkHk$dS7<2n z7)0#a;CyH^x;%!1+@G!)8cE0Mh3oI`zzg2m0XM)brr$LXHodUe^!)%m>-yD2_4y6R zHS=Zs4}0QYiCRYH|7I4s35)-+GZ$3PX$k>`c`cfFi9l9qXAMg z+tqakk<@7MS|3)ty03kD!q#K`bpq&zQg`Bwq~?7I32<8q2H$X8N@&1!(Gb^Nl{uQlh=&Lbr_7yS8RCi7O~ z+mpOba4XGCEmatcf89}u#1xsASW-iOGy;voa#C=o#*aompnO5)X3fdXZGSuDhVL!g z_B}ti7-^s9R*kZu`a2lZx^Ypg$6%f}bUHBr0I8B%`pytFn_96%EX^(O$u13B=p8qe zKga5(e%xaPtWptURpWkTaz2*E`Isjn&`s^Q!O)d6`wJ!nx=zb^x7Z06Lp;*m2)FEb zheMM7YB@sJCiNCScYxv-T)N^OvZ#!{-+KNwy~7f2aQqx*yXa6E5!A!}rnzW#0nm`0 zHOe(2qtaoLTq*jDf;?(d*CL($g}3 zh3NaNEpK8X4|U$HByFlB?c{`DdB*<;f8oEvKRN-GlUvQ-Zosn;2sO}3hrH`-dyXX#yqk#PcieVWM4+Do#!D^vxW8f9xpV!-c!0xPqm~8~{FN zcv9OomwrRsdw`Yo#hPSYWJ}*=jEYzB*DzO|@RA_1QM&9%+3$Q3mk{VCtt|$zLVKe) z)PqqVrTZNY&Dj=B8lQIOJ!-sxLbD;p(l&e&oyfGNM+s7EaV!Oyb$1f*`J81jNfAo1 zBxC*)8l+n1!{!Jv26M>*jb+8dVO`6L9qrlXqa_puHBO$yn`~P@&q|TK%D>gh)(Kx< zPTPd=e+c-0w1X9$4V>KVjOhf$g+y#^oRt1P(+mFpTu@?QW%;iQO2Nv%q_9pfcml5+ z0|m=$V>IyEJ5`DbH5rJDBQ=CG6R7o*a*O^{NxuVw!MM=B;u#9y>-BAV^H^Qp8b$$w z#1O*^WRT{YV;%GPRH!H`P;BL%R+L;3R%?oMuMJhW2u4p-wURY(zB>8f$RTq1-xdXxCcKI!yonYI$fbKJ zQ*@^iWXYs*Kg6P}ae%}-Rf`W6!u5kE#YWhkrZE!5qs!NbB8!KCi1W|Kg~mCL=Em0& zJA~iwM7JoZQ^LF{hj^5ZovoEkjg4)Y)O%c(U=}8>rzJidB89&)H!DkWSJqZFx8XFC zO6ckeb5|BeG%3{&(U?dpl@#Yx(4#*cTZ}76pTW?{(T4uH&a=2gO7jCqa&MoAS}fDHz0?8H$$V`R#y z&7u+6P1>n3$ks5m!!Nmj6X(*(;5# zdFkxIL|EfwMAU%t2CCq98s|_1#6a*AhGS0}IUH`u(!imgkF1ZNqd*8razyq;%1)P_ zdMvIRS8gPED@3X5@@cJxpc_H*P@G$V#@#t*P}y1x^l1^}hjHq;TlCFrU(nGt6J)!s zouuoXb&bP?$ip$Wx^sA8kY;nAJr`M@_!=DCAZ>)v5&7NTS|lTH_ISx)=JTFT)kDwg zY#v2!7^^b9S9mviBfb|7eZE=1xRq0(coGXYOec$ED?Vs9 zMtd1%DA|Pxw&*Isi*guijRv8>n-1j(Fj8vQqImlUP8p8|W!ezQ zZbw5VX@^tt-T}FjI1k+Tw-dwW8zF^MKA5g5puXVRoueKQ9xw1I5%Ke^j*mL`HrwW| znS~QOENgTO93C!FPl=AWT;=toVQHSU5zQ^AOd=H{awlyOv05!QqWX<@sI!dG(J=tG zOi2xlPjwYlg+|Ok#o$Ar(`9w?@dtAqO84w{d|blI{6sNsK)mm22bD^IZ4G&i6=}LL zdkb;ZdrR;w9t~TTC->YwPdx08y#Z6%SZkXc-nYq@Co~?67qJ$lmiaK z(iK}&SETX%Z!;A^Q9Rq#GA3yTlSV<~_IHsk2?p(q{x zSMjYZT1NVBHaBpV>ID;cMjp3E+%-J3-K6 z$ur!odoly{GWXdj3_E3_467W+6?3OJ#cS=yFFbefY6?YIuib292HqQbdK?a8 zL9&98$>i!FhN5uUv-LM`ku>V@hHN&GO%9{^;sl10(u!>qzvabDZ*j5ZGowks?`IW_RBMBqE7{x8lx|eoi9h2pN-r$W~K zB6O+g_0;8sT!DBoqVdnUrpJY<6Xd^#Zo6gG=U>K#nI+)bTwp@S&&S@zZokLmJoIHt zegTko3JVm+-i(X8Il-zE-=_KY^~_kK0H-k9UDlMd-(ONKbo>rwcjBaJ%;=R5r66=_ zyYd@eaH*>dTupc)7Bas2^7~~0_WmC71>!=RU$3=jRt;JuS=Gk2TZu(RK*>dGc&2CC zI-Dwr!^PL7@SZR;PHuJjQDr66g=P>wW2ai`%j+qG1pR`CiT`bPQi7it3&V3Se^cl0e^)g7FaKq!r?;!8C#NTu z*7v8bPaGHk9vH~-AC~?P6aRnB$}BAZJ^Iv z@>5UiWh$77PpHi!$i+9c7uE08943_bt%juhKKuQ}4_G|g^sA*^qF6#+j*l94-1Zhc zqO;)S~?D@5Pz>dSuP3t+L# zbd(9DG6w~1eF;uK{D2-?vY*sIvCe!I;u))^f9VH1N&g^n6&q{9Ju7GG;g`92Y{qND zluc>j;J~D;q$WM94W23EFR7a5wxk!xVwvIbwKOf@L{q(c_uMLubat*0h zs9@~@H_&AodRW#Q8YJA`^eKp?eXnk=6hyH$%R?HV%foK$SQjQ-B-D*vcFnZ=M1=)gE_F6~Mk=qD0?`--vgH-2kGT6Ui$5sKefyd_1*=%F}9H+GiS%lEyES7xx zA*YybsgQy45;cV)Xcr%bQB|{CoYvoTgndU!cph3m13`x4iRQ)VtmkR&wL-y;A5pnf zlR!(tK3V1Ko`XpLy~r6hYtkAxo8ztY<6pQB+3;dR(lCo@^x(>WH>`)|Lk_kAM^%KJ zRL?&@m4i5%i@AE7b2fG%*oVT6h&%}Sr=V-YeNa`>ACTIX=Rg-i6sc& zH`}7|H7;G5;~P@}y$F8s*gVxd)oHMT&u@MWWUoZAZ?_)D{8nPX4DM4H7#tQPk|dMQ zsTF#@Akv$S8;SVrwVhKK3zWd9174L9<*&fq*${aYDQSeXA%$!{Tcco#~Bx!aA-^AZr8Hydf{O$ zjaf&d|1toALILrso!e)E0A+xr)4tUi1|dvFt1>vBF-tJm2(|?5wItCczi$b4JS8#) zkUZl%D1?aFoM;+Q=T4q2B6|VsQn_9>Gk>{1#(j(TzI!~DSjeid8um>h^ZMT03R&c! z2J2~cX@2@Est`AL`M_o)jf_LRbFwv+x3{K#YPys@+DFCNJ16CpHS^QWr2u%?lQMql zewf@#o)@4!JV>W`yqY>>%p84go99!YW)SgG96k3&+t81O!5I)tw>0S0L#tpWe; zk&gz4q!3?VEG5$Nx@kN%0y|`f?1JG{5RqKFpTn4zhX^0u+6yVg)iuIO!$qAwI2M34FSw8bFlG_ccQp6aUjW!-wYeZ->O9BQ@{zCVlXYk$O)i6C0I3I^9Y3@ zky6?hqooaPLoOMt9}n;)vTNyUxxzHuUdn93KHm3>81dIXT#o}7U-PNXRDJW5Zw$lA zX;c!U^P4VVszaOD#pJDyk8_ft$c!TRsXK(~Vq-K07aq!L9QEn4t( zBzOCD?Co8OjLrN$u9l?{9dv*CD@TBx6Wn%&itGf-+Vk^^%L$lgOH^E+E`K#MvxW#r z(@38~&PyhW;4OXup4D%A{R7kd%Zkx|Rz3cbm>xX5qMaVRoSqrHo*e)H{5}x)Kdk*9 zX8!+{#eOsV_s=h))gkqelv{IVO%(D_PvI#bx5M%Cs6p3+KxoL5Q8nP9P@rgNoOI_) z%rj}vGHO`0DYjHQQWeH&In}7k+k`-T-cWppD9NGDYj2{q-?ux(Ree)m1TyHET#s`+ zr(LJtdaw`-&Bwxo3dFIrw3774P0^-zE-&y<_Ny7QwaBE9a{89V_@eqEL59%x#G&;& zinld{bn05@H8h_)kq+p_b)WXh&_#+e`FKZ0$sl8^N*8|aK$m6HW$c!jFe(|M!Z1dp z^h)R#(!gO5)We2hXPP`}=SNn|Yi-jwUX6(LH|4-8C^v?Hyq4;_R2oy;E#@>8c6|zgG~V zd{Y`Lxg9b+L$&(#i3vw3pj74UQ0~C%-iYmVN0ild_fQ6N^r_yRFVomSi5jPA)Dzgn z+Nu@R8hm>N6V9)wBIK!r)~eO<;T+l4D!tL&78(+w1;M~XXz6v>{y&txLwIIkmxh~E zDzZoR*~8(rD{}Hx7AH=g2cy<%hhasr4s5nZvSjrcDrbwjJX*@WBS_U!X)z~ju{*T~F^`zEtqGyWc2nX{8Cb}8& z16E_kT0ekMJHs}H`+=)5`p%>?eJh6jVBnPzScU+3@HaD|3!2xz}jeO@P@29 zZ_${0d3orWk9?2Ki81MIOmn_%zG%K9zSgJHWn5XgqV&_Otcg04Di@2SF$p(?dqQ4* zgR|OF>!E4Juo;%^bAx~flh`UO7T%yJG+~>Jc49S8CE$L($RQ}3hiow_zS!!C@V?ID zGHX0(5Ah%_L!{a$v8Br1%~4873nLJ5^1bL8Rqim~ zXDx8OgGHqs()DVRudSUDeZObYq4^mZN>KM`V2hSnz2qLN)LUQwaW zO*9^OR!>b!MO~42bg;}DA>*T&y;1Dns;U;l9TeoCV3od2$^HxR*w(S04g2DdF|r;q z^Uos_E_|VE$Qp^*_@gUf@rs_& zt7P5exZd$zD0m%&lJIf7%q1`uILcBXO>BD-3$Sj zSqL6iJ(!vlH<6=L1y;Cek+};sk?$dN+Bb~zZbk?Hi>|NpN|X~H9X)%u>~`dZmFF&L z3V@M?jaW1-uz}gpt#G`8)I$C0!ik79RYWX%%qxHAmP5mL`=ta^1|L&|JqBUTobWYJ zVjU@L;Xt{;*oA^ox6!%TXKe!C$F&8NgrJr?O8E`QH?~D^TS0xv%NXpL1P*(wX=w2M zBEtasv*4z}!mO9XAxM&F6*7rtx5a?+3d$DhSv54|wOJI0XMS}%P9pvYe>&rud5d}C z7ap4wtarTEm0~#9M@t14RP`V|`*|i`;3{CU^}5_oTmr zQTP<@H(7rs(K36t=T0G>lWYMZ1?kIO5LKeNLWCdTG{n)X%LIAjTTVoo-0==PY2svl zoF^yO$Wzs{5zbE|>&B;G+G`e6uFtpG$rqdZZJ$HxYzbTUpg{T%2ityqN*emKrd3;Er$Zdk#!~ceadPXWhZ$4^0Bl4rYC}LUn`o?u?~=I3b0}D^Ij2hK%$eb?G8B5_i$W6YO-w^ z3m2oANo{RRzjG&~>%j}34o zegFVDd*}cD!+NDuXe0>AA7LwTsjA_7G*x*!zcgFfQf+S;lNDPxjd7<|de__vh>TI7 zSe1mgSZ^h}8HL;o1vtt``Up(Xy?v_Vq+ZoPRm|RIM@YuW$3mrmCRau!A(UkS){%QM zX|&&ysAa;&_@Dg3083Fds}Hs8MTH_a@8(C=YfU2AS;OST2lphoS-IIdxytdTZ*50! zABt|o*$`jI6%7H+u%asbTP}|Q@3RF`JU=l6X3fx%y zOB0Veq|DPWY}Uebtb#%@55Zp_Ul-)+H4iY076^cE$ctI8*h22;H78`Yv+7RtDpUL| z9;ZE}HYZ{tMsM)jO&cuM9iFIC8xUEIBzSHR8OeX{FsOXVHA|`03meN50i{eS{v$B7 zelsg%P~IcYAhh&E5(05CQt*XZ(#i4z;$z28frc#~6X?MBWem>XUB=hCGY9kh;iR*o zYosK-`_*z7e(qZoJHiaQMQQEA^OW&&ijoGtijtD#J>{kJs{L32L3oXBYXJbP1F8q zF0U+HQgk$okoG;}_Q5g31G}(}$f`oe`yAr}>)cv_)!x(8qAri8AQoJE%LwIcGl9gZ z5BA(8*yyiM)wj{w5s-=G|BEOr+f{5t@!bfHX1_mpyv*bC7=eq2463~L!GMNB zBF{6KZyyVX8B;c=4~(bX-Wo7+XercZuqmW*4`UMyNr31c7Zmgbao?fQ5HpE9AP<7) zj3@Zj=64mR$jMOQbMqSV^-2~)4hJUs(ImtL8K2$tLQ6+H+FOR@YWXav zMO$f`$7G~I@!zlEODyj9h2Sp^sMYUaOnE$fA{^m8)|15pl=*qZ5qB}CXHO6nESp-F zo9k}?cIh1bbwGlA9$IX0Vtxuvao~Pi2^nH|OC4XvdW4D}?w!oNJghW)R^~uywkAx; zTBsTy5niI(vBt>9v9%5U)lgj{NdwOBCnqit*tw-7z0h6^mm3Hi-3x(C`> z%@WkqbEzZKqmS{IJI~NOLBlgrYG41~)?Q(rdphG39p02r4PA?{2eLO~3jk-Kj5RC& z-w7dlADzx;Yi6#g7>=$wkjkuzVoTL!Nx~@AQBY=;hyugi}tIZ@|8$5)i4UKohBY_-iAw?+E_=CA@=HKiEMNt-tuC z_54Giwo)BdH|U;4eWMT=KPG zg0$D20d8zC$qwI(#)&3*rHi9h>(=J_Q|Z3l_WkeegdX1;7z1HTkujZq)dbh+QmE9E z@fJ$TON|S3Pi!W!uBqK|&aFvhRB#m)9((L1Dwq2Y35`i8kK+gSgMpZ2Y{;?s7Hc^q zZ0moKCK42WQxieYC;GsL!m3CGFU8W|C};NAwjSs_l9+r=@^PF2V$l>+fYH*ivN&VP zV)7ZB+c%dpS%%W_x1c0O{;Y#4_bEM} zLfjI3K&hsp9I1~!n=XolByJ7Hp(NdvF(>!jc%r79FskM0|CBB~~oOgF6gg^=HeQV^2T5(}oPVJxgdb@ahS{T;js zJ?lzXf@by-%}M_Gve^&VrPfK@-rl&XA9!I6R~2u~=5;@;)`nn#FIj?AWlZFPjU}>t zx2@)2Cgm0;Ur-rdVL?$%J0!OFK2ml3{@x5CS{wS#D`EfgYO#OWX!3GhDSbHU@UkPC zC%;VcvP_ng%uH1tsoWSJuMqY{aCyv{4qIrPDwA7cNlRT0@rIQaO!B$>D)MRyl;4e! z*bTLwXZ8&IKsw5-GpXh2j`4^@e)OE9y&n!br{a}Q9k}knWw^yTMf55mzh6XM^k-zG z;$tsO21Fh_y`QRiR(97Y@e6(1QbId3d0|^qew}n9T3t)SD;ee%4i|7z&~DJnMat`Y zVc$se{!>aVxvnEoUzfReSiy{#=JmLQ#|WET{lrIrZkbuH*WDA$d}-mw7Im6Vb2O1* zgsL?U;{WN{y1hosr3u0>;pWlTj?fFshu5sR+5RhwM{EjtjNR8C!GC}Tb=U7%R5`iZ77xRh22~>~3lBtV3AS z^BGz57>4Mad%>j>fe1vMxBe#oGoCP0e_N=!L|t*!DecQWGfJ1u?TtIEItfoHqUcF_ z)*SqS;&$vCH5vn$W%<`(ZC+HCqrp+5({^7!Tadsb*)yssB>h@QCuMrs%2K0pKV-iV z+@8uHcdDqaaB`{$J|ZKXMCO*{QEOg8PsOJZD;R;fTRAj%+C%ph-oV1g%3n7i^yniw z>xpcl0|0etQNd04H$RqFof&+v=cR^=9Tsr-+Ua}*s15@cF*Uc_sGJ;yvO3)dy?g$E z@(*n-+@H+z$!(H7JV0v!oTbNXQ7fiLX(BP|jN{f~_N9E)Dew8+DLX{rqT&3!QY(OZ z^v$xA$)(Z~(&I+UdN`w8?>ISR(XxDhH(*n)NT}FeA6l6^)Ya4Latcn6^|dMaV2@3XjaGSEVW)bp;gU zTyUJm*0Kvzdm`xB?h~LUNQvhS-*DYLR`%XVi7OtU{|rt0?)(f-$;&+xHdgh3ahF%! zY^LIsf>&sbFT9u^6+Ay* z05rb8J^g29LjrCo_&9`8uJUY~IhldO$7G;|%QWqN{*@;6oVOYb_j1vtr$uf-K}AEG z2S_~UT5AqfRHex``;ANbWcy4!iMC zu_%qsv5D+)_&YoEp@5vecP<@T{LR7!Li%(PjQS63$kgZhe5O^k0Wc;Iwoha>2Yg6R zVe33jkzuYkbV>PrwkhfMtz-=|7c;dIw<*NZb;l0w3ozbZ+srYim+&#{)hG2x-xu|L zHBV>QYpPQ65Ua+PUE7MnP)9ID5St#54cZWzy}yD%JZ{Ai&2=Ioc8G|ZNUbqQ{l?-{ zcG%EJPWm_+QkLzV$3WK<1+3uRXsxh+;+y;-4Z${$H2y$IuNy+T>#x%Lu;kOJ#Z~NA zp}+A=sh;E(6+=t+v>>}3qWo(cs2-?zO2NcQ?KQe^k4?j%SK_n~l!>`xwn0PPQP_4n z36M1J8tKjWt?E-B^FdKq^p@Q3Yw2|4%}}?)M81twJ=%1im?Blqv?!sn?h$!ff?U(= z@}pP{kxr3WGyedi!xT#0zGCsvg#0orZK6dQAtEyTS4owI+r|3E>B!MRIH}8!jVw!w z)9%8}Qbi(=FE}F1SOVezH^o=B=#U)%8q$`TSAe2W^iHGPM8LGG7wd)g2&xtpjmWh`9ty z*}_Yc4JaAxS>7?ai4ekuHQ=#7RGN1^b^-q8_KUE3m`bSP85`nwX3CIA4|!|UIKJ}4 z;NI3s=KH`QzmNW|XR<1vi({R+=MGJ=hN^-TWDYa4-7QNpxe7(C<0-MT8kpT=7Q0my zhb7}RYS1Hno4NYahu57Ox;9H(gB^mLa#>?=VT!;5(;{1!Q`G?ySC^CYh3cbE78vPw zR^N`G+`U-5d}ehiXuk4#qG4xl$n_?gofp(t2UD3VtwJb=etevllVBVQ9W`N+Eo^MV z%InF*L&w(tF`MfgzYWy!#|IA$0&gG!9|8R&)*<M&KN+oGTRV`2evT=-hTUa7n2ElZ55#-sZg1GZzL(XaVAlywHQlX zO!NT2Au|03fA6yVz)sRaPz)PsxXu<7bP-}<<{BBa?Wu|!BTUoq9kXkWtxd$E?YCOW zVOpIG<3CfC6b)}3WcBr22xi~{4NPP0(pXzokWoSJs`og7(6d*!i zA*4S7NWTSZm>|&+XC(kTf;CEt*49_gEo$l|tjjHf>uy&q8)_R%xo7UHH?8Ji2;FW! zZ$f^M=1xxA>@y#oGan>K{R>G65+pD7K6{`!YOQlk1TNpsT(U6^wAudYd4><5JOmzU z{S)MO@gBDeJXhUFk0G@q3AHD;)~7`~us;g|(*zJ>xs?4y)>Myoz9>K9+`i_J=-Z3Gds|foep9aD9LHA9Km=OhSh>0LU5k<=*IfxQxk+_H=84$>bhCmKt>>naL zf9>ara1C>*?Ps(2&Id~VT+|O0%^rlExXs`x$sH?f{GBNg$fWxkn>c*3zhSs>Cm)gj z-Zrn1&%H6(5!i|P$OYVF?-=`K__dbJDlaN`ckw@eYb!T9l<3!3$3o{iz4dU4T)b82 zX|^ugEtR69nu3%k_%$4XWq!R+ei=H#E}Naq-5L(3EzL)2O2iS7x8OtK}@r`=6p*BSTDGW02h}jA#U$o^YjR>x)G|Umn0B z>g~6TgEzDf54+>^wR=zRz7dw%VYrTfJ8%=Kg_(`@p2Wk%wS@t{Yh>v@U0_icRVaJ| zdWk1j6ys*hpFh)#@3OeM7P8%asRkzZ{+8!})#3AA2%EKBJMY5@1Xg0c!}+v5&V#9&>8hr0;K!p3+YK=ukVUv+?1KrD8&@*q~YytuY%>lcXy6$e#hM090HJjVs8CU_F5l~ z4VCT1+?ui)A(t!Sh!aK3;_5S5Y49TeR|hI7CpoFyik4%L&7t)k+TFXLL@CyHMH7~x z>&|XEOU5?3>trTz?;{;06q8_$uLH0fg04eldmv|L2`-) z5g=5;7|yyyR!5w>k%J$gCiRq!#;XAovI+=e-4TlKhOWX5OT20su!YFFv642@~Zv{uz@U}|s@?DNwWbgNJ|{=BZpfys=MKnYkbf<@pTICHpPVedhfyp5aq zX~5>YmU|8Do*EI9TK?N|qQL|eqe(zKQ?=S#1;OwCC9y| z5qmr>7qI<&8E@aWvOiqA=eLVWH+Eu|&VSf<6KHPgFh+zCf%nT|1>>MnW2BDA*9X*H z@iwqWm{TXHEu73&RFAn2NzXC3{e$gZ9LcS?4g7cM097mOkL*Ly%RMS!0{QB%z)+cD z5e~jNN2c78wGlieCJQA4#F$1qgzOjHZR0KMl@serPT+TQ38^r5Bd7HBv zTSRrtNi)f?aW3a|E;t!shM|tXrp^nI&Tfd)`6v&5dV~39*{#-PpDsN01=@}(60YVsX)#1 zLw*WXQWGo~)R3#FfE|%)(q2hkNj#PQupKbknMB!`jN z%hj0NGN{G4tm#?6xA}3VZi{OMaP*B$@2hN&^S4;-(PSlUzDsSP9dKhlkyRhz z-euZH-^;S__eZ=;!2bDRTIc%O;Hlyw88l)*X_9Ac#yE?SM-?V#xz~oFUj1JDFa@XZ z*}tl^Vv4Zn%Ix$mxjZZS>rnFnB7Zeb-3$qSE2Vg#bFa&2^zyvYaxl<=rH@ei;n2fc zVbib04@7w)L#>is8;Mu^;E{Y8q7(FS{B;r+ ziz6IP)SEW34jT&z2^LINFgH6lx4H`anb0?s7I6aq2~}bgBJWAx9OG{lGEI@*F3xz$ zJgH#W@`?hhaPHcIGTgbMm)@5u!Tq>2TudVUxzPH8AMX4wAoCQMp*;@&mhME9u+s^9 zp!2?M<86>rt_RBxtwWv;*)y@Xb^Hf-=8n^$CPc_Onc-9Rg5Z?=nf21pVg>u6kn5*F zD>>b^LeVmu(K{9PeWT8fiaEra&MbmPU<;5ZFZ7xqZS%(DawIA#N;s$dZNTx^Dcur{ z-GL14n0Ty+#yi{yD!f}vQ^S)X~kRG#zD0~!4|xlmR-0uPAfbIt&6sqp51t1Ks2hkWA((ntP{#Kn2tu5{tWe?;1l|% zx|+9FK+W)+O}sLQ!(OgaiL_e4)s!)cwAJ+iv%3{FXNghknXxiDqrY&Xar<}i!5FrFPI$Feo{DLoIkfB}s_Ie<^O|y@ z(Vnu>`y+Y|NY|37HycQCQmI2~X9$oELYabsq4xP`3w=A7mla%`4O6^r#b=MIKCd9c z`!@+hRuQOR{s1TzT7rkpyH~*O6!;d)JpdvlzMVP2iGiZ@G_*aZ9FeVH7 zoW**4yfFOf-9dZ;*o2g+w4P>{+ZA=1z0S|=zyms;#~q|29F4PJu=G7VJIkJ8LVs<7 z@6Vld>yMOiuL4Ro)8 zNnY8$x3}}Rfda5BG+s*1sbak)CNmAX>CaSFuTYwj1j4lIz!3l5<-Di8rta{VD7T)R z_mk>w!n{+x;~~mdm8y2W*_m1aZXdz9HfsT=^oU>v@#>=``?L>8B#}TZ)3SC}=6U5h zFk7H39bgT#OXpAPKVia@WA$Aocx~v+=yYY=4KGY#+?lkxN_DxHf@Xo8Kk@Ei$>O+>E7Of~M+*WC1vB z`pAC_^g;Jg+6N_@t|J<+Jf0r7v2wH+L`xf<^@A2<|1(ng%Gm-O*gdIAg z$`UB;`Qc^NrO&R4$VL^+7bcXnUSX#H$fqwCcf!HA2QPtKwLV^3wa@JDbEgM5%XErq z3zL&*$`o!}ad?oazYCyKFgo*hPiKsjRu01Bu#%H^U&uCdDpGCq)M2`$UFJ4m1gupN z$#H3>L%;n8sR=meejhqc)GfpwQODAwPo3fA zBbnK-sl7w`+=y{gzCR{WGdEkerJ?~*m%LOBl&iTE@mO6O%rE6`H?+$M+_0oBJ1hn1 zqV}xii>{FR{JKwl65m(=<`ORvcUDv!;Q)u8QH(Qw_FKMjfZB<-wT9%UC^iIj9=yqg zRQ1fk;kNb|qVWS=v#g~$gFwK2#Hs%{uzK*;kbp-|h>D`B3AU_ken~rk<`IA1t@pZ_ zh5Bw_xK!$6CN?62ctf<>xb0jq{I%8~Z@)2OLbCCQc}bTHi1p9rVbV)64`DH4h4qAs z>teAXE`n=X#gXys>e@s2fSdG$HceO$w%rsQo-?}O@73G4Jw){OmGlnCV>ay#-C&uRJxe$#{G!utCG1#W4v7$2D3IDgw z$J$$nUZ>xC5F_u(6yjsJh07LnQ=ERTmy}ohtHFvy?Tp3ooPtWPOMG!PJ>L>QXIp@} zi+-1{6!~XcR1h*Vu$Ur2C0fo@|sSkU6;;9R79q@%+dEc&uw zZ_nlZO^@iyEzZyB>FUVI!LAXtijs7KF`Y|7_(ft|yzFpk+lhq5i1yM#oAaAfH1E*DJ{%Hr8J8R4kK^{u4vehH} zjY0Zqz-g)|>Iw!8<{{H78^;$1nU~L;s`@tQcBD%~;6A?e=<8P{9;(V_^bcL}wOP+u zm;C&Yg026sbwBrp%sw7sVtC_Fc@7rChZ`qZpR;pEN58(<5Jd+{_p*09Tn=}YZNc;+6 z%?DQ0DYsUwm}`kzwkzY^l7Gp~Z7KU2_RICY{$1?gz45Vv`wGhRc29frZ~O4TCkeym z#e#;6%)A%sdhsgy364guK`TQ&Iu)_KF}gXhDLn!LW&ndaa4W{RV3cuIM0>}$d%#+M z_7jO8ni~^;6_4*L9r|wH{Zt9sgFav+<1U8pp93*6B5(dfjJ60k z%nZ?zj80H!kIwl?8NE0KjnBbC(f)?ZP#?kPLK-)jv%;W0!IJ308|^x$f%jri`YOF{ zqpxFB=ujj=`e^#yt0|PyBeZ0?Pu+m<+?o?;s0Ap7XQF!Vy0cXn8#XrZ4*aZe+FmrH z=sNH^@cQt&kvj7FvO4oR^ZJrirb|v6(iSLhJni7>p=CqtI#~^*7SN3-+kqD2j=HT? zJQ`T(5vMwxDg^5OR}DtBAV>y6wXjGANt!H>XRqJhB$=^ums9#iQbybOsyN(qEkw_a zbBm`=J~-Qb8N-kHfnR2XI4QA8=c%jK=aYSv)lyxe@&Ae9VSgLT3F_+-cjE znROQZ?~v$Z(>WUamS4p^WZelKUia5ys7e0&$6KiW>W6l3TRXD#vc52MviZdPqUo0V z7{h4CVN$0^3R5fxx=QT;6_JxMpnqEBs5JBiF<#Q8%utp-_&L5?kP-aCfy-N^^F?_M zpJe0AzzJ!Kq+F$QS1Qmx`;xEAd;x6S(pe8cXmqK=u8hZ!(J-0{&J-_n6?stP_G@0ut9>0K8(tOty!jf9FWtdQ4mhs~ku^<Fp?eRtxjC$ z>oacoQ7@$az$!fjq|Jeyyq6bP`WPpuvI9rr17w$&@Rh&^U?NZd3rHX`n~$P@uPggy z^Fpe;uTeOg_nTL}ppHLvt`F{7U9y%xC#!^F3PgN22{2^n$`y%_rxp~7V~W^e5~14= z-}u`%!usB_{!cpUnhWl*U^XlTsG3$=!~?;B{Yx%%L;oK z6+CV4#bxf{?8?*C%FQ+5xUy}Zf$G|HZI`jcbBeb0@K0?1NqZxL`@!14LwvWca*@S% z^-DRhct-YEtaV&FO!+$qhm>5(KD(8uV829Lp3lZnkN>tCIrC*%#eaw^XXQ$s+kT`PHw5<4~}{PxGqMFF_`YNU8sw`ppJuqM6xm6e2r^rhdt0 z1=?}VZk~Q%LZ!)x-ML;%2MUj0`0L3n@5i-cMeE=KTBor+jwQreezl#}QBPl%I7I=` zu!4w@zn=0fTXXs<>}3xvt9e{((c+gWQkSlq*q6ioJ0g zHgxBn5ceLPGN&}vQH#V>p`ykUsfV!<=A929J`i9XVC47qdHwuBvc@5nCv;OL`#8@R?a$lNC$pE5`8~z%m*c(yRtz@$Mi-I(XTTkVnu) z+L{8OgQH8}aN@>M!!jE7wzVWGn#GYTNraq;WvRbZkK^tuuPC&iVpT`8W`xO};Q^3$ z*vmcZ5pWM?T@<-3533?JXJ9H~2$jTnYsi6a)`z?3e5BJN!Po>P3;Mvp5U9Aj<6vNe znwh}cfIwEy<8c0S2+R>CiK&Su4^!(NlN5=z`y*ehfEM`o((#I~W@--ZK<9V#)UWP( zCUuZ;^@H+UXS~$CTe>(&YlVtPsSX!?-`JudNUiTc?QFI1Xb99fu5QT=VbED!8vNq@ z+0nT))lzIozO09KEbm6--~5BLZj}>!9?9Cq;hv?I3FZx9@*}q#!0=a7+ZMt{rtv#rxAK*0a@xV6)PM?!CSrxirfC?L&M$-75>>y6!16=ojy{(|EAjdbxWDNVde$3Ij z?pkfBpYV~|Rr#87wWG&ozP*+vijs=sjNjJwc%H3Wu%F^vU8-Gad$r1ZdmbB_gvQQ-yi<-LI^kE_0JSn= zhlr-u4A|tpe08M$_WRQ1V*~PtYe+uKKy~7zn&*YD$R86t&rE3Jj45IhDJpdd*nD#S zEmf&PMhJ19-^V(e9>kkhw%5h45F@H#pJfU#l$H6_N6ABG^{VyuHV7!t6Hv>3LioXr z^giyx&^)*5*G=LG-8dxEvZznFVT||#iYxMO?B)uPG0MLo)#_zspj+GJt`s=u0MW`e zT1BeTssDhhG#pS&U_zJbU_#Sm zEgZX>!Q^{hql6vVrX^#Pr*f)zDW|hzk5G{H%-PPLS;V(BUw zoKB$v>>O0uT;$}?b%pz|apcLjL}3-@%_qG#17Y)p2Y_Br41fz5iC<3%Mn;|qs_rTO zt_9=#9)T(TbYs~5v_VpNZha!}9-#1#4iz#5!k%Ehhn8&HT-vSRRX{C%?pg6ce0kBx z0p6d?1<^L^intzKSRKNpg=?Jw-nQaHvmm(ONjb6#UbiVu&3DnMWw9C>TsG#U|Ct3J zxdxByR5>QJs~l8U(;-8UvYHU;YiW-IR$%0vi}m^DOcrNnas_|6x$SZ2$a7Oc_U(YT z6RM}4eJu=Jx?J)@2jFnc;gMngN+|(jTXi}Ie#GZd&UwFuQmQVvN}jTE{Q~>zdXt5< zS5D2}qc~y*G;Qwp9gKTQ6caWysQqRAA@7%iffEQ7sseuqHWo#rq-$mld?(jyXh*EK zg}1H-&2z0@yLx3l6IKN*t<$zcTX(>=Iq;9}?7|>;|P)3)0aFO=?=v zy4ObOzr6Jc$L*>oUxfVF8-7=JhAhjJh>?6YRWjJF+(zDW5z_mR#isfWQ1b@IIRVIa zr?i~|H!Q=EeK3uzcyWK7&KYVn!4r|XVsKA-#O-)zS^cxg_6nt%NZXGH=Brb}2oxjN zt_q*Kt#jcv4e3b>!ZkXZ(B_W~fWK!CbTp%|k6OgWX*hPhPQq^`+``+wA3qX4UhgNT z&4(&kfoWxQNNN#GWm_HV&<1NYqBhm_txDX3m)R6ufS0H-ZH9c?-dd10g#;a7@Yy9g zvNWwuMcFQacr=sb zD;g{%o_O4cB2Ed!8?sz4rUNsrj7AXW&JQJwe$b(PEzM9}j2|QGXrhsSjS&=tRBs?= z@n;gs_|qCdCC>&~GRTZ>;ayoY#=9uU2>&wj4cE+#T6oE%WbV@5b>hPrbgZQuF5v3Z%3|KPQ=3iI% zk~VSeNq7oBZVw|L^dq=XZaI9SY#xiAl$I7%JhAhVPfm!5n~57AM+Wo3M6au?93NOd zDmV{_2N9-ly*Y|u%7G-)oE+Hv6L&6ix{BjPui3&s{W*N=`y}Uwz>a^_s@MW>EvWcT zX7oI4B8YhxJ>`%xW62#=I9nwfbBfT+XjKqRflYNAE2oXY%l1@0KlnR8`UrXoe*-eH zbIhiDm&33Y2mQ*8H(01I-2C*mUTCnwhYKwuv{qa9g-U0}g;J`KW{~ZsQBX*B z8(9w-;8o=+pjtS2&47)8MGM%o)MrF22jI-$(J^z_r*A8Bkngf>nylq{hJ~!o-1?3F zBLq+3JG!0=Qr5PY|4P)m>Ilhos^n#e_WP{{iwoWH_e~#Y< zwG#}xPjb;7i>ue!oHf0+;*`^7{2%ZFjvqDy3gt-V)Ko0`s*C+=`K-)o_bvYo*Cd4KXin>W@rFW7KgEE5CtcD6@l6C zW@cJ0?tkOo3xfDJb|3G86fcX_@{_*ca^n|sIp_Ybp^TaglgUc z^K=wNlr5{CQy;{Y z*wsBqviKbbwJ~?2;VCJrkKY-ij!-QlQ>W4SdR$e!ui&cXHG|EnmD%?GSQIy@K%cri z)`gW<{b760QC*TVsQ1FQodz5};4ki?eL=I$g?Df#a zY?XH}U`nD;KRo$&Qv&yvh6q*CsR5&Dqvuy(VAGiM;>K~G_rKd=NTpwKIM$c_J}DTB zn;2t4KDb9*;l(us(Jd(MkOB2fnaSnrEGpbAOM$D;^OUV!kcgX zZaS~`N<={J(B8Ineaw3cMhX^H304A)l&m!A5s~@m*m6L;7wYn^KAh4@{sV-tQ9hrI zNLQztRxzzwmH=8iKK>kW*MujY>;49OS@-xmP&&f<)k1LiyEv9br~>$)*Bcn*Uxs(y z;0fKpDG<0Jxu%H9WFK06>tEipN;>5eVvV_^&aAe$xT?J3d$=gnzf|@1b<`+~4Y~HC z3e(}%y8O5S7#47#l4-hSN|;oxM=HlGYpGjoW!nXErfX$W&|hN7i3X9qP%yo zP}|4GTt(9WeDQssfq-`~4@#{k?PgiBQ+BrJWsYL&x3nT^agt06mby%=adF`k>T)dy zv&7uKl6KeqbosM)@?J@i!&(7b>$y6ARme&$bGndLST~%AJ}}( zIeZ$?H~G${IOmibtUzB#R{XT@Ziivrf}MpTCD*+3*Y!Mc*r% zxr`MPw_ehO&DWBOeiU(ni&~iI#Y$x0jovKaU0px5atHgHnPy=D6&L8VKhvPrbV4rnDi_z3kduYZ>9W{ey(}X#_x% zf{_Zvv-xv5K123EDx0%?4l->PMY>%9msPgRzjOny)@b=~e&{CkcX`wVWKny;xWmWM z-CaqnRD%L&Z=c5A1pNlcekmwnp?!{3Kca)OtvIoJhNyuMTn+7d&iw-)1o<;voIyThQySr zOkNz+*M(V@L`+KqzCw<(S>4L5i)Q7mTwhw-;###bC?&y?l;_jvH}8gq2j^%fC@AOyw@Zl~9XS<_i4ANV zIC)eH$R_m<3H{i(6e>;SA|jc7MfJTyJSxN8O3tvDJYV`SLwS)Nxr+=H#epif-ZEY` zXz&=^oWb~>;5dAA%nkmU74t|vH)+`vV!rRrq3gN^dE3+gYCoO@jyLpce~`yeA=m$5 z#r+>Hf&ZWFIm>?%b^kBS$G}FP?9;X6KOTH=g9Str2TuAw%>EBh@c+6#XJh#hKVk%9+TC-B)Us;?q5W+L(91q1#+T5>&sX-7PGSZCf(3uZ{92B!b zMJmuyibU2UV~~IttEuDHqDY< z9Sm>Q?s57#(>L4Yw+TD=IJ+e)ZEe(-sA?Ih+$ zxS3(|;bfscib9#nw~B^*p6bcU^W%x96xNgV;r3_4dKP^eN>lS9$~xkI(d6(f#5vNzP5z7KWj4YJgndlh)QBEh@CUlyMB}C#^TndKEgf zi6bdQb_&29JYoCq>;L{~bNmOO`oA9=#{W;~gb^qJ1mpt-l=wdk{Ermt|9V!K{s|-h z^L*N>K`Nk}ptTS>|Z&f4NbMh=3uP7-f!kkwm#9oXSz=Tf*I!BbBo;plU z&cK8RwHGoljjmGV2eo(X``RW$X|0JQrC>B3*{OD>_{R1~mVh_fRj~Q1fBCQc^QnBj zcirGJ2^A2SFlk!+Kb4(TR2<5dhJ#CRcPB_l8X6j!5Znpw?(Wi9aCdiihu{`0Kp?og zOK{i5nVfk#XYQRlYu;+rs2vynb-!g$Vn3jLc;wrvESz5nP zt)DaAWtSGSigvdK=ftL&>^emiTkC$3(ahqUt(4iW3g)N^Yc{sLM+ke5bEjU=-Sf+= zYL;X~(|b!Ko6D-vv$!vwot>t~a1@y<9Y`}pPvI1JN}j+?L&e4Bau6RFcH!W(2hd>S zU#XkyDIt~dOrMKSFJcdQ?P)wi`4aigt2{b(4xYw8RYaP>6pKxSr8f31Rb7mQ0VkBj zkUUOiA0D2B41+xf zJp2qo|HsAkw%@T-!KKf;p8~qAIf)KOynS@Ca{8cnUAaM_gx0zl_8PulbR`HORe8TY zZ3^&I;U6KQ%y@eAX~=qtPCnYFM$ITRyApT~8IaE!OO(kO+n-?@)sjrh_4{7#rWuKQ zYLkhM!!*)ha@%C6Km26&iU$Qr4wdrT?#?xXzcrYrug*W4sps9^O-4u?9VOHGRV)Q& zx~F4Z7-e8yH)3rkfgnB6F0EkVhtl^ar}^Y#LTw4?!;wPEX-QU?Tw35~wbBkrIPpYX z%yO^vJ0i><-Pi_G-`sgI7?HW*F5$Gv&=j^$q=>X$xYpTrP3TRhTCgS!YyU&Cq*0_GD)mu?}wu9Tkd*wzRi(`*xMM4PWscL zUAH}(%e59%`WAR#5BPN;?#g&WW+c?xSrn0wisgxAwK&=YORgI-#jB)`5Pq9)2BIrB)R%{ONQ~mnEabt5EJUDDNnlB$ zz*8NedYx~NxGW(__zP+~ETYIJDqZ7`AnDs%spY}L0g{Y`-)fK}2vXG5P9A*fpA5kL zxpyd>{M9Qt(SsXlo{~>>dL!ZZ=7ur&2FBO9@wMz(glCoMG*P)kYZBo@h=sbq_Z86{ z3Ymkfqa>ci^&Bhb`DFOFiM=)W#A%Uzr}3V$;^g)!OHG!JSIvuW)jyMk#O4R(lfEKl z*j22P(q9S02H=$jaYBMUBv;!-*2ves)5z63C|&gb;N7Ht_D-1|zj<?MIET>6DH!uJVOnf-NmK#0B+cuQI#Lfu-Zci zPa}{bdx0n$hicn-gx6j#BCy@pS%+we<}#)Um4m@a(o&8mgxr}29N#Akx&1!QWtnz? zuT4%QcdwbOq);~&-BZNg=eTNkb#m$ydY7G@Z1g|bpt;H`Mz`F8^*%CB$N;uAd7z8 zQlifG9<-P~+@y59?K;+`q>wt&G3t5tIS6S0n&%f&sxz}&H}{jDePVAGSKetFX|waH zM6`N#uHkphePd~%7@TQ1h1;0VoKN)qcGO^MqBRi>ek7(-_eTLw+x-fR1WXRT2lUUr zFNg1o;h8n7W%NQRRO+dINWjDdx`k&ODt0FjI~VyX55}_ za`HYLYvg&!;Z8dak~EICT6+OY^pbX1Tm5xh>g#=L+fcQaK~1R2quHu!cDk$tm{B0Q zl995`GomJ>N?b$R;7ff zQBtE2AhFg-zo3g1Qi-Npdi+&Vh^}THz&Gs+Jgf%8Crn}|s5Vf`Id7jPKeRtcx8b&u z9ajYqW>f=XWJfc*C!6`S?-hCs2^cn<3rB-9O`{~34pLuT0;}P{vk5qY| zhe&Vmyij9aS}A}2;V*p%O;df^aZgt5h6}fKclE&Je&^su26`*n6F6K#+Id^ApcsIB zAX~jcH553Gu=sLn?hZv|ll~wtThVP|(!(3CoiLnXHi1WDHmS;qb4CIuKCrd=(KT&% z0!6tObw&cIj4QF_Mp4jwZEr<$FbQAv;60~opJ)Js0J=s4Y3moCtv;Z5%vjTE^zkZL z;1=cJ6EMaLv;LP(XLFEdBqs@zE+*V?wSeUX@y zQvUFQ)T|E7`Zv<%Z(TJ1yH@(A0lby|8(7&OI4N`h5s?($8=?@n(0>#7Z*Q0XuV!N7 zKij2erT1kownevOT zN1Zl6cduEol(r>hQRC0=ehE{@Fnvo&cwH%u&x|^n|F*2$KDNJll2op3J7{^Od5jTr z!O!+QA5LS0ZIXXk3YvkI3R%K61}#Y?$(s(zx~ssm03nGVF*4GZq$?i8Dh|RPgqkF$Xk5(b zD<J&2oy(3{Z%*TL&FoiFuK9f#kV*8{inTJ2hU&QU}-m_V* zk$%1JGtIM~e|T`DFbnkUqU)({Sjp<-obdtLZ-*powG`d$6Hx5&CHdW`KL82qs2^8W zKTg8GMQ@QLC4L)oi^hxXOB;tfusA!J%a zgTC|T%wFGVeezDRGo|rTQ%IzAL;BNKykZ1kbVW0|rPeYz!LZt~_%*OlwouAyrkEC8 zfS68v$`V;Ta1Rv?ZhOG5G4pT?m95EJq4a{0%XA_!fC0 z?S)%zhvT4si-y5y#=$FNJZjW-e|n5x1;AUnQKDYhzyzJ^)^kolkvM)}KC#&<-2$Bj zXST4jkc2naly{<7;hyWE9FLmI-cx<9jpUzJn0p2vQPR(kA)geY#&V~h4v6t0xDbGH*nZV`fXe#Rhf~f?D zWj0h^cl(8p*J46kygoK~*5L98thb+M)OU2Mz(&9DhgEaWgD~Hhv3-(Fr2SEz$k>AR zBg1yP$gS^^!5;NxR~dsP2tQ{HvBP&!I5-KJ!4OVXF!maoS`S)tu6fp~X7Vz+`AIFs zt(_z(x#Um+UGul=69Ou`kMQqHv&FNfr>w?QQ$f1ca!W1z( z#(co0dwe(+`;WEVe2sVTn^{B_F1=O+3OMBcan`QZ5xq8@mLK-hdM1LuUf=2T+&wU? z*j>cBoDmp@r^<8_TMcujj&>wqz$_8lc8`zie&!r{y}dMjB5Gyt%Gm~#O^%GD#12Rf zx#IQPcS3TXh&?)s6p;O-zN9u^c71UOxFc&PE~#xNLnZ$Behy=pO459VaA7)DlX!W0 zo$`o;_SFx7JQj)8=l#6y*A&^??qI7a8GIick*sL?EL*b84JnfIovu;ukH}l-39K8c zuhW%X>9(J@+oHC45MP7xr?ypp)ZH;3{8JFPaZCpdYGKCWz$g_mGuD^H@P)%tO1!-y)`Gi?_mDo(M z!f$Yg#EB;^_iygEzva*V!^QbOk_>+tVJqv)FT{3FZ2W+EgTn+{{;#2de?z7H#iIbo z8CohqYyh%UAnre83P&dh6$c0Be_&JqS;7CJ9$HfX}{$RKjS~4vj%r1n&704tE-$xlr zrD#dRp&*4L+OR8vbAp!~W)O`m%;FM6uPXc#+X_7{5sXM6mKg{un`r<~yGIrm&N>8& zs}atFUm~s#NA3X=26|Y6Hj`PY^K!7?5iL{poRiX{yIPhpzA_mM@Z?+WWQ2-4AKsSRt?0({a z4CF9c)F^ej5QA`ajyddFhhUGMEYi zd)UF*DMx?)D*WHIXNfqUn;U$@k|DZNViNE7AQKyVBoYfvQo-T7q;i7bJ@XAes%c=0cjaV? z%`X~ZXRVg>9=FhS#Aniri@%z0uly-8lpKCzqm?!aUgS6R!_6J|z5I_%Jh?t>~$jlrcI?sEXKd-!nqw3>~quwKT8yEC~?ZPr=TkETAeV*Ij!^e4*4h~f19}17@G6CYb5!T4Lyj7HS1UA zM_GHFm3Db;JAA_Hpe@_B7IQ4uqb+|s!g&Cm8t8%^+AhVkZvU`q&RU%-E|mz9EDz_Z zxeSM=PkJi}i9;8&0Qf|D<#V9sI>aX)jdB6v3Grycv;z=(VJ9$UOY&gdNYN>JhL_k@Q!eJQe-M!D>-D4@;OWlUX!br zK8AiKlo4+$D&3-#tF7ge@Rt>$Junpa#=Y~*%>#IO5_6a-tN zGVOeGmNMCf=+^wOO?rh|w$HUd)ku2TZ2{vgme4OmDh5MDQH{8{z_}&=Lv_uhJ8_*p zsUeYYb<`vYd?!8kLiGNcd}$V)8pAYM7EE@#K42+;D=tmI%bB%SD8yHe#*HjgyK15D z{EROQl2I9^$)wmVl@F_wzxZYLqjOr)N2SBMYkDahhV+HlD4>3BVHE!+>)h_=p9vD0 ziaH1AH=eXbq!u!CddqQNlYk_4?`ns#8N)Pf_Zy8a!B!F)2RS;$bhSl3wzRn_LaZ)F z9@g|a^sqA@;l6cSkVad=cpP)mGAfiK64#-6o-hs#;0kZA>a*E?fYBP~aoDj9OC{jG zC$*mtwM`X6sUCcp(Sl+dxz`(Bh4WugAgyM1*q5$6AxsiUC4xZsijn8Kh9~z|7 z(1q`Oj`&8wN0^6|opsl`o~r=JGBarQaPWA%z1_Qt7=Gf*Vy*W{L#OON7^&Ce9gR4C zv35)ed2a(wC#)~&IHh{cbk2v?3v!am1bdjlX-gMumBD?PJ^;5tp?IQu8#e_B2jgN& zE7UK}BZNB%S}Xh<-*4>PD{f6UPr~^R#~yVPiiV)V2JPdS;oml<(bLtHZx5;$&F_kS zA9$0mMI*N4;(-KJ#`P>7_q_%>eeg|Sv)&m=XhDKW4Q9?{gSR6m_92-Vdm_STxSEy1 z+SCc2+2m7wG5J>f;wr{3B$i{@dtmY?_DnVBN!lz8za!@_6T`G$Hy4iqik+`k*QQhB zNZ{boCe>oA*q)#jC_!=iUiB4b0(Q%VqkyY>EjeR{tIx{ySjyP>JY4uLsX!b6KKvV; zsH(uEB|77GM7&x=yfI#RM&GSMr11MV0gI6}R7TB87}3TzQVF+B0uJN8Jxg#qmk;^S zWl(1800VbHbtrC!AMPVfX0Cd*eGj+Z#?M*%Eq6h5bOd#~l(931f#k?j#BwfOYq_{})usUSH5=Me&ihps zuMQ?>ESeBkj|0Y{5&8!)^$)C2CP?v+vtYOOs5@a$yHZ zF`KLPJFlYc8o8^Soh3Q+2m){^=xe<7(C;y)mE~9~u>yCWgt$__%4u#p(2PXC1fA~d zrFYly`hf~7Z(5sVa@tg!wNlUb<|nsgpWezWo@o552I2l2jrhMch`OSQDL_(0>$4H7 zu`v+L%?<*BfnZhxqrZkAb`S?Q2RoY~*pQp=|6ZX3P_(myp#D{+Q3OcZn%YtQrFawp zN)}&DsJPhxYFcc6W`H{XN#gvI_wiw@RaQs=FKDv^i$UJLJ)AatfT~wiK(y+VeL-O% zUE+&@jF6FF-lTcnyPv<#5sqa_-O#>lRPwp=^ZH@N=zZ6H$%(Itdp1Tu8|gJcyP? zkBvj`WSsqTfFmqHv`%1dkOXMrDu3G>7sHL(96goP+vTAA5N#>jMV@lme)suYO((?WGviSYAGTDh|)ssAEnPo1tWa-!R o1o{1o2`3^V5rf~q9-I@zz!Bo+Xkv=W!TRTDQ0eJK<-}0`3*s+PQ2+n{ literal 0 HcmV?d00001 diff --git a/src/readii/io/writers/base_writer.py b/src/readii/io/writers/base_writer.py index d163814..d722bb1 100644 --- a/src/readii/io/writers/base_writer.py +++ b/src/readii/io/writers/base_writer.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from pathlib import Path from types import TracebackType -from typing import Any, Dict, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from imgtools.dicom.sort.exceptions import InvalidPatternError from imgtools.dicom.sort.parser import PatternParser @@ -16,9 +16,10 @@ class PatternResolver: """Handles parsing and validating filename patterns.""" - DEFAULT_PATTERN: re.Pattern = field(default=re.compile(r"%(\w+)|\{(\w+)\}"), init=False) filename_format: str = field(init=True) + DEFAULT_PATTERN: ClassVar[re.Pattern] = re.compile(r"%(\w+)|\{(\w+)\}") + def __init__(self, filename_format: str) -> None: self.filename_format = filename_format @@ -41,12 +42,12 @@ def parse(self) -> Tuple[str, list[str]]: Returns ------- Tuple[str, List[str]] - The formatted pattern string and a list of extracted keys. + The formatted pattern string and a list of extracted keys. Raises ------ InvalidPatternError - If the pattern contains no valid placeholders or is invalid. + If the pattern contains no valid placeholders or is invalid. """ return self.pattern_parser.parse() @@ -56,17 +57,17 @@ def resolve(self, context: Dict[str, Any]) -> str: Parameters ---------- context : Dict[str, Any] - Dictionary containing key-value pairs to substitute in the pattern. + Dictionary containing key-value pairs to substitute in the pattern. Returns ------- str - The resolved pattern string with placeholders replaced by values. + The resolved pattern string with placeholders replaced by values. Raises ------ ValueError - If a required key is missing from the context dictionary. + If a required key is missing from the context dictionary. """ try: return self.formatted_pattern % context @@ -90,8 +91,8 @@ class BaseWriter(ABC): # optionally, you can set create_dirs to False if you want to handle the directory creation yourself create_dirs: bool = field(default=True) - # subclasses dont need to worry about the pattern_resolver - pattern_resolver: PatternResolver = field(init=False) + # class-level pattern resolver instance shared across all instances + pattern_resolver: ClassVar[PatternResolver] = field(init=False) def __post_init__(self) -> None: """Initialize the writer with the given root directory and filename format.""" @@ -116,7 +117,7 @@ def _generate_datetime_strings(self) -> dict[str, str]: "date_time": now.strftime("%Y-%m-%d_%H%M%S"), } - def resolve_path(self, **kwargs: str) -> Path: + def resolve_path(self, **kwargs: Any) -> Path: # noqa """Generate a file path based on the filename format, subject ID, and additional parameters.""" context = {**self._generate_datetime_strings(), **kwargs} filename = self.pattern_resolver.resolve(context) @@ -148,11 +149,11 @@ def __exit__( Parameters ---------- exc_type : Optional[type] - The exception type, if an exception was raised, otherwise None. + The exception type, if an exception was raised, otherwise None. exc_value : Optional[BaseException] - The exception instance, if an exception was raised, otherwise None. + The exception instance, if an exception was raised, otherwise None. traceback : Optional[Any] - The traceback object, if an exception was raised, otherwise None. + The traceback object, if an exception was raised, otherwise None. """ if exc_type: logger.error( diff --git a/src/readii/io/writers/nifti_writer.py b/src/readii/io/writers/nifti_writer.py index ed10def..959699d 100644 --- a/src/readii/io/writers/nifti_writer.py +++ b/src/readii/io/writers/nifti_writer.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path +from typing import ClassVar import SimpleITK as sitk @@ -7,45 +8,123 @@ from readii.utils import logger +class NiftiWriterError(Exception): + """Base exception for NiftiWriter errors.""" + + pass + + +class NiftiWriterValidationError(NiftiWriterError): + """Raised when validation of writer configuration fails.""" + + pass + + +class NiftiWriterIOError(NiftiWriterError): + """Raised when I/O operations fail.""" + + pass + + @dataclass class NIFTIWriter(BaseWriter): """Class for managing file writing with customizable paths and filenames for NIFTI files.""" - # The default compression level for NIFTI files - # compression_level: int = 9 - # overwrite: bool = True - compression_level: int = field(default=9) - overwrite: bool = field(default=False) + compression_level: int = field( + default=9, + metadata={ + "help": "Compression level (0-9). Higher values mean better compression but slower writing." + }, + ) + overwrite: bool = field( + default=False, + metadata={ + "help": "If True, allows overwriting existing files. If False, raises FileExistsError." + }, + ) + + # Make extensions immutable + VALID_EXTENSIONS: ClassVar[list[str]]= [ + ".nia", + ".nii", + ".nii.gz", + ".hdr", + ".img", + ".img.gz", + ] + MAX_COMPRESSION_LEVEL: ClassVar[int] = 9 + MIN_COMPRESSION_LEVEL: ClassVar[int] = 0 + + def __post_init__(self) -> None: + """Validate writer configuration.""" + super().__post_init__() + + if not self.MIN_COMPRESSION_LEVEL <= self.compression_level <= self.MAX_COMPRESSION_LEVEL: + msg = f"Invalid compression level {self.compression_level}. Must be between {self.MIN_COMPRESSION_LEVEL} and {self.MAX_COMPRESSION_LEVEL}." + raise NiftiWriterValidationError(msg) + + if not any(self.filename_format.endswith(ext) for ext in self.VALID_EXTENSIONS): + msg = f"Invalid filename format {self.filename_format}. Must end with one of {self.VALID_EXTENSIONS}." + raise NiftiWriterValidationError(msg) - # You can enforce some required keys by explicitly defining them in the - # signnature of the save method. I.e force including the PatientID key def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: - """Write the given data to the file resolved by the given kwargs.""" - # iterate over all the class attributes and log themn + """Write the SimpleITK image to a NIFTI file. + + Parameters + ---------- + image : sitk.Image + The SimpleITK image to save + PatientID : str + Required patient identifier + **kwargs : str | int + Additional formatting parameters for the output path + + Returns + ------- + Path + Path to the saved file + + Raises + ------ + NiftiWriterIOError + If file exists and overwrite=False or if writing fails + NiftiWriterValidationError + If image is invalid + """ + if not isinstance(image, sitk.Image): + msg = "Input must be a SimpleITK Image" + raise NiftiWriterValidationError(msg) + logger.debug("Saving.", kwargs=kwargs) out_path = self.resolve_path(PatientID=PatientID, **kwargs) if out_path.exists(): if not self.overwrite: msg = f"File {out_path} already exists. \nSet {self.__class__.__name__}.overwrite to True to overwrite." - raise FileExistsError(msg) + raise NiftiWriterIOError(msg) else: logger.warning(f"File {out_path} already exists. Overwriting.") logger.debug("Writing image to file", out_path=out_path) - sitk.WriteImage(image, str(out_path), useCompression=True, compressionLevel=9) - return out_path + try: + sitk.WriteImage( + image, str(out_path), useCompression=True, compressionLevel=self.compression_level + ) + except Exception as e: + msg = f"Error writing image to file {out_path}: {e}" + raise NiftiWriterIOError(msg) from e + else: + logger.info("Image saved successfully.", out_path=out_path) + return out_path +if __name__ == "__main__": + from rich import print # noqa + nifti_writer = NIFTIWriter( + root_directory=Path("TRASH", "nifti_writer_examples"), + filename_format="{NegativeControl}_{Region}/{SubjectID}_{Modality}.nii.gz", + compression_level=9, + overwrite=False, + create_dirs=True, + ) -@dataclass -class NRRDWriter(BaseWriter): - """Class for managing file writing with customizable paths and filenames for NRRD files.""" - - # The default compression level for NRRD files - compression_level: int = 9 - overwrite: bool = True - - def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: - """Write the given data to the file resolved by the given kwargs.""" - # iterate over all the class attributes and log themn - logger.debug("Saving.", kwargs=kwargs) + print(nifti_writer) \ No newline at end of file From 7934c76009d3ed0ae0a549b4e4f57ceae58fa1ed Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Thu, 12 Dec 2024 10:55:52 -0500 Subject: [PATCH 04/14] feat: add detailed documentation for BaseWriter and its subclasses --- src/readii/io/writers/README.md | 112 ++++++++++++++++++++++++++ src/readii/io/writers/nifti_writer.py | 6 +- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/readii/io/writers/README.md diff --git a/src/readii/io/writers/README.md b/src/readii/io/writers/README.md new file mode 100644 index 0000000..703b24e --- /dev/null +++ b/src/readii/io/writers/README.md @@ -0,0 +1,112 @@ +# Understanding How the Writer Works + +The Writer system is designed to provide a flexible, reusable, and customizable way to handle file +writing. Here's how it works at a **base level** and how it can be **extended** with subclasses. + +--- + +## **1. Base Level (BaseWriter)** + +The `BaseWriter` is an **abstract base class (ABC)** that defines the core logic and structure for +writing files. It cannot be used directly but provides a foundation that subclasses can build upon. + +### Key Components + +1. **Root Directory**: + - You must specify a `root_directory` where the files will be saved. + - If the directory doesn’t exist and `create_dirs` is `True`, it will be automatically created. + +2. **Filename Format**: + - You define a `filename_format` that specifies how the file names should be structured. + - The format can include placeholders like `{SubjectID}`, `{date}`, or any custom keys. + - Format of placeholders is `{key}` or `%key` which allows for both python code and CLI usage. + - These placeholders are replaced with actual values provided when calling the `save()` method (actual values passed in as keyword arguments(`**kwargs`)). + + ```python + # Example filename_format + filename_format = "Patient_{SubjectID}_{date}.txt" + ``` + +3. **Pattern Resolution**: + - The `BaseWriter` uses a `PatternResolver`, inherited from `Med-ImageTools` to validate and parse the `filename_format`. + - It ensures all placeholders are valid and logs errors if any are missing during file creation. + +4. **Core Methods**: + - `resolve_path(**kwargs)`: Generates the file path by replacing placeholders in the + `filename_format`. + - `save(*args, **kwargs)`: Abstract method. Subclasses implement the logic for writing files. + + ```python + # Example resolve_path usage for an ImplementedWriter subclass + writer = ImplementedWriter( + root_directory="output", + filename_format="{SubjectID}_data_{date}.txt" + ) + file_path = writer.resolve_path(SubjectID="JohnDoe", date="2024-01-01") + print(file_path) + # Output: output/JohnDoe_data_2024-01-01.txt + ``` + +5. **Context Management**: + - `BaseWriter` can be used as a context manager for setup and teardown logic. + - Automatically cleans up empty directories created during file operations. + + ```python + with writer: + writer.save(...) + ``` + +--- + +## **2. Subclass Level** + +Subclasses of `BaseWriter` provide the actual file writing logic. Each subclass must implement the +`save()` method to define how files of a specific type are written. + +### Subclass Responsibilities + +1. **Implement the `save()` Method**: + - This method takes in data (e.g., text, images, or other file types) and writes it to disk using + the path generated by `resolve_path()`. + + ```python + class TextWriter(BaseWriter): + def save(self, content: str, **kwargs: Any) -> Path: + output_path = self.resolve_path(**kwargs) + with output_path.open("w") as file: + file.write(content) + return output_path + ``` + +2. **Handle File-Specific Logic**: + - Each subclass can validate its data or handle specific requirements, such as compression or + formatting. + + ```python + class NIFTIWriter(BaseWriter): + def save(self, image: sitk.Image, **kwargs: Any) -> Path: + output_path = self.resolve_path(**kwargs) + sitk.WriteImage(image, str(output_path), useCompression=True) + return output_path + ``` + +3. **Use Class-Level Validation**: + - Subclasses can define their own validation for file extensions, required placeholders, etc. + +--- + +## **Summary** + +1. **BaseWriter**: + - Defines the core functionality for handling directories, generating paths, and managing + resources. + - Requires `save()` to be implemented by subclasses. + +2. **PatternResolver**: + - Validates and parses filename formats. + +3. **Subclasses**: + - Provide specific file writing logic (e.g., text files, NIFTI images). + - Implement validation, compression, or other requirements. + +By extending `BaseWriter`, you can create flexible and reusable file writers for any type of data. diff --git a/src/readii/io/writers/nifti_writer.py b/src/readii/io/writers/nifti_writer.py index 959699d..1e62bc8 100644 --- a/src/readii/io/writers/nifti_writer.py +++ b/src/readii/io/writers/nifti_writer.py @@ -44,7 +44,7 @@ class NIFTIWriter(BaseWriter): ) # Make extensions immutable - VALID_EXTENSIONS: ClassVar[list[str]]= [ + VALID_EXTENSIONS: ClassVar[list[str]] = [ ".nia", ".nii", ".nii.gz", @@ -117,8 +117,10 @@ def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: logger.info("Image saved successfully.", out_path=out_path) return out_path + if __name__ == "__main__": from rich import print # noqa + nifti_writer = NIFTIWriter( root_directory=Path("TRASH", "nifti_writer_examples"), filename_format="{NegativeControl}_{Region}/{SubjectID}_{Modality}.nii.gz", @@ -127,4 +129,4 @@ def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: create_dirs=True, ) - print(nifti_writer) \ No newline at end of file + print(nifti_writer) From 30f13e7c462f35b9f339890934be0d736fca6500 Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Thu, 12 Dec 2024 12:14:32 -0500 Subject: [PATCH 05/14] fix: correct type annotation for pattern_resolver and add directory removal in BaseWriter --- src/readii/io/writers/base_writer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/readii/io/writers/base_writer.py b/src/readii/io/writers/base_writer.py index d722bb1..49a6a8e 100644 --- a/src/readii/io/writers/base_writer.py +++ b/src/readii/io/writers/base_writer.py @@ -92,7 +92,7 @@ class BaseWriter(ABC): create_dirs: bool = field(default=True) # class-level pattern resolver instance shared across all instances - pattern_resolver: ClassVar[PatternResolver] = field(init=False) + pattern_resolver: PatternResolver = field(init=False) def __post_init__(self) -> None: """Initialize the writer with the given root directory and filename format.""" @@ -168,3 +168,4 @@ def __exit__( and not (self.root_directory.iterdir()) ): logger.debug(f"Deleting empty directory {self.root_directory}") + self.root_directory.rmdir() # remove the directory if it's empty From 9bd885dddac8845ee792fb0c85a96309d90967a4 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Thu, 12 Dec 2024 17:41:14 -0500 Subject: [PATCH 06/14] docs: added some markdown separating heaaders --- notebooks/writer_examples.ipynb | 36 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/notebooks/writer_examples.ipynb b/notebooks/writer_examples.ipynb index b4cae78..4617515 100644 --- a/notebooks/writer_examples.ipynb +++ b/notebooks/writer_examples.ipynb @@ -1,8 +1,15 @@ { "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Writing Files with the a BaseWriter Subclass" + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -14,9 +21,16 @@ "from typing import Any" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create a subclass of BaseWriter for writing text files" + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -31,6 +45,13 @@ " return output_path" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Creating and using different writers for different filename patterns" + ] + }, { "cell_type": "code", "execution_count": null, @@ -92,7 +113,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -227,18 +248,13 @@ "\n", "```\n" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "dev", + "display_name": "Python (Pixi)", "language": "python", - "name": "python3" + "name": "pixi-kernel-python3" }, "language_info": { "codemirror_mode": { From b8accc157ec85ad916a9553c09751c3a4baf2eef Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Thu, 12 Dec 2024 17:55:40 -0500 Subject: [PATCH 07/14] docs: add some markdown header separators explaining what cells are doing --- notebooks/nifti_writer_example.ipynb | 78 +++++++++++++++++++++------- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/notebooks/nifti_writer_example.ipynb b/notebooks/nifti_writer_example.ipynb index 3ff0eea..1bd04db 100644 --- a/notebooks/nifti_writer_example.ipynb +++ b/notebooks/nifti_writer_example.ipynb @@ -1,8 +1,22 @@ { "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Example subclass for writing NIFTI files" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Import the NIFTIWriter class created in READII along with other necessary imports" + ] + }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -15,8 +29,22 @@ "import uuid\n", "import random\n", "import sys\n", - "from readii.utils import logger\n", - "\n", + "from readii.utils import logger" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Define a writer subclass for writing .csv files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "# copy this writer from the other notebook:\n", "class CSVWriter(BaseWriter): # noqa\n", "\n", @@ -28,6 +56,13 @@ " return output_path\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Show how the NIFTI Writer can be used on SimpleITK images" + ] + }, { "cell_type": "code", "execution_count": 5, @@ -40,37 +75,44 @@ "TRASH/writer_examples/nifti_writer_examples/\n", "├── PatientID-AliceSmith/\n", "│   └── Study-Study003/\n", - "│   ├── CT_SeriesUID-13278.nii.gz\n", - "│   ├── CT_SeriesUID-13278_metadata.csv\n", - "│   ├── RTSTRUCT_SeriesUID-39256.nii.gz\n", - "│   └── RTSTRUCT_SeriesUID-39256_metadata.csv\n", + "│   ├── CT/\n", + "│   │   ├── CT_SeriesUID-13278.nii.gz\n", + "│   │   └── CT_SeriesUID-13278_metadata.csv\n", + "│   └── RTSTRUCT/\n", + "│   ├── RTSTRUCT_SeriesUID-39256.nii.gz\n", + "│   └── RTSTRUCT_SeriesUID-39256_metadata.csv\n", "├── PatientID-JaneDoe/\n", "│   └── Study-Study002/\n", - "│   ├── CT_SeriesUID-24592.nii.gz\n", - "│   ├── CT_SeriesUID-24592_metadata.csv\n", - "│   ├── RTSTRUCT_SeriesUID-42098.nii.gz\n", - "│   └── RTSTRUCT_SeriesUID-42098_metadata.csv\n", + "│   ├── CT/\n", + "│   │   ├── CT_SeriesUID-24592.nii.gz\n", + "│   │   └── CT_SeriesUID-24592_metadata.csv\n", + "│   └── RTSTRUCT/\n", + "│   ├── RTSTRUCT_SeriesUID-42098.nii.gz\n", + "│   └── RTSTRUCT_SeriesUID-42098_metadata.csv\n", "└── PatientID-JohnAdams/\n", " └── Study-Study001/\n", - " ├── CT_SeriesUID-93810.nii.gz\n", - " ├── CT_SeriesUID-93810_metadata.csv\n", - " ├── RTSTRUCT_SeriesUID-46048.nii.gz\n", - " └── RTSTRUCT_SeriesUID-46048_metadata.csv\n", + " ├── CT/\n", + " │   ├── CT_SeriesUID-93810.nii.gz\n", + " │   └── CT_SeriesUID-93810_metadata.csv\n", + " └── RTSTRUCT/\n", + " ├── RTSTRUCT_SeriesUID-46048.nii.gz\n", + " └── RTSTRUCT_SeriesUID-46048_metadata.csv\n", "\n", - "7 directories, 12 files\n", + "13 directories, 12 files\n", "\n" ] } ], "source": [ "ROOT_DIRECTORY = Path(\"TRASH\", \"writer_examples\", \"nifti_writer_examples\")\n", - "FILENAME_FORMAT = \"PatientID-{PatientID}/Study-{Study}/{Modality}_SeriesUID-{SeriesUID}\"\n", + "FILENAME_FORMAT = \"PatientID-{PatientID}/Study-{Study}/{Modality}/{Modality}_SeriesUID-{SeriesUID}\"\n", "\n", "data_sets = []\n", "random.seed(42) # Set random seed for reproducibility\n", "\n", "random_5d = lambda: random.randint(10000, 99999)\n", "\n", + "# Set up some dummy images to save as NIFTI files\n", "for MODALITY in [\"CT\", \"RTSTRUCT\"]:\n", " data_sets.extend([\n", " {\n", @@ -158,7 +200,7 @@ "kernelspec": { "display_name": "dev", "language": "python", - "name": "python3" + "name": "dev" }, "language_info": { "codemirror_mode": { From cc7fa16e9b58e5a31e2403ea8aee2ec90d9c936d Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Thu, 12 Dec 2024 18:06:30 -0500 Subject: [PATCH 08/14] feat: changed filename format set up to be closer to what a real world example would be I know this is just a dummy example, but wanted to update based on Sejin's feedback at the R2R project update meeting. Images now save in a modality specific directory and there's only one metadata file per patient since that's a "data_set" in this example. --- notebooks/nifti_writer_example.ipynb | 34 +++++++++++++--------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/notebooks/nifti_writer_example.ipynb b/notebooks/nifti_writer_example.ipynb index 1bd04db..685c59a 100644 --- a/notebooks/nifti_writer_example.ipynb +++ b/notebooks/nifti_writer_example.ipynb @@ -41,7 +41,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -65,7 +65,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -74,38 +74,36 @@ "text": [ "TRASH/writer_examples/nifti_writer_examples/\n", "├── PatientID-AliceSmith/\n", + "│   ├── AliceSmith_metadata.csv\n", "│   └── Study-Study003/\n", "│   ├── CT/\n", - "│   │   ├── CT_SeriesUID-13278.nii.gz\n", - "│   │   └── CT_SeriesUID-13278_metadata.csv\n", + "│   │   └── CT_SeriesUID-13278.nii.gz\n", "│   └── RTSTRUCT/\n", - "│   ├── RTSTRUCT_SeriesUID-39256.nii.gz\n", - "│   └── RTSTRUCT_SeriesUID-39256_metadata.csv\n", + "│   └── RTSTRUCT_SeriesUID-39256.nii.gz\n", "├── PatientID-JaneDoe/\n", + "│   ├── JaneDoe_metadata.csv\n", "│   └── Study-Study002/\n", "│   ├── CT/\n", - "│   │   ├── CT_SeriesUID-24592.nii.gz\n", - "│   │   └── CT_SeriesUID-24592_metadata.csv\n", + "│   │   └── CT_SeriesUID-24592.nii.gz\n", "│   └── RTSTRUCT/\n", - "│   ├── RTSTRUCT_SeriesUID-42098.nii.gz\n", - "│   └── RTSTRUCT_SeriesUID-42098_metadata.csv\n", + "│   └── RTSTRUCT_SeriesUID-42098.nii.gz\n", "└── PatientID-JohnAdams/\n", + " ├── JohnAdams_metadata.csv\n", " └── Study-Study001/\n", " ├── CT/\n", - " │   ├── CT_SeriesUID-93810.nii.gz\n", - " │   └── CT_SeriesUID-93810_metadata.csv\n", + " │   └── CT_SeriesUID-93810.nii.gz\n", " └── RTSTRUCT/\n", - " ├── RTSTRUCT_SeriesUID-46048.nii.gz\n", - " └── RTSTRUCT_SeriesUID-46048_metadata.csv\n", + " └── RTSTRUCT_SeriesUID-46048.nii.gz\n", "\n", - "13 directories, 12 files\n", + "13 directories, 9 files\n", "\n" ] } ], "source": [ "ROOT_DIRECTORY = Path(\"TRASH\", \"writer_examples\", \"nifti_writer_examples\")\n", - "FILENAME_FORMAT = \"PatientID-{PatientID}/Study-{Study}/{Modality}/{Modality}_SeriesUID-{SeriesUID}\"\n", + "IMAGE_FILENAME_FORMAT = \"PatientID-{PatientID}/Study-{Study}/{Modality}/{Modality}_SeriesUID-{SeriesUID}\"\n", + "METADATA_FILENAME_FORMAT = \"PatientID-{PatientID}/{PatientID}\"\n", "\n", "data_sets = []\n", "random.seed(42) # Set random seed for reproducibility\n", @@ -145,12 +143,12 @@ "with (\n", " NIFTIWriter(\n", " root_directory=ROOT_DIRECTORY, \n", - " filename_format=f\"{FILENAME_FORMAT}.nii.gz\",\n", + " filename_format=f\"{IMAGE_FILENAME_FORMAT}.nii.gz\",\n", " overwrite=True\n", " ) as nifti_writer,\n", " CSVWriter(\n", " root_directory=ROOT_DIRECTORY, \n", - " filename_format=f\"{FILENAME_FORMAT}_metadata.csv\"\n", + " filename_format=f\"{METADATA_FILENAME_FORMAT}_metadata.csv\",\n", " ) as metadata_writer\n", "):\n", " # Iterate over the data sets and save them\n", From 90ea60a4f38bc717c623c6054360b754d5c1031b Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:10:00 -0500 Subject: [PATCH 09/14] feat: reorganize structure, create utils module. --- src/readii/io/utils/__init__.py | 8 ++ src/readii/io/utils/pattern_resolver.py | 116 ++++++++++++++++++++++++ src/readii/io/writers/base_writer.py | 82 ++--------------- src/readii/io/writers/nifti_writer.py | 4 - 4 files changed, 130 insertions(+), 80 deletions(-) create mode 100644 src/readii/io/utils/__init__.py create mode 100644 src/readii/io/utils/pattern_resolver.py diff --git a/src/readii/io/utils/__init__.py b/src/readii/io/utils/__init__.py new file mode 100644 index 0000000..62c805d --- /dev/null +++ b/src/readii/io/utils/__init__.py @@ -0,0 +1,8 @@ +"""Utilities for the io module.""" + +from .pattern_resolver import PatternResolver, PatternResolverError + +__all__ = [ + "PatternResolver", + "PatternResolverError", +] \ No newline at end of file diff --git a/src/readii/io/utils/pattern_resolver.py b/src/readii/io/utils/pattern_resolver.py new file mode 100644 index 0000000..24c9813 --- /dev/null +++ b/src/readii/io/utils/pattern_resolver.py @@ -0,0 +1,116 @@ +import re +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, Tuple + +from imgtools.dicom.sort.exceptions import InvalidPatternError # type: ignore +from imgtools.dicom.sort.parser import PatternParser # type: ignore + +from readii.utils import logger + + +# Define custom exceptions +class PatternResolverError(Exception): + """Base exception for errors in pattern resolution.""" + + pass + +@dataclass +class PatternResolver: + r"""Handles parsing and validating filename patterns. + + By default, this class uses the following pattern parser: + + >>> DEFAULT_PATTERN: re.Pattern = re.compile(r"%(\w+)|\{(\w+)\}") + + This will match placeholders of the form `{key}` or `%(key)s`. + + Example + ------- + Given a filename format like `"{subject_id}_{date}/{disease}.txt"`, the pattern parser + will extract the following keys: + + >>> keys + {'subject_id', 'date', 'disease'} + + And the following formatted pattern: + + >>> formatted_pattern + %(subject_id)s_%(date)s/%(disease)s.txt + + So you could resolve the pattern like this: + + >>> data_dict = {"subject_id": "JohnDoe", "date": "January-01-2025", "disease": "cancer"} + + >>> formatted_pattern % data_dict + 'JohnDoe_01-01-2025/cancer.txt' + """ + + filename_format: str = field(init=True) + + DEFAULT_PATTERN: ClassVar[re.Pattern] = re.compile(r"%(\w+)|\{(\w+)\}") + + def __init__(self, filename_format: str) -> None: + self.filename_format = filename_format + + try: + self.pattern_parser = PatternParser( + self.filename_format, pattern_parser=self.DEFAULT_PATTERN + ) + self.formatted_pattern, self.keys = self.parse() # Validate the pattern by parsing it + except InvalidPatternError as e: + msg = f"Invalid filename format: {e}" + raise PatternResolverError(msg) from e + else: + logger.debug("All keys are valid.", keys=self.keys) + logger.debug("Formatted Pattern valid.", formatted_pattern=self.formatted_pattern) + + def parse(self) -> Tuple[str, list[str]]: + """ + Parse and validate the pattern. + + Returns + ------- + Tuple[str, List[str]] + The formatted pattern string and a list of extracted keys. + + Raises + ------ + InvalidPatternError + If the pattern contains no valid placeholders or is invalid. + """ + formatted_pattern, keys = self.pattern_parser.parse() + return formatted_pattern, keys + + def resolve(self, context: Dict[str, Any]) -> str: + """Resolve the pattern using the provided context dictionary. + + Parameters + ---------- + context : Dict[str, Any] + Dictionary containing key-value pairs to substitute in the pattern. + + Returns + ------- + str + The resolved pattern string with placeholders replaced by values. + + Raises + ------ + ValueError + If a required key is missing from the context dictionary. + """ + if None in context.values(): + msg = "None is not a valid value for a placeholder in the pattern." + none_keys = [key for key, value in context.items() if value is None] + msg += f" None keys: {none_keys}" + raise PatternResolverError(msg) + + try: + return self.formatted_pattern % context + except KeyError as e: + missing_key = e.args[0] + valid_keys = ", ".join(context.keys()) + msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" + msg += "\nPlease provide a value for this key in the `kwargs` argument," + msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." + raise PatternResolverError(msg) from e diff --git a/src/readii/io/writers/base_writer.py b/src/readii/io/writers/base_writer.py index 49a6a8e..411d54e 100644 --- a/src/readii/io/writers/base_writer.py +++ b/src/readii/io/writers/base_writer.py @@ -1,85 +1,14 @@ -import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from types import TracebackType -from typing import Any, ClassVar, Dict, Optional, Tuple - -from imgtools.dicom.sort.exceptions import InvalidPatternError -from imgtools.dicom.sort.parser import PatternParser +from typing import Any, Optional +from readii.io.utils import PatternResolver from readii.utils import logger -@dataclass -class PatternResolver: - """Handles parsing and validating filename patterns.""" - - filename_format: str = field(init=True) - - DEFAULT_PATTERN: ClassVar[re.Pattern] = re.compile(r"%(\w+)|\{(\w+)\}") - - def __init__(self, filename_format: str) -> None: - self.filename_format = filename_format - - try: - self.pattern_parser = PatternParser( - self.filename_format, pattern_parser=self.DEFAULT_PATTERN - ) - self.formatted_pattern, self.keys = self.parse() # Validate the pattern by parsing it - except InvalidPatternError as e: - msg = f"Invalid filename format: {e}" - raise ValueError(msg) from e - else: - logger.debug("All keys are valid.", keys=self.keys) - logger.debug("Formatted Pattern valid.", formatted_pattern=self.formatted_pattern) - - def parse(self) -> Tuple[str, list[str]]: - """ - Parse and validate the pattern. - - Returns - ------- - Tuple[str, List[str]] - The formatted pattern string and a list of extracted keys. - - Raises - ------ - InvalidPatternError - If the pattern contains no valid placeholders or is invalid. - """ - return self.pattern_parser.parse() - - def resolve(self, context: Dict[str, Any]) -> str: - """Resolve the pattern using the provided context dictionary. - - Parameters - ---------- - context : Dict[str, Any] - Dictionary containing key-value pairs to substitute in the pattern. - - Returns - ------- - str - The resolved pattern string with placeholders replaced by values. - - Raises - ------ - ValueError - If a required key is missing from the context dictionary. - """ - try: - return self.formatted_pattern % context - except KeyError as e: - missing_key = e.args[0] - valid_keys = ", ".join(context.keys()) - msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" - msg += "\nPlease provide a value for this key in the `kwargs` argument," - msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." - raise ValueError(msg) from e - - @dataclass class BaseWriter(ABC): """Abstract base class for managing file writing with customizable paths and filenames.""" @@ -156,8 +85,9 @@ def __exit__( The traceback object, if an exception was raised, otherwise None. """ if exc_type: - logger.error( - f"Exception raised in {self.__class__.__name__} while in context manager: {exc_value}" + logger.exception( + f"Exception raised in {self.__class__.__name__} while in context manager.", + exc_info=exc_value, ) logger.debug(f"Exiting context manager for {self.__class__.__name__}") @@ -165,7 +95,7 @@ def __exit__( if ( self.create_dirs and self.root_directory.exists() - and not (self.root_directory.iterdir()) + and not any(self.root_directory.iterdir()) ): logger.debug(f"Deleting empty directory {self.root_directory}") self.root_directory.rmdir() # remove the directory if it's empty diff --git a/src/readii/io/writers/nifti_writer.py b/src/readii/io/writers/nifti_writer.py index 1e62bc8..4c08a66 100644 --- a/src/readii/io/writers/nifti_writer.py +++ b/src/readii/io/writers/nifti_writer.py @@ -45,12 +45,8 @@ class NIFTIWriter(BaseWriter): # Make extensions immutable VALID_EXTENSIONS: ClassVar[list[str]] = [ - ".nia", ".nii", ".nii.gz", - ".hdr", - ".img", - ".img.gz", ] MAX_COMPRESSION_LEVEL: ClassVar[int] = 9 MIN_COMPRESSION_LEVEL: ClassVar[int] = 0 From cdfcdfcf1ef54cf3aeaa20d94cfaf3610f49792d Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:15:36 -0500 Subject: [PATCH 10/14] test: add unit tests for BaseWriter, NIFTIWriter, and PatternResolver --- tests/io/test_base_writer.py | 81 +++++++++++++++++++++++++++++++ tests/io/test_nifti_writer.py | 80 ++++++++++++++++++++++++++++++ tests/io/test_pattern_resolver.py | 23 +++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/io/test_base_writer.py create mode 100644 tests/io/test_nifti_writer.py create mode 100644 tests/io/test_pattern_resolver.py diff --git a/tests/io/test_base_writer.py b/tests/io/test_base_writer.py new file mode 100644 index 0000000..e3bf1f8 --- /dev/null +++ b/tests/io/test_base_writer.py @@ -0,0 +1,81 @@ + +import os +import pytest +from pathlib import Path +from readii.io.writers.base_writer import BaseWriter # type: ignore + +class SimpleWriter(BaseWriter): + def save(self, content: str) -> Path: + file_path = self.resolve_path() + with open(file_path, 'w') as f: + f.write(content) + return file_path + +class MediumWriter(BaseWriter): + def save(self, content: str, suffix: str = '') -> Path: + file_path = self.resolve_path(suffix=suffix) + with open(file_path, 'w') as f: + f.write(content) + return file_path + +class ComplexWriter(BaseWriter): + def save(self, content: str, metadata: dict) -> Path: + file_path = self.resolve_path(**metadata) + with open(file_path, 'w') as f: + f.write(content) + return file_path + +@pytest.fixture +def temp_dir(tmp_path): + return tmp_path + +def test_simple_writer(temp_dir): + writer = SimpleWriter(root_directory=temp_dir, filename_format="{date_time}.txt") + with writer: + file_path = writer.save("Simple content") + assert file_path.exists() + assert file_path.read_text() == "Simple content" + +def test_medium_writer(temp_dir): + writer = MediumWriter(root_directory=temp_dir, filename_format="{date_time}_{suffix}.txt") + with writer: + file_path = writer.save("Medium content", suffix="test") + assert file_path.exists() + assert file_path.read_text() == "Medium content" + +def test_complex_writer(temp_dir): + writer = ComplexWriter(root_directory=temp_dir, filename_format="{date_time}_{user}.txt") + with writer: + file_path = writer.save("Complex content", metadata={"user": "testuser"}) + assert file_path.exists() + assert file_path.read_text() == "Complex content" + +def test_context_manager_cleanup(temp_dir): + subdir = temp_dir / "nested" + writer = SimpleWriter(root_directory=subdir, filename_format="{date_time}.txt") + with writer: + assert subdir.exists() + assert not subdir.exists() + +def test_directory_creation(temp_dir): + writer = SimpleWriter(root_directory=temp_dir / "nested", filename_format="{date_time}.txt") + with writer: + file_path = writer.save("Content") + assert file_path.exists() + assert file_path.read_text() == "Content" + assert (temp_dir / "nested").exists() + +def test_directory_not_created_if_exists(temp_dir): + existing_dir = temp_dir / "existing" + existing_dir.mkdir() + writer = SimpleWriter(root_directory=existing_dir, filename_format="{date_time}.txt") + with writer: + file_path = writer.save("Content") + assert file_path.exists() + assert file_path.read_text() == "Content" + assert existing_dir.exists() + +def test_no_create_dirs_non_existent(temp_dir): + with pytest.raises(FileNotFoundError): + with SimpleWriter(root_directory=temp_dir / "nested_non_existent", filename_format="{date_time}.txt", create_dirs=False) as writer: + file_path = writer.save("Content") diff --git a/tests/io/test_nifti_writer.py b/tests/io/test_nifti_writer.py new file mode 100644 index 0000000..03b99ef --- /dev/null +++ b/tests/io/test_nifti_writer.py @@ -0,0 +1,80 @@ +import pytest +import SimpleITK as sitk +from pathlib import Path +from readii.io.writers.nifti_writer import NIFTIWriter, NiftiWriterValidationError, NiftiWriterIOError # type: ignore + +@pytest.fixture +def sample_image(): + """Fixture for creating a sample SimpleITK image.""" + image = sitk.Image(10, 10, sitk.sitkUInt8) + return image + +@pytest.fixture +def nifti_writer(tmp_path): + """Fixture for creating a NIFTIWriter instance.""" + return NIFTIWriter( + root_directory=tmp_path, + filename_format="{PatientID}.nii.gz", + compression_level=5, + overwrite=False, + create_dirs=True, + ) + +def test_invalid_compression_level(): + """Test for invalid compression level.""" + with pytest.raises(NiftiWriterValidationError): + NIFTIWriter( + root_directory=Path("TRASH"), + filename_format="{PatientID}.nii.gz", + compression_level=10, # Invalid compression level + overwrite=False, + create_dirs=True, + ) + +def test_invalid_filename_format(): + """Test for invalid filename format.""" + with pytest.raises(NiftiWriterValidationError): + NIFTIWriter( + root_directory=Path("TRASH"), + filename_format="{PatientID}.invalid_ext", # Invalid extension + compression_level=5, + overwrite=False, + create_dirs=True, + ) + +def test_save_invalid_image(nifti_writer): + """Test saving an invalid image.""" + with pytest.raises(NiftiWriterValidationError): + nifti_writer.save(image="not_an_image", PatientID="12345") + +def test_save_existing_file_without_overwrite(nifti_writer, sample_image): + """Test saving when file already exists and overwrite is False.""" + nifti_writer.save(sample_image, PatientID="12345") + with pytest.raises(NiftiWriterIOError): + nifti_writer.save(sample_image, PatientID="12345") + +def test_save_existing_file_with_overwrite(nifti_writer, sample_image): + """Test saving when file already exists and overwrite is True.""" + nifti_writer.overwrite = True + nifti_writer.save(sample_image, PatientID="12345") + assert nifti_writer.save(sample_image, PatientID="12345").exists() + +@pytest.mark.parametrize("compression_level", [0, 5, 9]) +def test_save_with_different_compression_levels(nifti_writer, sample_image, compression_level): + """Test saving with different compression levels.""" + nifti_writer.compression_level = compression_level + out_path = nifti_writer.save(sample_image, PatientID="12345") + assert out_path.exists() + +@pytest.mark.parametrize("filename_format", ["{PatientID}.nii", "{PatientID}.nii.gz"]) +def test_save_with_different_filename_formats(nifti_writer, sample_image, filename_format): + """Test saving with different filename formats.""" + nifti_writer.filename_format = filename_format + out_path = nifti_writer.save(sample_image, PatientID="12345") + assert out_path.exists() + +@pytest.mark.parametrize("key,value", [("Modality", "T1"), ("Region", "Brain")]) +def test_save_with_additional_keys(nifti_writer, sample_image, key, value): + """Test saving with additional keys.""" + out_path = nifti_writer.save(sample_image, PatientID="12345", **{key: value}) + assert out_path.exists() diff --git a/tests/io/test_pattern_resolver.py b/tests/io/test_pattern_resolver.py new file mode 100644 index 0000000..f77da6d --- /dev/null +++ b/tests/io/test_pattern_resolver.py @@ -0,0 +1,23 @@ +import pytest +from readii.io.utils import PatternResolver, PatternResolverError # type: ignore + +@pytest.mark.parametrize("pattern, context, expected", [ + ("{subject_id}_{date}/{disease}.txt", {"subject_id": "JohnDoe", "date": "2025-01-01", "disease": "cancer"}, "JohnDoe_2025-01-01/cancer.txt"), + ("{subject_id}_{date}/{disease}.txt", {"subject_id": "JohnDoe", "date": "2025-01-01"}, PatternResolverError), + ("{subject_id}_{date}/{disease.txt", {}, PatternResolverError), + # New complex test cases + ("{subject_id}_{date}/{disease}/{sample_id}.txt", {"subject_id": "JaneDoe", "date": "2025-01-01", "disease": "flu", "sample_id": "S123"}, "JaneDoe_2025-01-01/flu/S123.txt"), + ("{subject_id}_{date}/{disease}/{sample_id}.txt", {"subject_id": "JaneDoe", "date": "2025-01-01", "disease": "flu"}, PatternResolverError), + ("{subject_id}_{date}/{disease}/{sample_id}.txt", {"subject_id": "JaneDoe", "date": "2025-01-01", "disease": "flu", "sample_id": ""}, "JaneDoe_2025-01-01/flu/.txt"), + ("{subject_id}_{date}/{disease}/{sample_id}.txt", {"subject_id": "JaneDoe", "date": "2025-01-01", "disease": "flu", "sample_id": None}, PatternResolverError), + ("{subject_id}_{date}/{disease}/{sample_id}.txt", {"subject_id": "JaneDoe", "date": "2025-01-01", "disease": "flu", "sample_id": "S123", "extra_key": "extra_value"}, "JaneDoe_2025-01-01/flu/S123.txt"), +]) +def test_resolve(pattern, context, expected): + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + resolver = PatternResolver(pattern) + resolver.resolve(context) + else: + resolver = PatternResolver(pattern) + result = resolver.resolve(context) + assert result == expected From 08a899aa9a92cc739f5da104ae7ad37278bea481 Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:28:03 -0500 Subject: [PATCH 11/14] fix: improve error handling in PatternResolver for missing keys --- src/readii/io/utils/pattern_resolver.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/readii/io/utils/pattern_resolver.py b/src/readii/io/utils/pattern_resolver.py index 24c9813..7e56166 100644 --- a/src/readii/io/utils/pattern_resolver.py +++ b/src/readii/io/utils/pattern_resolver.py @@ -29,19 +29,23 @@ class PatternResolver: Given a filename format like `"{subject_id}_{date}/{disease}.txt"`, the pattern parser will extract the following keys: - >>> keys + >>> pattern_resolver.keys {'subject_id', 'date', 'disease'} And the following formatted pattern: - >>> formatted_pattern + >>> pattern_resolver.formatted_pattern %(subject_id)s_%(date)s/%(disease)s.txt So you could resolve the pattern like this: >>> data_dict = {"subject_id": "JohnDoe", "date": "January-01-2025", "disease": "cancer"} - >>> formatted_pattern % data_dict + >>> pattern_resolver.formatted_pattern % data_dict + 'JohnDoe_01-01-2025/cancer.txt' + + A more convenient way to resolve the pattern is to use the `resolve` method: + >>> pattern_resolver.resolve(data_dict)) 'JohnDoe_01-01-2025/cancer.txt' """ @@ -108,9 +112,9 @@ def resolve(self, context: Dict[str, Any]) -> str: try: return self.formatted_pattern % context except KeyError as e: - missing_key = e.args[0] - valid_keys = ", ".join(context.keys()) - msg = f"Missing value for placeholder '{missing_key}'. Valid keys: {valid_keys}" - msg += "\nPlease provide a value for this key in the `kwargs` argument," - msg += f" i.e `{self.__class__.__name__}.save(..., {missing_key}=value)`." - raise PatternResolverError(msg) from e + # missing_key = e.args[0] + missing_keys = set(context.keys()) - set(self.keys) + msg = f"Missing value for placeholder(s): {missing_keys}" + msg += "\nPlease provide a value for this key in the `context` argument." + msg += f" i.e `{self.__class__.__name__}.save(..., {e.args[0]}=value)`." + raise PatternResolverError(msg) from e \ No newline at end of file From 25b98818be498090fbf11ce5f3b374721402abf8 Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:45:45 -0500 Subject: [PATCH 12/14] fix: update exception type in PatternResolver documentation --- src/readii/io/utils/pattern_resolver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readii/io/utils/pattern_resolver.py b/src/readii/io/utils/pattern_resolver.py index 7e56166..15d58a2 100644 --- a/src/readii/io/utils/pattern_resolver.py +++ b/src/readii/io/utils/pattern_resolver.py @@ -100,7 +100,7 @@ def resolve(self, context: Dict[str, Any]) -> str: Raises ------ - ValueError + PatternResolverError If a required key is missing from the context dictionary. """ if None in context.values(): From fb821149acf007b3d114ed9dda501db2d2944e8e Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:45:52 -0500 Subject: [PATCH 13/14] feat: enhance NIFTIWriter to accept numpy arrays and improve validation + update tests --- src/readii/io/writers/nifti_writer.py | 18 ++++++++---- tests/io/test_nifti_writer.py | 41 +++++++++++---------------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/readii/io/writers/nifti_writer.py b/src/readii/io/writers/nifti_writer.py index 4c08a66..52f0130 100644 --- a/src/readii/io/writers/nifti_writer.py +++ b/src/readii/io/writers/nifti_writer.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import ClassVar +import numpy as np import SimpleITK as sitk from readii.io.writers.base_writer import BaseWriter @@ -63,12 +64,12 @@ def __post_init__(self) -> None: msg = f"Invalid filename format {self.filename_format}. Must end with one of {self.VALID_EXTENSIONS}." raise NiftiWriterValidationError(msg) - def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: + def save(self, image: sitk.Image | np.ndarray, PatientID: str, **kwargs: str | int) -> Path: """Write the SimpleITK image to a NIFTI file. Parameters ---------- - image : sitk.Image + image : sitk.Image | np.ndarray The SimpleITK image to save PatientID : str Required patient identifier @@ -87,9 +88,14 @@ def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: NiftiWriterValidationError If image is invalid """ - if not isinstance(image, sitk.Image): - msg = "Input must be a SimpleITK Image" - raise NiftiWriterValidationError(msg) + match image: + case sitk.Image(): + pass + case np.ndarray(): + image = sitk.GetImageFromArray(image) + case _: + msg = "Input must be a SimpleITK Image or a numpy array" + raise NiftiWriterValidationError(msg) logger.debug("Saving.", kwargs=kwargs) @@ -114,7 +120,7 @@ def save(self, image: sitk.Image, PatientID: str, **kwargs: str | int) -> Path: return out_path -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover from rich import print # noqa nifti_writer = NIFTIWriter( diff --git a/tests/io/test_nifti_writer.py b/tests/io/test_nifti_writer.py index 03b99ef..70eae1b 100644 --- a/tests/io/test_nifti_writer.py +++ b/tests/io/test_nifti_writer.py @@ -1,5 +1,6 @@ import pytest import SimpleITK as sitk +import numpy as np from pathlib import Path from readii.io.writers.nifti_writer import NIFTIWriter, NiftiWriterValidationError, NiftiWriterIOError # type: ignore @@ -9,6 +10,12 @@ def sample_image(): image = sitk.Image(10, 10, sitk.sitkUInt8) return image +@pytest.fixture +def sample_array(): + """Fixture for creating a sample numpy array.""" + array = np.zeros((10, 10), dtype=np.uint8) + return array + @pytest.fixture def nifti_writer(tmp_path): """Fixture for creating a NIFTIWriter instance.""" @@ -20,32 +27,18 @@ def nifti_writer(tmp_path): create_dirs=True, ) -def test_invalid_compression_level(): - """Test for invalid compression level.""" - with pytest.raises(NiftiWriterValidationError): - NIFTIWriter( - root_directory=Path("TRASH"), - filename_format="{PatientID}.nii.gz", - compression_level=10, # Invalid compression level - overwrite=False, - create_dirs=True, - ) - -def test_invalid_filename_format(): - """Test for invalid filename format.""" - with pytest.raises(NiftiWriterValidationError): - NIFTIWriter( - root_directory=Path("TRASH"), - filename_format="{PatientID}.invalid_ext", # Invalid extension - compression_level=5, - overwrite=False, - create_dirs=True, - ) - -def test_save_invalid_image(nifti_writer): +@pytest.mark.parametrize("image", ["not_an_image", 12345]) +def test_save_invalid_image(nifti_writer, image): """Test saving an invalid image.""" with pytest.raises(NiftiWriterValidationError): - nifti_writer.save(image="not_an_image", PatientID="12345") + nifti_writer.save(image=image, PatientID="12345") + +@pytest.mark.parametrize("image", ["sample_image", "sample_array"]) +def test_save_valid_image(nifti_writer, request, image): + """Test saving a valid image.""" + image = request.getfixturevalue(image) + out_path = nifti_writer.save(image=image, PatientID="12345") + assert out_path.exists() def test_save_existing_file_without_overwrite(nifti_writer, sample_image): """Test saving when file already exists and overwrite is False.""" From c80693475a9836dae0a722855bd2f95de8c3924e Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Fri, 13 Dec 2024 11:48:15 -0500 Subject: [PATCH 14/14] chore: update lockfile --- pixi.lock | 46 +++++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/pixi.lock b/pixi.lock index d794f66..8ebebdf 100644 --- a/pixi.lock +++ b/pixi.lock @@ -54,7 +54,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl @@ -125,7 +125,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl @@ -393,7 +393,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -635,7 +635,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -779,7 +779,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -906,7 +906,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -1071,7 +1071,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/3c/245c45730e088d0467621cb736bf4c07c90f5ced084ef0ff6ed178d44de7/med_imagetools-1.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -1211,7 +1211,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/3c/245c45730e088d0467621cb736bf4c07c90f5ced084ef0ff6ed178d44de7/med_imagetools-1.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl @@ -1300,7 +1300,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/c3/94f33af0762ed76b5a237c5797e088aa57f2b7fa8ee7932d399087be66a8/pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fc/e1/e0a2ed6394b5772508868a977d3238f4afb2eebaf9976f0b44a8d347ad63/propcache-0.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1383,7 +1383,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/6a/1d/1f51e6e912d8ff316bb3935a8cda617c801783e0b998bf7a894e91d3bd4c/pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/2d/62/685d3cf268b8401ec12b250b925b21d152b9d193b7bffa5fdc4815c392c2/propcache-0.2.1-cp310-cp310-macosx_11_0_arm64.whl @@ -1482,7 +1482,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/39/63/b3fc299528d7df1f678b0666002b37affe6b8751225c3d9c12cf530e73ed/pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1565,7 +1565,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/25/b3/2b54a1d541accebe6bd8b1358b34ceb2c509f51cb7dcda8687362490da5b/pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl @@ -1664,7 +1664,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1747,7 +1747,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl @@ -5299,10 +5299,10 @@ packages: - pkg:pypi/optype?source=hash-mapping size: 123504 timestamp: 1733329825349 -- pypi: https://files.pythonhosted.org/packages/8b/95/1f279f406cd97b62a4058188736383bf612d633874be0cca2f97b06de728/orcestra_downloader-0.10.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/60/e6/219bca783de4d7c3b479e21d0a86b1de577154855242ca55d574eea504af/orcestra_downloader-0.11.0-py3-none-any.whl name: orcestra-downloader - version: 0.10.0 - sha256: 3d6dbc8426d34496bb39ba416498cfaef151f5d8df108551c01f6c61fbc65db4 + version: 0.11.0 + sha256: 463a1573b8c4fdf466841ac47b4a2d7dd1158e5f3055223d87c57bfa66b0046e requires_dist: - aiohttp>=3.11.4 - click>=8.1.7 @@ -7117,16 +7117,8 @@ packages: timestamp: 1728642457661 - pypi: . name: readii -<<<<<<< HEAD - version: 1.19.0 - sha256: 9ae30b80b97e4d9f1a19ebc753a55226e088367851d842d1da8d2f104cf3d998 -||||||| 0e868f6 - version: 1.18.0 - sha256: 4c0d9f950a9aa12b40de952a4637380312baf5ceb1c17922322d0b3b84588f52 -======= - version: 1.20.0 - sha256: 081b822f21841d6330d585a4788daa01ee864f58fb2d70395cfed063961a5a3c ->>>>>>> origin + version: 1.21.0 + sha256: 712416b55c52a31c85e7ae84be7842a9ec9df227d3b1de0018aa5d1ff0a15ad4 requires_dist: - simpleitk>=2.3.1 - matplotlib>=3.9.2,<4