Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
973d7f9
Rewrite the base Importer class to allow more generic file reading an…
krzywon Nov 4, 2026
a938366
Convert the ascii reader to an Importer object that auto-associates i…
krzywon Nov 4, 2026
613b7df
Import public h5py classes and not ones from private classes
krzywon Nov 4, 2026
2587af1
Convert hdf5 importer to a child of the Importer class
krzywon Nov 4, 2026
5584da4
Convert sesans importer to a child of the Importer class
krzywon Nov 4, 2026
ea4d398
Convert xml importer to a child of the Importer class
krzywon Nov 4, 2026
5a307ea
Register all importers in the base importers package to load on import
krzywon Nov 4, 2026
00a8736
Simplify the local_load for test files in the base test directory
krzywon Nov 4, 2026
97e139a
Update averaging unit tests to use new Importer classes, as needed
krzywon Nov 4, 2026
67081ae
Remove undefined pytest mark to fix worning
krzywon Nov 4, 2026
adc5fb9
Rename TestFileReader to MockFileReader so pytest does not mistake it…
krzywon Nov 4, 2026
5dd8760
Update generic file import tests to use new Importer class and children
krzywon Nov 4, 2026
0a9ce7b
Update trend generation tests to use new Importer class and children
krzywon Nov 4, 2026
3053986
Update ascii importer tests to use new AsciiImporter class
krzywon Nov 4, 2026
5b4f394
Update sesans importer tests to use new Importer class and children
krzywon Nov 4, 2026
557757f
Remove unrecognized pytest mark to eliminate unit test warnings
krzywon Nov 4, 2026
6803ee1
Remove unrecognized pytest mark to eliminate test warnings
krzywon Nov 4, 2026
846d959
Use importer.Path_Type for type checks in child classes
krzywon Nov 8, 2026
cf9b032
Use default sniff size for hdf5 reader instead of magic number
krzywon Nov 8, 2026
10aad2a
Check for explicit errors when checking if a file is importable inste…
krzywon Nov 8, 2026
1930b72
Check for cases where more configs are passed to the Importer than fi…
krzywon Nov 8, 2026
5e9ec80
Change import test local_load to be file extension agnostic, like it …
krzywon Nov 8, 2026
238d445
Be more explicit with the importer priorities tests
krzywon Nov 8, 2026
1a269df
Document why exceptions are swallowed
krzywon Nov 8, 2026
3835c57
Merge branch 'refactor_24' into 253-class-importer
krzywon Nov 9, 2026
12eae47
Fix utest_import_ascii.py tests to match latest return structure afte…
krzywon Nov 9, 2026
22bca3e
Merge branch 'refactor_24' into 253-class-importer
krzywon Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 187 additions & 33 deletions sasdata/data_io/importer.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,211 @@
import mimetypes
"""
Base class and content-based dispatch for all sasdata importers.

Every module in :mod:`sasdata.data_io.importers` defines a child of :class:`Importer`. Each child knows

* how to recognise a file it can read, from the file *contents* (:meth:`Importer.can_import`), and
* how to read that file into SasMeasurement objects (:meth:`Importer.load_data`).

Children register themselves automatically when they are defined. :meth:`Importer.find_importer` then picks the
appropriate child for any file, so callers do not need to know the file format (or trust the file extension)::

from sasdata.data_io.importer import Importer

data = Importer.load("my_file.xml") # dict[str, SasMeasurement]
measurements, errors = Importer().import_data(["a.h5", "b.ses", "c.txt"])
"""

import importlib
import io
from itertools import zip_longest
from pathlib import Path
from typing import IO, ClassVar

from sasdata.data import SasMeasurement
from sasdata.data_io.importers import *

Path_Type = str | Path
Source_Type = Path_Type | IO[bytes]
Value_Type = str | int | float | list[str] | list[int] | list[float]

# Number of bytes read from the start of a file when sniffing its contents.
SNIFF_SIZE = 4096


