From c6a8c77607a80811c35c0701172a8a0c8c70f945 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Tue, 25 Nov 2025 13:18:15 -0500 Subject: [PATCH 01/13] UTF Encoding Enhancement Implementation --- cdisc_rules_engine/models/validation_args.py | 1 + .../data_readers/data_reader_factory.py | 21 ++++- .../data_readers/dataset_json_reader.py | 5 +- .../data_readers/dataset_ndjson_reader.py | 29 ++++++- .../services/data_readers/json_reader.py | 28 ++++++- .../services/data_readers/xpt_reader.py | 68 +++++++++++++++- .../data_services/data_service_factory.py | 12 ++- .../data_services/dummy_data_service.py | 10 +-- .../data_services/excel_data_service.py | 3 +- .../data_services/local_data_service.py | 6 +- .../data_services/usdm_data_service.py | 10 ++- .../services/datasetjson_metadata_reader.py | 5 +- .../services/datasetndjson_metadata_reader.py | 23 +++++- .../services/datasetxpt_metadata_reader.py | 80 +++++++++++++++++-- core.py | 12 +++ scripts/run_validation.py | 1 + 16 files changed, 275 insertions(+), 39 deletions(-) diff --git a/cdisc_rules_engine/models/validation_args.py b/cdisc_rules_engine/models/validation_args.py index 961ac2bf4..c3a314aa1 100644 --- a/cdisc_rules_engine/models/validation_args.py +++ b/cdisc_rules_engine/models/validation_args.py @@ -27,5 +27,6 @@ "jsonata_custom_functions", "max_report_rows", "max_errors_per_rule", + "encoding", ], ) diff --git a/cdisc_rules_engine/services/data_readers/data_reader_factory.py b/cdisc_rules_engine/services/data_readers/data_reader_factory.py index 5fb718975..553dc4f99 100644 --- a/cdisc_rules_engine/services/data_readers/data_reader_factory.py +++ b/cdisc_rules_engine/services/data_readers/data_reader_factory.py @@ -26,9 +26,15 @@ class DataReaderFactory(FactoryInterface): DataFormatTypes.USDM.value: JSONReader, } - def __init__(self, service_name: str = None, dataset_implementation=PandasDataset): + def __init__( + self, + service_name: str = None, + dataset_implementation=PandasDataset, + encoding: str = None, + ): self._default_service_name = service_name self.dataset_implementation = dataset_implementation + self.encoding = encoding @classmethod def register_service(cls, name: str, service: Type[DataReaderInterface]): @@ -47,7 +53,18 @@ def get_service(self, name: str = None, **kwargs) -> DataReaderInterface: """ service_name = name or self._default_service_name if service_name in self._reader_map: - return self._reader_map[service_name](self.dataset_implementation) + reader_class = self._reader_map[service_name] + if service_name in [ + DataFormatTypes.JSON.value, + DataFormatTypes.NDJSON.value, + ]: + return reader_class(self.dataset_implementation, encoding=self.encoding) + elif service_name == DataFormatTypes.USDM.value: + return reader_class() + elif service_name == DataFormatTypes.XPT.value: + return reader_class(self.dataset_implementation, encoding=self.encoding) + else: + return reader_class(self.dataset_implementation) raise ValueError( f"Service name must be in {list(self._reader_map.keys())}, " f"given service name is {service_name}" diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index 937b7bf51..d5576963d 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -15,6 +15,9 @@ class DatasetJSONReader(DataReaderInterface): + def __init__(self, encoding: str = None): + self.encoding = encoding + def get_schema(self) -> dict: schema = JSONReader().from_file( os.path.join("resources", "schema", "dataset.schema.json") @@ -22,7 +25,7 @@ def get_schema(self) -> dict: return schema def read_json_file(self, file_path: str) -> dict: - return JSONReader().from_file(file_path) + return JSONReader().from_file(file_path, encoding=self.encoding) def _raw_dataset_from_file(self, file_path) -> pd.DataFrame: # Load Dataset-JSON Schema diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index 89f0c663b..e38897acf 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -16,6 +16,10 @@ class DatasetNDJSONReader(DataReaderInterface): + def __init__(self, dataset_implementation, encoding: str = None): + self.dataset_implementation = dataset_implementation + self.encoding = encoding + def get_schema(self) -> dict: schema = JSONReader().from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") @@ -23,9 +27,28 @@ def get_schema(self) -> dict: return schema def read_json_file(self, file_path: str) -> dict: - with open(file_path, "r") as file: - lines = file.readlines() - return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] + if self.encoding: + with open(file_path, "r", encoding=self.encoding) as file: + lines = file.readlines() + return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] + + encodings_to_try = ["utf-8", "utf-16", "utf-32"] + last_error = None + + for enc in encodings_to_try: + try: + with open(file_path, "r", encoding=enc) as file: + lines = file.readlines() + return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] + except (UnicodeDecodeError, UnicodeError) as e: + last_error = e + continue + else: + if last_error: + raise last_error + raise ValueError( + f"Could not decode NDJSON file {file_path} with UTF-8, UTF-16, or UTF-32 encoding" + ) def _raw_dataset_from_file(self, file_path) -> pd.DataFrame: # Load Dataset-JSON Schema diff --git a/cdisc_rules_engine/services/data_readers/json_reader.py b/cdisc_rules_engine/services/data_readers/json_reader.py index f7928ae07..5bb373e2a 100644 --- a/cdisc_rules_engine/services/data_readers/json_reader.py +++ b/cdisc_rules_engine/services/data_readers/json_reader.py @@ -6,11 +6,31 @@ class JSONReader(DataReaderInterface): - def from_file(self, file_path): + def from_file(self, file_path, encoding: str = None): try: - with open(file_path, "rb") as fp: - json = load(fp) - return json + if encoding: + with open(file_path, "r", encoding=encoding) as fp: + json = load(fp) + return json + + encodings_to_try = ["utf-8", "utf-16", "utf-32"] + last_error = None + + for enc in encodings_to_try: + try: + with open(file_path, "r", encoding=enc) as fp: + json = load(fp) + return json + except (UnicodeDecodeError, UnicodeError) as e: + last_error = e + continue + else: + if last_error: + raise last_error + raise InvalidJSONFormat( + f"\n Error reading JSON from: {file_path}" + f"\n Could not decode file with UTF-8, UTF-16, or UTF-32 encoding" + ) except Exception as e: raise InvalidJSONFormat( f"\n Error reading JSON from: {file_path}" diff --git a/cdisc_rules_engine/services/data_readers/xpt_reader.py b/cdisc_rules_engine/services/data_readers/xpt_reader.py index b7176620a..4f3ee621c 100644 --- a/cdisc_rules_engine/services/data_readers/xpt_reader.py +++ b/cdisc_rules_engine/services/data_readers/xpt_reader.py @@ -10,18 +10,80 @@ class XPTReader(DataReaderInterface): + def __init__(self, dataset_implementation, encoding: str = None): + self.dataset_implementation = dataset_implementation + self.encoding = encoding + def read(self, data): - df = pd.read_sas(BytesIO(data), format="xport", encoding="utf-8") + if self.encoding: + df = pd.read_sas(BytesIO(data), format="xport", encoding=self.encoding) + else: + encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + last_error = None + + for encoding in encodings_to_try: + try: + df = pd.read_sas(BytesIO(data), format="xport", encoding=encoding) + break + except UnicodeDecodeError as e: + last_error = e + continue + else: + if last_error: + raise last_error + raise UnicodeDecodeError( + "utf-8", b"", 0, 1, "Could not decode XPT data with any encoding" + ) + df = self._format_floats(df) return df def _read_pandas(self, file_path): - data = pd.read_sas(file_path, format="xport", encoding="utf-8") + if self.encoding: + data = pd.read_sas(file_path, format="xport", encoding=self.encoding) + else: + encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + last_error = None + + for encoding in encodings_to_try: + try: + data = pd.read_sas(file_path, format="xport", encoding=encoding) + break + except UnicodeDecodeError as e: + last_error = e + continue + else: + if last_error: + raise last_error + raise UnicodeDecodeError( + "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" + ) + return PandasDataset(self._format_floats(data)) + def _read_xpt_with_encoding(self, file_path: str, chunksize: int = None): + if self.encoding: + return pd.read_sas(file_path, chunksize=chunksize, encoding=self.encoding) + + encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + last_error = None + + for encoding in encodings_to_try: + try: + return pd.read_sas(file_path, chunksize=chunksize, encoding=encoding) + except UnicodeDecodeError as e: + last_error = e + continue + else: + if last_error: + raise last_error + raise UnicodeDecodeError( + "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" + ) + def to_parquet(self, file_path: str) -> str: temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") - dataset = pd.read_sas(file_path, chunksize=20000, encoding="utf-8") + dataset = self._read_xpt_with_encoding(file_path, chunksize=20000) created = False num_rows = 0 for chunk in dataset: diff --git a/cdisc_rules_engine/services/data_services/data_service_factory.py b/cdisc_rules_engine/services/data_services/data_service_factory.py index b580d9bd0..60c3ab739 100644 --- a/cdisc_rules_engine/services/data_services/data_service_factory.py +++ b/cdisc_rules_engine/services/data_services/data_service_factory.py @@ -37,6 +37,7 @@ def __init__( standard_substandard: str = None, library_metadata: LibraryMetadataContainer = None, max_dataset_size: int = 0, + encoding: str = None, ): if config.getValue("DATA_SERVICE_TYPE"): self.data_service_name = config.getValue("DATA_SERVICE_TYPE") @@ -51,12 +52,13 @@ def __init__( self.standard_substandard = standard_substandard self.library_metadata = library_metadata self.max_dataset_size = max_dataset_size + self.encoding = encoding self.dataset_size_threshold = self.config.get_dataset_size_threshold() def get_data_service( self, dataset_paths: Iterable[str] = [] ) -> DataServiceInterface: - if USDMDataService.is_valid_data(dataset_paths): + if USDMDataService.is_valid_data(dataset_paths, encoding=self.encoding): """Get json file tree to dataset data service""" return self.get_service( "usdm", @@ -66,11 +68,12 @@ def get_data_service( library_metadata=self.library_metadata, dataset_path=dataset_paths[0], dataset_implementation=self.get_dataset_implementation(), + encoding=self.encoding, ) - elif DummyDataService.is_valid_data(dataset_paths): + elif DummyDataService.is_valid_data(dataset_paths, encoding=self.encoding): """Get dummy data service""" return self.get_dummy_data_service( - data=DummyDataService.get_data(dataset_paths) + data=DummyDataService.get_data(dataset_paths, encoding=self.encoding) ) elif ExcelDataService.is_valid_data(dataset_paths): """Get Excel file to dataset data service""" @@ -82,6 +85,7 @@ def get_data_service( library_metadata=self.library_metadata, dataset_path=dataset_paths[0], dataset_implementation=self.get_dataset_implementation(), + encoding=self.encoding, ) else: """Get local Directory data service""" @@ -93,6 +97,7 @@ def get_data_service( library_metadata=self.library_metadata, dataset_paths=dataset_paths, dataset_implementation=self.get_dataset_implementation(), + encoding=self.encoding, ) def get_dummy_data_service(self, data: List[DummyDataset]) -> DataServiceInterface: @@ -104,6 +109,7 @@ def get_dummy_data_service(self, data: List[DummyDataset]) -> DataServiceInterfa standard_substandard=self.standard_substandard, library_metadata=self.library_metadata, dataset_implementation=self.get_dataset_implementation(), + encoding=self.encoding, ) def get_dataset_implementation(self): diff --git a/cdisc_rules_engine/services/data_services/dummy_data_service.py b/cdisc_rules_engine/services/data_services/dummy_data_service.py index f22247f97..b5f3da417 100644 --- a/cdisc_rules_engine/services/data_services/dummy_data_service.py +++ b/cdisc_rules_engine/services/data_services/dummy_data_service.py @@ -42,7 +42,7 @@ def get_instance( ): return cls( cache_service=cache_service, - reader_factory=DataReaderFactory(), + reader_factory=DataReaderFactory(encoding=kwargs.get("encoding")), config=config, **kwargs, ) @@ -162,17 +162,17 @@ def get_datasets(self) -> Iterable[SDTMDatasetMetadata]: return self.data @staticmethod - def get_data(dataset_paths: Sequence[str]): - json = JSONReader().from_file(dataset_paths[0]) + def get_data(dataset_paths: Sequence[str], encoding: str = None): + json = JSONReader().from_file(dataset_paths[0], encoding=encoding) return [DummyDataset(data) for data in json.get("datasets", [])] @staticmethod - def is_valid_data(dataset_paths: Sequence[str]): + def is_valid_data(dataset_paths: Sequence[str], encoding: str = None): if ( dataset_paths and len(dataset_paths) == 1 and dataset_paths[0].lower().endswith(".json") ): - json = JSONReader().from_file(dataset_paths[0]) + json = JSONReader().from_file(dataset_paths[0], encoding=encoding) return "datasets" in json return False diff --git a/cdisc_rules_engine/services/data_services/excel_data_service.py b/cdisc_rules_engine/services/data_services/excel_data_service.py index 6627a2997..fc2e121e5 100644 --- a/cdisc_rules_engine/services/data_services/excel_data_service.py +++ b/cdisc_rules_engine/services/data_services/excel_data_service.py @@ -54,7 +54,8 @@ def get_instance( reader_factory=DataReaderFactory( dataset_implementation=kwargs.get( "dataset_implementation", PandasDataset - ) + ), + encoding=kwargs.get("encoding"), ), config=config, **kwargs, diff --git a/cdisc_rules_engine/services/data_services/local_data_service.py b/cdisc_rules_engine/services/data_services/local_data_service.py index ace053e2d..7cb738756 100644 --- a/cdisc_rules_engine/services/data_services/local_data_service.py +++ b/cdisc_rules_engine/services/data_services/local_data_service.py @@ -45,6 +45,7 @@ def __init__( cache_service, reader_factory, config, **kwargs ) self.dataset_paths: Iterable[str] = kwargs.get("dataset_paths", []) + self.encoding: str = kwargs.get("encoding") @classmethod def get_instance( @@ -59,7 +60,8 @@ def get_instance( reader_factory=DataReaderFactory( dataset_implementation=kwargs.get( "dataset_implementation", PandasDataset - ) + ), + encoding=kwargs.get("encoding"), ), config=config, **kwargs, @@ -195,7 +197,7 @@ def read_metadata( ) contents_metadata = _metadata_reader_map[file_extension]( - file_metadata["path"], file_name + file_metadata["path"], file_name, encoding=self.encoding ).read() return { "file_metadata": file_metadata, diff --git a/cdisc_rules_engine/services/data_services/usdm_data_service.py b/cdisc_rules_engine/services/data_services/usdm_data_service.py index 3d603a1ed..835234138 100644 --- a/cdisc_rules_engine/services/data_services/usdm_data_service.py +++ b/cdisc_rules_engine/services/data_services/usdm_data_service.py @@ -77,12 +77,13 @@ def __init__( cache_service, reader_factory, config, **kwargs ) self.dataset_path: str = kwargs.get("dataset_path", "") + self.encoding: str = kwargs.get("encoding") with open(os.path.join("resources", "schema", "USDM.yaml")) as entity_dict: self.entity_dict: dict = safe_load(entity_dict) self.json = self._reader_factory.get_service("USDM").from_file( - self.dataset_path + self.dataset_path, encoding=self.encoding ) # Build the id lookup dict once for fast reference resolution @@ -107,7 +108,8 @@ def get_instance( reader_factory=DataReaderFactory( dataset_implementation=kwargs.get( "dataset_implementation", PandasDataset - ) + ), + encoding=kwargs.get("encoding"), ), config=config, **kwargs, @@ -477,12 +479,12 @@ def __get_domain_from_dataset_name(self, dataset_name: str) -> str: return extract_file_name_from_path_string(dataset_name).split(".")[0] @staticmethod - def is_valid_data(dataset_paths: Sequence[str]): + def is_valid_data(dataset_paths: Sequence[str], encoding: str = None): if ( dataset_paths and len(dataset_paths) == 1 and dataset_paths[0].lower().endswith(".json") ): - json = JSONReader().from_file(dataset_paths[0]) + json = JSONReader().from_file(dataset_paths[0], encoding=encoding) return "study" in json and "datasetJSONVersion" not in json return False diff --git a/cdisc_rules_engine/services/datasetjson_metadata_reader.py b/cdisc_rules_engine/services/datasetjson_metadata_reader.py index f77856977..940921317 100644 --- a/cdisc_rules_engine/services/datasetjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetjson_metadata_reader.py @@ -14,11 +14,12 @@ class DatasetJSONMetadataReader: from .json file. """ - def __init__(self, file_path: str, file_name: str): + def __init__(self, file_path: str, file_name: str, encoding: str = None): self._metadata_container = {} self._file_path = file_path self._first_record = None self._dataset_name = file_name.split(".")[0].upper() + self.encoding = encoding def read(self) -> dict: """ @@ -29,7 +30,7 @@ def read(self) -> dict: os.path.join("resources", "schema", "dataset.schema.json") ) - datasetjson = JSONReader().from_file(self._file_path) + datasetjson = JSONReader().from_file(self._file_path, encoding=self.encoding) try: jsonschema.validate(datasetjson, schema) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index ded014fa5..da02354d2 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -15,11 +15,12 @@ class DatasetNDJSONMetadataReader: from .ndjson file. """ - def __init__(self, file_path: str, file_name: str): + def __init__(self, file_path: str, file_name: str, encoding: str = None): self._metadata_container = {} self._file_path = file_path self._first_record = None self._dataset_name = file_name.split(".")[0].upper() + self.encoding = encoding def read(self) -> dict: """ @@ -30,8 +31,24 @@ def read(self) -> dict: os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) - with open(self._file_path, "r") as file: - lines = file.readlines() + if self.encoding: + with open(self._file_path, "r", encoding=self.encoding) as file: + lines = file.readlines() + else: + encodings_to_try = ["utf-8", "utf-16", "utf-32"] + last_error = None + + for enc in encodings_to_try: + try: + with open(self._file_path, "r", encoding=enc) as file: + lines = file.readlines() + break + except (UnicodeDecodeError, UnicodeError) as e: + last_error = e + continue + else: + if last_error: + raise last_error metadatandjson = json.loads(lines[0]) diff --git a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py index 90d4c214c..c2c475469 100644 --- a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py @@ -14,7 +14,7 @@ class DatasetXPTMetadataReader: # TODO. Maybe in future it is worth having multiple constructors # like from_bytes, from_file etc. But now there is no immediate need for that. - def __init__(self, file_path: str, file_name: str): + def __init__(self, file_path: str, file_name: str, encoding: str = None): file_size = os.path.getsize(file_path) if file_size > config.get_dataset_size_threshold(): self._estimate_dataset_length = True @@ -26,16 +26,15 @@ def __init__(self, file_path: str, file_name: str): self._first_record = None self._dataset_name = file_name.split(".")[0].upper() self._file_path = file_path + self.encoding = encoding def read(self) -> dict: """ Extracts metadata from binary contents of .xpt file. """ try: - dataset, metadata = pyreadstat.read_xport( - self._file_path, row_limit=self.row_limit - ) - except pyreadstat.ReadstatError: + dataset, metadata = self._read_xport_with_encoding() + except (pyreadstat.ReadstatError, UnicodeDecodeError): return { "variable_labels": [], "variable_names": [], @@ -94,7 +93,7 @@ def _extract_first_record(self, df): return None def _calculate_dataset_length(self): - df, meta = pyreadstat.read_xport(self._file_path, metadataonly=True) + df, meta = self._read_xport_with_encoding_metadata_only() row_size = sum(meta.variable_storage_width.values()) total_size = os.path.getsize(self._file_path) start = self._read_header(self._file_path) @@ -199,3 +198,72 @@ def _extract_adam_info(self, variable_names): "selection_algorithm": ad.selection_algorithm, } return adam_info_dict + + def _try_read_xport_with_encoding( + self, encoding, row_limit=None, metadataonly=False + ): + try: + if metadataonly: + return pyreadstat.read_xport( + self._file_path, metadataonly=True, encoding=encoding + ) + return pyreadstat.read_xport( + self._file_path, row_limit=row_limit, encoding=encoding + ) + except TypeError: + if metadataonly: + return pyreadstat.read_xport(self._file_path, metadataonly=True) + return pyreadstat.read_xport(self._file_path, row_limit=row_limit) + + def _read_xport_with_encoding(self): + if self.encoding: + return self._try_read_xport_with_encoding( + self.encoding, row_limit=self.row_limit + ) + + encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + last_error = None + + for encoding in encodings_to_try: + try: + return self._try_read_xport_with_encoding( + encoding, row_limit=self.row_limit + ) + except UnicodeDecodeError as e: + last_error = e + logger.debug( + f"Failed to read {self._file_path} with encoding {encoding}, trying next" + ) + continue + except pyreadstat.ReadstatError as e: + last_error = e + raise + else: + if last_error: + raise last_error + raise UnicodeDecodeError( + "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" + ) + + def _read_xport_with_encoding_metadata_only(self): + if self.encoding: + return self._try_read_xport_with_encoding(self.encoding, metadataonly=True) + + encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + last_error = None + + for encoding in encodings_to_try: + try: + return self._try_read_xport_with_encoding(encoding, metadataonly=True) + except UnicodeDecodeError as e: + last_error = e + continue + except pyreadstat.ReadstatError as e: + last_error = e + raise + else: + if last_error: + raise last_error + raise UnicodeDecodeError( + "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" + ) diff --git a/core.py b/core.py index 52c44fc44..abc4c2fbc 100644 --- a/core.py +++ b/core.py @@ -346,6 +346,16 @@ def _validate_no_arguments(logger) -> None: "If true, limits reported issues per dataset per rule." ), ) +@click.option( + "--encoding", + default=None, + required=False, + help=( + "File encoding for reading datasets. " + "If not specified, automatically detects encoding (UTF-8 for JSON, UTF-8 with cp1252 fallback for XPT). " + "Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc." + ), +) @click.pass_context def validate( ctx, @@ -381,6 +391,7 @@ def validate( jsonata_custom_functions: tuple[()] | tuple[tuple[str, str], ...], max_report_rows: int, max_errors_per_rule: tuple[int, bool], + encoding: str, ): """ Validate data using CDISC Rules Engine @@ -472,6 +483,7 @@ def validate( jsonata_custom_functions, max_report_rows, max_errors_per_rule, + encoding, ) ) diff --git a/scripts/run_validation.py b/scripts/run_validation.py index 4c8f7a569..2df0d9735 100644 --- a/scripts/run_validation.py +++ b/scripts/run_validation.py @@ -144,6 +144,7 @@ def run_validation(args: Validation_args): standard_version=standard_version, standard_substandard=standard_substandard, library_metadata=library_metadata, + encoding=args.encoding, ).get_data_service(args.dataset_paths) # install dictionaries if needed dictionary_versions = fill_cache_with_dictionaries(shared_cache, args, data_service) From 9bbbe487e1758e4a99ef4b9dcde7babe31f67193 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 26 Nov 2025 08:13:06 -0500 Subject: [PATCH 02/13] add dataset_implementation to DatasetJSONReader and encoding parameter to Validation_args test instantiations --- .../services/data_readers/dataset_json_reader.py | 3 ++- tests/unit/test_dataset_json_reader.py | 4 +++- tests/unit/test_dataset_ndjson_reader.py | 4 +++- .../unit/test_services/test_data_service/test_data_service.py | 2 ++ tests/unit/test_xpt_reader.py | 4 +++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index d5576963d..a5614405b 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -15,7 +15,8 @@ class DatasetJSONReader(DataReaderInterface): - def __init__(self, encoding: str = None): + def __init__(self, dataset_implementation=PandasDataset, encoding: str = None): + self.dataset_implementation = dataset_implementation self.encoding = encoding def get_schema(self) -> dict: diff --git a/tests/unit/test_dataset_json_reader.py b/tests/unit/test_dataset_json_reader.py index 649cbb5cb..33a3dcd69 100644 --- a/tests/unit/test_dataset_json_reader.py +++ b/tests/unit/test_dataset_json_reader.py @@ -10,7 +10,9 @@ def test_from_file(): f"{os.path.dirname(__file__)}/../resources/test_dataset.json" ) - reader = DatasetJSONReader() + from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset + + reader = DatasetJSONReader(PandasDataset) dataframe = reader.from_file(test_dataset_path) for value in dataframe["EXDOSE"]: """ diff --git a/tests/unit/test_dataset_ndjson_reader.py b/tests/unit/test_dataset_ndjson_reader.py index 9d987f1f6..bb664fb43 100644 --- a/tests/unit/test_dataset_ndjson_reader.py +++ b/tests/unit/test_dataset_ndjson_reader.py @@ -10,7 +10,9 @@ def test_from_file(): f"{os.path.dirname(__file__)}/../resources/test_dataset.ndjson" ) - reader = DatasetNDJSONReader() + from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset + + reader = DatasetNDJSONReader(PandasDataset) dataframe = reader.from_file(test_dataset_path) for value in dataframe["EXDOSE"]: """ diff --git a/tests/unit/test_services/test_data_service/test_data_service.py b/tests/unit/test_services/test_data_service/test_data_service.py index 00b4cb208..837eab632 100644 --- a/tests/unit/test_services/test_data_service/test_data_service.py +++ b/tests/unit/test_services/test_data_service/test_data_service.py @@ -206,6 +206,7 @@ def test_get_dataset_class(dataset_metadata, data, expected_class): None, None, None, + None, ) ) data_service = LocalDataService( @@ -287,6 +288,7 @@ def test_get_dataset_class_associated_domains(): None, None, None, + None, ) ) data_service = LocalDataService( diff --git a/tests/unit/test_xpt_reader.py b/tests/unit/test_xpt_reader.py index 6a3af07ea..fe0f464f7 100644 --- a/tests/unit/test_xpt_reader.py +++ b/tests/unit/test_xpt_reader.py @@ -10,7 +10,9 @@ def test_read(): with open(test_dataset_path, "rb") as f: data = f.read() - reader = XPTReader() + from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset + + reader = XPTReader(PandasDataset) dataframe = reader.read(data) for value in dataframe["EXDOSE"]: """ From 2d4f6ac9cdb5ab236e37ed2077a5f12b881030b6 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Tue, 2 Dec 2025 17:52:00 -0500 Subject: [PATCH 03/13] move imports to top and add encoding parameter to test_validate --- core.py | 2 ++ tests/unit/test_dataset_json_reader.py | 3 +-- tests/unit/test_dataset_ndjson_reader.py | 3 +-- tests/unit/test_xpt_reader.py | 3 +-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/core.py b/core.py index abc4c2fbc..e061447ec 100644 --- a/core.py +++ b/core.py @@ -856,6 +856,7 @@ def test_validate(): jsonata_custom_functions, max_report_rows, max_report_errors, + None, ) ) print("JSON validation completed successfully!") @@ -886,6 +887,7 @@ def test_validate(): jsonata_custom_functions, max_report_rows, max_report_errors, + None, ) ) print("XPT validation completed successfully!") diff --git a/tests/unit/test_dataset_json_reader.py b/tests/unit/test_dataset_json_reader.py index 33a3dcd69..63c6281f8 100644 --- a/tests/unit/test_dataset_json_reader.py +++ b/tests/unit/test_dataset_json_reader.py @@ -1,5 +1,6 @@ import os +from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.dataset_json_reader import ( DatasetJSONReader, ) @@ -10,8 +11,6 @@ def test_from_file(): f"{os.path.dirname(__file__)}/../resources/test_dataset.json" ) - from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset - reader = DatasetJSONReader(PandasDataset) dataframe = reader.from_file(test_dataset_path) for value in dataframe["EXDOSE"]: diff --git a/tests/unit/test_dataset_ndjson_reader.py b/tests/unit/test_dataset_ndjson_reader.py index bb664fb43..b0fa00c75 100644 --- a/tests/unit/test_dataset_ndjson_reader.py +++ b/tests/unit/test_dataset_ndjson_reader.py @@ -1,5 +1,6 @@ import os +from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.dataset_ndjson_reader import ( DatasetNDJSONReader, ) @@ -10,8 +11,6 @@ def test_from_file(): f"{os.path.dirname(__file__)}/../resources/test_dataset.ndjson" ) - from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset - reader = DatasetNDJSONReader(PandasDataset) dataframe = reader.from_file(test_dataset_path) for value in dataframe["EXDOSE"]: diff --git a/tests/unit/test_xpt_reader.py b/tests/unit/test_xpt_reader.py index fe0f464f7..c16c802a4 100644 --- a/tests/unit/test_xpt_reader.py +++ b/tests/unit/test_xpt_reader.py @@ -1,5 +1,6 @@ import os +from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.xpt_reader import XPTReader @@ -10,8 +11,6 @@ def test_read(): with open(test_dataset_path, "rb") as f: data = f.read() - from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset - reader = XPTReader(PandasDataset) dataframe = reader.read(data) for value in dataframe["EXDOSE"]: From ee0d5accf09885033f859b738233d1decd6f067e Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 3 Dec 2025 23:02:23 -0500 Subject: [PATCH 04/13] Add short form flag (-e) for encoding option with validation and update README documentation --- README.md | 19 +++++++++++++++++++ core.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/README.md b/README.md index 68d4a49f4..adce77f57 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Run `python core.py validate --help` to see the list of validation options. "[████████████████████████████--------] 78%"is printed. -jcf, --jsonata-custom-functions Pair containing a variable name and a Path to directory containing a set of custom JSONata functions. Can be specified multiple times + -e, --encoding TEXT File encoding for reading datasets. If not specified, automatically detects encoding (UTF-8 for JSON, UTF-8 with cp1252 fallback for XPT). Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc. --help Show this message and exit. ``` @@ -181,6 +182,24 @@ CORE supports the following dataset file formats for validation: - Define-XML files should be provided via the `--define-xml-path` (or `-dxp`) option, not through the dataset directory (`-d` or `-dp`). - If you point to a folder containing unsupported file formats, CORE will display an error message indicating which formats are supported. +#### File Encoding + +CORE automatically detects file encoding when reading datasets. For JSON/NDJSON files, it tries UTF-8, UTF-16, and UTF-32. For XPT files, it tries UTF-8, UTF-16, UTF-32, cp1252, and latin-1. + +You can manually specify the encoding using the `-e` or `--encoding` flag: + +```bash +python core.py validate -s sdtmig -v 3-4 -dp path/to/dataset.xpt -e cp1252 +``` + +The encoding name must be a valid Python codec name. Common encodings include: + +- `utf-8`, `utf-16`, `utf-32` - Unicode encodings +- `cp1252` - Windows-1252 (commonly used for files exported from Excel) +- `latin-1` - ISO-8859-1 + +If an invalid encoding is specified, CORE will display an error message with the supported encoding names. + #### Validate single rule `python core.py validate -s sdtmig -v 3-4 -dp -lr --meddra ./meddra/ --whodrug ./whodrug/` diff --git a/core.py b/core.py index e061447ec..c24f6cb18 100644 --- a/core.py +++ b/core.py @@ -1,4 +1,5 @@ import asyncio +import codecs import json import logging import os @@ -40,6 +41,19 @@ ) +def validate_encoding(ctx, param, value): + if value is None: + return value + try: + codecs.lookup(value) + return value + except LookupError: + raise click.BadParameter( + f"Invalid encoding '{value}'. Please provide a valid encoding name " + f"(e.g., utf-8, utf-16, utf-32, cp1252, latin-1)." + ) + + def valid_data_file(data_path: list) -> tuple[list, set]: allowed_formats = [ DataFormatTypes.XPT.value, @@ -347,9 +361,11 @@ def _validate_no_arguments(logger) -> None: ), ) @click.option( + "-e", "--encoding", default=None, required=False, + callback=validate_encoding, help=( "File encoding for reading datasets. " "If not specified, automatically detects encoding (UTF-8 for JSON, UTF-8 with cp1252 fallback for XPT). " From 0d1c9c67116793edd2e17cd187d3c9f4d3221855 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 3 Dec 2025 23:15:24 -0500 Subject: [PATCH 05/13] Fix encoding error handling fallback and add missing dataset_implementation parameter in DummyDataService --- .../services/data_services/dummy_data_service.py | 7 ++++++- .../services/datasetndjson_metadata_reader.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cdisc_rules_engine/services/data_services/dummy_data_service.py b/cdisc_rules_engine/services/data_services/dummy_data_service.py index 76e120f85..67b6acf4b 100644 --- a/cdisc_rules_engine/services/data_services/dummy_data_service.py +++ b/cdisc_rules_engine/services/data_services/dummy_data_service.py @@ -42,7 +42,12 @@ def get_instance( ): return cls( cache_service=cache_service, - reader_factory=DataReaderFactory(encoding=kwargs.get("encoding")), + reader_factory=DataReaderFactory( + dataset_implementation=kwargs.get( + "dataset_implementation", PandasDataset + ), + encoding=kwargs.get("encoding"), + ), config=config, **kwargs, ) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index da02354d2..07907687e 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -49,6 +49,9 @@ def read(self) -> dict: else: if last_error: raise last_error + raise ValueError( + f"Could not decode NDJSON file {self._file_path} with UTF-8, UTF-16, or UTF-32 encoding" + ) metadatandjson = json.loads(lines[0]) From 277aca7aa12e6fe43b164d93a9292f6aa0b6180a Mon Sep 17 00:00:00 2001 From: Rakesh Date: Thu, 4 Dec 2025 00:59:48 -0500 Subject: [PATCH 06/13] Fix XPT encoding detection order and add graceful error handling for dataset metadata reading failures --- .../services/data_readers/xpt_reader.py | 6 ++-- .../data_services/local_data_service.py | 34 ++++++++++++++++--- .../services/datasetxpt_metadata_reader.py | 14 +++++--- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/cdisc_rules_engine/services/data_readers/xpt_reader.py b/cdisc_rules_engine/services/data_readers/xpt_reader.py index 4f3ee621c..c31d7f877 100644 --- a/cdisc_rules_engine/services/data_readers/xpt_reader.py +++ b/cdisc_rules_engine/services/data_readers/xpt_reader.py @@ -18,7 +18,7 @@ def read(self, data): if self.encoding: df = pd.read_sas(BytesIO(data), format="xport", encoding=self.encoding) else: - encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] last_error = None for encoding in encodings_to_try: @@ -42,7 +42,7 @@ def _read_pandas(self, file_path): if self.encoding: data = pd.read_sas(file_path, format="xport", encoding=self.encoding) else: - encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] last_error = None for encoding in encodings_to_try: @@ -65,7 +65,7 @@ def _read_xpt_with_encoding(self, file_path: str, chunksize: int = None): if self.encoding: return pd.read_sas(file_path, chunksize=chunksize, encoding=self.encoding) - encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] last_error = None for encoding in encodings_to_try: diff --git a/cdisc_rules_engine/services/data_services/local_data_service.py b/cdisc_rules_engine/services/data_services/local_data_service.py index 7cb738756..f0768216c 100644 --- a/cdisc_rules_engine/services/data_services/local_data_service.py +++ b/cdisc_rules_engine/services/data_services/local_data_service.py @@ -28,6 +28,7 @@ from cdisc_rules_engine.enums.dataformat_types import DataFormatTypes from cdisc_rules_engine.models.dataset.dataset_interface import DatasetInterface from cdisc_rules_engine.models.dataset import PandasDataset +from cdisc_rules_engine.services import logger import re @@ -228,10 +229,35 @@ def to_parquet(self, file_path: str) -> str: return reader.to_parquet(file_path) def get_datasets(self) -> List[dict]: - datasets = [ - self.get_raw_dataset_metadata(dataset_name=dataset_path) - for dataset_path in self.dataset_paths - ] + datasets = [] + for dataset_path in self.dataset_paths: + try: + dataset_metadata = self.get_raw_dataset_metadata( + dataset_name=dataset_path + ) + datasets.append(dataset_metadata) + except Exception as e: + logger.error( + f"Failed to read metadata for dataset {dataset_path}. " + f"Error: {type(e).__name__}: {e}. Skipping this dataset." + ) + file_name = extract_file_name_from_path_string(dataset_path) + datasets.append( + SDTMDatasetMetadata( + name=( + file_name.split(".")[0].upper() + if "." in file_name + else file_name.upper() + ), + first_record={}, + label="", + modification_date="", + filename=file_name, + full_path=dataset_path, + file_size=0, + record_count=0, + ) + ) return datasets @staticmethod diff --git a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py index c2c475469..a69047689 100644 --- a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py @@ -221,7 +221,7 @@ def _read_xport_with_encoding(self): self.encoding, row_limit=self.row_limit ) - encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] last_error = None for encoding in encodings_to_try: @@ -237,7 +237,10 @@ def _read_xport_with_encoding(self): continue except pyreadstat.ReadstatError as e: last_error = e - raise + logger.debug( + f"Failed to read {self._file_path} with encoding {encoding}, trying next" + ) + continue else: if last_error: raise last_error @@ -249,7 +252,7 @@ def _read_xport_with_encoding_metadata_only(self): if self.encoding: return self._try_read_xport_with_encoding(self.encoding, metadataonly=True) - encodings_to_try = ["utf-8", "utf-16", "utf-32", "cp1252", "latin-1"] + encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] last_error = None for encoding in encodings_to_try: @@ -260,7 +263,10 @@ def _read_xport_with_encoding_metadata_only(self): continue except pyreadstat.ReadstatError as e: last_error = e - raise + logger.debug( + f"Failed to read {self._file_path} with encoding {encoding}, trying next" + ) + continue else: if last_error: raise last_error From 8ff517c617f7ba03e7a84307fd4257991457882e Mon Sep 17 00:00:00 2001 From: Rakesh Date: Sun, 7 Dec 2025 21:27:31 -0500 Subject: [PATCH 07/13] Default to UTF-8 encoding with explicit -e flag support, remove automatic detection --- README.md | 8 +- cdisc_rules_engine/rules_engine.py | 1 + .../data_readers/data_reader_factory.py | 9 +-- .../data_readers/dataset_ndjson_reader.py | 27 +++---- .../services/data_readers/json_reader.py | 33 +++----- .../services/data_readers/xpt_reader.py | 65 ++-------------- .../data_services/local_data_service.py | 7 +- .../services/datasetndjson_metadata_reader.py | 31 +++----- .../services/datasetxpt_metadata_reader.py | 76 +++---------------- core.py | 4 +- scripts/run_validation.py | 1 + 11 files changed, 63 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index a1a2977fa..1ef84f39d 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Run `python core.py validate --help` to see the list of validation options. "[████████████████████████████--------] 78%"is printed. -jcf, --jsonata-custom-functions Pair containing a variable name and a Path to directory containing a set of custom JSONata functions. Can be specified multiple times - -e, --encoding TEXT File encoding for reading datasets. If not specified, automatically detects encoding (UTF-8 for JSON, UTF-8 with cp1252 fallback for XPT). Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc. + -e, --encoding TEXT File encoding for reading datasets. If not specified, defaults to UTF-8. Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc. --help Show this message and exit. ``` @@ -184,9 +184,7 @@ CORE supports the following dataset file formats for validation: #### File Encoding -CORE automatically detects file encoding when reading datasets. For JSON/NDJSON files, it tries UTF-8, UTF-16, and UTF-32. For XPT files, it tries UTF-8, UTF-16, UTF-32, cp1252, and latin-1. - -You can manually specify the encoding using the `-e` or `--encoding` flag: +CORE defaults to UTF-8 encoding when reading datasets. If your files use a different encoding, you must specify it using the `-e` or `--encoding` flag: ```bash python core.py validate -s sdtmig -v 3-4 -dp path/to/dataset.xpt -e cp1252 @@ -195,7 +193,7 @@ python core.py validate -s sdtmig -v 3-4 -dp path/to/dataset.xpt -e cp1252 The encoding name must be a valid Python codec name. Common encodings include: - `utf-8`, `utf-16`, `utf-32` - Unicode encodings -- `cp1252` - Windows-1252 (commonly used for files exported from Excel) +- `cp1252` - Windows-1252 (commonly used for files exported from Excel or SAS) - `latin-1` - ISO-8859-1 If an invalid encoding is specified, CORE will display an error message with the supported encoding names. diff --git a/cdisc_rules_engine/rules_engine.py b/cdisc_rules_engine/rules_engine.py index 90253697b..175de823a 100644 --- a/cdisc_rules_engine/rules_engine.py +++ b/cdisc_rules_engine/rules_engine.py @@ -82,6 +82,7 @@ def __init__( standard_substandard=self.standard_substandard, library_metadata=self.library_metadata, max_dataset_size=self.max_dataset_size, + encoding=kwargs.get("encoding"), ) self.dataset_implementation = data_service_factory.get_dataset_implementation() kwargs["dataset_implementation"] = self.dataset_implementation diff --git a/cdisc_rules_engine/services/data_readers/data_reader_factory.py b/cdisc_rules_engine/services/data_readers/data_reader_factory.py index 553dc4f99..6242e500d 100644 --- a/cdisc_rules_engine/services/data_readers/data_reader_factory.py +++ b/cdisc_rules_engine/services/data_readers/data_reader_factory.py @@ -54,15 +54,14 @@ def get_service(self, name: str = None, **kwargs) -> DataReaderInterface: service_name = name or self._default_service_name if service_name in self._reader_map: reader_class = self._reader_map[service_name] - if service_name in [ + if service_name == DataFormatTypes.USDM.value: + return reader_class() + elif service_name in [ DataFormatTypes.JSON.value, DataFormatTypes.NDJSON.value, + DataFormatTypes.XPT.value, ]: return reader_class(self.dataset_implementation, encoding=self.encoding) - elif service_name == DataFormatTypes.USDM.value: - return reader_class() - elif service_name == DataFormatTypes.XPT.value: - return reader_class(self.dataset_implementation, encoding=self.encoding) else: return reader_class(self.dataset_implementation) raise ValueError( diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index e38897acf..adb20039e 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -20,6 +20,10 @@ def __init__(self, dataset_implementation, encoding: str = None): self.dataset_implementation = dataset_implementation self.encoding = encoding + @property + def _encoding(self): + return self.encoding or "utf-8" + def get_schema(self) -> dict: schema = JSONReader().from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") @@ -27,27 +31,14 @@ def get_schema(self) -> dict: return schema def read_json_file(self, file_path: str) -> dict: - if self.encoding: - with open(file_path, "r", encoding=self.encoding) as file: + try: + with open(file_path, "r", encoding=self._encoding) as file: lines = file.readlines() return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] - - encodings_to_try = ["utf-8", "utf-16", "utf-32"] - last_error = None - - for enc in encodings_to_try: - try: - with open(file_path, "r", encoding=enc) as file: - lines = file.readlines() - return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] - except (UnicodeDecodeError, UnicodeError) as e: - last_error = e - continue - else: - if last_error: - raise last_error + except (UnicodeDecodeError, UnicodeError) as e: raise ValueError( - f"Could not decode NDJSON file {file_path} with UTF-8, UTF-16, or UTF-32 encoding" + f"Could not decode NDJSON file {file_path} with {self._encoding} encoding: {e}. " + f"Please specify the correct encoding using the -e flag." ) def _raw_dataset_from_file(self, file_path) -> pd.DataFrame: diff --git a/cdisc_rules_engine/services/data_readers/json_reader.py b/cdisc_rules_engine/services/data_readers/json_reader.py index 5bb373e2a..874aa5fdd 100644 --- a/cdisc_rules_engine/services/data_readers/json_reader.py +++ b/cdisc_rules_engine/services/data_readers/json_reader.py @@ -8,29 +8,16 @@ class JSONReader(DataReaderInterface): def from_file(self, file_path, encoding: str = None): try: - if encoding: - with open(file_path, "r", encoding=encoding) as fp: - json = load(fp) - return json - - encodings_to_try = ["utf-8", "utf-16", "utf-32"] - last_error = None - - for enc in encodings_to_try: - try: - with open(file_path, "r", encoding=enc) as fp: - json = load(fp) - return json - except (UnicodeDecodeError, UnicodeError) as e: - last_error = e - continue - else: - if last_error: - raise last_error - raise InvalidJSONFormat( - f"\n Error reading JSON from: {file_path}" - f"\n Could not decode file with UTF-8, UTF-16, or UTF-32 encoding" - ) + encoding = encoding or "utf-8" + with open(file_path, "r", encoding=encoding) as fp: + json_data = load(fp) + return json_data + except (UnicodeDecodeError, UnicodeError) as e: + raise InvalidJSONFormat( + f"\n Error reading JSON from: {file_path}" + f"\n Failed to decode with {encoding} encoding: {e}" + f"\n Please specify the correct encoding using the -e flag." + ) except Exception as e: raise InvalidJSONFormat( f"\n Error reading JSON from: {file_path}" diff --git a/cdisc_rules_engine/services/data_readers/xpt_reader.py b/cdisc_rules_engine/services/data_readers/xpt_reader.py index c31d7f877..0e22f03d0 100644 --- a/cdisc_rules_engine/services/data_readers/xpt_reader.py +++ b/cdisc_rules_engine/services/data_readers/xpt_reader.py @@ -14,72 +14,21 @@ def __init__(self, dataset_implementation, encoding: str = None): self.dataset_implementation = dataset_implementation self.encoding = encoding - def read(self, data): - if self.encoding: - df = pd.read_sas(BytesIO(data), format="xport", encoding=self.encoding) - else: - encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] - last_error = None - - for encoding in encodings_to_try: - try: - df = pd.read_sas(BytesIO(data), format="xport", encoding=encoding) - break - except UnicodeDecodeError as e: - last_error = e - continue - else: - if last_error: - raise last_error - raise UnicodeDecodeError( - "utf-8", b"", 0, 1, "Could not decode XPT data with any encoding" - ) + @property + def _encoding(self): + return self.encoding or "utf-8" + def read(self, data): + df = pd.read_sas(BytesIO(data), format="xport", encoding=self._encoding) df = self._format_floats(df) return df def _read_pandas(self, file_path): - if self.encoding: - data = pd.read_sas(file_path, format="xport", encoding=self.encoding) - else: - encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] - last_error = None - - for encoding in encodings_to_try: - try: - data = pd.read_sas(file_path, format="xport", encoding=encoding) - break - except UnicodeDecodeError as e: - last_error = e - continue - else: - if last_error: - raise last_error - raise UnicodeDecodeError( - "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" - ) - + data = pd.read_sas(file_path, format="xport", encoding=self._encoding) return PandasDataset(self._format_floats(data)) def _read_xpt_with_encoding(self, file_path: str, chunksize: int = None): - if self.encoding: - return pd.read_sas(file_path, chunksize=chunksize, encoding=self.encoding) - - encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] - last_error = None - - for encoding in encodings_to_try: - try: - return pd.read_sas(file_path, chunksize=chunksize, encoding=encoding) - except UnicodeDecodeError as e: - last_error = e - continue - else: - if last_error: - raise last_error - raise UnicodeDecodeError( - "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" - ) + return pd.read_sas(file_path, chunksize=chunksize, encoding=self._encoding) def to_parquet(self, file_path: str) -> str: temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") diff --git a/cdisc_rules_engine/services/data_services/local_data_service.py b/cdisc_rules_engine/services/data_services/local_data_service.py index f0768216c..b287c3b53 100644 --- a/cdisc_rules_engine/services/data_services/local_data_service.py +++ b/cdisc_rules_engine/services/data_services/local_data_service.py @@ -55,14 +55,17 @@ def get_instance( config: ConfigInterface = None, **kwargs, ): - if cls._instance is None: + encoding = kwargs.get("encoding") + if cls._instance is None or ( + encoding is not None and cls._instance.encoding != encoding + ): service = cls( cache_service=cache_service, reader_factory=DataReaderFactory( dataset_implementation=kwargs.get( "dataset_implementation", PandasDataset ), - encoding=kwargs.get("encoding"), + encoding=encoding, ), config=config, **kwargs, diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index 07907687e..bdef3450e 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -22,6 +22,10 @@ def __init__(self, file_path: str, file_name: str, encoding: str = None): self._dataset_name = file_name.split(".")[0].upper() self.encoding = encoding + @property + def _encoding(self): + return self.encoding or "utf-8" + def read(self) -> dict: """ Extracts metadata from .ndjson file. @@ -31,27 +35,14 @@ def read(self) -> dict: os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) - if self.encoding: - with open(self._file_path, "r", encoding=self.encoding) as file: + try: + with open(self._file_path, "r", encoding=self._encoding) as file: lines = file.readlines() - else: - encodings_to_try = ["utf-8", "utf-16", "utf-32"] - last_error = None - - for enc in encodings_to_try: - try: - with open(self._file_path, "r", encoding=enc) as file: - lines = file.readlines() - break - except (UnicodeDecodeError, UnicodeError) as e: - last_error = e - continue - else: - if last_error: - raise last_error - raise ValueError( - f"Could not decode NDJSON file {self._file_path} with UTF-8, UTF-16, or UTF-32 encoding" - ) + except (UnicodeDecodeError, UnicodeError) as e: + raise ValueError( + f"Could not decode NDJSON file {self._file_path} with {self._encoding} encoding: {e}. " + f"Please specify the correct encoding using the -e flag." + ) metadatandjson = json.loads(lines[0]) diff --git a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py index a69047689..0857f0f5b 100644 --- a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py @@ -199,77 +199,21 @@ def _extract_adam_info(self, variable_names): } return adam_info_dict - def _try_read_xport_with_encoding( - self, encoding, row_limit=None, metadataonly=False - ): + @property + def _encoding(self): + return self.encoding or "utf-8" + + def _read_xport(self, **kwargs): + """Read XPT file with encoding, fallback to no encoding if TypeError.""" try: - if metadataonly: - return pyreadstat.read_xport( - self._file_path, metadataonly=True, encoding=encoding - ) return pyreadstat.read_xport( - self._file_path, row_limit=row_limit, encoding=encoding + self._file_path, encoding=self._encoding, **kwargs ) except TypeError: - if metadataonly: - return pyreadstat.read_xport(self._file_path, metadataonly=True) - return pyreadstat.read_xport(self._file_path, row_limit=row_limit) + return pyreadstat.read_xport(self._file_path, **kwargs) def _read_xport_with_encoding(self): - if self.encoding: - return self._try_read_xport_with_encoding( - self.encoding, row_limit=self.row_limit - ) - - encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] - last_error = None - - for encoding in encodings_to_try: - try: - return self._try_read_xport_with_encoding( - encoding, row_limit=self.row_limit - ) - except UnicodeDecodeError as e: - last_error = e - logger.debug( - f"Failed to read {self._file_path} with encoding {encoding}, trying next" - ) - continue - except pyreadstat.ReadstatError as e: - last_error = e - logger.debug( - f"Failed to read {self._file_path} with encoding {encoding}, trying next" - ) - continue - else: - if last_error: - raise last_error - raise UnicodeDecodeError( - "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" - ) + return self._read_xport(row_limit=self.row_limit) def _read_xport_with_encoding_metadata_only(self): - if self.encoding: - return self._try_read_xport_with_encoding(self.encoding, metadataonly=True) - - encodings_to_try = ["utf-8", "cp1252", "latin-1", "utf-16", "utf-32"] - last_error = None - - for encoding in encodings_to_try: - try: - return self._try_read_xport_with_encoding(encoding, metadataonly=True) - except UnicodeDecodeError as e: - last_error = e - continue - except pyreadstat.ReadstatError as e: - last_error = e - logger.debug( - f"Failed to read {self._file_path} with encoding {encoding}, trying next" - ) - continue - else: - if last_error: - raise last_error - raise UnicodeDecodeError( - "utf-8", b"", 0, 1, "Could not decode XPT file with any encoding" - ) + return self._read_xport(metadataonly=True) diff --git a/core.py b/core.py index c24f6cb18..a8b9fb7b8 100644 --- a/core.py +++ b/core.py @@ -43,7 +43,7 @@ def validate_encoding(ctx, param, value): if value is None: - return value + return None try: codecs.lookup(value) return value @@ -368,7 +368,7 @@ def _validate_no_arguments(logger) -> None: callback=validate_encoding, help=( "File encoding for reading datasets. " - "If not specified, automatically detects encoding (UTF-8 for JSON, UTF-8 with cp1252 fallback for XPT). " + "If not specified, defaults to UTF-8. " "Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc." ), ) diff --git a/scripts/run_validation.py b/scripts/run_validation.py index 2df0d9735..6232da9dd 100644 --- a/scripts/run_validation.py +++ b/scripts/run_validation.py @@ -94,6 +94,7 @@ def validate_single_rule( jsonata_custom_functions=args.jsonata_custom_functions, max_errors_per_rule=max_errors_per_rule, errors_per_dataset_flag=per_dataset_flag, + encoding=args.encoding, ) results = engine.validate_single_rule(rule, datasets) results = list(itertools.chain(*results.values())) From 68e25fc1b50ec5b587463d48d48da22e005de835 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Fri, 16 Jan 2026 13:06:56 -0500 Subject: [PATCH 08/13] Refactor encoding handling: centralize utf-8 default in DataReaderInterface and remove redundant encoding logic from reader subclasses --- README.md | 4 +- .../interfaces/data_reader_interface.py | 4 +- .../data_readers/data_reader_factory.py | 12 +-- .../data_readers/dataset_json_reader.py | 7 +- .../data_readers/dataset_ndjson_reader.py | 13 +-- .../services/data_readers/json_reader.py | 7 +- .../services/data_readers/xpt_reader.py | 16 +--- .../data_services/dummy_data_service.py | 4 +- .../data_services/usdm_data_service.py | 2 +- .../services/datasetjson_metadata_reader.py | 4 +- .../services/datasetndjson_metadata_reader.py | 2 +- core.py | 6 +- tests/unit/test_dataset_json_reader.py | 79 +++++++++++++++++++ 13 files changed, 106 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b147af75e..4f7c03560 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Run `python core.py validate --help` to see the list of validation options. "[████████████████████████████--------] 78%"is printed. -jcf, --jsonata-custom-functions Pair containing a variable name and a Path to directory containing a set of custom JSONata functions. Can be specified multiple times - -e, --encoding TEXT File encoding for reading datasets. If not specified, defaults to UTF-8. Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc. + -e, --encoding TEXT File encoding for reading datasets. If not specified, defaults to utf-8. Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc. --help Show this message and exit. ``` @@ -187,7 +187,7 @@ CORE supports the following dataset file formats for validation: #### File Encoding -CORE defaults to UTF-8 encoding when reading datasets. If your files use a different encoding, you must specify it using the `-e` or `--encoding` flag: +CORE defaults to utf-8 encoding when reading datasets. If your files use a different encoding, you must specify it using the `-e` or `--encoding` flag: ```bash python core.py validate -s sdtmig -v 3-4 -dp path/to/dataset.xpt -e cp1252 diff --git a/cdisc_rules_engine/interfaces/data_reader_interface.py b/cdisc_rules_engine/interfaces/data_reader_interface.py index bc2df4d9a..92f58e710 100644 --- a/cdisc_rules_engine/interfaces/data_reader_interface.py +++ b/cdisc_rules_engine/interfaces/data_reader_interface.py @@ -6,11 +6,13 @@ class DataReaderInterface: Interface for reading binary data from different file typs into pandas dataframes """ - def __init__(self, dataset_implementation=PandasDataset): + def __init__(self, dataset_implementation=PandasDataset, encoding: str = "utf-8"): """ :param dataset_implementation DatasetInterface: The dataset type to return. + :param encoding str: The encoding to use when reading files. Defaults to utf-8. """ self.dataset_implementation = dataset_implementation + self.encoding = encoding def read(self, data): """ diff --git a/cdisc_rules_engine/services/data_readers/data_reader_factory.py b/cdisc_rules_engine/services/data_readers/data_reader_factory.py index 6242e500d..35bc058a2 100644 --- a/cdisc_rules_engine/services/data_readers/data_reader_factory.py +++ b/cdisc_rules_engine/services/data_readers/data_reader_factory.py @@ -54,16 +54,8 @@ def get_service(self, name: str = None, **kwargs) -> DataReaderInterface: service_name = name or self._default_service_name if service_name in self._reader_map: reader_class = self._reader_map[service_name] - if service_name == DataFormatTypes.USDM.value: - return reader_class() - elif service_name in [ - DataFormatTypes.JSON.value, - DataFormatTypes.NDJSON.value, - DataFormatTypes.XPT.value, - ]: - return reader_class(self.dataset_implementation, encoding=self.encoding) - else: - return reader_class(self.dataset_implementation) + encoding = self.encoding or "utf-8" + return reader_class(self.dataset_implementation, encoding=encoding) raise ValueError( f"Service name must be in {list(self._reader_map.keys())}, " f"given service name is {service_name}" diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index a5614405b..63a5b5d24 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -15,18 +15,15 @@ class DatasetJSONReader(DataReaderInterface): - def __init__(self, dataset_implementation=PandasDataset, encoding: str = None): - self.dataset_implementation = dataset_implementation - self.encoding = encoding def get_schema(self) -> dict: - schema = JSONReader().from_file( + schema = JSONReader(encoding=self.encoding).from_file( os.path.join("resources", "schema", "dataset.schema.json") ) return schema def read_json_file(self, file_path: str) -> dict: - return JSONReader().from_file(file_path, encoding=self.encoding) + return JSONReader(encoding=self.encoding).from_file(file_path) def _raw_dataset_from_file(self, file_path) -> pd.DataFrame: # Load Dataset-JSON Schema diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index adb20039e..bb4bdedfc 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -16,28 +16,21 @@ class DatasetNDJSONReader(DataReaderInterface): - def __init__(self, dataset_implementation, encoding: str = None): - self.dataset_implementation = dataset_implementation - self.encoding = encoding - - @property - def _encoding(self): - return self.encoding or "utf-8" def get_schema(self) -> dict: - schema = JSONReader().from_file( + schema = JSONReader(encoding=self.encoding).from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) return schema def read_json_file(self, file_path: str) -> dict: try: - with open(file_path, "r", encoding=self._encoding) as file: + with open(file_path, "r", encoding=self.encoding) as file: lines = file.readlines() return json.loads(lines[0]), [json.loads(line) for line in lines[1:]] except (UnicodeDecodeError, UnicodeError) as e: raise ValueError( - f"Could not decode NDJSON file {file_path} with {self._encoding} encoding: {e}. " + f"Could not decode NDJSON file {file_path} with {self.encoding} encoding: {e}. " f"Please specify the correct encoding using the -e flag." ) diff --git a/cdisc_rules_engine/services/data_readers/json_reader.py b/cdisc_rules_engine/services/data_readers/json_reader.py index 874aa5fdd..fb80530f7 100644 --- a/cdisc_rules_engine/services/data_readers/json_reader.py +++ b/cdisc_rules_engine/services/data_readers/json_reader.py @@ -6,16 +6,15 @@ class JSONReader(DataReaderInterface): - def from_file(self, file_path, encoding: str = None): + def from_file(self, file_path): try: - encoding = encoding or "utf-8" - with open(file_path, "r", encoding=encoding) as fp: + with open(file_path, "r", encoding=self.encoding) as fp: json_data = load(fp) return json_data except (UnicodeDecodeError, UnicodeError) as e: raise InvalidJSONFormat( f"\n Error reading JSON from: {file_path}" - f"\n Failed to decode with {encoding} encoding: {e}" + f"\n Failed to decode with {self.encoding} encoding: {e}" f"\n Please specify the correct encoding using the -e flag." ) except Exception as e: diff --git a/cdisc_rules_engine/services/data_readers/xpt_reader.py b/cdisc_rules_engine/services/data_readers/xpt_reader.py index 0e22f03d0..11ec4d936 100644 --- a/cdisc_rules_engine/services/data_readers/xpt_reader.py +++ b/cdisc_rules_engine/services/data_readers/xpt_reader.py @@ -10,29 +10,19 @@ class XPTReader(DataReaderInterface): - def __init__(self, dataset_implementation, encoding: str = None): - self.dataset_implementation = dataset_implementation - self.encoding = encoding - - @property - def _encoding(self): - return self.encoding or "utf-8" def read(self, data): - df = pd.read_sas(BytesIO(data), format="xport", encoding=self._encoding) + df = pd.read_sas(BytesIO(data), format="xport", encoding=self.encoding) df = self._format_floats(df) return df def _read_pandas(self, file_path): - data = pd.read_sas(file_path, format="xport", encoding=self._encoding) + data = pd.read_sas(file_path, format="xport", encoding=self.encoding) return PandasDataset(self._format_floats(data)) - def _read_xpt_with_encoding(self, file_path: str, chunksize: int = None): - return pd.read_sas(file_path, chunksize=chunksize, encoding=self._encoding) - def to_parquet(self, file_path: str) -> str: temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") - dataset = self._read_xpt_with_encoding(file_path, chunksize=20000) + dataset = pd.read_sas(file_path, chunksize=20000, encoding=self.encoding) created = False num_rows = 0 for chunk in dataset: diff --git a/cdisc_rules_engine/services/data_services/dummy_data_service.py b/cdisc_rules_engine/services/data_services/dummy_data_service.py index 67b6acf4b..0e43fd59e 100644 --- a/cdisc_rules_engine/services/data_services/dummy_data_service.py +++ b/cdisc_rules_engine/services/data_services/dummy_data_service.py @@ -183,7 +183,7 @@ def get_datasets(self) -> Iterable[SDTMDatasetMetadata]: @staticmethod def get_data(dataset_paths: Sequence[str], encoding: str = None): - json = JSONReader().from_file(dataset_paths[0], encoding=encoding) + json = JSONReader(encoding=encoding).from_file(dataset_paths[0]) return [DummyDataset(data) for data in json.get("datasets", [])] @staticmethod @@ -193,6 +193,6 @@ def is_valid_data(dataset_paths: Sequence[str], encoding: str = None): and len(dataset_paths) == 1 and dataset_paths[0].lower().endswith(".json") ): - json = JSONReader().from_file(dataset_paths[0], encoding=encoding) + json = JSONReader(encoding=encoding).from_file(dataset_paths[0]) return "datasets" in json return False diff --git a/cdisc_rules_engine/services/data_services/usdm_data_service.py b/cdisc_rules_engine/services/data_services/usdm_data_service.py index 835234138..9c3fcfb65 100644 --- a/cdisc_rules_engine/services/data_services/usdm_data_service.py +++ b/cdisc_rules_engine/services/data_services/usdm_data_service.py @@ -485,6 +485,6 @@ def is_valid_data(dataset_paths: Sequence[str], encoding: str = None): and len(dataset_paths) == 1 and dataset_paths[0].lower().endswith(".json") ): - json = JSONReader().from_file(dataset_paths[0], encoding=encoding) + json = JSONReader(encoding=encoding).from_file(dataset_paths[0]) return "study" in json and "datasetJSONVersion" not in json return False diff --git a/cdisc_rules_engine/services/datasetjson_metadata_reader.py b/cdisc_rules_engine/services/datasetjson_metadata_reader.py index 940921317..98cc93464 100644 --- a/cdisc_rules_engine/services/datasetjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetjson_metadata_reader.py @@ -26,11 +26,11 @@ def read(self) -> dict: Extracts metadata from .json file. """ # Load Dataset-JSON Schema - schema = JSONReader().from_file( + schema = JSONReader(encoding=self.encoding).from_file( os.path.join("resources", "schema", "dataset.schema.json") ) - datasetjson = JSONReader().from_file(self._file_path, encoding=self.encoding) + datasetjson = JSONReader(encoding=self.encoding).from_file(self._file_path) try: jsonschema.validate(datasetjson, schema) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index bdef3450e..491ed51bc 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -31,7 +31,7 @@ def read(self) -> dict: Extracts metadata from .ndjson file. """ # Load Dataset-NDJSON Schema - schema = JSONReader().from_file( + schema = JSONReader(encoding=self._encoding).from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) diff --git a/core.py b/core.py index b8666b64e..c11fc9d59 100644 --- a/core.py +++ b/core.py @@ -43,7 +43,7 @@ def validate_encoding(ctx, param, value): if value is None: - return None + return "utf-8" try: codecs.lookup(value) return value @@ -379,12 +379,12 @@ def _validate_no_arguments(logger) -> None: @click.option( "-e", "--encoding", - default=None, + default="utf-8", required=False, callback=validate_encoding, help=( "File encoding for reading datasets. " - "If not specified, defaults to UTF-8. " + "Defaults to utf-8. " "Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc." ), ) diff --git a/tests/unit/test_dataset_json_reader.py b/tests/unit/test_dataset_json_reader.py index 63c6281f8..8c5692be8 100644 --- a/tests/unit/test_dataset_json_reader.py +++ b/tests/unit/test_dataset_json_reader.py @@ -1,9 +1,14 @@ import os +import tempfile +import json + +import pytest from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.dataset_json_reader import ( DatasetJSONReader, ) +from cdisc_rules_engine.exceptions.custom_exceptions import InvalidJSONFormat def test_from_file(): @@ -18,3 +23,77 @@ def test_from_file(): Verify that the rounding of incredibly small values to 0 is applied. """ assert value == 0 or abs(value) > 10**-16 + + +def test_read_json_file_fails_with_wrong_encoding(): + test_data = { + "datasetJSONVersion": "1.1", + "datasetJSONCreationDateTime": "2024-01-01T00:00:00", + "sourceSystem": {"name": "Test", "version": "1.0"}, + "studyOID": "TEST.1", + "metaDataVersionOID": "MDV.1", + "itemGroupOID": "IG.TEST", + "records": 1, + "name": "TEST", + "label": "Test Dataset", + "columns": [ + { + "itemOID": "IT.TEST.STUDYID", + "name": "STUDYID", + "label": "Study Identifier", + "dataType": "string", + "length": 10, + } + ], + "rows": [["STUDY001"]], + } + with tempfile.NamedTemporaryFile(mode="wb", suffix=".json", delete=False) as f: + json_str = json.dumps(test_data, ensure_ascii=False) + json_bytes = json_str.encode("cp1252").replace( + b'"Test Dataset"', b'"Test\x92s Dataset"' + ) + f.write(json_bytes) + temp_path = f.name + + try: + reader = DatasetJSONReader(PandasDataset, encoding="utf-8") + with pytest.raises(InvalidJSONFormat): + reader.read_json_file(temp_path) + finally: + os.unlink(temp_path) + + +def test_read_json_file_succeeds_with_correct_encoding(): + test_data = { + "datasetJSONVersion": "1.1", + "datasetJSONCreationDateTime": "2024-01-01T00:00:00", + "sourceSystem": {"name": "Test", "version": "1.0"}, + "studyOID": "TEST.1", + "metaDataVersionOID": "MDV.1", + "itemGroupOID": "IG.TEST", + "records": 1, + "name": "TEST", + "label": "Test Dataset", + "columns": [ + { + "itemOID": "IT.TEST.STUDYID", + "name": "STUDYID", + "label": "Study Identifier", + "dataType": "string", + "length": 10, + } + ], + "rows": [["STUDY001"]], + } + with tempfile.NamedTemporaryFile(mode="wb", suffix=".json", delete=False) as f: + json_str = json.dumps(test_data, ensure_ascii=False) + f.write(json_str.encode("cp1252")) + temp_path = f.name + + try: + reader = DatasetJSONReader(PandasDataset, encoding="cp1252") + result = reader.read_json_file(temp_path) + assert result["name"] == "TEST" + assert len(result["rows"]) == 1 + finally: + os.unlink(temp_path) From 77e11291f461d17b68e3005601a3b177d4a653fd Mon Sep 17 00:00:00 2001 From: Rakesh Date: Fri, 16 Jan 2026 13:36:53 -0500 Subject: [PATCH 09/13] Remove encoding parameter from from_file() call --- cdisc_rules_engine/services/data_services/usdm_data_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cdisc_rules_engine/services/data_services/usdm_data_service.py b/cdisc_rules_engine/services/data_services/usdm_data_service.py index 9c3fcfb65..275e1c676 100644 --- a/cdisc_rules_engine/services/data_services/usdm_data_service.py +++ b/cdisc_rules_engine/services/data_services/usdm_data_service.py @@ -83,7 +83,7 @@ def __init__( self.entity_dict: dict = safe_load(entity_dict) self.json = self._reader_factory.get_service("USDM").from_file( - self.dataset_path, encoding=self.encoding + self.dataset_path ) # Build the id lookup dict once for fast reference resolution From e988c744452adf56e259a363270af1655f38c74e Mon Sep 17 00:00:00 2001 From: Rakesh Date: Fri, 30 Jan 2026 19:39:42 -0500 Subject: [PATCH 10/13] Fix schema loading to always use UTF-8 instead of user encoding --- cdisc_rules_engine/services/data_readers/dataset_json_reader.py | 2 +- .../services/data_readers/dataset_ndjson_reader.py | 2 +- cdisc_rules_engine/services/datasetjson_metadata_reader.py | 2 +- cdisc_rules_engine/services/datasetndjson_metadata_reader.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index 63a5b5d24..71e312528 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -17,7 +17,7 @@ class DatasetJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding=self.encoding).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset.schema.json") ) return schema diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index bb4bdedfc..48b998e40 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -18,7 +18,7 @@ class DatasetNDJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding=self.encoding).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) return schema diff --git a/cdisc_rules_engine/services/datasetjson_metadata_reader.py b/cdisc_rules_engine/services/datasetjson_metadata_reader.py index 98cc93464..3db6254f9 100644 --- a/cdisc_rules_engine/services/datasetjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetjson_metadata_reader.py @@ -26,7 +26,7 @@ def read(self) -> dict: Extracts metadata from .json file. """ # Load Dataset-JSON Schema - schema = JSONReader(encoding=self.encoding).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset.schema.json") ) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index 491ed51bc..bc776a001 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -31,7 +31,7 @@ def read(self) -> dict: Extracts metadata from .ndjson file. """ # Load Dataset-NDJSON Schema - schema = JSONReader(encoding=self._encoding).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) From 5f410b9d7273542a53e5323164301cfe6f1b9740 Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 4 Feb 2026 12:33:45 -0500 Subject: [PATCH 11/13] Use DEFAULT_ENCODING everywhere and make encoding handling consistent --- cdisc_rules_engine/constants/__init__.py | 2 ++ .../interfaces/data_reader_interface.py | 7 +++++-- .../data_readers/data_reader_factory.py | 3 ++- .../data_readers/dataset_json_reader.py | 3 ++- .../data_readers/dataset_ndjson_reader.py | 3 ++- .../data_services/dummy_data_service.py | 13 +++++++++---- .../data_services/local_data_service.py | 5 +++++ .../services/datasetjson_metadata_reader.py | 11 ++++++++--- .../services/datasetndjson_metadata_reader.py | 16 ++++++++-------- .../services/datasetxpt_metadata_reader.py | 19 +++++++------------ core.py | 12 ++++++------ tests/unit/test_dataset_ndjson_reader.py | 3 +-- tests/unit/test_xpt_reader.py | 3 +-- 13 files changed, 58 insertions(+), 42 deletions(-) diff --git a/cdisc_rules_engine/constants/__init__.py b/cdisc_rules_engine/constants/__init__.py index dd9b42e14..77d0a6aea 100644 --- a/cdisc_rules_engine/constants/__init__.py +++ b/cdisc_rules_engine/constants/__init__.py @@ -21,3 +21,5 @@ VALIDATION_FORMATS_MESSAGE = ( "SAS V5 XPT, Dataset-JSON (JSON or NDJSON), or Excel (XLSX)" ) + +DEFAULT_ENCODING: str = "utf-8" diff --git a/cdisc_rules_engine/interfaces/data_reader_interface.py b/cdisc_rules_engine/interfaces/data_reader_interface.py index 92f58e710..a21c43416 100644 --- a/cdisc_rules_engine/interfaces/data_reader_interface.py +++ b/cdisc_rules_engine/interfaces/data_reader_interface.py @@ -1,4 +1,5 @@ from cdisc_rules_engine.models.dataset import PandasDataset +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DataReaderInterface: @@ -6,10 +7,12 @@ class DataReaderInterface: Interface for reading binary data from different file typs into pandas dataframes """ - def __init__(self, dataset_implementation=PandasDataset, encoding: str = "utf-8"): + def __init__( + self, dataset_implementation=PandasDataset, encoding: str = DEFAULT_ENCODING + ): """ :param dataset_implementation DatasetInterface: The dataset type to return. - :param encoding str: The encoding to use when reading files. Defaults to utf-8. + :param encoding str: The encoding to use when reading files. Defaults to DEFAULT_ENCODING (e.g. utf-8). """ self.dataset_implementation = dataset_implementation self.encoding = encoding diff --git a/cdisc_rules_engine/services/data_readers/data_reader_factory.py b/cdisc_rules_engine/services/data_readers/data_reader_factory.py index 35bc058a2..2df492a86 100644 --- a/cdisc_rules_engine/services/data_readers/data_reader_factory.py +++ b/cdisc_rules_engine/services/data_readers/data_reader_factory.py @@ -15,6 +15,7 @@ from cdisc_rules_engine.services.data_readers.json_reader import JSONReader from cdisc_rules_engine.enums.dataformat_types import DataFormatTypes from cdisc_rules_engine.models.dataset import PandasDataset +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DataReaderFactory(FactoryInterface): @@ -54,7 +55,7 @@ def get_service(self, name: str = None, **kwargs) -> DataReaderInterface: service_name = name or self._default_service_name if service_name in self._reader_map: reader_class = self._reader_map[service_name] - encoding = self.encoding or "utf-8" + encoding = self.encoding or DEFAULT_ENCODING return reader_class(self.dataset_implementation, encoding=encoding) raise ValueError( f"Service name must be in {list(self._reader_map.keys())}, " diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index 71e312528..c1d4cba93 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -12,12 +12,13 @@ import tempfile from cdisc_rules_engine.services.data_readers.json_reader import JSONReader +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding="utf-8").from_file( + schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( os.path.join("resources", "schema", "dataset.schema.json") ) return schema diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index 48b998e40..b57059a46 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -13,12 +13,13 @@ import tempfile from cdisc_rules_engine.services.data_readers.json_reader import JSONReader +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetNDJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding="utf-8").from_file( + schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) return schema diff --git a/cdisc_rules_engine/services/data_services/dummy_data_service.py b/cdisc_rules_engine/services/data_services/dummy_data_service.py index 0e43fd59e..9275e2262 100644 --- a/cdisc_rules_engine/services/data_services/dummy_data_service.py +++ b/cdisc_rules_engine/services/data_services/dummy_data_service.py @@ -15,6 +15,7 @@ from cdisc_rules_engine.services.data_readers import DataReaderFactory from cdisc_rules_engine.services.data_readers.json_reader import JSONReader from cdisc_rules_engine.services.data_services import BaseDataService +from cdisc_rules_engine.constants import DEFAULT_ENCODING from cdisc_rules_engine.models.dataset import PandasDataset @@ -182,17 +183,21 @@ def get_datasets(self) -> Iterable[SDTMDatasetMetadata]: return self.data @staticmethod - def get_data(dataset_paths: Sequence[str], encoding: str = None): - json = JSONReader(encoding=encoding).from_file(dataset_paths[0]) + def get_data(dataset_paths: Sequence[str], encoding: str = DEFAULT_ENCODING): + json = JSONReader(encoding=encoding or DEFAULT_ENCODING).from_file( + dataset_paths[0] + ) return [DummyDataset(data) for data in json.get("datasets", [])] @staticmethod - def is_valid_data(dataset_paths: Sequence[str], encoding: str = None): + def is_valid_data(dataset_paths: Sequence[str], encoding: str = DEFAULT_ENCODING): if ( dataset_paths and len(dataset_paths) == 1 and dataset_paths[0].lower().endswith(".json") ): - json = JSONReader(encoding=encoding).from_file(dataset_paths[0]) + json = JSONReader(encoding=encoding or DEFAULT_ENCODING).from_file( + dataset_paths[0] + ) return "datasets" in json return False diff --git a/cdisc_rules_engine/services/data_services/local_data_service.py b/cdisc_rules_engine/services/data_services/local_data_service.py index b287c3b53..cffb61bd3 100644 --- a/cdisc_rules_engine/services/data_services/local_data_service.py +++ b/cdisc_rules_engine/services/data_services/local_data_service.py @@ -55,6 +55,11 @@ def get_instance( config: ConfigInterface = None, **kwargs, ): + """ + Return the singleton instance. Reset the instance when encoding is + explicitly requested and differs from the cached one (e.g., validation + runs multiple times with different encodings in the same process). + """ encoding = kwargs.get("encoding") if cls._instance is None or ( encoding is not None and cls._instance.encoding != encoding diff --git a/cdisc_rules_engine/services/datasetjson_metadata_reader.py b/cdisc_rules_engine/services/datasetjson_metadata_reader.py index 3db6254f9..e16cfff7c 100644 --- a/cdisc_rules_engine/services/datasetjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetjson_metadata_reader.py @@ -6,6 +6,7 @@ from cdisc_rules_engine.services import logger from cdisc_rules_engine.services.adam_variable_reader import AdamVariableReader from cdisc_rules_engine.services.data_readers.json_reader import JSONReader +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetJSONMetadataReader: @@ -14,7 +15,9 @@ class DatasetJSONMetadataReader: from .json file. """ - def __init__(self, file_path: str, file_name: str, encoding: str = None): + def __init__( + self, file_path: str, file_name: str, encoding: str = DEFAULT_ENCODING + ): self._metadata_container = {} self._file_path = file_path self._first_record = None @@ -26,11 +29,13 @@ def read(self) -> dict: Extracts metadata from .json file. """ # Load Dataset-JSON Schema - schema = JSONReader(encoding="utf-8").from_file( + schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( os.path.join("resources", "schema", "dataset.schema.json") ) - datasetjson = JSONReader(encoding=self.encoding).from_file(self._file_path) + datasetjson = JSONReader(encoding=self.encoding or DEFAULT_ENCODING).from_file( + self._file_path + ) try: jsonschema.validate(datasetjson, schema) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index bc776a001..d91a27804 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -7,6 +7,7 @@ from cdisc_rules_engine.services import logger from cdisc_rules_engine.services.adam_variable_reader import AdamVariableReader from cdisc_rules_engine.services.data_readers.json_reader import JSONReader +from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetNDJSONMetadataReader: @@ -15,32 +16,31 @@ class DatasetNDJSONMetadataReader: from .ndjson file. """ - def __init__(self, file_path: str, file_name: str, encoding: str = None): + def __init__( + self, file_path: str, file_name: str, encoding: str = DEFAULT_ENCODING + ): self._metadata_container = {} self._file_path = file_path self._first_record = None self._dataset_name = file_name.split(".")[0].upper() self.encoding = encoding - @property - def _encoding(self): - return self.encoding or "utf-8" - def read(self) -> dict: """ Extracts metadata from .ndjson file. """ # Load Dataset-NDJSON Schema - schema = JSONReader(encoding="utf-8").from_file( + schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) + encoding = self.encoding or DEFAULT_ENCODING try: - with open(self._file_path, "r", encoding=self._encoding) as file: + with open(self._file_path, "r", encoding=encoding) as file: lines = file.readlines() except (UnicodeDecodeError, UnicodeError) as e: raise ValueError( - f"Could not decode NDJSON file {self._file_path} with {self._encoding} encoding: {e}. " + f"Could not decode NDJSON file {self._file_path} with {encoding} encoding: {e}. " f"Please specify the correct encoding using the -e flag." ) diff --git a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py index 0857f0f5b..3fc0d01cd 100644 --- a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py @@ -3,6 +3,7 @@ from cdisc_rules_engine.services import logger from cdisc_rules_engine.config import config from cdisc_rules_engine.services.adam_variable_reader import AdamVariableReader +from cdisc_rules_engine.constants import DEFAULT_ENCODING import os @@ -14,7 +15,9 @@ class DatasetXPTMetadataReader: # TODO. Maybe in future it is worth having multiple constructors # like from_bytes, from_file etc. But now there is no immediate need for that. - def __init__(self, file_path: str, file_name: str, encoding: str = None): + def __init__( + self, file_path: str, file_name: str, encoding: str = DEFAULT_ENCODING + ): file_size = os.path.getsize(file_path) if file_size > config.get_dataset_size_threshold(): self._estimate_dataset_length = True @@ -199,18 +202,10 @@ def _extract_adam_info(self, variable_names): } return adam_info_dict - @property - def _encoding(self): - return self.encoding or "utf-8" - def _read_xport(self, **kwargs): - """Read XPT file with encoding, fallback to no encoding if TypeError.""" - try: - return pyreadstat.read_xport( - self._file_path, encoding=self._encoding, **kwargs - ) - except TypeError: - return pyreadstat.read_xport(self._file_path, **kwargs) + """Read XPT file using the configured encoding.""" + encoding = self.encoding or DEFAULT_ENCODING + return pyreadstat.read_xport(self._file_path, encoding=encoding, **kwargs) def _read_xport_with_encoding(self): return self._read_xport(row_limit=self.row_limit) diff --git a/core.py b/core.py index fc5e9b11a..71e4651d0 100644 --- a/core.py +++ b/core.py @@ -36,7 +36,7 @@ get_rules_cache_key, validate_dataset_files_exist, ) -from cdisc_rules_engine.constants import VALIDATION_FORMATS_MESSAGE +from cdisc_rules_engine.constants import VALIDATION_FORMATS_MESSAGE, DEFAULT_ENCODING from scripts.list_dataset_metadata_handler import list_dataset_metadata_handler from scripts.run_validation import run_validation from version import __version__ @@ -48,7 +48,7 @@ def validate_encoding(ctx, param, value): if value is None: - return "utf-8" + return DEFAULT_ENCODING try: codecs.lookup(value) return value @@ -384,13 +384,13 @@ def _validate_no_arguments(logger) -> None: @click.option( "-e", "--encoding", - default="utf-8", + default=DEFAULT_ENCODING, required=False, callback=validate_encoding, help=( - "File encoding for reading datasets. " - "Defaults to utf-8. " - "Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc." + f"File encoding for reading datasets. " + f"Defaults to {DEFAULT_ENCODING}. " + f"Supported encodings: utf-8, utf-16, utf-32, cp1252, latin-1, etc." ), ) @click.pass_context diff --git a/tests/unit/test_dataset_ndjson_reader.py b/tests/unit/test_dataset_ndjson_reader.py index b0fa00c75..9d987f1f6 100644 --- a/tests/unit/test_dataset_ndjson_reader.py +++ b/tests/unit/test_dataset_ndjson_reader.py @@ -1,6 +1,5 @@ import os -from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.dataset_ndjson_reader import ( DatasetNDJSONReader, ) @@ -11,7 +10,7 @@ def test_from_file(): f"{os.path.dirname(__file__)}/../resources/test_dataset.ndjson" ) - reader = DatasetNDJSONReader(PandasDataset) + reader = DatasetNDJSONReader() dataframe = reader.from_file(test_dataset_path) for value in dataframe["EXDOSE"]: """ diff --git a/tests/unit/test_xpt_reader.py b/tests/unit/test_xpt_reader.py index c16c802a4..6a3af07ea 100644 --- a/tests/unit/test_xpt_reader.py +++ b/tests/unit/test_xpt_reader.py @@ -1,6 +1,5 @@ import os -from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset from cdisc_rules_engine.services.data_readers.xpt_reader import XPTReader @@ -11,7 +10,7 @@ def test_read(): with open(test_dataset_path, "rb") as f: data = f.read() - reader = XPTReader(PandasDataset) + reader = XPTReader() dataframe = reader.read(data) for value in dataframe["EXDOSE"]: """ From 73e1abaf8139c941be60d6bc80efb91518f6e6cd Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 4 Feb 2026 15:19:33 -0500 Subject: [PATCH 12/13] Add parametrized tests for each README encoding --- tests/unit/test_dataset_json_reader.py | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_dataset_json_reader.py b/tests/unit/test_dataset_json_reader.py index 8c5692be8..8d4dc6186 100644 --- a/tests/unit/test_dataset_json_reader.py +++ b/tests/unit/test_dataset_json_reader.py @@ -63,8 +63,9 @@ def test_read_json_file_fails_with_wrong_encoding(): os.unlink(temp_path) -def test_read_json_file_succeeds_with_correct_encoding(): - test_data = { +def _minimal_dataset_json(): + """Minimal valid Dataset-JSON with non-ASCII character for encoding tests.""" + return { "datasetJSONVersion": "1.1", "datasetJSONCreationDateTime": "2024-01-01T00:00:00", "sourceSystem": {"name": "Test", "version": "1.0"}, @@ -85,15 +86,29 @@ def test_read_json_file_succeeds_with_correct_encoding(): ], "rows": [["STUDY001"]], } + + +@pytest.mark.parametrize( + "encoding,label", + [ + ("utf-8", "Test Dataset — utf-8"), + ("utf-16", "Test Dataset — utf-16"), + ("utf-32", "Test Dataset — utf-32"), + ("cp1252", "Test Dataset"), + ("latin-1", "Test Dataset latin-1 \xe9"), + ], +) +def test_read_json_file_succeeds_with_encoding(encoding, label): + """Test each encoding mentioned in README (utf-8, utf-16, utf-32, cp1252, latin-1).""" + test_data = _minimal_dataset_json() + test_data["label"] = label with tempfile.NamedTemporaryFile(mode="wb", suffix=".json", delete=False) as f: - json_str = json.dumps(test_data, ensure_ascii=False) - f.write(json_str.encode("cp1252")) + f.write(json.dumps(test_data, ensure_ascii=False).encode(encoding)) temp_path = f.name - try: - reader = DatasetJSONReader(PandasDataset, encoding="cp1252") + reader = DatasetJSONReader(PandasDataset, encoding=encoding) result = reader.read_json_file(temp_path) assert result["name"] == "TEST" - assert len(result["rows"]) == 1 + assert result["label"] == label finally: os.unlink(temp_path) From d8272ed42cba63ace6a05b1f83bc04a0cc41cf5f Mon Sep 17 00:00:00 2001 From: Rakesh Date: Wed, 11 Feb 2026 10:02:00 -0500 Subject: [PATCH 13/13] Use hardcoded utf-8 for schema files, inline pyreadstat calls in XPT metadata reader --- .../data_readers/dataset_json_reader.py | 3 +-- .../data_readers/dataset_ndjson_reader.py | 3 +-- .../services/datasetjson_metadata_reader.py | 2 +- .../services/datasetndjson_metadata_reader.py | 2 +- .../services/datasetxpt_metadata_reader.py | 21 +++++++------------ 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py index c1d4cba93..71e312528 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_json_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_json_reader.py @@ -12,13 +12,12 @@ import tempfile from cdisc_rules_engine.services.data_readers.json_reader import JSONReader -from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset.schema.json") ) return schema diff --git a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py index b57059a46..48b998e40 100644 --- a/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py +++ b/cdisc_rules_engine/services/data_readers/dataset_ndjson_reader.py @@ -13,13 +13,12 @@ import tempfile from cdisc_rules_engine.services.data_readers.json_reader import JSONReader -from cdisc_rules_engine.constants import DEFAULT_ENCODING class DatasetNDJSONReader(DataReaderInterface): def get_schema(self) -> dict: - schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) return schema diff --git a/cdisc_rules_engine/services/datasetjson_metadata_reader.py b/cdisc_rules_engine/services/datasetjson_metadata_reader.py index e16cfff7c..5aa1b6d93 100644 --- a/cdisc_rules_engine/services/datasetjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetjson_metadata_reader.py @@ -29,7 +29,7 @@ def read(self) -> dict: Extracts metadata from .json file. """ # Load Dataset-JSON Schema - schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset.schema.json") ) diff --git a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py index d91a27804..d4f0987a2 100644 --- a/cdisc_rules_engine/services/datasetndjson_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetndjson_metadata_reader.py @@ -30,7 +30,7 @@ def read(self) -> dict: Extracts metadata from .ndjson file. """ # Load Dataset-NDJSON Schema - schema = JSONReader(encoding=DEFAULT_ENCODING).from_file( + schema = JSONReader(encoding="utf-8").from_file( os.path.join("resources", "schema", "dataset-ndjson-schema.json") ) diff --git a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py index 3fc0d01cd..914747c17 100644 --- a/cdisc_rules_engine/services/datasetxpt_metadata_reader.py +++ b/cdisc_rules_engine/services/datasetxpt_metadata_reader.py @@ -35,8 +35,11 @@ def read(self) -> dict: """ Extracts metadata from binary contents of .xpt file. """ + encoding = self.encoding or DEFAULT_ENCODING try: - dataset, metadata = self._read_xport_with_encoding() + dataset, metadata = pyreadstat.read_xport( + self._file_path, encoding=encoding, row_limit=self.row_limit + ) except (pyreadstat.ReadstatError, UnicodeDecodeError): return { "variable_labels": [], @@ -96,7 +99,10 @@ def _extract_first_record(self, df): return None def _calculate_dataset_length(self): - df, meta = self._read_xport_with_encoding_metadata_only() + encoding = self.encoding or DEFAULT_ENCODING + _, meta = pyreadstat.read_xport( + self._file_path, encoding=encoding, metadataonly=True + ) row_size = sum(meta.variable_storage_width.values()) total_size = os.path.getsize(self._file_path) start = self._read_header(self._file_path) @@ -201,14 +207,3 @@ def _extract_adam_info(self, variable_names): "selection_algorithm": ad.selection_algorithm, } return adam_info_dict - - def _read_xport(self, **kwargs): - """Read XPT file using the configured encoding.""" - encoding = self.encoding or DEFAULT_ENCODING - return pyreadstat.read_xport(self._file_path, encoding=encoding, **kwargs) - - def _read_xport_with_encoding(self): - return self._read_xport(row_limit=self.row_limit) - - def _read_xport_with_encoding_metadata_only(self): - return self._read_xport(metadataonly=True)