class Importer:
def __init__(self):
pass
"""
Base class for all data importers.

Child classes must implement :meth:`load_data` and :meth:`can_import`, and may set :attr:`priority` (lower values
are tested first) and :attr:`name`. Instantiating ``Importer`` directly gives a generic importer whose
:meth:`load_data` and :meth:`import_data` select the correct child importer for each file automatically.
"""

# Human-readable name of the format handled by the importer.
name: ClassVar[str] = "Generic"
# Order in which the importer is tested when inferring the file type; lower values are tested first.
# Generic fallback importers (e.g. ASCII) should use a high value.
priority: ClassVar[int] = 100
# All concrete importers, populated automatically by :meth:`__init_subclass__`.
_registry: ClassVar[list[type["Importer"]]] = []

def __init_subclass__(cls, register: bool = True, **kwargs):
super().__init_subclass__(**kwargs)
if register and cls not in Importer._registry:
Importer._registry.append(cls)

def __init__(self, config: dict[str, Value_Type] | None = None):
self.config: dict[str, Value_Type] = config if config is not None else {}

# ------------------------------------------------------------------------------------------------------------------
# Interface to be implemented by every child importer
# ------------------------------------------------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given they must be implemented, would it be appropriate to make these methods abstract methods?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class holds a registry of all child Importers. Making this class abstract breaks some of that convention.


@classmethod
def can_import(cls, source: Source_Type) -> bool:
"""Return True if the contents of ``source`` look like a file this importer can read. Must not raise."""
return False

def load_data(self, source: Source_Type) -> dict[str, SasMeasurement]:
"""
Load all measurements contained in ``source``.

:param source: Path to the file to load (or an open binary file object, where supported).
:return: A dictionary of the measurements found in the file, keyed by a name unique within the file.
"""
importer = self.find_importer(source)
if importer is None:
raise ValueError(f'Unable to determine the file type of "{source}".')
return importer(self.config).load_data(source)

# ------------------------------------------------------------------------------------------------------------------
# File-type inference
# ------------------------------------------------------------------------------------------------------------------

@staticmethod
def importers() -> list[type["Importer"]]:
"""All registered importers, in the order in which they will be tested."""
# Importing the package imports every importer module, which registers the child classes.
importlib.import_module("sasdata.data_io.importers")
return sorted(Importer._registry, key=lambda importer: importer.priority)

@classmethod
def find_importer(cls, source: Source_Type) -> type["Importer"] | None:
"""Infer the type of ``source`` from its contents and return the importer class able to read it."""
for importer in cls.importers():
if importer.can_import(source):
return importer
return None

@classmethod
def load(cls, source: Source_Type, config: dict[str, Value_Type] | None = None) -> dict[str, SasMeasurement]:
"""Convenience method: infer the file type of ``source`` and load it."""
return Importer(config).load_data(source)

# ------------------------------------------------------------------------------------------------------------------
# Bulk import
# ------------------------------------------------------------------------------------------------------------------

def import_data(self, files: list[Path_Type], configs: list[dict[str, Value_Type]] | None = None) \
-> (list[SasMeasurement], list[str]):
output = []
errors = []
-> tuple[list[SasMeasurement], list[str]]:
"""
Import a series of files, inferring the importer for each one from its contents.

:param files: Files to import.
:param configs: Optional per-file configuration. If there are fewer configs than files, the last config is
reused for the remaining files.
:return: All of the measurements loaded, and a list of error messages for anything that could not be loaded.
"""
output: list[SasMeasurement] = []
errors: list[str] = []
if configs is None:
configs = []
fill_value = configs[-1] if len(configs) > 0 else None
for file, config in zip_longest(files, configs, fillvalue=fill_value):
file = Path(file)
measurements, errors = self._import_from_url(file, config)
if file == fill_value:
# If this is reached, the most likely scenario is len(configs) > len(files) in which case, break out of the loop
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be break or continue? I'd have though if a single file is None, that doesn't affect the entire list, unless you want an error to be raised immediately on finding this. What's the approach here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my change here, but the idea is, if zip_longest finds more configs than files, this should not continue.

measurements, file_errors = self._import_file(Path(file), config)
output.extend(measurements)
errors.extend(errors)
errors.extend(file_errors)
return output, errors

def _import_from_url(self, url: Path_Type, config: dict[str, Value_Type] | None = None) -> (list[SasMeasurement], list[str]):
file = Path(url)
def _import_file(self, file: Path, config: dict[str, Value_Type] | None = None) \
-> tuple[list[SasMeasurement], list[str]]:
if not file.exists():
return [], [f'File does not exist: "{file}"']
# Deprecation Warning:
# mimetypes.guess_type() is soft deprecated in favor of mimetypes.guess_file_type() as of python 3.13
# Once we move forward from python 3.12, this line should change
mime_type, encoding = mimetypes.guess_type(url)
imported = []
errors = []
# TODO: Need a viable config system that can pass a dictionary of value locations to each importer
importer = self.find_importer(file)
if importer is None:
return [], [f'Unable to determine the file type of "{file}"']
merged_config = {**self.config, **(config or {})}
try:
match mime_type:
case 'application/xml' | 'text/xml':
file_dict = import_xml.load_data(file)
imported.extend(list(file_dict.values()))
case 'application/vnd.hdfgroup.hdf5':
file_list = import_hdf5.load_data(file)
imported.extend(file_list)
case _:
file_list = import_ascii.load_data_default_params(file)
imported.extend(file_list)
imported = list(importer(merged_config).load_data(file).values())
except Exception as e:
errors.append(f'Error accessing "{file}": {e}')
for file in imported:
if hasattr(file, "errors"):
errors.extend(file.errors)
return [], [f'Error loading "{file}" with the {importer.name} importer: {e}']
errors = []
for measurement in imported:
errors.extend(getattr(measurement, "errors", []))
return imported, errors


# ----------------------------------------------------------------------------------------------------------------------
# Helpers shared by the importers when sniffing file contents
# ----------------------------------------------------------------------------------------------------------------------

def read_header(source: Source_Type, size: int = SNIFF_SIZE) -> bytes:
"""
Read up to ``size`` bytes from the start of ``source`` without changing the position of a file object.
Returns an empty bytes object if the source cannot be read.
"""
try:
if isinstance(source, Path_Type):
path = Path(source)
if not path.is_file():
return b""
with open(path, "rb") as f:
return f.read(size)
if isinstance(source, io.IOBase) or hasattr(source, "read"):
position = source.tell()
try:
source.seek(0)
header = source.read(size)
finally:
source.seek(position)
return header if isinstance(header, bytes) else header.encode()
except (OSError, ValueError):
# File access or read errors will fall through here, implying the reader cannot interpret the file.
# An empty byte string will be returned, but there may be other cases where this try/except are bypassed, so
# explicitly do nothing here.
pass
Comment thread
krzywon marked this conversation as resolved.
Resolved
return b""


def is_text(header: bytes) -> bool:
"""Heuristic check that a file header is text rather than binary data."""
if not header or b"\x00" in header:
return False
try:
header.decode("utf-8")
except UnicodeDecodeError as e:
# The header may have been truncated in the middle of a multibyte character.
if e.start < len(header) - 4:
try:
header.decode("latin-1")
except UnicodeDecodeError:
return False
# latin-1 decodes anything, so reject if there are many control characters.
controls = sum(1 for b in header if b < 32 and b not in (9, 10, 12, 13))
return controls / len(header) < 0.05
return True


def measurements_by_name(measurements: list[SasMeasurement]) -> dict[str, SasMeasurement]:
"""Key a list of measurements by name, adding a numeric suffix to any repeated names so none are lost."""
output: dict[str, SasMeasurement] = {}
for measurement in measurements:
key = measurement.name
index = 2
while key in output:
key = f"{measurement.name} ({index})"
index += 1
output[key] = measurement
return output
16 changes: 15 additions & 1 deletion sasdata/data_io/importers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
__all__ = ['import_ascii', 'import_hdf5', 'import_sesans', 'import_xml']
"""
Importers for each of the file formats supported by sasdata.

Each module defines a child of :class:`sasdata.data_io.importer.Importer`. Importing this package registers all of
them, so :meth:`Importer.find_importer` can select the correct one from the contents of a file.
"""

from sasdata.data_io.importers.import_ascii import AsciiImporter
from sasdata.data_io.importers.import_hdf5 import HDF5Importer
from sasdata.data_io.importers.import_sesans import SesansImporter
from sasdata.data_io.importers.import_xml import XmlImporter

__all__ = [
'AsciiImporter', 'HDF5Importer', 'SesansImporter', 'XmlImporter',
]
51 changes: 46 additions & 5 deletions sasdata/data_io/importers/import_ascii.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from dataclasses import dataclass, field, replace
from enum import Enum
from os import path
from pathlib import Path

import numpy as np

Expand All @@ -14,6 +13,7 @@
pairings,
)
from sasdata.data import SasMeasurement
from sasdata.data_io.importer import Importer, Path_Type, Source_Type, is_text, measurements_by_name, read_header
from sasdata.dataset_types import DatasetType, one_dim, unit_kinds
from sasdata.default_units import get_default_unit
from sasdata.guess import (
Expand Down Expand Up @@ -207,7 +207,7 @@ def merge_uncertainties(quantities: dict[str, Quantity]) -> dict[str, Quantity]:
return new_quantities


def load_data(params: AsciiReaderParams) -> list[SasMeasurement]:
def load_measurements(params: AsciiReaderParams) -> list[SasMeasurement]:
"""
This loads a series of SasMeasurement objects based on the params.
The amount of SasMeasurement objects loaded will depend on how many
Expand Down Expand Up @@ -239,6 +239,47 @@ def load_data(params: AsciiReaderParams) -> list[SasMeasurement]:
return loaded_data


def load_data_default_params(filename: str | Path) -> list[SasMeasurement]:
params = guess_params_from_filename(filename, guess_dataset_type(filename))
return load_data(params)
class AsciiImporter(Importer):
"""
Importer for column-based ASCII files.

The importer can be given an :class:`AsciiReaderParams` object (e.g. generated by the ASCII Reader Dialog in
SasView) describing how to read the file(s). If no parameters are given, they are guessed from the file contents.
As almost any text file can be read as ASCII, this importer is the fallback used when no other importer recognises
a file, so it is tested last.
"""

name = "ASCII"
priority = 1000

def __init__(self, config: dict | None = None, params: AsciiReaderParams | None = None):
super().__init__(config)
self.params = params

@classmethod
def can_import(cls, source: Source_Type) -> bool:
"""Any text file on disk could be a column-based ASCII file."""
if not isinstance(source, Path_Type):
return False
return is_text(read_header(source))

def load_data(self, source: Source_Type | None = None) -> dict[str, SasMeasurement]:
"""
Load ASCII data, keyed by the basename of each file.

:param source: The file to load. If parameters were given to the importer, they are used to read this file;
otherwise, the parameters are guessed from the file contents. If ``source`` is None, every file listed in
the importer's parameters is loaded.
"""
if source is None:
if self.params is None:
raise ValueError("A file to load, or AsciiReaderParams listing the files to load, must be given.")
params = self.params
elif not isinstance(source, Path_Type):
raise TypeError("The ASCII importer can only load files from a path.")
elif self.params is not None:
params = replace(self.params, filenames=[str(source)])
else:
filename = str(source)
params = guess_params_from_filename(filename, guess_dataset_type(filename))
return measurements_by_name(load_measurements(params))
Loading
Loading