From c135116a4c597aed7b092292939e74dd75fd9ee2 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Thu, 17 Aug 2023 13:45:46 +0100 Subject: [PATCH 01/23] feat(test): add newspaper metadat and plaintext fixtures and fix some doctests --- alto2txt2fixture/__main__.py | 6 ++++-- alto2txt2fixture/router.py | 4 ++-- alto2txt2fixture/utils.py | 13 +++++++++++++ docs/gen_ref_pages.py | 1 + tests/conftest.py | 14 ++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/alto2txt2fixture/__main__.py b/alto2txt2fixture/__main__.py index c3e5929..0e49f71 100644 --- a/alto2txt2fixture/__main__.py +++ b/alto2txt2fixture/__main__.py @@ -139,8 +139,10 @@ def run(local_args: list[str] | None = None) -> None: (pending the user's confirmation). Arguments: - local_args: - Options passed to `parse_args()` + local_args: Options passed to `parse_args()` + + Returns: + None """ args: Namespace = parse_args(argv=local_args) diff --git a/alto2txt2fixture/router.py b/alto2txt2fixture/router.py index 3f2ad20..4f62d7d 100644 --- a/alto2txt2fixture/router.py +++ b/alto2txt2fixture/router.py @@ -295,8 +295,8 @@ def publication_code(self) -> str: if not len(self._publication_code) == 7: raise RuntimeError( - f"Publication code is of wrong length: \ - {len(self._publication_code)} ({self._publication_code})." + f"Publication code is of wrong length: " + f"{len(self._publication_code)} ({self._publication_code})." ) return self._publication_code diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index b6e6571..b269a55 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -564,6 +564,8 @@ def fixture_or_default_dict( >>> hmd_dict: FixtureDict = fixture_or_default_dict( ... 'hmd', newspaper_dict ... ) + >>> hmd_dict == newspaper_dict['hmd'] + True >>> fixture_or_default_dict( ... 'hmd', NEWSPAPER_COLLECTION_METADATA ... ) @@ -604,9 +606,20 @@ def check_newspaper_collection_configuration( ```pycon >>> check_newspaper_collection_configuration() set() + >>> unmatched: set[str] = check_newspaper_collection_configuration( + ... ["cat", "dog"]) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + + ...Warning: 2 `collections` not in `newspaper_collections`: ... + >>> unmatched == {'dog', 'cat'} + True ``` + !!! note + + Set orders are random so checking `unmatched == {'dog, 'cat'}` to + ensure correctness irrespective of order in the example above. + """ newspaper_collection_names: tuple[str, ...] = tuple( dict_from_list_fixture_fields( diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index 0e4fe7d..09630f9 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -13,6 +13,7 @@ DOCS_PATH_NAME: str = "docs" TESTS_PATH_NAME: str = "tests" + for path in sorted(Path(PACKAGE_PATH).rglob("*.py")): if DOCS_PATH_NAME in str(path) or TESTS_PATH_NAME in str(path): continue diff --git a/tests/conftest.py b/tests/conftest.py index 9916d2d..b8484b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,20 @@ BADGE_PATH: Path = Path("docs") / "img" / "coverage.svg" +HMD_PLAINTEXT_FIXTURE: Path = Path("tests") / "0002645_plaintext.zip" + + +@pytest.fixture +def hmd_metadata_fixture() -> Path: + """Path for 0002645 1853 metadata fixture.""" + return Path("tests") / "0002645_metadata.zip" + + +@pytest.fixture +def hmd_plaintext_fixture() -> Path: + """Path for 0002645 1853 plaintext fixture.""" + return Path("tests") / "0002645_plaintext.zip" + @pytest.fixture def uncached_folder(monkeypatch, tmpdir) -> Path: From 162c9e2f6f5ec68b8eea0c314625bd93d9ade318 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Mon, 21 Aug 2023 21:47:34 +0100 Subject: [PATCH 02/23] feat(plaintext): initial plaintext export with doctest bug fixes --- alto2txt2fixture/cli.py | 8 +- alto2txt2fixture/create_adjacent_tables.py | 4 +- alto2txt2fixture/plaintext.py | 531 +++++++++++++++++++++ alto2txt2fixture/router.py | 2 +- alto2txt2fixture/settings.py | 24 +- alto2txt2fixture/types.py | 58 ++- alto2txt2fixture/utils.py | 172 ++++++- tests/conftest.py => conftest.py | 40 +- pyproject.toml | 1 + tests/test_newspaper.py | 2 +- 10 files changed, 781 insertions(+), 61 deletions(-) create mode 100644 alto2txt2fixture/plaintext.py rename tests/conftest.py => conftest.py (58%) diff --git a/alto2txt2fixture/cli.py b/alto2txt2fixture/cli.py index 3429746..9e197a4 100644 --- a/alto2txt2fixture/cli.py +++ b/alto2txt2fixture/cli.py @@ -1,13 +1,10 @@ import os -from rich.console import Console from rich.table import Table from .settings import DATA_PROVIDER_INDEX, SETUP_TITLE, settings from .types import dotdict -from .utils import check_newspaper_collection_configuration, gen_fixture_tables - -console = Console() +from .utils import check_newspaper_collection_configuration, console, gen_fixture_tables def show_setup(clear: bool = True, title: str = SETUP_TITLE, **kwargs) -> None: @@ -56,9 +53,8 @@ def show_fixture_tables( >>> [column.header for column in fixture_tables[0].columns] ['pk', 'name', 'code', 'legacy_code', 'collection', 'source_note'] >>> fixture_tables = show_fixture_tables(settings) - ... # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE - ...dataprovider...Heritage...│ bl-hmd...│ hmd... + ...dataprovider...Heritage...│ bl_hmd...│ hmd... ``` diff --git a/alto2txt2fixture/create_adjacent_tables.py b/alto2txt2fixture/create_adjacent_tables.py index e0c066f..4a4b644 100755 --- a/alto2txt2fixture/create_adjacent_tables.py +++ b/alto2txt2fixture/create_adjacent_tables.py @@ -239,9 +239,7 @@ def download_data( ```pycon >>> tmp: Path = getfixture('tmpdir') >>> set_path: Path = tmp.chdir() - >>> download_data(exclude=[ - ... "mitchells", "Newspaper-1", "linking" - ... ]) # doctest: +ELLIPSIS + >>> download_data(exclude=["mitchells", "Newspaper-1", "linking"]) Excluding mitchells... Excluding Newspaper-1... Excluding linking... diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py new file mode 100644 index 0000000..42605b6 --- /dev/null +++ b/alto2txt2fixture/plaintext.py @@ -0,0 +1,531 @@ +from dataclasses import dataclass, field +from logging import getLogger +from os import PathLike +from pathlib import Path +from shutil import disk_usage, rmtree, unpack_archive +from typing import Final, Generator +from zipfile import ZipFile, ZipInfo + +from .settings import NEWSPAPER_DATA_PROVIDER_CODE_DICT +from .types import DataProviderFixtureDict +from .utils import ( + DiskUsageTuple, + console, + free_hd_space_in_GB, + path_globs_to_tuple, + valid_compression_files, +) + +logger = getLogger("rich") + +FULLTEXT_DJANGO_MODEL: Final[str] = "fulltext.fulltext" + +HOME_DIR: PathLike = Path.home() +DOWNLOAD_DIR: PathLike = HOME_DIR / "metadata-db/" +ARCHIVE_SUBDIR: PathLike = Path("archives") +EXTRACTED_SUBDIR: PathLike = Path("extracted") +FULLTEXT_METHOD: str = "download" +FULLTEXT_CONTAINER_SUFFIX: str = "-alto2txt" +FULLTEXT_CONTAINER_PATH: PathLike = Path("plaintext/") +FULLTEXT_STORAGE_ACCOUNT_URL: str = "https://alto2txt.blob.core.windows.net" + +FULLTEXT_FILE_NAME_SUFFIX: Final[str] = "_plaintext" +FULLTEXT_FILE_COMPRESSED_EXTENSION: Final[str] = "zip" +FULLTEXT_DECOMPRESSED_PATH = Path("uncompressed/") +FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX: Final[ + str +] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{FULLTEXT_FILE_COMPRESSED_EXTENSION}" + +SAS_ENV_VARIABLE = "FULLTEXT_SAS_TOKEN" + +# def fixture_to_json( +# fields: dict[str, Any], +# pk: str | int, +# model: str, +# # model: str = FULLTEXT_DJANGO_MODEL, +# # lwmdb_fulltext: bool = True +# ) -> FixtureDict: +# """Read `path` and construct a `dict` for a `django` fixture. +# +# Arguments: +# path: +# `PathLike` `path` to fixture +# pk: +# `django` record primary key (id) +# model: +# `django` `model` the record is saved in +# lwmdb_fulltext: +# Whether to include extra elements in `json` specific to `lwmdb` +# +# Returns: +# A `dict` of `plaintext` for `json` export as a `django` `fixture`. +# +# Example: +# ```pycon +# >>> fulltext_zip_path = getfixture('hmd_plaintext_fixture') +# >>> fixture_to_json() +# +# ``` +# """ +# # fields: dict[str, Any] = +# return {'pk': pk, 'model': model, 'fields': fields} + + +# def compressed_file_name_from_publication_code( +# publication_code: str, +# suffix: str = FULLTEXT_FILE_NAME_SUFFIX, +# extension: str = FULLTEXT_FILE_COMPRESSED_EXTENSION) -> str: +# """Filename for an Item's archive containing the full text. +# +# Example: +# ```pycon +# >>> zip_file_name('0002645') +# 0002645_plaintext.zip +# >>> zip_file_name('0002645', suffix='-diff-suff', extension='bz2') +# 0002645-diff-suff.bz2 +# ``` +# """ +# return f"{publication_code}{suffix}.{extension}" + + +@dataclass +class PlainTextFixture: + + """Convert `plaintext` results from `alto2txt` into `json` fixtures. + + Example: + ```pycon + >>> from pprint import pprint + >>> plain_text_bl_lwm = PlainTextFixture( + ... data_provider_code='bl_lwm', + ... path='tests/test_plaintext/bl_lwm', + ... glob_regex_str="*_plaintext.zip", + ... ) + >>> plain_text_bl_lwm + + >>> str(plain_text_bl_lwm) + "PlainTextFixture for 2 'bl_lwm' files" + >>> plain_text_bl_lwm.free_hd_space_in_GB > 1 + True + >>> pprint(plain_text_bl_lwm.compressed_files) + (PosixPath('tests/test_plaintext/bl_lwm/0003079_plaintext.zip'), + PosixPath('tests/test_plaintext/bl_lwm/0003548_plaintext.zip')) + >>> zipfile_info_list = list(plain_text_bl_lwm.zipinfo) + Getting zipfile info from + >>> zipfile_info_list[0][-1].filename + '0003079/1898/0114/0003079_18980114_art0041.txt' + >>> zipfile_info_list[-1][-1].filename + '0003548/1904/0616/0003548_19040616_art0053.txt' + >>> zipfile_info_list[-1][-1].file_size + 2460 + >>> zipfile_info_list[-1][-1].compress_size + 1187 + >>> plain_text_bl_lwm.extract_compressed() + [...] Extract path:...tests/test_plaintext/bl_lwm/extracted... + ...Extracting:...tests/test_plaintext/bl_lwm/0003079_plaintext.zip ... + ...Extracting:...tests/test_plaintext/bl_lwm/0003548_plaintext.zip ... + >>> plain_text_bl_lwm.delete_decompressed() + Deleteing all files in: tests/test_plaintext/bl_lwm/extracted + + ``` + + Todo: + Work through lines below to conclude `doctest` + + ```python + plain_text_hmd.newspaper_publication_paths + plain_text_hmd.issues_paths + plain_text_hmd.items_paths + plain_text_hmd.summary + plain_text_hmd.output_paths + plain_text_hmd.export_to_json() + plain_text_hmd.output_paths + plain_text_hmd.compress_json() + ``` + """ + + path: PathLike + data_provider_code: str | None = None + files: tuple[PathLike, ...] | None = None + glob_regex_str: str = FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX + # format: str + # mount_path: PathLike | None = Path(settings.MOUNTPOINT) + data_provider: DataProviderFixtureDict | None = None + model: str = FULLTEXT_DJANGO_MODEL + archive_subdir: PathLike = ARCHIVE_SUBDIR + extract_subdir: PathLike = EXTRACTED_SUBDIR + # decompress_subdir: PathLike = FULLTEXT_DECOMPRESSED_PATH + download_dir: PathLike = DOWNLOAD_DIR + fulltext_container_suffix: str = FULLTEXT_CONTAINER_SUFFIX + data_provider_code_dict: dict[str, DataProviderFixtureDict] = field( + default_factory=lambda: NEWSPAPER_DATA_PROVIDER_CODE_DICT + ) + + def __post_init__(self) -> None: + """Manage populating additional attributes if necessary.""" + self._check_and_set_files_attr(force=True) + self._check_and_set_data_provider(force=True) + self._disk_usage: DiskUsageTuple = disk_usage(self.path) + + def __len__(self) -> int: + """Return the number of files to process.""" + return len(self.files) if self.files else 0 + + def __str__(self) -> str: + """Return summary with `DataProvider` if available.""" + return ( + f"{type(self).__name__} " + f"for {len(self)} " + f"{self._data_provider_name_quoted_with_trailing_space}files" + ) + + def __repr__(self) -> str: + """Return summary with `DataProvider` if available.""" + return f"<{type(self).__name__}(path='{self.path}')>" + + @property + def _data_provider_name_quoted_with_trailing_space(self) -> str | None: + """Return `self.data_provider` `code` attributre with trailing space or `None`.""" + return f"'{self.data_provider_name}' " if self.data_provider_name else None + + @property + def data_provider_name(self) -> str | None: + """Return `self.data_provider` `code` attributre or `None`. + + Todo: + * Add check without risk of recursion for `self.data_provider_code` + """ + return self.data_provider_code if self.data_provider_code else None + + def _set_and_check_path_is_file(self, force: bool = False) -> None: + """Test if `self.path` is a file and change if `force = True`.""" + assert Path(self.path).is_file() + file_path_tuple: tuple[PathLike, ...] = (self.path,) + if not self.files: + self.files = file_path_tuple + elif self.files == file_path_tuple: + logger.debug( + f"No change from running " f"{repr(self)}._set_and_check_path_is_file()" + ) + elif force: + self.files = file_path_tuple + logger.debug(f"Force change to {repr(self)}\n" f"`files`: {self.files}") + else: + raise ValueError( + f"{repr(self)} `path` inconsistent with `files`.\n" + f"`path`: {self.path}\n`files`: {self.files}" + ) + + def _set_and_check_path_is_dir(self, force: bool = False) -> None: + """Test if `self.path` is a path and change if `force = True`.""" + assert Path(self.path).is_dir() + file_paths_tuple: tuple[PathLike, ...] = path_globs_to_tuple( + self.path, self.glob_regex_str + ) + if self.files: + if self.files == file_paths_tuple: + logger.debug( + f"No changes from " f"{repr(self)}._set_and_check_path_is_dir()" + ) + return + if force: + logger.debug( + f"Forcing change to {repr(self)}:\n" + f"`glob_regex_str`: {self.glob_regex_str}\n" + f"`files`: {self.files}\n" + f"`path`: {self.path}\n `files`: {self.files}" + ) + else: + raise ValueError( + f"{repr(self)} `path` inconsistent with `files`.\n" + f"`glob_regex_str`: {self.glob_regex_str}\n" + f"`path`: {self.path}\n`files`: {self.files}" + ) + self.files = file_paths_tuple + + @property + def extract_path(self) -> Path: + """Path any compressed files would be extracted to.""" + return Path(self.path) / self.extract_subdir + + @property + def compressed_files(self) -> tuple[PathLike, ...]: + """Return a tuple of all `self.files` with known archive filenames.""" + return tuple(valid_compression_files(files=self.files)) if self.files else () + + @property + def zipinfo(self) -> Generator[list[ZipInfo], None, None]: + """If `self.compressed_files` is in `zip`, return info, else None.""" + if any(Path(file).suffix == ".zip" for file in self.compressed_files): + console.print(f"Getting zipfile info from {repr(self)}") + for compressed_file in self.compressed_files: + if Path(compressed_file).suffix == ".zip": + yield ZipFile(compressed_file).infolist() + else: + console.log(f"No `self.compressed_files` end with `.zip` for {repr(self)}.") + + # def extract_compressed(self, index: int | str | None = None) -> None: + def extract_compressed(self) -> None: + """Extract `self.compressed_files` to `self.extracted_subdir_name`.""" + self.extract_path.mkdir(parents=True, exist_ok=True) + console.log(f"Extract path: {self.extract_path}") + for compressed_file in self.compressed_files: + console.log(f"Extracting: {compressed_file} ...") + unpack_archive(compressed_file, self.extract_path) + + # def delete_compressed(self, index: int | str | None = None) -> None: + def delete_decompressed(self) -> None: + """Remove all uncompressed files.""" + console.print(f"Deleteing all files in: {self.extract_path}") + rmtree(self.extract_path) + + def _check_and_set_files_attr(self, force: bool = False) -> None: + """Check and populate attributes from `self.path` and `self.files`. + + If `self.path` is a file, ensure `self.files = (self.path,)` and + raise a ValueError if not. + + If `self.path` is a directory, then collect all `tuple` of `Paths` + in that directory matching `self.glob_regex_str`, or just all files + if `self.glob_regex_str` is `None`. If `self.files` is set check if + that `tuple` + + Example: + ```pycon + >>> plaintext_lwm = getfixture('bl_lwm_plaintext') + >>> len(plaintext_lwm) + 2 + >>> plaintext_lwm._check_and_set_files_attr() + [...]...DEBUG...No changes from... + ...>> plaintext_lwm.path = ( + ... 'tests/test_plaintext/bl_lwm/0003548_plaintext.zip') + >>> plaintext_lwm._check_and_set_files_attr() + Traceback (most recent call last): + ... + ValueError:...`path` inconsistent with `files`... + >>> len(plaintext_lwm) + 2 + >>> plaintext_lwm._check_and_set_files_attr(force=True) + DEBUG...Force change to...>> plaintext_lwm.files + ('tests/test_plaintext/bl_lwm/0003548_plaintext.zip',) + >>> len(plaintext_lwm) + 1 + + ``` + """ + if Path(self.path).is_file(): + self._set_and_check_path_is_file(force=force) + elif Path(self.path).is_dir(): + self._set_and_check_path_is_dir(force=force) + else: + raise ValueError( + f"`self.path` must be a file or directory. " f"Currently: {self.path}" + ) + + def _check_and_set_data_provider(self, force: bool = False) -> None: + """Set `self.data_provider` and check `self.data_provider_code`.""" + if self.data_provider_code: + if self.data_provider: + if self.data_provider["fields"]["code"] != self.data_provider_code: + raise ValueError( + f"`self.data_provider_code` {self.data_provider_code} " + f"!= {self.data_provider} (`self.data_provider`)." + ) + else: + logger.debug( + f"{repr(self)} `self.data_provider['fields']['code']` " + f"== `self.data_provider_code`" + ) + else: + if self.data_provider_code in self.data_provider_code_dict: + self.data_provider = self.data_provider_code_dict[ + self.data_provider_code + ] + else: + raise ValueError( + f"`self.data_provider_code` {self.data_provider_code} " + f"not included in `self.data_provider_code_dict`." + f"Available `codes`: {self.data_provider_code_dict.keys()}" + ) + elif self.data_provider: + self.data_provider_code = self.data_provider["fields"]["code"] + else: + logger.debug( + f"Neither `self.data_provider` nor " + f"`self.data_provider_code` provided; both are `None` for {repr(self)}" + ) + + @property + def free_hd_space_in_GB(self) -> float: + """Return remaing hard drive space estimate in gigabytes.""" + return free_hd_space_in_GB(self._disk_usage) + + # @property + # def download_dir(self): + # """Path to the download directory for full text data. + # + # The `DOWNLOAD_DIR` attribute contains the directory under + # which full text data will be stored. Users can change it by + # setting `Item.DOWNLOAD_DIR = "/path/to/wherever/"` + # """ + # return Path(self.DOWNLOAD_DIR) + + @property + def text_archive_dir(self) -> Path: + """Path to the storage directory for full text archives.""" + return Path(self.download_dir) / self.archive_subdir + + @property + def text_extracted_dir(self) -> Path: + """Path to the storage directory for extracted full text files.""" + return Path(self.download_dir) / self.extracted_subdir + + # @property + # def zip_file(self): + # """Filename for this Item's zip archive containing the full text.""" + # return f"{self.issue.newspaper.publication_code}_plaintext.zip" + + @property + def text_container(self) -> str: + """Azure blob storage container containing the Item full text.""" + return f"{self.data_provider_name}{self.fulltext_container_suffix}" + + # @property + # def issue_sub_paths(self, publication_code: str | None = None) -> Generator[str, None, None]: + # """Return `issue_sub_paths` of `publication_code`, else all `issue_sub_paths`""" + # if publication_code: + # return + + @property + def text_paths(self): + """Return a list of paths relative to the full text file for + `self.data_provider_name` + + This is generated from the zip archive (once downloaded and + extracted) from the `DOWNLOAD_DIR` and the filename. + """ + return Path(self.issue.input_sub_path) / self.input_filename + + # @property + # def text_path(self): + # """Return a path relative to the full text file for this Item. + # + # This is generated from the zip archive (once downloaded and + # extracted) from the `DOWNLOAD_DIR` and the filename. + # """ + # return Path(self.issue.input_sub_path) / self.input_filename + + # Commenting this out as it will fail with the dev on #56 (see branch kallewesterling/issue56). + # As this will likely not be the first go-to for fulltext access, we can keep it as a method: + # .extract_fulltext() + # + # @property + # def fulltext(self): + # try: + # return self.extract_fulltext() + # except Exception as ex: + # print(ex) + + def is_downloaded(self): + """Check whether a text archive has already been downloaded.""" + file = self.text_archive_dir / self.zip_file + if not os.path.exists(file): + return False + return os.path.getsize(file) != 0 + + def download_zip(self): + """Download this Item's full text zip archive from cloud storage.""" + sas_token = os.getenv(self.SAS_ENV_VARIABLE).strip('"') + if sas_token is None: + raise KeyError( + f"The environment variable {self.SAS_ENV_VARIABLE} was not found." + ) + + url = self.FULLTEXT_STORAGE_ACCOUNT_URL + container = self.text_container + blob_name = str(Path(self.FULLTEXT_CONTAINER_PATH) / self.zip_file) + download_file_path = self.text_archive_dir / self.zip_file + + # Make sure the archive download directory exists. + self.text_archive_dir.mkdir(parents=True, exist_ok=True) + + if not os.path.exists(self.text_archive_dir): + raise RuntimeError( + f"Failed to make archive download directory at {self.text_archive_dir}" + ) + + # Download the blob archive. + try: + client = BlobClient( + url, container, blob_name=blob_name, credential=sas_token + ) + + with open(download_file_path, "wb") as download_file: + download_file.write(client.download_blob().readall()) + + except Exception as ex: + if "status_code" in str(ex): + print("Zip archive download failed.") + print( + f"Ensure the {self.SAS_ENV_VARIABLE} env variable contains a valid SAS token" + ) + + if os.path.exists(download_file_path): + if os.path.getsize(download_file_path) == 0: + os.remove(download_file_path) + print(f"Removing empty download: {download_file_path}") + + def extract_fulltext_file(self): + """Extract Item's full text file from a zip archive to DOWNLOAD_DIR.""" + archive = self.text_archive_dir / self.zip_file + with ZipFile(archive, "r") as zip_ref: + zip_ref.extract(str(self.text_path), path=self.text_extracted_dir) + + def read_fulltext_file(self) -> list[str]: + """Read the full text for this Item from a file.""" + with open(self.text_extracted_dir / self.text_path) as f: + lines = f.readlines() + return lines + + def extract_fulltext(self) -> list[str]: + """Extract the full text of this newspaper item.""" + # If the item full text has already been extracted, read it. + if os.path.exists(self.text_extracted_dir / self.text_path): + return self.read_fulltext_file() + + if self.FULLTEXT_METHOD == "download": + # If not already available locally, download the full text archive. + if not self.is_downloaded(): + self.download_zip() + + if not self.is_downloaded(): + raise RuntimeError( + f"Failed to download full text archive for item {self.item_code}: Expected finished download." + ) + + # Extract the text for this item. + self.extract_fulltext_file() + + elif self.FULLTEXT_METHOD == "blobfuse": + raise NotImplementedError("Blobfuse access is not yet implemented.") + blobfuse = "/mounted/blob/storage/path/" + zip_path = blobfuse / self.zip_file + + else: + raise RuntimeError( + "A valid fulltext access method must be selected: options are 'download' or 'blobfuse'." + ) + + # If the item full text still hasn't been extracted, report failure. + if not os.path.exists(self.text_extracted_dir / self.text_path): + raise RuntimeError( + f"Failed to extract fulltext for {self.item_code}; path does not exist: {self.text_extracted_dir / self.text_path}" + ) + + return self.read_fulltext_file() + + +# def unzip_plaintext(path: PathLike) -> list[FixtureDict]: +# for diff --git a/alto2txt2fixture/router.py b/alto2txt2fixture/router.py index 4f62d7d..800068b 100644 --- a/alto2txt2fixture/router.py +++ b/alto2txt2fixture/router.py @@ -708,7 +708,7 @@ class DataProvider(Cache): >>> hmd.pk 2 >>> pprint(hmd.as_dict()) - {'code': 'bl-hmd', + {'code': 'bl_hmd', 'collection': 'newspapers', 'legacy_code': 'hmd', 'name': 'Heritage Made Digital', diff --git a/alto2txt2fixture/settings.py b/alto2txt2fixture/settings.py index 90e51ee..bf518c1 100644 --- a/alto2txt2fixture/settings.py +++ b/alto2txt2fixture/settings.py @@ -21,20 +21,18 @@ """ from typing import Final, Literal, TypeAlias -from .types import FixtureDict, dotdict +from .types import DataProviderFixtureDict, dotdict # To understand the settings object, see documentation. JSON_INDENT: int = 2 - DATA_PROVIDER_INDEX: Final[str] = "legacy_code" - SETUP_TITLE: str = "alto2txt2fixture setup" - EXPORT_FORMATS: TypeAlias = Literal["json", "csv"] -NEWSPAPER_COLLECTION_METADATA: Final[list[FixtureDict]] = [ - FixtureDict( + +NEWSPAPER_COLLECTION_METADATA: Final[list[DataProviderFixtureDict]] = [ + DataProviderFixtureDict( pk=1, model="newspapers.dataprovider", fields={ @@ -45,18 +43,18 @@ "source_note": "FindMyPast-funded digitised newspapers provided by the British Newspaper Archive", }, ), - FixtureDict( + DataProviderFixtureDict( pk=2, model="newspapers.dataprovider", fields={ "name": "Heritage Made Digital", - "code": "bl-hmd", + "code": "bl_hmd", "legacy_code": "hmd", "collection": "newspapers", "source_note": "British Library-funded digitised newspapers provided by the British Newspaper Archive", }, ), - FixtureDict( + DataProviderFixtureDict( pk=3, model="newspapers.dataprovider", fields={ @@ -67,7 +65,7 @@ "source_note": "JISC-funded digitised newspapers provided by the British Newspaper Archive", }, ), - FixtureDict( + DataProviderFixtureDict( pk=4, model="newspapers.dataprovider", fields={ @@ -80,6 +78,12 @@ ), ] + +NEWSPAPER_DATA_PROVIDER_CODE_DICT: dict[str, DataProviderFixtureDict] = { + provider["fields"]["code"]: provider for provider in NEWSPAPER_COLLECTION_METADATA +} + + settings: dotdict = dotdict( **{ "MOUNTPOINT": "./input/alto2txt/", diff --git a/alto2txt2fixture/types.py b/alto2txt2fixture/types.py index 2694464..59f435e 100644 --- a/alto2txt2fixture/types.py +++ b/alto2txt2fixture/types.py @@ -1,4 +1,7 @@ -from typing import Any, NamedTuple, TypedDict +from typing import Any, Literal, NamedTuple, TypedDict + +LEGACY_NEWSPAPER_OCR_FORMATS = Literal["bna", "hmd", "jisc", "lwm"] +NEWSPAPER_OCR_FORMATS = Literal["fmp", "bl_hmd", "jisc", "bl_lwm"] class dotdict(dict): @@ -9,18 +12,65 @@ class dotdict(dict): __delattr__ = dict.__delitem__ -class FixtureDict(TypedDict): +class FixtureDictBaseClass(TypedDict): + """A base `dict` structure for `json` fixtures.""" + + pk: int + model: str + + +class FixtureDict(FixtureDictBaseClass): """A `dict` structure to ease use as a `json` database fixture. Attributes: pk: an id to uniquely define and query each entry model: what model a given record is for - fields: a `dict` of record information conforming to ``model`` table + fields: a `dict` of record information conforming to `model` table + """ + + fields: dict[str, Any] + + +class DataProviderFieldsDict(TypedDict): + """Fields within the `fields` portion of a `FixtureDict` to fit `lwmdb`. + + Attributes: + name: + The name of the collection data source. For `lwmdb` this should + be less than 600 characters. + code: + A short slug-like, url-compatible (replace spaces with `-`) + `str` to uniquely identify a data provider in `urls`, `api` calls etc. + Designed to fit `NEWSPAPER_OCR_FORMATS` and any future slug-like codes. + legacy_code: + Either blank or a legacy slug-like, url-compatible (replace spaces with + `-`) `str` originally used by `alto2txt` following + `LEGACY_NEWSPAPER_OCR_FORMATSNEWSPAPER_OCR_FORMATS`. + collection: + Data Provider type. + source_note: + A sentence about the data provider. + """ + + name: str + code: str | NEWSPAPER_OCR_FORMATS + legacy_code: LEGACY_NEWSPAPER_OCR_FORMATS | None + collection: str + source_note: str | None + + +class DataProviderFixtureDict(FixtureDictBaseClass): + """A `dict` structure for `DataProvider` sources in line with `lwmdb`. + + Attributes: + pk: an id to uniquely define and query each entry + model: what model a given record is for + fields: a `DataProviderFieldsDict` """ pk: int model: str - fields: dict[str, Any] + fields: DataProviderFieldsDict class TranslatorTuple(NamedTuple): diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index b269a55..6053f32 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -3,8 +3,9 @@ import json import logging from collections import OrderedDict -from os import PathLike +from os import PathLike, getcwd from pathlib import Path +from shutil import disk_usage, get_unpack_formats from typing import ( Any, Final, @@ -12,6 +13,7 @@ Hashable, Iterable, Literal, + NamedTuple, Sequence, TypeAlias, ) @@ -19,6 +21,7 @@ import pytz from numpy import array_split from pandas import DataFrame +from rich.console import Console from rich.logging import RichHandler from rich.table import Table @@ -33,7 +36,6 @@ from .types import FixtureDict FORMAT: str = "%(message)s" -NewspaperElements: Final[TypeAlias] = Literal["newspaper", "issue", "item"] logging.basicConfig( level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] @@ -41,6 +43,19 @@ logger = logging.getLogger("rich") +console = Console() + +VALID_COMPRESSION_FORMATS: Final[tuple[str, ...]] = tuple( + [ + extension + for format_tuple in get_unpack_formats() + for extension in format_tuple[1] + ] +) +BYTES_PER_GIGABYTE: Final[int] = 1024 * 1024 * 1024 + +NewspaperElements: Final[TypeAlias] = Literal["newspaper", "issue", "item"] + def get_now(as_str: bool = False) -> datetime.datetime | str: """ @@ -535,7 +550,7 @@ def dict_from_list_fixture_fields( >>> fixture_dict['hmd']['fields'][DATA_PROVIDER_INDEX] 'hmd' >>> fixture_dict['hmd']['fields']['code'] - 'bl-hmd' + 'bl_hmd' ``` """ @@ -552,10 +567,11 @@ def fixture_or_default_dict( Args: key: a `str` to query ``fixture_dict`` with - fixture_dict: a `dict` of `str` to `FixtureDict`, often generated by - ``dict_from_list_fixture_fields`` - default_dict: a `dict` to return if ``key`` is not in - ``fixture_dict`` index + fixture_dict: + a `dict` of `str` to `FixtureDict`, often generated by + ``dict_from_list_fixture_fields`` + default_dict: + a `dict` to return if ``key`` is not in ``fixture_dict`` index Example: ```pycon @@ -607,7 +623,7 @@ def check_newspaper_collection_configuration( >>> check_newspaper_collection_configuration() set() >>> unmatched: set[str] = check_newspaper_collection_configuration( - ... ["cat", "dog"]) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + ... ["cat", "dog"]) ...Warning: 2 `collections` not in `newspaper_collections`: ... >>> unmatched == {'dog', 'cat'} @@ -656,7 +672,7 @@ def fixture_fields( >>> hmd_dict: dict[str, Any] = fixture_fields( ... NEWSPAPER_COLLECTION_METADATA[1], as_dict=True) >>> hmd_dict['code'] - 'bl-hmd' + 'bl_hmd' >>> hmd_dict['pk'] 2 >>> hmd_dict = fixture_fields( @@ -731,13 +747,19 @@ def save_fixture( is determined by the ``max_elements_per_file`` parameter. Args: - generator: A generator that yields the fixtures to be saved. - prefix: A string prefix to be added to the file names of the + generator: + A generator that yields the fixtures to be saved. + prefix: + A string prefix to be added to the file names of the saved fixtures. - output_path: Path to folder fixtures are saved to - max_elements_per_file: Maximum `JSON` records saved in each file - add_created: Whether to add `created_at` and `updated_at` `timestamps` - json_indent: Number of indent spaces per line in saved `JSON` + output_path: + Path to folder fixtures are saved to + max_elements_per_file: + Maximum `JSON` records saved in each file + add_created: + Whether to add `created_at` and `updated_at` `timestamps` + json_indent: + Number of indent spaces per line in saved `JSON` Returns: @@ -807,11 +829,15 @@ def fixtures_dict2csv( is determined by the ``max_elements_per_file`` parameter. Args: - fixtures: An `Iterable` or `Generator` of the fixtures to be saved. - prefix: A string prefix to be added to the file names of the + fixtures: + An `Iterable` or `Generator` of the fixtures to be saved. + prefix: + A string prefix to be added to the file names of the saved fixtures. - output_path: Path to folder fixtures are saved to - max_elements_per_file: Maximum `JSON` records saved in each file + output_path: + Path to folder fixtures are saved to + max_elements_per_file: + Maximum `JSON` records saved in each file Returns: This function saves fixtures to files and does not return a value. @@ -870,10 +896,14 @@ def export_fixtures( for production. Args: - fixture_tables: `dict` of table name (eg: `dataprovider`) and `FixtureDict` - path: Path to save exports in - prefix: `str` to prefix export filenames with - formats: list of `EXPORT_FORMATS` to export + fixture_tables: + `dict` of table name (eg: `dataprovider`) and `FixtureDict` + path: + `Path` to save exports in + prefix: + `str` to prefix export filenames with + formats: + `list` of `EXPORT_FORMATS` to export Example: ```pycon @@ -881,7 +911,6 @@ def export_fixtures( ... 'test0': NEWSPAPER_COLLECTION_METADATA, ... 'test1': NEWSPAPER_COLLECTION_METADATA} >>> export_fixtures(test_fixture_tables, path='tests/') - ... # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE ...Warning: Saving test0... ...Warning: Saving test1... @@ -920,3 +949,98 @@ def export_fixtures( ) if "csv" in formats: fixtures_dict2csv(records, prefix=f"{prefix}{table_name}", output_path=path) + + +def path_globs_to_tuple( + path: PathLike, glob_regex_str: str = "*" +) -> tuple[PathLike, ...]: + """Return `glob` from `path` using `glob_regex_str` as a tuple. + + Args: + path: + Patch to search via `glob` + + glob_regex_str: + Regular expression to use with `glob` at `path` + + Returns: + `tuple` of matching paths. + + Example: + ```pycon + >>> from pprint import pprint + >>> pprint(path_globs_to_tuple('tests/test_plaintext/bl_lwm', '*text.zip')) + (PosixPath('tests/test_plaintext/bl_lwm/0003079_plaintext.zip'), + PosixPath('tests/test_plaintext/bl_lwm/0003548_plaintext.zip')) + + ``` + + """ + return tuple(Path(path).glob(glob_regex_str)) + + +class DiskUsageTuple(NamedTuple): + + """Type hint for `nametuple` returned from `disk_usage`.""" + + total: int + used: int + free: int + + +def free_hd_space_in_GB( + disk_usage_tuple: DiskUsageTuple | None = None, path: PathLike | None = None +) -> float: + """Return remaing hard drive space estimate in gigabytes. + + Args: + disk_usage_tuple: + A `NamedTuple` normally returned from `disk_usage()` or `None`. + + path: + A `path` to pass to `disk_usage` if `disk_usage_tuple` is `None`. + + Returns: + A `float` from dividing the `disk_usage_tuple.free` value by `BYTES_PER_GIGABYTE` + + Example: + ```pycon + >>> space_in_gb = free_hd_space_in_GB() + >>> space_in_gb > 1 # Hopefully true wherever run... + True + + ``` + """ + if not disk_usage_tuple: + if not path: + path = Path(getcwd()) + disk_usage_tuple = disk_usage(path=path) + assert disk_usage_tuple + return disk_usage_tuple.free / BYTES_PER_GIGABYTE + + +def valid_compression_files(files: Sequence[PathLike]) -> list[PathLike]: + """Return a `tuple` of valid compression paths in `files`. + + Args: + files: + `Sequence` of files to filter compression types from. + + Returns: + A list of files that could be decompressed. + + Example: + ```pycon + >>> valid_compression_files([ + ... 'cat.tar.bz2', 'dog.tar.bz3', 'fish.tgz', 'bird.zip', + ... 'giraffe.txt', 'frog' + ... ]) + ['cat.tar.bz2', 'fish.tgz', 'bird.zip'] + + ``` + """ + return [ + file + for file in files + if "".join(Path(file).suffixes) in VALID_COMPRESSION_FORMATS + ] diff --git a/tests/conftest.py b/conftest.py similarity index 58% rename from tests/conftest.py rename to conftest.py index b8484b2..a71cc05 100644 --- a/tests/conftest.py +++ b/conftest.py @@ -5,29 +5,40 @@ from coverage_badge.__main__ import main as gen_cov_badge from alto2txt2fixture.create_adjacent_tables import OUTPUT, run +from alto2txt2fixture.plaintext import PlainTextFixture from alto2txt2fixture.utils import load_multiple_json +MODULE_PATH: Path = Path().absolute() + BADGE_PATH: Path = Path("docs") / "img" / "coverage.svg" -HMD_PLAINTEXT_FIXTURE: Path = Path("tests") / "0002645_plaintext.zip" +HMD_PLAINTEXT_FIXTURE: Path = ( + Path("tests") / "test_plaintext" / "bl_hmd" +) # "0002645_plaintext.zip" +LWM_PLAINTEXT_FIXTURE: Path = Path("tests") / "test_plaintext" / "bl_lwm" -@pytest.fixture -def hmd_metadata_fixture() -> Path: - """Path for 0002645 1853 metadata fixture.""" - return Path("tests") / "0002645_metadata.zip" +# @pytest.fixture +# def hmd_metadata_fixture() -> Path: +# """Path for 0002645 1853 metadata fixture.""" +# return Path("tests") / "0002645_metadata.zip" +# +# +# @pytest.fixture +# def hmd_plaintext_fixture() -> Path: +# """Path for 0002645 1853 plaintext fixture.""" +# return HMD_PLAINTEXT_FIXTURE @pytest.fixture -def hmd_plaintext_fixture() -> Path: - """Path for 0002645 1853 plaintext fixture.""" - return Path("tests") / "0002645_plaintext.zip" +def uncached_folder(monkeypatch, tmpdir) -> None: + """Change local path to avoid using pre-cached data.""" + monkeypatch.chdir(tmpdir) -@pytest.fixture -def uncached_folder(monkeypatch, tmpdir) -> Path: - """Change local path to avoid using pre-cached data.""" - return monkeypatch.chdir(tmpdir) +@pytest.fixture(autouse=True) +def package_path(monkeypatch) -> None: + monkeypatch.chdir(MODULE_PATH) @pytest.mark.downloaded @@ -48,6 +59,11 @@ def all_create_adjacent_tables_json_results() -> Generator[list, None, None]: yield load_multiple_json(Path(OUTPUT)) +@pytest.fixture +def bl_lwm_plaintext() -> PlainTextFixture: + return PlainTextFixture(path=LWM_PLAINTEXT_FIXTURE, data_provider_code="bl_lwm") + + def pytest_sessionfinish(session, exitstatus): """Generate badges for docs after tests finish.""" if exitstatus == 0: diff --git a/pyproject.toml b/pyproject.toml index 9a918f7..4db5b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ markers = [ "slow: slow (deselect with '-m \"not slow\"')", "download: requires downloading (deselect with '-m \"not download\"')", ] +doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"] [tool.mypy] python_version = "3.11" diff --git a/tests/test_newspaper.py b/tests/test_newspaper.py index 49ecfe2..56eaa4d 100644 --- a/tests/test_newspaper.py +++ b/tests/test_newspaper.py @@ -15,7 +15,7 @@ def test_newspaper_show_tables( ) -> None: """Test using `test_config` to only print out run config.""" collections_config_snippet: str = "COLLECTIONS │ ['hmd', 'lwm', 'jisc', 'bna'] │" - fixture_config_snipit: str = "bl-hmd │ hmd" + fixture_config_snipit: str = "bl_hmd │ hmd" with pytest.raises(SystemExit) as e_info: run([test_config_param]) assert e_info.traceback[1].path.name == "__main__.py" From 907f1bf1d3c4762ba6ab92d21ea3dfade12fa237 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 22 Aug 2023 05:18:02 +0100 Subject: [PATCH 03/23] feat(plaintext): add skeletal structure for generating json fixtures --- alto2txt2fixture/plaintext.py | 188 +++++++++++++++++++++++++++++++++- alto2txt2fixture/types.py | 41 ++++++++ 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 42605b6..6b949f2 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -1,18 +1,26 @@ +from collections import OrderedDict from dataclasses import dataclass, field from logging import getLogger from os import PathLike from pathlib import Path from shutil import disk_usage, rmtree, unpack_archive -from typing import Final, Generator +from typing import Final, Generator, TypedDict from zipfile import ZipFile, ZipInfo +from tqdm.rich import tqdm + from .settings import NEWSPAPER_DATA_PROVIDER_CODE_DICT -from .types import DataProviderFixtureDict +from .types import ( + DataProviderFixtureDict, + PlaintextFixtureDict, + PlaintextFixtureFieldsDict, +) from .utils import ( DiskUsageTuple, console, free_hd_space_in_GB, path_globs_to_tuple, + save_fixture, valid_compression_files, ) @@ -35,6 +43,9 @@ FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX: Final[ str ] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{FULLTEXT_FILE_COMPRESSED_EXTENSION}" +DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE: Final[int] = 2000 +DEFAULT_PLAINTEXT_FILE_NAME_PREFIX: Final[str] = "plaintext_fixture_" +DEFAULT_PLAINTEXT_FIXTURE_OUTPUT: Final[PathLike] = Path("output") / "plaintext" SAS_ENV_VARIABLE = "FULLTEXT_SAS_TOKEN" @@ -88,11 +99,82 @@ # return f"{publication_code}{suffix}.{extension}" +class FulltextPathDict(TypedDict): + """A `dict` for storing fixture paths and primary key. + + Attributes: + + path: + Plaintext file path. + + compressed_path: + If `path` is within a compressed file, + `compressed_path` is that source. Else None. + + primary_key: + An `int` >= 1 for `SQL` fixture `pk`. + """ + + path: PathLike + compressed_path: PathLike | None + primary_key: int + + @dataclass class PlainTextFixture: """Convert `plaintext` results from `alto2txt` into `json` fixtures. + Attributes: + path: + PathLike source for fixtures as either a folder or file. + + data_provider_code: + A short string to uniquely identify `DataProviders`, + primarily to match sources in `self.data_provider_code_dict`. + + files: + A iterable `PathLike` collection of to either read as + plaintext or decomepress to extract plaintext. + + glob_regex_str: + A Regular Expression to filter plaintext files from uncompressed + `self.files`, more specifically `self.compressed_files`. + + data_provider: + If available a `DataProviderFixtureDict` for `DataProvider` metadata. + By default all options are stored in `self.data_provider_code_dict`. + + model: + Name of `lwmdb` model the exported `json` `fixture` is designed for. + + extract_subdir: + Folder to extract `self.compressed_files` to. + + plaintext_extension: + What file extension to use to filter `plaintext` files. + + data_provider_code_dict: + A `dict` of metadata for preconfigured `DataProvider` records in `lwmdb`. + + max_plaintext_per_fixture_file: + A maximum number of fixtures per fixture file, designed to configure + chunking fixtures. + + fixture_prefix: + A `str` to prefix all saved `json` fixture filenames. + + export_directory: + Directory to save all exported fixtures to. + + _disk_usage: + Available harddrive space. Designed to help mitigate decompressing too + many files for available disk space. + + self._uncompressed_source_file_dict: + A dictionary of extracted plaintext to compressed source file. This is + a field in `json` fixture records. + Example: ```pycon >>> from pprint import pprint @@ -154,18 +236,26 @@ class PlainTextFixture: model: str = FULLTEXT_DJANGO_MODEL archive_subdir: PathLike = ARCHIVE_SUBDIR extract_subdir: PathLike = EXTRACTED_SUBDIR + plaintext_extension: str = "txt" # decompress_subdir: PathLike = FULLTEXT_DECOMPRESSED_PATH download_dir: PathLike = DOWNLOAD_DIR fulltext_container_suffix: str = FULLTEXT_CONTAINER_SUFFIX data_provider_code_dict: dict[str, DataProviderFixtureDict] = field( default_factory=lambda: NEWSPAPER_DATA_PROVIDER_CODE_DICT ) + max_plaintext_per_fixture_file: int = DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE + fixture_prefix: str = DEFAULT_PLAINTEXT_FILE_NAME_PREFIX + export_directory: PathLike = DEFAULT_PLAINTEXT_FIXTURE_OUTPUT def __post_init__(self) -> None: """Manage populating additional attributes if necessary.""" self._check_and_set_files_attr(force=True) self._check_and_set_data_provider(force=True) self._disk_usage: DiskUsageTuple = disk_usage(self.path) + self._uncompressed_source_file_dict: OrderedDict[ + PathLike, PathLike + ] = OrderedDict() + self._pk_plaintext_dict: OrderedDict[PathLike, int] = OrderedDict() def __len__(self) -> int: """Return the number of files to process.""" @@ -253,6 +343,27 @@ def compressed_files(self) -> tuple[PathLike, ...]: """Return a tuple of all `self.files` with known archive filenames.""" return tuple(valid_compression_files(files=self.files)) if self.files else () + # @property + # def decompressed_paths_generator(self) -> Generator[Path, None, None]: + # """Return a generated of all `self.files` with known archive filenames.""" + # if self.compressed_files and not self.extract_path.exists(): + # console.print('Compressed files not yet extracted. Try `extract_compression()`.') + # else: + # for path in Path(self.extract_path).glob(f'*{self.plaintext_extension}'): + # yield path + + @property + def plaintext_provided_uncompressed(self) -> tuple[PathLike, ...]: + """Return a tuple of all `self.files` with `self.plaintext_extension`.""" + if self.files: + return tuple( + file + for file in self.files.glob() + if Path(file).suffix == self.plaintext_extension + ) + else: + return () + @property def zipinfo(self) -> Generator[list[ZipInfo], None, None]: """If `self.compressed_files` is in `zip`, return info, else None.""" @@ -269,9 +380,80 @@ def extract_compressed(self) -> None: """Extract `self.compressed_files` to `self.extracted_subdir_name`.""" self.extract_path.mkdir(parents=True, exist_ok=True) console.log(f"Extract path: {self.extract_path}") - for compressed_file in self.compressed_files: + for compressed_file in tqdm( + self.compressed_files, + desc=f"Compressed files from {repr(self)}", + ): console.log(f"Extracting: {compressed_file} ...") unpack_archive(compressed_file, self.extract_path) + for path in self.extract_path.glob(f"*{self.plaintext_extension}"): + if path not in self._uncompressed_source_file_dict: + self._uncompressed_source_file_dict[path] = compressed_file + + # @property + # def is_likely_decompressed(self) -> bool: + # """Return estimate if all `self.compressed_files` are decompressed.""" + # if self.extract_path.exists(): + # return all( for file in self.compressed_files) + # return all() + + def plaintext_paths(self) -> Generator[FulltextPathDict, None, None]: + """Return a generator of all `plaintext` files for potential fixtures.""" + if self.compressed_files and not self.extract_path.exists(): + console.print( + "Compressed files not yet extracted. Try `extract_compression()`." + ) + else: + i: int = 0 + pk: int + for i, uncompressed_tuple in enumerate( + tqdm( + self._uncompressed_source_file_dict.items(), + desc="Configuring compressed path configs", + ) + ): + pk = i + 1 # Most `SQL` `pk` begins at 1 + self._pk_plaintext_dict[uncompressed_tuple[0]] = pk + yield FulltextPathDict( + path=uncompressed_tuple[0], + compressed_path=uncompressed_tuple[1], + primary_key=pk, + ) + for j, path in enumerate( + tqdm( + self.plaintext_provided_uncompressed, + desc="Configuring uncompressed path configs", + ) + ): + pk = j + i + 1 + self._pk_plaintext_dict[path] = pk + yield FulltextPathDict(path=path, compressed_path=None, primary_key=pk) + + def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None]: + """Generate fixture dicts from `self.plaintext_paths`.""" + for plaintext_path_dict in tqdm( + self.plaintext_paths(), + desc=f"Processing {self.plaintext_extension} files to PlaintextFixtureDict", + ): + fields: PlaintextFixtureFieldsDict = PlaintextFixtureFieldsDict( + text=Path(plaintext_path_dict["path"]).read_text(), + path=plaintext_path_dict["path"], + compressed_path=plaintext_path_dict["compressed_path"], + ) + yield PlaintextFixtureDict( + model=self.model, + fields=fields, + pk=plaintext_path_dict["primary_key"], + ) + + def export_to_json_fixtures(self) -> None: + """Iterate over `self.plaintext_paths` exporting to `json` `django` fixtures.""" + save_fixture( + self.plaintext_paths_to_dicts(), + prefix=self.fixture_prefix, + output_path=self.export_directory, + add_created=True, + ) # def delete_compressed(self, index: int | str | None = None) -> None: def delete_decompressed(self) -> None: diff --git a/alto2txt2fixture/types.py b/alto2txt2fixture/types.py index 59f435e..f2001eb 100644 --- a/alto2txt2fixture/types.py +++ b/alto2txt2fixture/types.py @@ -1,3 +1,4 @@ +from os import PathLike from typing import Any, Literal, NamedTuple, TypedDict LEGACY_NEWSPAPER_OCR_FORMATS = Literal["bna", "hmd", "jisc", "lwm"] @@ -94,3 +95,43 @@ class TranslatorTuple(NamedTuple): start: str finish: str | list lst: list[dict] + + +class PlaintextFixtureFieldsDict(TypedDict): + + """A typed `dict` for Plaintext Fixutres to match `lwmdb.Fulltext` `model` + + Attributes: + text: + Plaintext, potentially quite large newspaper articles. + May have unusual or unreadable sequences of characters + due to issues with Optical Character Recognition quality. + path: + Path of provided plaintext file. If `compressed_path` is + `None`, this is the original relative `Path` of the `plaintext` file. + compressed_path: + The path of a compressed data source, the extraction of which provides + access to `plaintext` files. + """ + + text: str + path: PathLike + compressed_path: PathLike | None + + +class PlaintextFixtureDict(FixtureDictBaseClass): + """A `dict` structure for `Fulltext` sources in line with `lwmdb`. + + Attributes: + model: `str` in `django` fixture spec to indicate what model a record is for + fields: a `PlaintextFixtureFieldsDict` `dict` instance + pk: `int` id for fixture record + + Note: + No `pk` is included. By not specifying one, `django` should generate new onces during + import. + """ + + pk: int + model: str + fields: PlaintextFixtureFieldsDict From 4003d5e24f45c5c4d26738d4bf289d9ddcad8010 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 23 Aug 2023 17:45:52 +0100 Subject: [PATCH 04/23] feat(plaintext): add last component to basic `json` fixture export pipeline --- alto2txt2fixture/plaintext.py | 490 +++++++++++++--------------------- alto2txt2fixture/types.py | 5 +- conftest.py | 16 +- 3 files changed, 206 insertions(+), 305 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 6b949f2..bff9e5d 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -38,81 +38,31 @@ FULLTEXT_STORAGE_ACCOUNT_URL: str = "https://alto2txt.blob.core.windows.net" FULLTEXT_FILE_NAME_SUFFIX: Final[str] = "_plaintext" -FULLTEXT_FILE_COMPRESSED_EXTENSION: Final[str] = "zip" +ZIP_FILE_EXTENSION: Final[str] = "zip" FULLTEXT_DECOMPRESSED_PATH = Path("uncompressed/") FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX: Final[ str -] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{FULLTEXT_FILE_COMPRESSED_EXTENSION}" +] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{ZIP_FILE_EXTENSION}" +TXT_FIXTURE_FILE_EXTENSION: Final[str] = "txt" +TXT_FIXTURE_FILE_GLOB_REGEX: Final[str] = f"**/*.{TXT_FIXTURE_FILE_EXTENSION}" DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE: Final[int] = 2000 -DEFAULT_PLAINTEXT_FILE_NAME_PREFIX: Final[str] = "plaintext_fixture_" +DEFAULT_PLAINTEXT_FILE_NAME_PREFIX: Final[str] = "plaintext_fixture" DEFAULT_PLAINTEXT_FIXTURE_OUTPUT: Final[PathLike] = Path("output") / "plaintext" SAS_ENV_VARIABLE = "FULLTEXT_SAS_TOKEN" -# def fixture_to_json( -# fields: dict[str, Any], -# pk: str | int, -# model: str, -# # model: str = FULLTEXT_DJANGO_MODEL, -# # lwmdb_fulltext: bool = True -# ) -> FixtureDict: -# """Read `path` and construct a `dict` for a `django` fixture. -# -# Arguments: -# path: -# `PathLike` `path` to fixture -# pk: -# `django` record primary key (id) -# model: -# `django` `model` the record is saved in -# lwmdb_fulltext: -# Whether to include extra elements in `json` specific to `lwmdb` -# -# Returns: -# A `dict` of `plaintext` for `json` export as a `django` `fixture`. -# -# Example: -# ```pycon -# >>> fulltext_zip_path = getfixture('hmd_plaintext_fixture') -# >>> fixture_to_json() -# -# ``` -# """ -# # fields: dict[str, Any] = -# return {'pk': pk, 'model': model, 'fields': fields} - - -# def compressed_file_name_from_publication_code( -# publication_code: str, -# suffix: str = FULLTEXT_FILE_NAME_SUFFIX, -# extension: str = FULLTEXT_FILE_COMPRESSED_EXTENSION) -> str: -# """Filename for an Item's archive containing the full text. -# -# Example: -# ```pycon -# >>> zip_file_name('0002645') -# 0002645_plaintext.zip -# >>> zip_file_name('0002645', suffix='-diff-suff', extension='bz2') -# 0002645-diff-suff.bz2 -# ``` -# """ -# return f"{publication_code}{suffix}.{extension}" - class FulltextPathDict(TypedDict): """A `dict` for storing fixture paths and primary key. Attributes: - path: Plaintext file path. - compressed_path: If `path` is within a compressed file, `compressed_path` is that source. Else None. - primary_key: - An `int` >= 1 for `SQL` fixture `pk`. + An `int >= 1` for a `SQL` table primary key (`pk`). """ path: PathLike @@ -137,7 +87,7 @@ class PlainTextFixture: A iterable `PathLike` collection of to either read as plaintext or decomepress to extract plaintext. - glob_regex_str: + compressed_glob_regex: A Regular Expression to filter plaintext files from uncompressed `self.files`, more specifically `self.compressed_files`. @@ -145,8 +95,16 @@ class PlainTextFixture: If available a `DataProviderFixtureDict` for `DataProvider` metadata. By default all options are stored in `self.data_provider_code_dict`. - model: - Name of `lwmdb` model the exported `json` `fixture` is designed for. + model_str: + The name of the `lwmdb` `django` model the fixture is for. This is of + the form `app_name.model_name`. Following the default config for `lwmdb`: + ```python + FULLTEXT_DJANGO_MODEL: Final[str] = "fulltext.fulltext" + ``` + the `fulltext` app has a `fulltext` `model` `class` specified in + `lwmdb.fulltext.models.fulltext`. A `sql` table is generated from + on that `fulltext` `class` and the `json` `fixture` structure generated + from this class is where records will be stored. extract_subdir: Folder to extract `self.compressed_files` to. @@ -178,35 +136,26 @@ class PlainTextFixture: Example: ```pycon >>> from pprint import pprint - >>> plain_text_bl_lwm = PlainTextFixture( + >>> plaintext_bl_lwm = PlainTextFixture( ... data_provider_code='bl_lwm', ... path='tests/test_plaintext/bl_lwm', - ... glob_regex_str="*_plaintext.zip", + ... compressed_glob_regex="*_plaintext.zip", ... ) - >>> plain_text_bl_lwm + >>> plaintext_bl_lwm - >>> str(plain_text_bl_lwm) + >>> str(plaintext_bl_lwm) "PlainTextFixture for 2 'bl_lwm' files" - >>> plain_text_bl_lwm.free_hd_space_in_GB > 1 + >>> plaintext_bl_lwm.free_hd_space_in_GB > 1 True - >>> pprint(plain_text_bl_lwm.compressed_files) + >>> pprint(plaintext_bl_lwm.compressed_files) (PosixPath('tests/test_plaintext/bl_lwm/0003079_plaintext.zip'), PosixPath('tests/test_plaintext/bl_lwm/0003548_plaintext.zip')) - >>> zipfile_info_list = list(plain_text_bl_lwm.zipinfo) - Getting zipfile info from - >>> zipfile_info_list[0][-1].filename - '0003079/1898/0114/0003079_18980114_art0041.txt' - >>> zipfile_info_list[-1][-1].filename - '0003548/1904/0616/0003548_19040616_art0053.txt' - >>> zipfile_info_list[-1][-1].file_size - 2460 - >>> zipfile_info_list[-1][-1].compress_size - 1187 - >>> plain_text_bl_lwm.extract_compressed() + >>> plaintext_bl_lwm.extract_compressed() [...] Extract path:...tests/test_plaintext/bl_lwm/extracted... ...Extracting:...tests/test_plaintext/bl_lwm/0003079_plaintext.zip ... ...Extracting:...tests/test_plaintext/bl_lwm/0003548_plaintext.zip ... - >>> plain_text_bl_lwm.delete_decompressed() + 100%...━━━━━━━━━...[...] + >>> plaintext_bl_lwm.delete_decompressed() Deleteing all files in: tests/test_plaintext/bl_lwm/extracted ``` @@ -229,14 +178,15 @@ class PlainTextFixture: path: PathLike data_provider_code: str | None = None files: tuple[PathLike, ...] | None = None - glob_regex_str: str = FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX + compressed_glob_regex: str = FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX # format: str # mount_path: PathLike | None = Path(settings.MOUNTPOINT) data_provider: DataProviderFixtureDict | None = None - model: str = FULLTEXT_DJANGO_MODEL + model_str: str = FULLTEXT_DJANGO_MODEL archive_subdir: PathLike = ARCHIVE_SUBDIR extract_subdir: PathLike = EXTRACTED_SUBDIR - plaintext_extension: str = "txt" + plaintext_extension: str = TXT_FIXTURE_FILE_EXTENSION + plaintext_glob_regex: str = TXT_FIXTURE_FILE_GLOB_REGEX # decompress_subdir: PathLike = FULLTEXT_DECOMPRESSED_PATH download_dir: PathLike = DOWNLOAD_DIR fulltext_container_suffix: str = FULLTEXT_CONTAINER_SUFFIX @@ -287,6 +237,11 @@ def data_provider_name(self) -> str | None: """ return self.data_provider_code if self.data_provider_code else None + @property + def free_hd_space_in_GB(self) -> float: + """Return remaing hard drive space estimate in gigabytes.""" + return free_hd_space_in_GB(self._disk_usage) + def _set_and_check_path_is_file(self, force: bool = False) -> None: """Test if `self.path` is a file and change if `force = True`.""" assert Path(self.path).is_file() @@ -310,7 +265,7 @@ def _set_and_check_path_is_dir(self, force: bool = False) -> None: """Test if `self.path` is a path and change if `force = True`.""" assert Path(self.path).is_dir() file_paths_tuple: tuple[PathLike, ...] = path_globs_to_tuple( - self.path, self.glob_regex_str + self.path, self.compressed_glob_regex ) if self.files: if self.files == file_paths_tuple: @@ -321,14 +276,14 @@ def _set_and_check_path_is_dir(self, force: bool = False) -> None: if force: logger.debug( f"Forcing change to {repr(self)}:\n" - f"`glob_regex_str`: {self.glob_regex_str}\n" + f"`compressed_glob_regex`: {self.compressed_glob_regex}\n" f"`files`: {self.files}\n" f"`path`: {self.path}\n `files`: {self.files}" ) else: raise ValueError( f"{repr(self)} `path` inconsistent with `files`.\n" - f"`glob_regex_str`: {self.glob_regex_str}\n" + f"`compressed_glob_regex`: {self.compressed_glob_regex}\n" f"`path`: {self.path}\n`files`: {self.files}" ) self.files = file_paths_tuple @@ -336,29 +291,28 @@ def _set_and_check_path_is_dir(self, force: bool = False) -> None: @property def extract_path(self) -> Path: """Path any compressed files would be extracted to.""" - return Path(self.path) / self.extract_subdir + if Path(self.path).is_file(): + return Path(self.path).parent / self.extract_subdir + elif Path(self.path).is_dir(): + return Path(self.path) / self.extract_subdir + else: + raise ValueError( + f"`extract_path` only valid if `self.path` is a " + f"`file` or `dir`: {self.path}" + ) @property def compressed_files(self) -> tuple[PathLike, ...]: """Return a tuple of all `self.files` with known archive filenames.""" return tuple(valid_compression_files(files=self.files)) if self.files else () - # @property - # def decompressed_paths_generator(self) -> Generator[Path, None, None]: - # """Return a generated of all `self.files` with known archive filenames.""" - # if self.compressed_files and not self.extract_path.exists(): - # console.print('Compressed files not yet extracted. Try `extract_compression()`.') - # else: - # for path in Path(self.extract_path).glob(f'*{self.plaintext_extension}'): - # yield path - @property def plaintext_provided_uncompressed(self) -> tuple[PathLike, ...]: """Return a tuple of all `self.files` with `self.plaintext_extension`.""" if self.files: return tuple( file - for file in self.files.glob() + for file in self.files if Path(file).suffix == self.plaintext_extension ) else: @@ -366,39 +320,90 @@ def plaintext_provided_uncompressed(self) -> tuple[PathLike, ...]: @property def zipinfo(self) -> Generator[list[ZipInfo], None, None]: - """If `self.compressed_files` is in `zip`, return info, else None.""" + """If `self.compressed_files` is in `zip`, return info, else None. + + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') + >>> zipfile_info_list = list(plaintext_bl_lwm.zipinfo) + Getting zipfile info from + >>> zipfile_info_list[0][-1].filename + '0003079/1898/0114/0003079_18980114_art0041.txt' + >>> zipfile_info_list[-1][-1].filename + '0003548/1904/0616/0003548_19040616_art0053.txt' + >>> zipfile_info_list[-1][-1].file_size + 2460 + >>> zipfile_info_list[-1][-1].compress_size + 1187 + + ``` + """ if any(Path(file).suffix == ".zip" for file in self.compressed_files): console.print(f"Getting zipfile info from {repr(self)}") for compressed_file in self.compressed_files: - if Path(compressed_file).suffix == ".zip": + if Path(compressed_file).suffix == f".{ZIP_FILE_EXTENSION}": yield ZipFile(compressed_file).infolist() else: console.log(f"No `self.compressed_files` end with `.zip` for {repr(self)}.") # def extract_compressed(self, index: int | str | None = None) -> None: def extract_compressed(self) -> None: - """Extract `self.compressed_files` to `self.extracted_subdir_name`.""" + """Extract `self.compressed_files` to `self.extracted_subdir_name`. + + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') + >>> plaintext_bl_lwm.extract_compressed() + [...] Extract path:...tests/test_plaintext/bl_lwm/extracted... + ...Extracting:...tests/.../0003079_plaintext.zip ... + ...Extracting:...tests/.../0003548_plaintext.zip ... + 100%...━━━━━...[...] + >>> plaintext_bl_lwm.delete_decompressed() + Deleteing all files in: tests/test_plaintext/bl_lwm/extracted + + ``` + + """ self.extract_path.mkdir(parents=True, exist_ok=True) console.log(f"Extract path: {self.extract_path}") for compressed_file in tqdm( self.compressed_files, - desc=f"Compressed files from {repr(self)}", + total=len(self.compressed_files), ): - console.log(f"Extracting: {compressed_file} ...") + logger.info(f"Extracting: {compressed_file} ...") unpack_archive(compressed_file, self.extract_path) - for path in self.extract_path.glob(f"*{self.plaintext_extension}"): + for path in self.extract_path.glob(self.plaintext_glob_regex): if path not in self._uncompressed_source_file_dict: self._uncompressed_source_file_dict[path] = compressed_file - # @property - # def is_likely_decompressed(self) -> bool: - # """Return estimate if all `self.compressed_files` are decompressed.""" - # if self.extract_path.exists(): - # return all( for file in self.compressed_files) - # return all() + def plaintext_paths( + self, reset_cache=False + ) -> Generator[FulltextPathDict, None, None]: + """Return a generator of all `plaintext` files for potential fixtures. - def plaintext_paths(self) -> Generator[FulltextPathDict, None, None]: - """Return a generator of all `plaintext` files for potential fixtures.""" + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') + + ...Extract path:...tests/.../extracted + ...Extracting:...tests/.../0003079_plaintext.zip ... + ...Extracting:...tests/.../0003548_plaintext.zip ... + 100%...━━━━━━━...[...] + >>> plaintext_paths = plaintext_bl_lwm.plaintext_paths() + >>> first_path_fixture_dict = next(iter(plaintext_paths)) + >>> first_path_fixture_dict['path'].name + '0003079_18980114_art0044.txt' + >>> first_path_fixture_dict['compressed_path'].name + '0003079_plaintext.zip' + >>> len(plaintext_bl_lwm._pk_plaintext_dict) + 1 + >>> plaintext_bl_lwm._pk_plaintext_dict[ + ... first_path_fixture_dict['path'] + ... ] # This demonstrates the `pk` begins from 1 following `SQL` standards + 1 + + ``` + """ if self.compressed_files and not self.extract_path.exists(): console.print( "Compressed files not yet extracted. Try `extract_compression()`." @@ -409,7 +414,8 @@ def plaintext_paths(self) -> Generator[FulltextPathDict, None, None]: for i, uncompressed_tuple in enumerate( tqdm( self._uncompressed_source_file_dict.items(), - desc="Configuring compressed path configs", + desc="Compressed configs :", + total=len(self._uncompressed_source_file_dict), ) ): pk = i + 1 # Most `SQL` `pk` begins at 1 @@ -422,7 +428,8 @@ def plaintext_paths(self) -> Generator[FulltextPathDict, None, None]: for j, path in enumerate( tqdm( self.plaintext_provided_uncompressed, - desc="Configuring uncompressed path configs", + desc="Uncompressed configs:", + total=len(self.plaintext_provided_uncompressed), ) ): pk = j + i + 1 @@ -430,36 +437,92 @@ def plaintext_paths(self) -> Generator[FulltextPathDict, None, None]: yield FulltextPathDict(path=path, compressed_path=None, primary_key=pk) def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None]: - """Generate fixture dicts from `self.plaintext_paths`.""" - for plaintext_path_dict in tqdm( - self.plaintext_paths(), - desc=f"Processing {self.plaintext_extension} files to PlaintextFixtureDict", - ): + """Generate fixture dicts from `self.plaintext_paths`. + + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') + + ...Extract path:...tests/.../extracted + ...Extracting:...tests/.../0003079_plaintext.zip ... + ...Extracting:...tests/.../0003548_plaintext.zip ... + 100%...━━━━━━━...[...] + >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) + Compressed configs :...% ━━━...━━━.../...[ ... it/s ] + Uncompressed configs:...% ━━━...━━━.../...[ ... it/s ] + >>> plaintext_bl_lwm.delete_decompressed() + Deleteing all files in: tests/.../extracted + + ``` + """ + for plaintext_path_dict in self.plaintext_paths(): fields: PlaintextFixtureFieldsDict = PlaintextFixtureFieldsDict( text=Path(plaintext_path_dict["path"]).read_text(), - path=plaintext_path_dict["path"], - compressed_path=plaintext_path_dict["compressed_path"], + path=str(plaintext_path_dict["path"]), + compressed_path=str(plaintext_path_dict["compressed_path"]), ) yield PlaintextFixtureDict( - model=self.model, + model=self.model_str, fields=fields, pk=plaintext_path_dict["primary_key"], ) - def export_to_json_fixtures(self) -> None: - """Iterate over `self.plaintext_paths` exporting to `json` `django` fixtures.""" + def export_to_json_fixtures( + self, output_path: PathLike | None = None, prefix: str | None = None + ) -> None: + """Iterate over `self.plaintext_paths` exporting to `json` `django` fixtures. + + Args: + output_path: + Folder to save all `json` fixtures in. + prefix: + Any `str` prefix for saved fixture files. + + Example: + ```pycon + >>> tmpdir: Path = getfixture("tmpdir") + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') + + ...Extract path:...tests/.../extracted... + 100%...━━━━━━━...[...] + >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=tmpdir) + Compressed configs...%...[...] + Uncompressed configs...%...[...] + >>> import json + >>> exported_json = json.load(tmpdir/'plaintext_fixture-1.json') + >>> exported_json[0]['pk'] + 1 + >>> exported_json[0]['model'] + 'fulltext.fulltext' + >>> exported_json[0]['fields']['text'] + '~,,!...' + >>> exported_json[0]['fields']['path'] + '.../extracted/.../0003079_18980114_art0044.txt' + >>> exported_json[0]['fields']['compressed_path'] + 'tests/.../0003079_plaintext.zip' + >>> exported_json[0]['fields']['created_at'] + '20...' + >>> (exported_json[0]['fields']['updated_at'] == + ... exported_json[0]['fields']['updated_at']) + True + + ``` + + """ + output_path = self.export_directory if not output_path else output_path + prefix = self.fixture_prefix if not prefix else prefix save_fixture( self.plaintext_paths_to_dicts(), - prefix=self.fixture_prefix, - output_path=self.export_directory, + prefix=prefix, + output_path=output_path, add_created=True, ) # def delete_compressed(self, index: int | str | None = None) -> None: - def delete_decompressed(self) -> None: + def delete_decompressed(self, ignore_errors: bool = True) -> None: """Remove all uncompressed files.""" console.print(f"Deleteing all files in: {self.extract_path}") - rmtree(self.extract_path) + rmtree(self.extract_path, ignore_errors=ignore_errors) def _check_and_set_files_attr(self, force: bool = False) -> None: """Check and populate attributes from `self.path` and `self.files`. @@ -468,9 +531,9 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: raise a ValueError if not. If `self.path` is a directory, then collect all `tuple` of `Paths` - in that directory matching `self.glob_regex_str`, or just all files - if `self.glob_regex_str` is `None`. If `self.files` is set check if - that `tuple` + in that directory matching `self.compressed_glob_regex`, or + just all files if `self.compressed_glob_regex` is `None`. + If `self.files` is set check if that `tuple` Example: ```pycon @@ -538,176 +601,3 @@ def _check_and_set_data_provider(self, force: bool = False) -> None: f"Neither `self.data_provider` nor " f"`self.data_provider_code` provided; both are `None` for {repr(self)}" ) - - @property - def free_hd_space_in_GB(self) -> float: - """Return remaing hard drive space estimate in gigabytes.""" - return free_hd_space_in_GB(self._disk_usage) - - # @property - # def download_dir(self): - # """Path to the download directory for full text data. - # - # The `DOWNLOAD_DIR` attribute contains the directory under - # which full text data will be stored. Users can change it by - # setting `Item.DOWNLOAD_DIR = "/path/to/wherever/"` - # """ - # return Path(self.DOWNLOAD_DIR) - - @property - def text_archive_dir(self) -> Path: - """Path to the storage directory for full text archives.""" - return Path(self.download_dir) / self.archive_subdir - - @property - def text_extracted_dir(self) -> Path: - """Path to the storage directory for extracted full text files.""" - return Path(self.download_dir) / self.extracted_subdir - - # @property - # def zip_file(self): - # """Filename for this Item's zip archive containing the full text.""" - # return f"{self.issue.newspaper.publication_code}_plaintext.zip" - - @property - def text_container(self) -> str: - """Azure blob storage container containing the Item full text.""" - return f"{self.data_provider_name}{self.fulltext_container_suffix}" - - # @property - # def issue_sub_paths(self, publication_code: str | None = None) -> Generator[str, None, None]: - # """Return `issue_sub_paths` of `publication_code`, else all `issue_sub_paths`""" - # if publication_code: - # return - - @property - def text_paths(self): - """Return a list of paths relative to the full text file for - `self.data_provider_name` - - This is generated from the zip archive (once downloaded and - extracted) from the `DOWNLOAD_DIR` and the filename. - """ - return Path(self.issue.input_sub_path) / self.input_filename - - # @property - # def text_path(self): - # """Return a path relative to the full text file for this Item. - # - # This is generated from the zip archive (once downloaded and - # extracted) from the `DOWNLOAD_DIR` and the filename. - # """ - # return Path(self.issue.input_sub_path) / self.input_filename - - # Commenting this out as it will fail with the dev on #56 (see branch kallewesterling/issue56). - # As this will likely not be the first go-to for fulltext access, we can keep it as a method: - # .extract_fulltext() - # - # @property - # def fulltext(self): - # try: - # return self.extract_fulltext() - # except Exception as ex: - # print(ex) - - def is_downloaded(self): - """Check whether a text archive has already been downloaded.""" - file = self.text_archive_dir / self.zip_file - if not os.path.exists(file): - return False - return os.path.getsize(file) != 0 - - def download_zip(self): - """Download this Item's full text zip archive from cloud storage.""" - sas_token = os.getenv(self.SAS_ENV_VARIABLE).strip('"') - if sas_token is None: - raise KeyError( - f"The environment variable {self.SAS_ENV_VARIABLE} was not found." - ) - - url = self.FULLTEXT_STORAGE_ACCOUNT_URL - container = self.text_container - blob_name = str(Path(self.FULLTEXT_CONTAINER_PATH) / self.zip_file) - download_file_path = self.text_archive_dir / self.zip_file - - # Make sure the archive download directory exists. - self.text_archive_dir.mkdir(parents=True, exist_ok=True) - - if not os.path.exists(self.text_archive_dir): - raise RuntimeError( - f"Failed to make archive download directory at {self.text_archive_dir}" - ) - - # Download the blob archive. - try: - client = BlobClient( - url, container, blob_name=blob_name, credential=sas_token - ) - - with open(download_file_path, "wb") as download_file: - download_file.write(client.download_blob().readall()) - - except Exception as ex: - if "status_code" in str(ex): - print("Zip archive download failed.") - print( - f"Ensure the {self.SAS_ENV_VARIABLE} env variable contains a valid SAS token" - ) - - if os.path.exists(download_file_path): - if os.path.getsize(download_file_path) == 0: - os.remove(download_file_path) - print(f"Removing empty download: {download_file_path}") - - def extract_fulltext_file(self): - """Extract Item's full text file from a zip archive to DOWNLOAD_DIR.""" - archive = self.text_archive_dir / self.zip_file - with ZipFile(archive, "r") as zip_ref: - zip_ref.extract(str(self.text_path), path=self.text_extracted_dir) - - def read_fulltext_file(self) -> list[str]: - """Read the full text for this Item from a file.""" - with open(self.text_extracted_dir / self.text_path) as f: - lines = f.readlines() - return lines - - def extract_fulltext(self) -> list[str]: - """Extract the full text of this newspaper item.""" - # If the item full text has already been extracted, read it. - if os.path.exists(self.text_extracted_dir / self.text_path): - return self.read_fulltext_file() - - if self.FULLTEXT_METHOD == "download": - # If not already available locally, download the full text archive. - if not self.is_downloaded(): - self.download_zip() - - if not self.is_downloaded(): - raise RuntimeError( - f"Failed to download full text archive for item {self.item_code}: Expected finished download." - ) - - # Extract the text for this item. - self.extract_fulltext_file() - - elif self.FULLTEXT_METHOD == "blobfuse": - raise NotImplementedError("Blobfuse access is not yet implemented.") - blobfuse = "/mounted/blob/storage/path/" - zip_path = blobfuse / self.zip_file - - else: - raise RuntimeError( - "A valid fulltext access method must be selected: options are 'download' or 'blobfuse'." - ) - - # If the item full text still hasn't been extracted, report failure. - if not os.path.exists(self.text_extracted_dir / self.text_path): - raise RuntimeError( - f"Failed to extract fulltext for {self.item_code}; path does not exist: {self.text_extracted_dir / self.text_path}" - ) - - return self.read_fulltext_file() - - -# def unzip_plaintext(path: PathLike) -> list[FixtureDict]: -# for diff --git a/alto2txt2fixture/types.py b/alto2txt2fixture/types.py index f2001eb..8860c87 100644 --- a/alto2txt2fixture/types.py +++ b/alto2txt2fixture/types.py @@ -1,4 +1,3 @@ -from os import PathLike from typing import Any, Literal, NamedTuple, TypedDict LEGACY_NEWSPAPER_OCR_FORMATS = Literal["bna", "hmd", "jisc", "lwm"] @@ -115,8 +114,8 @@ class PlaintextFixtureFieldsDict(TypedDict): """ text: str - path: PathLike - compressed_path: PathLike | None + path: str + compressed_path: str | None class PlaintextFixtureDict(FixtureDictBaseClass): diff --git a/conftest.py b/conftest.py index a71cc05..1c791e2 100644 --- a/conftest.py +++ b/conftest.py @@ -60,8 +60,20 @@ def all_create_adjacent_tables_json_results() -> Generator[list, None, None]: @pytest.fixture -def bl_lwm_plaintext() -> PlainTextFixture: - return PlainTextFixture(path=LWM_PLAINTEXT_FIXTURE, data_provider_code="bl_lwm") +def bl_lwm_plaintext() -> Generator[PlainTextFixture, None, None]: + bl_lwm: PlainTextFixture = PlainTextFixture( + path=LWM_PLAINTEXT_FIXTURE, data_provider_code="bl_lwm" + ) + yield bl_lwm + bl_lwm.delete_decompressed() + + +@pytest.fixture +def bl_lwm_plaintext_extracted( + bl_lwm_plaintext, +) -> Generator[PlainTextFixture, None, None]: + bl_lwm_plaintext.extract_compressed() + yield bl_lwm_plaintext def pytest_sessionfinish(session, exitstatus): From f00df9814fcca461c8ae3d413f74f399ffc768d1 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 23 Aug 2023 22:55:14 +0100 Subject: [PATCH 05/23] feat(test): add and refactor for plaintext test data --- alto2txt2fixture/plaintext.py | 101 +++++++++++++----------- alto2txt2fixture/types.py | 1 + alto2txt2fixture/utils.py | 9 ++- conftest.py | 13 +-- tests/bl_lwm/0003079-test_plaintext.zip | Bin 0 -> 142526 bytes tests/bl_lwm/0003548-test_plaintext.zip | Bin 0 -> 29849 bytes 6 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 tests/bl_lwm/0003079-test_plaintext.zip create mode 100644 tests/bl_lwm/0003548-test_plaintext.zip diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index bff9e5d..96ad25b 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -119,7 +119,7 @@ class PlainTextFixture: A maximum number of fixtures per fixture file, designed to configure chunking fixtures. - fixture_prefix: + saved_fixture_prefix: A `str` to prefix all saved `json` fixture filenames. export_directory: @@ -138,28 +138,28 @@ class PlainTextFixture: >>> from pprint import pprint >>> plaintext_bl_lwm = PlainTextFixture( ... data_provider_code='bl_lwm', - ... path='tests/test_plaintext/bl_lwm', + ... path='tests/bl_lwm', ... compressed_glob_regex="*_plaintext.zip", ... ) >>> plaintext_bl_lwm - + >>> str(plaintext_bl_lwm) "PlainTextFixture for 2 'bl_lwm' files" >>> plaintext_bl_lwm.free_hd_space_in_GB > 1 True >>> pprint(plaintext_bl_lwm.compressed_files) - (PosixPath('tests/test_plaintext/bl_lwm/0003079_plaintext.zip'), - PosixPath('tests/test_plaintext/bl_lwm/0003548_plaintext.zip')) + (PosixPath('tests/bl_lwm/0003548-test_plaintext.zip'), + PosixPath('tests/bl_lwm/0003079-test_plaintext.zip')) >>> plaintext_bl_lwm.extract_compressed() - [...] Extract path:...tests/test_plaintext/bl_lwm/extracted... - ...Extracting:...tests/test_plaintext/bl_lwm/0003079_plaintext.zip ... - ...Extracting:...tests/test_plaintext/bl_lwm/0003548_plaintext.zip ... - 100%...━━━━━━━━━...[...] + [...] Extract path:...tests/bl_lwm/extracted... + ...Extracting:...tests/bl_lwm/0003548-test_plaintext.zip ... + ...Extracting:...tests/bl_lwm/0003079-test_plaintext.zip ... + ...%...[...] >>> plaintext_bl_lwm.delete_decompressed() - Deleteing all files in: tests/test_plaintext/bl_lwm/extracted + Deleteing all files in: tests/bl_lwm/extracted ``` - + tests/bl_lwm/0003079-test_plaintext.zip Todo: Work through lines below to conclude `doctest` @@ -194,7 +194,7 @@ class PlainTextFixture: default_factory=lambda: NEWSPAPER_DATA_PROVIDER_CODE_DICT ) max_plaintext_per_fixture_file: int = DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE - fixture_prefix: str = DEFAULT_PLAINTEXT_FILE_NAME_PREFIX + saved_fixture_prefix: str = DEFAULT_PLAINTEXT_FILE_NAME_PREFIX export_directory: PathLike = DEFAULT_PLAINTEXT_FIXTURE_OUTPUT def __post_init__(self) -> None: @@ -326,15 +326,15 @@ def zipinfo(self) -> Generator[list[ZipInfo], None, None]: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> zipfile_info_list = list(plaintext_bl_lwm.zipinfo) - Getting zipfile info from + Getting zipfile info from >>> zipfile_info_list[0][-1].filename - '0003079/1898/0114/0003079_18980114_art0041.txt' + '0003548/1904/0707/0003548_19040707_art0059.txt' >>> zipfile_info_list[-1][-1].filename - '0003548/1904/0616/0003548_19040616_art0053.txt' + '0003079/1898/0204/0003079_18980204_sect0001.txt' >>> zipfile_info_list[-1][-1].file_size - 2460 + 70192 >>> zipfile_info_list[-1][-1].compress_size - 1187 + 39911 ``` """ @@ -354,12 +354,15 @@ def extract_compressed(self) -> None: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> plaintext_bl_lwm.extract_compressed() - [...] Extract path:...tests/test_plaintext/bl_lwm/extracted... - ...Extracting:...tests/.../0003079_plaintext.zip ... - ...Extracting:...tests/.../0003548_plaintext.zip ... - 100%...━━━━━...[...] + + ...Extract path:...tests/bl_lwm/extracted... + >>> plaintext_bl_lwm._uncompressed_source_file_dict[ + ... Path('tests/bl_lwm/extracted/0003079/1898/' + ... '0204/0003079_18980204_sect0001.txt') + ... ] + PosixPath('tests/bl_lwm/0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleteing all files in: tests/test_plaintext/bl_lwm/extracted + Deleteing all files in: tests/bl_lwm/extracted ``` @@ -385,16 +388,13 @@ def plaintext_paths( ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/.../extracted - ...Extracting:...tests/.../0003079_plaintext.zip ... - ...Extracting:...tests/.../0003548_plaintext.zip ... - 100%...━━━━━━━...[...] + ...Extract path:...tests/bl_lwm/extracted... >>> plaintext_paths = plaintext_bl_lwm.plaintext_paths() >>> first_path_fixture_dict = next(iter(plaintext_paths)) >>> first_path_fixture_dict['path'].name - '0003079_18980114_art0044.txt' + '0003548_19040630_art0002.txt' >>> first_path_fixture_dict['compressed_path'].name - '0003079_plaintext.zip' + '0003548-test_plaintext.zip' >>> len(plaintext_bl_lwm._pk_plaintext_dict) 1 >>> plaintext_bl_lwm._pk_plaintext_dict[ @@ -443,23 +443,30 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/.../extracted - ...Extracting:...tests/.../0003079_plaintext.zip ... - ...Extracting:...tests/.../0003548_plaintext.zip ... - 100%...━━━━━━━...[...] + ...Extract path:...tests/bl_lwm/extracted... >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) - Compressed configs :...% ━━━...━━━.../...[ ... it/s ] - Uncompressed configs:...% ━━━...━━━.../...[ ... it/s ] + Compressed configs :...%.../...[ ... it/s ] + Uncompressed configs:...%.../...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() Deleteing all files in: tests/.../extracted ``` """ + text: str + error_str: str | None = None for plaintext_path_dict in self.plaintext_paths(): + error_str = None + try: + text = Path(plaintext_path_dict["path"]).read_text() + except UnicodeDecodeError as err: + logger.warning(err) + text = "" + error_str = str(err) fields: PlaintextFixtureFieldsDict = PlaintextFixtureFieldsDict( - text=Path(plaintext_path_dict["path"]).read_text(), + text=text, path=str(plaintext_path_dict["path"]), compressed_path=str(plaintext_path_dict["compressed_path"]), + errors=error_str, ) yield PlaintextFixtureDict( model=self.model_str, @@ -483,9 +490,9 @@ def export_to_json_fixtures( >>> tmpdir: Path = getfixture("tmpdir") >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/.../extracted... - 100%...━━━━━━━...[...] + ...Extract path:...tests/bl_lwm/extracted... >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=tmpdir) + Compressed configs...%...[...] Uncompressed configs...%...[...] >>> import json @@ -494,12 +501,13 @@ def export_to_json_fixtures( 1 >>> exported_json[0]['model'] 'fulltext.fulltext' - >>> exported_json[0]['fields']['text'] - '~,,!...' + >>> ('NEW TREDEGAR & BARGOED' in + ... exported_json[0]['fields']['text']) + True >>> exported_json[0]['fields']['path'] - '.../extracted/.../0003079_18980114_art0044.txt' + '.../extracted/.../0003548_19040630_art0002.txt' >>> exported_json[0]['fields']['compressed_path'] - 'tests/.../0003079_plaintext.zip' + 'tests/.../0003548-test_plaintext.zip' >>> exported_json[0]['fields']['created_at'] '20...' >>> (exported_json[0]['fields']['updated_at'] == @@ -510,7 +518,7 @@ def export_to_json_fixtures( """ output_path = self.export_directory if not output_path else output_path - prefix = self.fixture_prefix if not prefix else prefix + prefix = self.saved_fixture_prefix if not prefix else prefix save_fixture( self.plaintext_paths_to_dicts(), prefix=prefix, @@ -541,10 +549,11 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> len(plaintext_lwm) 2 >>> plaintext_lwm._check_and_set_files_attr() - [...]...DEBUG...No changes from... - ... + ...DEBUG...No changes from... + ...>> plaintext_lwm.path = ( - ... 'tests/test_plaintext/bl_lwm/0003548_plaintext.zip') + ... 'tests/bl_lwm/0003079-test_plaintext.zip') >>> plaintext_lwm._check_and_set_files_attr() Traceback (most recent call last): ... @@ -554,7 +563,7 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> plaintext_lwm._check_and_set_files_attr(force=True) DEBUG...Force change to...>> plaintext_lwm.files - ('tests/test_plaintext/bl_lwm/0003548_plaintext.zip',) + ('tests/bl_lwm/0003079-test_plaintext.zip',) >>> len(plaintext_lwm) 1 diff --git a/alto2txt2fixture/types.py b/alto2txt2fixture/types.py index 8860c87..b686e81 100644 --- a/alto2txt2fixture/types.py +++ b/alto2txt2fixture/types.py @@ -116,6 +116,7 @@ class PlaintextFixtureFieldsDict(TypedDict): text: str path: str compressed_path: str | None + errors: str | None class PlaintextFixtureDict(FixtureDictBaseClass): diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 6053f32..0bb7bc2 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -969,9 +969,12 @@ def path_globs_to_tuple( Example: ```pycon >>> from pprint import pprint - >>> pprint(path_globs_to_tuple('tests/test_plaintext/bl_lwm', '*text.zip')) - (PosixPath('tests/test_plaintext/bl_lwm/0003079_plaintext.zip'), - PosixPath('tests/test_plaintext/bl_lwm/0003548_plaintext.zip')) + >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*text.zip')) + (PosixPath('tests/bl_lwm/0003548-test_plaintext.zip'), + PosixPath('tests/bl_lwm/0003079-test_plaintext.zip')) + >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*.txt')) + (PosixPath('tests/bl_lwm/0003548_19040707_art0037.txt'), + PosixPath('tests/bl_lwm/0003079_18980121_sect0001.txt')) ``` diff --git a/conftest.py b/conftest.py index 1c791e2..d828615 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Generator +from typing import Final, Generator import pytest from coverage_badge.__main__ import main as gen_cov_badge @@ -12,14 +12,15 @@ BADGE_PATH: Path = Path("docs") / "img" / "coverage.svg" -HMD_PLAINTEXT_FIXTURE: Path = ( - Path("tests") / "test_plaintext" / "bl_hmd" -) # "0002645_plaintext.zip" -LWM_PLAINTEXT_FIXTURE: Path = Path("tests") / "test_plaintext" / "bl_lwm" +# HMD_PLAINTEXT_FIXTURE: Path = ( +# Path("tests") / "bl_hmd" +# ) # "0002645_plaintext.zip" +LWM_PLAINTEXT_FIXTURE: Final[Path] = Path("tests") / "bl_lwm" +# LWM_PLAINTEXT_FIXTURE_extension: Final[str] = # @pytest.fixture -# def hmd_metadata_fixture() -> Path: +# l def hmd_metadata_fixture() -> Path: # """Path for 0002645 1853 metadata fixture.""" # return Path("tests") / "0002645_metadata.zip" # diff --git a/tests/bl_lwm/0003079-test_plaintext.zip b/tests/bl_lwm/0003079-test_plaintext.zip new file mode 100644 index 0000000000000000000000000000000000000000..c7751702d597f49024410075186fdf79a41b6cda GIT binary patch literal 142526 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

EV zI?r^u&Sv&HE6r|fz47-9%{a3xx-1EQc5OYR5s;3km(lHBE%WXYK6=p*q)ssFjk{3P3>Vmg)IIfKq z|6tjHZ~AOmD?H4k*SkRG+7>?n^@JkZ>2O$7{cAd0^Hr~!;BA9ByP{;G}B0u{GHii z71EsvXgA^u@1Ax+1W_4S2zx~m@y0535`JI;i7y_N0L)K2q6Ch&uETS2tyRsebN<*z zcfhKO0^Mo>UPn{=7!IW0xQ$K;H2eol>I!H#TJOoZ)a|tJlhenOld#qQwy+_7yB@xj zn4*GbDJ`p;-7to?_-7%%zzN*aMjII3o~R)7LRgAJI8UE1CbH53Ec4>Cb$mmw>@>rUaM=Qn)j=b8x%60yx{dAYr>jXx zOq}azXU(7KyRkxc0w@%Z)gOk@^l89jX?fJtPTXpcr|8;=B zl(2@qp(XG+r6Miv9b@fW|5NGTUxtEg-=H#FLR$yD88qzH}oYuMX12THOJW(zbw zR&75ffe&{;w7W473wQ?uYBFuVveO|RsuU%6<#YMHUJZgaOIL1LWW8B~#uD_>p!?uk zEPjVMIL=QbQn5u?^T8jz`~*H60dre;F1ds!-wE|J)z)UGxraloKv2jGF zk+sbUYH+UnlJ0f~QKa@HoD$+-Hm7UCc#!{vhpFDz0UkW$eX|$|ZJ4cT!f1!Z_#|4a z<>e5AE{)&a*KZ^!87fN~22CTv9a_3VQSs8Q`Wq06|_e-CxC}=yv8Z)}$y7`R98;{X=oDpI+{=}F=K^-*| z@owID=`&sW)?-eC6?%ilT{I7m%awQfNB|56NeYXM>WL>zKvwOq3$3(rukkXc9VGs= zG4ap(YnISKS>?IiE=B<6fojXYzMP}oFGB~uNAuJCI8TwWu1oXaBfNxdW3T$*yYmtP zKdZ=mBQf%0SuXt%UU$?;{7WI|DMoTDyziikp4sG@&Y zhS2MTsjGF1oEBy2(GCnf{4-)ZvjY|bgim$)TCLlp~z^LbwE5On4HvoVlfnM8UL!vlV4ld2U{Ol&AXUP zE?CU>Q1)B-uMQy8yoYk39ewL~y&-|KwD)sHsyo=EEE-%G7u!3$-L{;){YZuLTRh}q z3XCHw*^2SL9_<;-cCLm~e(g0BiF^Mp`~9x+3JM;D2MzSoch*K`lXlhd{ceUkx&P~R zW!ROw9DoHuN5bG29==b;?jj@9=ev`Kf5iTH(T${wf)8ZNmuF3xaeAoRVuIp6j`8eSaD)qm3gTBz>miCixrNb{%CAvFBrKJ!nXqI)L}Qj z-Vo&FVfb1P-`F5+Xf(In1rbb9l7LP zdts^&sR2|K;5q6tYR2J+^G(pS9dH32Ph1??fPQZnz!pjy49>I-pD-MWd&+YIOtuy@ z{Xy?11VvCF#9{`nb~XFcMeuIa>`=7S#Td#RB>x*osXaIqcDd|!Lw?Hvw}t~a-oxN) zr$G-(aD8RUH|)K9fT`^>?+~q*%!@2SUo&WE$N|}lY0-rd3GDt2dzDDePk+2jMxK7r zvfeRy4SW5bVDU22yI>IXb9--}RDT`~Er5K>yD_4w1#;Mr26Qrdf-pD%=J2jS60`|mQ%I!{ZwVma2hA(+%?BCzGY$Elsi0AniF7U)RP?woUJLnY@tKQ1vlvNvP@ zYV3o9BwGnH=*#ju(FrDVh&Es?7jBf#1fC}Duh$M36Ld-}hjmF^U~oc|sq>H9n^}((0aIm2;kpAHb14XD_IfNH=2~p*ciPc@Pzj* z3!tUb6;2{o)Zs|oo*%Q=K)gcQQ=(i8>R}F*i3fb(^{2OxmNrKT?^*>3HY5i7!>2fuwM70l%1^|YLZ1(nQfUiJ zf{D53H76JQ5-;@}2K1s;GGezkzf-1VyV~*clhSUw+I99R&ZEE^t4kdCK4#G9oSsM$p9paih_kICgXI zM()8*;JR(D=K%6oqgq3mG(&EMYhI1rKuxB_1(*HG2sS*|qcv56zM+WtsEH)1n0X29 zx4i(iTr8=$7pJ4d7=fREj4wF4`w$Kwe~9m3kY~oA!(V5>i$p1Q<4V~)1)#Y`)~$PF z4>^xyJrvf#JL|e4io?-^Y|%Y$I4thbchKoHv)I)v8~!HN1g;3N`Cf`@F@GN+Y(X|( z#`jO3o^84zRFs2+{#}Cd=2Q6?F@fmkKX-N{SjgPmwamzar(M}A=P-o& ztk@7x51fW=&)IGJ_`d_%^%KeZK6F86QQOZsGVKeAnuz)E-BIQzko46{rt?M+*?V|w zXhw9M8}d$aHaxrMY=HW?Rek4c_9UM{_Ia%&$@^@IZo_ZL)ci|}YqAFySoIep5hHDK z&;M3y6N8@^b7Z%EL^ZE$KbQRpi0DHvMIo`!_aMY;7mQ1qya4&-(|MSJR?OkYeQ56; zq{!id31yM&RVIHPOXUp$Ve+vcWM|hlpzst(p4SA503ZKeX3W;WMxZwSeOk(>)5!@O z{pW}vtN?AYif^t#Wmuq4M?!JN_R-tXefVh z`|6l|b-pV-OL|*PcCC4;&_MEeR4TP&)_;HsE{F(|DFyC5sR#uNhzS5^nfPUI-AUD2 z7WH^dTR&DW5!j|#JA^`kqsV^{64^w2%vsMm@=(h*Bx3>~#aStE_|b9XAMyy5OHOex zNoVSjZ!DRKR~HGgb+Ts*VQOTb zTfc9e!UWf>=j$0{?z~{C?*)oP?x3fvqMIiFA5EeH%+Y^?_3R!Ij3GU#(~@G@m$ho| z;Hb!dZvX{9{q!!k$AvIFi2r7I0BBGE>qOK#dN??Sk|2i}#@HNgF_isG#-K1c0~rvA zX?rM8V#_1Z;B5}xZqbHVJzoLl!*e;FB9`1@^}u92XQQ0)PkQS>|I2^QY#a=}%Ti0R zx49-oTvJ}H6VdnDXFOyj#oVvwtc~R!=?dQ{P{_|Ob}ewF&iw4az__y-n3@>D@2m}L zY|-iF8vu##8Y||i;+ruT3Ac$=z!-G2XM@k=J;{JriP3)fJP`0&UtUegkWHQ?62w5? zK#q)~@fuc*NOFe*TWu(oChtLT^4`uj5EC6tX#ss`sP(?6LAA_o2L(5(MwQQNq{(;H z>^FLQGXRmqBjx$h->#r1g5;%4GSWWt-KfyxFDoS3 zB}&eT5fOls6oBZ2t^qyFW8Iq}JT^g*I_lB$U;cVIN8~wX$G>TECvV#lugI{sZbz|HBl%SIywiq{qJ2Cw$OV`p#(^DpX^q6#55V8bQc{}E|{5%!yqR*riG z5M^Hb@Dg5rL!|17?Uhw4iJE5!cD!Q3Bilv=4o7pDi7A@C0LEMI7{xRRRB zTj#~sm-^0pkfbIVh$>O0iT)jOr;Ja(H;p%+3=#=}(^YA|pHs-F{bWPKH9Lz#p5q4( zSZu>SJ!!`O^{)=x#{+0JR6iJz=D-^}4`M0Tyzc#P%1l5FeIm1iY_Uj)1( z(bAgsF(!J5>@g;8ScNg!BS%!0cbaXMBEnwH>qzM?VV^Ovj3&{pH5T_DW*300t=R*W zjyT-&u!bKLdW4Yg;7Zar-Gn{k6IZ@L;#znvM}gdgcUbcZr1e%As1$+lxB~m8i^L=a zF;VA@&8q{|2s3aUE!*r~0%_I8)vW7aPshb~rG$ez%oZsNFE~B{FHhboB6t|&3hC|F z8Zt?z6!;Izpr4XKso7qp`M^FOH7WMB8ThqZ%tW^_6kCbyphnWIK-nGXIb* z80ynJ%o3-c)?qeThq?X4H?04tbOPt_d5sAe5Xjd6bsU#$h4fJ%kM71MS+L& z2qLko)d88ZJ{9<5A6r((l0+82t~q()<7ER&)fL&l z+TF@;4Q|ku^VH^{EgABPw7u`-H1`z8tA&<{zU0qhs}M-DlhSbm(mLy?c^XT<&nIhc zk%QcSHvKQXx7!8nzDD}Ro_*%eb+#e8C=R#)IoV4Pgu+I3$Ezd@t>+cZQcV20tp^F% zID`H&o-yL80n;A5rnEz|8_V%}_4tF3(g~?Dzu*0qao+>^SkkF5$%S<9u(N>{`2Kv8 z4o0)hfTlryVyzL(w`H2tWGFun{||F)*X;z4p5LJ~2R1~_Zq`k~!|Wc0R?5gL4D;H5 zNPHw{y6;ctJ)~Ngw`&}&neY#tMeu!`W*vkVs3>o&N?%k+A>@--_B0~+R}n2Q6ZhEh z=kS}3p+{GTG#nnXKT}HfPWC=Larh18A1_ffvGr!Kz#X2tiTNbwy9Urv*DHISmr_F! zTicO-dKLIpNuBFo_lU|)*Ge`B>O2;M@a3KK+7L0$qeb*r5n{30rvLsW->Cml2jfrs z980`uu}XyG;Ehp73G{#_DBT9_dW&=?4+zSL_%3=i{vDPiHm``PkPF?~MKK>Qhbm%O z1Y_y!v`PqF^Cn0>h=~6xn!WPYEH@PPC0w+ZrD-zM+$KFx>>QLlnG0FBoX6rl0r51V z_=*Q;@*Z#>h}jqkJbLe7^H*plF)2t!Uf}IcGJp z7U#%#wlMT(mt9VpI_K?rE)ve>K#6+7vHp2uq`!4O=Cb;=Y|@`3^AP2@8s3$D9hKCuW73P;gezVTs=|aY0#MCKl)L zZHmw|xRa^$pC^1BiQCz^epId%82Z6dDQZim$28}3?+6Vwh5V-`-Gkx9L$%%^Fn!*qRP3%pWqo|CFg=m;O z+vDhn=7#`Ub75op%G<96ai0k4`yNpGmvi#O#<-3ar1|I&uY;y25R(ikje*CSAv69{ z{_N0*0*#J+7kjgT3!=)OnRYtYAR=m`T+-?>9#Qa~cXh(vCc)YQ!GNhZLWpgPp#FCx z5U#nQQLy9dEz&i`eWYvcZYH^#^Lj%aGE=nj^**p|my(ORQw2vXVlI+WwuAHK|KMB# zed@n92whRTW4urN)&8LG_rZ)EuEz*GB}#`h@wY=jypahy*zWVr)VFSOZsE&(ko6r+ zw!SSM3lWH$MTB10dwwr_TgkXoIepY^3K?x5UJjz>dp(`%TVhb4e2~hoo`QShQ=$4+ zoq@5y>c+-toHn1JG8oDdsoeHVZZq(?@MYybg~LN`YZ3o8(rt_J4Bv#{Xe|99Ka~>} z-}QdE6$n-ukWVcCTqOM?m>)MdW;iYf2*Vp>>w+e?bE~KylAajctlKspSW-Au!xJ(j z>Td4ue_n1Yz`F+q#-o}m8A)$rnYtMt(G$=JmgZ*`gI%e)>$f-F7egs?b)j+MW0W2)1A= zxdBqQ10t%|@9r(rsVfFe zJ*T{zI1Tx6-oIoRl>{D{8K#d}iyPwye@41UJT@qm^6-{pi z*wpX9rBFkyutwv55~_~tfxJ?S=63Z; zNZbifef}ga#2mge`$z{x{Jb=NZ-?)Kh7Jk<4 z_1tAy&P>_LTx+QPPi8W~^IS+b-@h5Pafo-`q~ELB^!*S)?&hA!?>LOzGruQ#BTI6= z{({iH+NkBK2~+Ss05P)82j(ep;7cJHEn{^xMMybKLpHZ}LTy>n8No7#%{TWO`Zj4? z?6~s#fKUm}A8J{4IEItjYUiY`y1LW2=xNPvfq(R#4WAQpIc{Uaf)1jfn!vz?F#*7P zur+2M?A52HQl+J-DUtKiC_BQaZ#`S9A@q?%=zSiZI1XuHtGtgV@!@wGxO_kDFWVlt zNSL-#Ivv9g95|CK3hy94KpuPfH)0L#Ra4(mY(v`VfLjEt(UL-~T!5R6+8^OAKR+tU z|I`L_WD;?!Cn&q1+IZ6f_*-A!q%C%>&UQwVp_o&-Wo56TIqxX(-Xl_4FN^vEs;!Fn zQ;3zReG~+}mrUE8P0P!aXW?}Jztorh8_vR^^Uc~`FSvE-tqpOPe}WJOm<6X z^$Zo->OdgUZ$N0KA?+W{0d1WhK9vvcJG|2R(IMF0f8cK?>LKm zXUC8f`9%*!zDV&9;50SbeGodYZF?g6K_Hj`iBxE?c3Vw_)pbL-VF*Z<`LjF1x)luq zlmk%l9C%8jo^`BBF`OJz-~NZF+c2`ipZ!uK;NZ8lak$&hmsu0+uerfh&^yI%$2C=U z*e{j0F#))<*wam6(#ESnX^OF`R=hlCR~Pl_|PfTCxo zZNtMcA}SV|kKDmNvpkc|qF&!ctg2!i=&I5~`!SQ+^1b0b-I=I%^GUTh%qn$#bZ1jK zZ9mt>YF`)l;>ygQ<124i(Zrg|ixI?VY8cflKU5!Uv%2`|x{GFAKXVYyd)&~wxv7s> zZ!JB%KML>@#6cW$P0i#!?JdnviX6^DsDm0N-PJM6GDL2UWa#e9qh`xvC><|o&ev0> zHav;;b|GYU=XWlv_9qWd^*A&$t*y;N2R8lg@<^k#C)(ACoT{y1jLpwut&&m);%Et$3_ZrL_8Bk7uCs+UZf^TVl03j<(%zB=Aozcg>rO zVjXObG1wZucgY^{wIp2HmpKAm>;b^!nzi-Tth>B&i!ywzY0iv#{pl%RVxJ%J437b!pTmGCxR>yvZ;vU@l3Y&S=o$g`B2-DuVo%5~D?n=b7Fgb4yN*3I7R=X}Lo zGs+GpXENQkxKG@AnB%j8QtlNT8HOi2gmF_3`mDaG*0;^re!t~0r=I&6#WZYU&a?3* zMMhj>d`N+_^72gLb=I4YFzF|w6zN8g{=RzK%eXqJ+d&hO9umhqQrYw%=YGI(xiiuK zwZZq={qrwzw`N{3+Xl-1G8)2A|2-rgDu@3r$(-%9$zMxGmSL82fJoWw5Y`sgMRuZk z`}*QeqOj9#Th|)~G75KRcb1(Cio&t{JeR~8<>n=At4xP@tF3R%2OFq@DWcUxJJ``l zTrh{G*gxxP*;HlB+~!xEMf<+4GX%nghUj6IHVp`j0 zKzE@lt)jtpGZuHpe%1-*`t_O@mzJ4+5 z6i6P8GAY5)9`bwQVg(xa)9W#DK?_35&%W+7yLO1Ys!xOm09U~}xGVzr-@xzxd+aap zt5e|s*d_Qv&Mx`!U3750G5;znAwsi9ZNlb3mhNl;voAY5L!tqjVLK#%OZVO{21u65s>EZ@t1Gkj&yBuvUn%f$lEWbS&A?K;j{VD_X;t~1l5n2?#Q#?CeC{OB zAZUw$@ZA0;_{J_d{xp@<%)*vEXQuM;JPHE$a6PAlK_tw!XS&-U6z9G%&duYrs{9Vn zm_dx>s3@8>#v-gj-nSpyK>Gs}DT1=T%!caOqGnee0z~5u5|?_}ZQd>?*Ip52Lc(E6 zWTL}dFfwiCgk!EFGnwezT5sB z?8mim&5_)K+%`>xf=?$awO)gqm2#OO?D8K*QAf3}oK)dIoLZ5m|Nbqy>5J0i5#4AD zYkTPZQ#Zq6fH3}U(ymhJk9Xc?HgED45r#3oKQg7Q&FJLYvbaSd|X8I+)#EFZ) zbe$c3V4P2Jg|juqbOrR4F(Qo&Qu3OSDgUXmWkGZaPWfF$HX)WJXQ2?7tn{(RnlG%}{9PlM_E-)$>PBF*$VK zXeXIlzS9UXb`xbgps)+L6oAJ4dK|zfHDAGg z=k5W;`s0{&>kTc1w?AP-G7~M$f_x!IkP_Eh`C6hu(;$MAC8bMYvY)+r6#~taRx(lU zF-k_D+6IfaZ$$rW$lHe?8RQ^!pQCq4A!&lb<(gAe8fx56zDvD?V8}XO9@;VXZNzul z?)ZrWN#$ZZw*Gu=#smJBElWvsmWMpz{K+xhqMuGHd&oD+#A-dvttk)}?L?v2NP z%}1a<2$Xn%n1ss+VG|lWR)YiS*Q%FN!nd|OE|GiTZ(}g>C&`-;+_zBWZJ3Kp^_~Mx zef71D`KCAn!DK$p+=~JdU9=1wZLq%$dmTM&^0JSZFr@u^TX_n=w0Ln&XO(+_Qj>U6 z);(~@#E+tw9>J?pdX0o}INwApx}|0r)t*}QirLWBZK8MqEOa7o-9eH2)9WT=`>+8c zE8I=?id^LO#p)b1dy&hYN5u{W==)UOvBG0=9fVo4tVDz*L++9GV~G^uGdJk&WecPP zdPZ<~|H+z$B)LRr^-j|9-VNzLwz!Yvv!$nPKloVL5dbAplsCU6RXLBeJ^2pW%J7mlau1@#QUNcjh15YUs00l& z+-k*Qc)U-DvhvJtX!kL(_7MC5GTtjqh+QxKo4P+G3=h(-O4&MN9O{*$GtCRMoD07=s})YS+L;3}eP8LV3Kb zt^Z<;&0Lan~{tLvSgEV()P3^DWDHx<#F-6!>% z*=u}vADi#%;c*t!Y;FLFVSA=oHf;_3RLW7wE-*`#^C?27y39m|4+$XUE;!;o_zGEX ztdd>BkZJ_i8U3LMG%r(3LJ=p)9h)^wx#0}cHQ{icF!|-zQhD!&+8{sGFX>JN!ldSCn~3Y3yon>rsYNx}>-^GCa$KC5S)sA&9!R^!T`G zJKk~Jh1Dmzr>8{*>9f??qq^^xdTqu)Dh5KAMa8=IK0nQjC;3}bfJ?btS*NZ zn{z_n8vo$w#hm?;q;=xLMDPv>?E(rrv7W~?eGf%^rMn{IYTxmVyq_7Zx%XAV%Tc9V zE(?s$u7wrqle>#&+d8>>n}Is0e7A<)RA}>Y7_TJmRHrad=}m z{g4u`8yJuK)}>yoUl}eWPEkhqP%v>=$J5VP25m#1#CylD_QBEZDF8oz^kA(Iz%NFC z8f$3=nwjCH(|u0C>{rVYW&Ry9d>!03CSy_MboyBQ^c^&V&=kGRJJ4`~*NLS<%j*7; zRJI!|eHHkG&;jQ-3rlo<)&dLKRhXkg-Jgqgg0^AgAS=Ob%wc-rd`DC2HjZ*|>bRp@ z9ieitC)4Q``{T8S#S&*IAk~+91p*RFvtwMl0{gDMdB}tVDHOfab4KExJHKT|){GF8 zwSJME)){P2`h_tfHPJXz{}(hDfb?U|j_^uzNfSs;o}@3v{+GnNtlnjHe->y~aE|r5uCkL>FgzUY2_s^PK&CkH{qkB*=@;n~Yn*oE10Ip<9HA8rfbH zJnA#0O3k#$TtVN5mLFNp?mr7_rlMA20Ce=9N;S+odweqcF{EF<*h-|?qf4i?# za+Li}+Y{sskCq%uE(HPiPb2OYRTUW(v98iEIh`u;p&fcCj|Jl6{w;3Vam+PjCY?>I z5svWg16ITWvW{;p~{nfrLovZNEU5`|Z(GU4UVeZu_0NoS=QS`T$!QNct+-ik3X^@Vt z!}>)a3ib8QqN?U4<)@poPWw%&_4@XyfPgJUM~?naV`BD)x^3xk;fQO_UHvbvW!BJ* zQ=0Ow%BH)>Y$>EY3bT_>a{RxQj`icMyRVCHwW zOn}|9uwb%5Am!aO9c10_`pm^j!LH?YQ~hsmM)*6y*WxxAn6o#ClNsM2g-iR2I)9O@8ibpenX~*`So~ zu{l0eNo|t+AHA_#L%xpu-n0RQeG13G@S_ZC%e3eheM|~~;Ba|=HXY!@!V@O+t};yT zlWS~1c$ogD*5Sa5es9~l=Os;^i}0^4t#6j(Az6gxlLUunMZDCE89s_8i5K1lJpw2_ zCH{8xQBM*M)`RxrD$JN1tH-S;!$Jwb*Bu>`e!5NNL_4S>V0nZ^-k)e~#7Th?@qj8*_16 zhP0%)s0Hn#-bH~kwkbpcSEa<-dv62u6$@_~!D=+6lkLvao@X>ep6RLj4;`UBkf zB5(2n7yOfDTcKP1-3S>vlsQm_oRA&7;#L1Ihn{ODg1%VX$DVXvfMfo=8M z<2QMmuKX$`x=!KaMJZeyU~l70M90cBTivgO3XZ*_$7x8frRX%u%%?7bIZYmw;|6_E zhz|gtz>f9yQ_WN8cH`XvI0x+0GXtxd06@xBHb_<^@Zp<*ZAa%;mFT=ES;j%N+wj!} zCNR=ix-UY}TfXtk?qwKtg@yw6v#!r$2x$tj0xZ17R z)Ac0zIRZv2gMDjkkXFq{?+`joy|dJgc%VgTpPlM-+2@^SGNDORCB^7 z8Z`(Cn6U#!ptywAKv zLo%Stheca>Jb{s@exTSWT;3G;bma2DGSl}JZ8fXG?#9yXh9l~`@SspO#hNKP89CLN_H6Lvdz=2cVUNY(R6649_+q+MpWcpDb2k7(?nx>}%o)Q}t z)e-wLrbP^{b4K#;_SaL=PaAbXw=aikf%TR;y93bT{~|nHX|}bjgr_ki&MtlsUCP~> z@;~I&@vo3qEm6JeUg-<0HOKot+<;gYYFNJ`Z0yN}{i#I;@iZLC<@;-M&qpKqt3$hM zvru>6h=#aA?k?^6Qb+1TZwdJd-yVWOK9Ln!uiI_jXZfi}8uyvTx6H`!tFUrqJI*U{ z^9)9w`as8wta=e>p!?2HV%f*`Ka{;g&@eo-t@+xv@x8Wf+qP}nwr$(CZQHi3{%==L zPTe!<3{n|okgC+GovgJVkq3Kn8k7;#=~ON1#g7*ou}|F)64|PQnV#8OL-2K$kkUy1 zhnKoy{xVGRy=JjGK#Gs)r;V(6&Hdu2ipBiv+?Mibghnt7_nS#NM%VPcJIpl6x2CVL;{>JT59bEVVsbDTP*|-zcRLXLTF$)27jI71yUCbmO zRoiM8brH1zng;1d&OIf-+V#iB(Nr6Q`nVP}NfAa4S4JfHo*OVaBi zhP}lr_2Z)AB47H8c(BE)7p9Gy#J2jq$`wLUr+ld%$PiDYmjhE2F4L9Wz6-xiqP!ev z8qS?WF?u|2p2eUsRjuTC7L#z)gmJkAeXyG+pPW=C*gYZg+;Hxk=W}27{sH6x65f`V zs-87Wfi>wRE{lT5>f%>JzaMF`uysObTE)CHg^D8&rP_jYh3d5~>txF@Ie$lvKdfz} zm`>VPsTG553B%{?3p~c5{bUg9wu!X|M{Q{N&ja#Ma6h>AuKdd4V`mfx(lw%<@GUhj z9_MmaPZeeyzr4@UPWPs#oAYl-mGdb9VuYv=6IaVL)a)hCgPUVv&BFkH#*gMr{gG zEpL&0Tz2Ql})!-s6 zyla2}1)fiFe^y2&-PD}s23c@N=+BGDFJal&P-&oa&ZGu_1XyW5fYoOBd8UlY^R#3EJ%qpmL z0Xng3u`EH8zY5Rgi25(L%jp?9|x6R+sM*zH$a#Hr&sEp`T?d z!kFqdy#FlFIJmpFnQ+rY(eSd=IR)zc2XZOtMO~AaYOAX(jd4}aMSPrWo(lGHH&(}{ zvwCvxPxby+%pMx!wJHiUv#0Z za!vKyLj{Pt#=~e|C;Z`3D=*YpffeT?*NEd+%U7_Goy#=gi*LtrH}ZattheQ5YMhBo z4Y1ZwprCi?mDTzhDh;FsJKq;Z$yr+!@qip{lxCi~hDQt5e)sAE(WO)jbzIL*W-l%P zD#ovg&$VOib>inT<9yiy$~hBd{@|oqI-1W#36g(;W1JG)7CGxL-->}0vYg(FN3f7O zRl9k*mZ7c#w#XY1s~$N0yO?Jy$-z8Pb*l=z9D{eE^@nqn*1Yc-fLk95o=Pozu}fD{ ziBBQy_~>6XjiCV*{tQ5#PwBx|l^lV?kwl)J5}_n?o+H4=xTaq0{5bqdb-4PcVHC`m zn=`PdcSX_)_VYoF@2tfTu2u2!*i&@J6K9}DOrHdj

RV9QHeiwR{2|VcB8YlcLw5#Cu|AQT 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 UIi)aLY@*_@Y1~k6n#q_GB?6fK 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 B?8pEB literal 0 HcmV?d00001 diff --git a/tests/bl_lwm/0003548-test_plaintext.zip b/tests/bl_lwm/0003548-test_plaintext.zip new file mode 100644 index 0000000000000000000000000000000000000000..8e566ac085751dbc1eaf875d6f4996a03de8f1f4 GIT binary patch literal 29849 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 Date: Thu, 24 Aug 2023 00:52:59 +0100 Subject: [PATCH 06/23] feat(plaintext): add `typer` `cli` --- alto2txt2fixture/cli.py | 28 ++++++++++++++++++++++++ alto2txt2fixture/plaintext.py | 14 ++++++------ poetry.lock | 37 ++++++++++++++++++++++++++++++- pyproject.toml | 4 +++- tests/test_cli.py | 41 +++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 tests/test_cli.py diff --git a/alto2txt2fixture/cli.py b/alto2txt2fixture/cli.py index 9e197a4..8d4915b 100644 --- a/alto2txt2fixture/cli.py +++ b/alto2txt2fixture/cli.py @@ -1,11 +1,39 @@ import os +from pathlib import Path +import typer from rich.table import Table +from typing_extensions import Annotated +from .plaintext import ( + DEFAULT_EXTRACTED_SUBDIR, + DEFAULT_PLAINTEXT_FIXTURE_OUTPUT, + PlainTextFixture, +) from .settings import DATA_PROVIDER_INDEX, SETUP_TITLE, settings from .types import dotdict from .utils import check_newspaper_collection_configuration, console, gen_fixture_tables +cli = typer.Typer(pretty_exceptions_show_locals=False) + + +@cli.command() +def plaintext( + path: Annotated[Path, typer.Argument()], + save_path: Annotated[Path, typer.Option()] = Path(DEFAULT_PLAINTEXT_FIXTURE_OUTPUT), + data_provider_code: Annotated[str, typer.Option()] = "", + extract_path: Annotated[Path, typer.Argument()] = Path(DEFAULT_EXTRACTED_SUBDIR), +) -> None: + """Create a PlainTextFixture and save to `save_path`.""" + plaintext_fixture = PlainTextFixture( + path=path, + data_provider_code=data_provider_code, + extract_subdir=extract_path, + export_directory=save_path, + ) + plaintext_fixture.extract_compressed() + plaintext_fixture.export_to_json_fixtures() + def show_setup(clear: bool = True, title: str = SETUP_TITLE, **kwargs) -> None: """Generate a `rich.table.Table` for printing configuration to console.""" diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 96ad25b..3168012 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -28,10 +28,10 @@ FULLTEXT_DJANGO_MODEL: Final[str] = "fulltext.fulltext" -HOME_DIR: PathLike = Path.home() -DOWNLOAD_DIR: PathLike = HOME_DIR / "metadata-db/" -ARCHIVE_SUBDIR: PathLike = Path("archives") -EXTRACTED_SUBDIR: PathLike = Path("extracted") +# HOME_DIR: PathLike = Path.home() +# DOWNLOAD_DIR: PathLike = HOME_DIR / "metadata-db/" +# ARCHIVE_SUBDIR: PathLike = Path("archives") +DEFAULT_EXTRACTED_SUBDIR: Final[PathLike] = Path("extracted") FULLTEXT_METHOD: str = "download" FULLTEXT_CONTAINER_SUFFIX: str = "-alto2txt" FULLTEXT_CONTAINER_PATH: PathLike = Path("plaintext/") @@ -183,12 +183,12 @@ class PlainTextFixture: # mount_path: PathLike | None = Path(settings.MOUNTPOINT) data_provider: DataProviderFixtureDict | None = None model_str: str = FULLTEXT_DJANGO_MODEL - archive_subdir: PathLike = ARCHIVE_SUBDIR - extract_subdir: PathLike = EXTRACTED_SUBDIR + # archive_subdir: PathLike = ARCHIVE_SUBDIR + extract_subdir: PathLike = DEFAULT_EXTRACTED_SUBDIR plaintext_extension: str = TXT_FIXTURE_FILE_EXTENSION plaintext_glob_regex: str = TXT_FIXTURE_FILE_GLOB_REGEX # decompress_subdir: PathLike = FULLTEXT_DECOMPRESSED_PATH - download_dir: PathLike = DOWNLOAD_DIR + # download_dir: PathLike = DOWNLOAD_DIR fulltext_container_suffix: str = FULLTEXT_CONTAINER_SUFFIX data_provider_code_dict: dict[str, DataProviderFixtureDict] = field( default_factory=lambda: NEWSPAPER_DATA_PROVIDER_CODE_DICT diff --git a/poetry.lock b/poetry.lock index eba7bdf..d5ba2bf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1497,6 +1497,17 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "shellingham" +version = "1.5.3" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.3-py2.py3-none-any.whl", hash = "sha256:419c6a164770c9c7cfcaeddfacb3d31ac7a8db0b0f3e9c1287679359734107e9"}, + {file = "shellingham-1.5.3.tar.gz", hash = "sha256:cb4a6fec583535bc6da17b647dd2330cf7ef30239e05d547d99ae3705fd0f7f8"}, +] + [[package]] name = "six" version = "1.16.0" @@ -1587,6 +1598,30 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] +[[package]] +name = "typer" +version = "0.9.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.6" +files = [ + {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, + {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" +colorama = {version = ">=0.4.3,<0.5.0", optional = true, markers = "extra == \"all\""} +rich = {version = ">=10.11.0,<14.0.0", optional = true, markers = "extra == \"all\""} +shellingham = {version = ">=1.3.0,<2.0.0", optional = true, markers = "extra == \"all\""} +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + [[package]] name = "types-pytz" version = "2023.3.0.0" @@ -1710,4 +1745,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.12" -content-hash = "1e6a2adffd2fb905c86988a9570cd17650021e282205b9f48ed1365647d75ba4" +content-hash = "c617c9e2854ed5c00f3d3fe10bd98bab17429aff038f71c9208694b5c244a80b" diff --git a/pyproject.toml b/pyproject.toml index 4db5b68..c62b75a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ pytz = "^2022.7.1" rich = "^12.6.0" types-pytz = "^2023.3.0.0" python-slugify = "^8.0.1" +typer = {extras = ["all"], version = "^0.9.0"} [tool.poetry.group.dev.dependencies] pytest-sugar = "^0.9.7" @@ -45,7 +46,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] a2t2f-news = "alto2txt2fixture.__main__:run" -a2tsf-adj = "alto2txt2fixture.create_adjacent_tables:run" +a2t2f-adj = "alto2txt2fixture.create_adjacent_tables:run" +a2t2f-plaintext = "alto2txt2fixture.cli:cli" [tool.pytest.ini_options] xfail_strict = true diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a5cb351 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,41 @@ +import json + +from typer.testing import CliRunner + +from alto2txt2fixture.cli import cli +from alto2txt2fixture.types import FixtureDict + +runner = CliRunner() + + +def test_plaintext_cli(tmpdir): + """Test running `plaintext` file export via `cli`.""" + result = runner.invoke( + cli, + [ + "tests/bl_lwm/", + "--save-path", + tmpdir / "test-cli-plaintext-fixture", + "--data-provider-code", + "bl_lwm", + ], + ) + assert result.exit_code == 0 + assert "Extract path: tests/bl_lwm/extracted" in result.stdout + exported_json: list[FixtureDict] = json.load( + tmpdir / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json" + ) + assert exported_json[0]["model"] == "fulltext.fulltext" + assert "NEW TREDEGAR & BARGOED" in exported_json[0]["fields"]["text"] + assert ( + exported_json[0]["fields"]["path"] + == "tests/bl_lwm/extracted/0003548/1904/0630/0003548_19040630_art0002.txt" + ) + assert ( + exported_json[0]["fields"]["compressed_path"] + == "tests/bl_lwm/0003548-test_plaintext.zip" + ) + assert ( + exported_json[0]["fields"]["updated_at"] + == exported_json[0]["fields"]["updated_at"] + ) From 276369a2cde08b78fb86184914bad9418e31437f Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Thu, 24 Aug 2023 11:39:51 +0100 Subject: [PATCH 07/23] fix(ci): add sorting to `plaintext` to make testing comparable across platforms --- alto2txt2fixture/plaintext.py | 137 ++++++++++++++++++---------------- alto2txt2fixture/utils.py | 10 +-- conftest.py | 22 +++++- pyproject.toml | 2 +- tests/test_cli.py | 12 ++- 5 files changed, 106 insertions(+), 77 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 3168012..309c058 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -28,18 +28,10 @@ FULLTEXT_DJANGO_MODEL: Final[str] = "fulltext.fulltext" -# HOME_DIR: PathLike = Path.home() -# DOWNLOAD_DIR: PathLike = HOME_DIR / "metadata-db/" -# ARCHIVE_SUBDIR: PathLike = Path("archives") DEFAULT_EXTRACTED_SUBDIR: Final[PathLike] = Path("extracted") -FULLTEXT_METHOD: str = "download" -FULLTEXT_CONTAINER_SUFFIX: str = "-alto2txt" -FULLTEXT_CONTAINER_PATH: PathLike = Path("plaintext/") -FULLTEXT_STORAGE_ACCOUNT_URL: str = "https://alto2txt.blob.core.windows.net" FULLTEXT_FILE_NAME_SUFFIX: Final[str] = "_plaintext" ZIP_FILE_EXTENSION: Final[str] = "zip" -FULLTEXT_DECOMPRESSED_PATH = Path("uncompressed/") FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX: Final[ str ] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{ZIP_FILE_EXTENSION}" @@ -48,6 +40,7 @@ DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE: Final[int] = 2000 DEFAULT_PLAINTEXT_FILE_NAME_PREFIX: Final[str] = "plaintext_fixture" DEFAULT_PLAINTEXT_FIXTURE_OUTPUT: Final[PathLike] = Path("output") / "plaintext" +DEFAULT_INITIAL_PK: int = 1 SAS_ENV_VARIABLE = "FULLTEXT_SAS_TOKEN" @@ -125,6 +118,10 @@ class PlainTextFixture: export_directory: Directory to save all exported fixtures to. + initial_pk: + Default begins at 1, can be set to another number if needed to + add to add more to pre-existing set of records up to a given `pk` + _disk_usage: Available harddrive space. Designed to help mitigate decompressing too many files for available disk space. @@ -148,12 +145,12 @@ class PlainTextFixture: >>> plaintext_bl_lwm.free_hd_space_in_GB > 1 True >>> pprint(plaintext_bl_lwm.compressed_files) - (PosixPath('tests/bl_lwm/0003548-test_plaintext.zip'), - PosixPath('tests/bl_lwm/0003079-test_plaintext.zip')) + (PosixPath('tests/bl_lwm/0003079-test_plaintext.zip'), + PosixPath('tests/bl_lwm/0003548-test_plaintext.zip')) >>> plaintext_bl_lwm.extract_compressed() [...] Extract path:...tests/bl_lwm/extracted... - ...Extracting:...tests/bl_lwm/0003548-test_plaintext.zip ... ...Extracting:...tests/bl_lwm/0003079-test_plaintext.zip ... + ...Extracting:...tests/bl_lwm/0003548-test_plaintext.zip ... ...%...[...] >>> plaintext_bl_lwm.delete_decompressed() Deleteing all files in: tests/bl_lwm/extracted @@ -179,20 +176,15 @@ class PlainTextFixture: data_provider_code: str | None = None files: tuple[PathLike, ...] | None = None compressed_glob_regex: str = FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX - # format: str - # mount_path: PathLike | None = Path(settings.MOUNTPOINT) data_provider: DataProviderFixtureDict | None = None model_str: str = FULLTEXT_DJANGO_MODEL - # archive_subdir: PathLike = ARCHIVE_SUBDIR extract_subdir: PathLike = DEFAULT_EXTRACTED_SUBDIR plaintext_extension: str = TXT_FIXTURE_FILE_EXTENSION plaintext_glob_regex: str = TXT_FIXTURE_FILE_GLOB_REGEX - # decompress_subdir: PathLike = FULLTEXT_DECOMPRESSED_PATH - # download_dir: PathLike = DOWNLOAD_DIR - fulltext_container_suffix: str = FULLTEXT_CONTAINER_SUFFIX data_provider_code_dict: dict[str, DataProviderFixtureDict] = field( default_factory=lambda: NEWSPAPER_DATA_PROVIDER_CODE_DICT ) + initial_pk: int = 1 max_plaintext_per_fixture_file: int = DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE saved_fixture_prefix: str = DEFAULT_PLAINTEXT_FILE_NAME_PREFIX export_directory: PathLike = DEFAULT_PLAINTEXT_FIXTURE_OUTPUT @@ -264,8 +256,8 @@ def _set_and_check_path_is_file(self, force: bool = False) -> None: def _set_and_check_path_is_dir(self, force: bool = False) -> None: """Test if `self.path` is a path and change if `force = True`.""" assert Path(self.path).is_dir() - file_paths_tuple: tuple[PathLike, ...] = path_globs_to_tuple( - self.path, self.compressed_glob_regex + file_paths_tuple: tuple[PathLike, ...] = tuple( + sorted(path_globs_to_tuple(self.path, self.compressed_glob_regex)) ) if self.files: if self.files == file_paths_tuple: @@ -304,16 +296,22 @@ def extract_path(self) -> Path: @property def compressed_files(self) -> tuple[PathLike, ...]: """Return a tuple of all `self.files` with known archive filenames.""" - return tuple(valid_compression_files(files=self.files)) if self.files else () + return ( + tuple(sorted(valid_compression_files(files=self.files))) + if self.files + else () + ) @property def plaintext_provided_uncompressed(self) -> tuple[PathLike, ...]: """Return a tuple of all `self.files` with `self.plaintext_extension`.""" if self.files: return tuple( - file - for file in self.files - if Path(file).suffix == self.plaintext_extension + sorted( + file + for file in self.files + if Path(file).suffix == self.plaintext_extension + ) ) else: return () @@ -328,12 +326,12 @@ def zipinfo(self) -> Generator[list[ZipInfo], None, None]: >>> zipfile_info_list = list(plaintext_bl_lwm.zipinfo) Getting zipfile info from >>> zipfile_info_list[0][-1].filename - '0003548/1904/0707/0003548_19040707_art0059.txt' - >>> zipfile_info_list[-1][-1].filename '0003079/1898/0204/0003079_18980204_sect0001.txt' - >>> zipfile_info_list[-1][-1].file_size + >>> zipfile_info_list[-1][-1].filename + '0003548/1904/0707/0003548_19040707_art0059.txt' + >>> zipfile_info_list[0][-1].file_size 70192 - >>> zipfile_info_list[-1][-1].compress_size + >>> zipfile_info_list[0][-1].compress_size 39911 ``` @@ -375,7 +373,7 @@ def extract_compressed(self) -> None: ): logger.info(f"Extracting: {compressed_file} ...") unpack_archive(compressed_file, self.extract_path) - for path in self.extract_path.glob(self.plaintext_glob_regex): + for path in sorted(self.extract_path.glob(self.plaintext_glob_regex)): if path not in self._uncompressed_source_file_dict: self._uncompressed_source_file_dict[path] = compressed_file @@ -392,9 +390,9 @@ def plaintext_paths( >>> plaintext_paths = plaintext_bl_lwm.plaintext_paths() >>> first_path_fixture_dict = next(iter(plaintext_paths)) >>> first_path_fixture_dict['path'].name - '0003548_19040630_art0002.txt' + '0003079_18980107_art0001.txt' >>> first_path_fixture_dict['compressed_path'].name - '0003548-test_plaintext.zip' + '0003079-test_plaintext.zip' >>> len(plaintext_bl_lwm._pk_plaintext_dict) 1 >>> plaintext_bl_lwm._pk_plaintext_dict[ @@ -411,30 +409,34 @@ def plaintext_paths( else: i: int = 0 pk: int - for i, uncompressed_tuple in enumerate( - tqdm( - self._uncompressed_source_file_dict.items(), - desc="Compressed configs :", - total=len(self._uncompressed_source_file_dict), - ) - ): - pk = i + 1 # Most `SQL` `pk` begins at 1 - self._pk_plaintext_dict[uncompressed_tuple[0]] = pk - yield FulltextPathDict( - path=uncompressed_tuple[0], - compressed_path=uncompressed_tuple[1], - primary_key=pk, - ) - for j, path in enumerate( - tqdm( - self.plaintext_provided_uncompressed, - desc="Uncompressed configs:", - total=len(self.plaintext_provided_uncompressed), - ) - ): - pk = j + i + 1 - self._pk_plaintext_dict[path] = pk - yield FulltextPathDict(path=path, compressed_path=None, primary_key=pk) + if self._uncompressed_source_file_dict: + for i, uncompressed_tuple in enumerate( + tqdm( + self._uncompressed_source_file_dict.items(), + desc="Compressed configs :", + total=len(self._uncompressed_source_file_dict), + ) + ): + pk = i + self.initial_pk # Most `SQL` `pk` begins at 1 + self._pk_plaintext_dict[uncompressed_tuple[0]] = pk + yield FulltextPathDict( + path=uncompressed_tuple[0], + compressed_path=uncompressed_tuple[1], + primary_key=pk, + ) + if self.plaintext_provided_uncompressed: + for j, path in enumerate( + tqdm( + self.plaintext_provided_uncompressed, + desc="Uncompressed configs:", + total=len(self.plaintext_provided_uncompressed), + ) + ): + pk = j + i + self.initial_pk + self._pk_plaintext_dict[path] = pk + yield FulltextPathDict( + path=path, compressed_path=None, primary_key=pk + ) def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None]: """Generate fixture dicts from `self.plaintext_paths`. @@ -446,7 +448,6 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None ...Extract path:...tests/bl_lwm/extracted... >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) Compressed configs :...%.../...[ ... it/s ] - Uncompressed configs:...%.../...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() Deleteing all files in: tests/.../extracted @@ -488,26 +489,30 @@ def export_to_json_fixtures( Example: ```pycon >>> tmpdir: Path = getfixture("tmpdir") + >>> first_lwm_plaintext_json_dict: PlaintextFixtureDict = ( + ... getfixture("first_lwm_plaintext_json_dict") + ... ) >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') ...Extract path:...tests/bl_lwm/extracted... >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=tmpdir) Compressed configs...%...[...] - Uncompressed configs...%...[...] >>> import json >>> exported_json = json.load(tmpdir/'plaintext_fixture-1.json') - >>> exported_json[0]['pk'] - 1 - >>> exported_json[0]['model'] - 'fulltext.fulltext' - >>> ('NEW TREDEGAR & BARGOED' in - ... exported_json[0]['fields']['text']) + >>> exported_json[0]['pk'] == first_lwm_plaintext_json_dict['pk'] + True + >>> exported_json[0]['model'] == first_lwm_plaintext_json_dict['model'] + True + >>> (exported_json[0]['fields']['text'] == + ... first_lwm_plaintext_json_dict['fields']['text']) + True + >>> (exported_json[0]['fields']['path'] == + ... first_lwm_plaintext_json_dict['fields']['path']) + True + >>> (exported_json[0]['fields']['compressed_path'] == + ... first_lwm_plaintext_json_dict['fields']['compressed_path']) True - >>> exported_json[0]['fields']['path'] - '.../extracted/.../0003548_19040630_art0002.txt' - >>> exported_json[0]['fields']['compressed_path'] - 'tests/.../0003548-test_plaintext.zip' >>> exported_json[0]['fields']['created_at'] '20...' >>> (exported_json[0]['fields']['updated_at'] == diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 0bb7bc2..22f46b0 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -970,16 +970,16 @@ def path_globs_to_tuple( ```pycon >>> from pprint import pprint >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*text.zip')) - (PosixPath('tests/bl_lwm/0003548-test_plaintext.zip'), - PosixPath('tests/bl_lwm/0003079-test_plaintext.zip')) + (PosixPath('tests/bl_lwm/0003079-test_plaintext.zip'), + PosixPath('tests/bl_lwm/0003548-test_plaintext.zip')) >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*.txt')) - (PosixPath('tests/bl_lwm/0003548_19040707_art0037.txt'), - PosixPath('tests/bl_lwm/0003079_18980121_sect0001.txt')) + (PosixPath('tests/bl_lwm/0003079_18980121_sect0001.txt'), + PosixPath('tests/bl_lwm/0003548_19040707_art0037.txt')) ``` """ - return tuple(Path(path).glob(glob_regex_str)) + return tuple(sorted(Path(path).glob(glob_regex_str))) class DiskUsageTuple(NamedTuple): diff --git a/conftest.py b/conftest.py index d828615..dcfaacc 100644 --- a/conftest.py +++ b/conftest.py @@ -5,7 +5,13 @@ from coverage_badge.__main__ import main as gen_cov_badge from alto2txt2fixture.create_adjacent_tables import OUTPUT, run -from alto2txt2fixture.plaintext import PlainTextFixture +from alto2txt2fixture.plaintext import ( + DEFAULT_INITIAL_PK, + FULLTEXT_DJANGO_MODEL, + PlainTextFixture, + PlaintextFixtureDict, + PlaintextFixtureFieldsDict, +) from alto2txt2fixture.utils import load_multiple_json MODULE_PATH: Path = Path().absolute() @@ -77,6 +83,20 @@ def bl_lwm_plaintext_extracted( yield bl_lwm_plaintext +@pytest.fixture +def first_lwm_plaintext_json_dict() -> PlaintextFixtureDict: + return PlaintextFixtureDict( + pk=DEFAULT_INITIAL_PK, + model=FULLTEXT_DJANGO_MODEL, + fields=PlaintextFixtureFieldsDict( + text="billel\n\nB. RANNS,\n\nDRAPER & OUTFITTER,\nSTATION ROAD,\nCHAPELTOWN,\nu NNW SWIM I • LUSA LIMIT\nOF MI\n\n' NE'TEST Gi\n\n110111 TEM SIMON.\n", + path="tests/bl_lwm/extracted/0003079/1898/0107/0003079_18980107_art0001.txt", + compressed_path="tests/bl_lwm/0003079-test_plaintext.zip", + errors=None, + ), + ) + + def pytest_sessionfinish(session, exitstatus): """Generate badges for docs after tests finish.""" if exitstatus == 0: diff --git a/pyproject.toml b/pyproject.toml index c62b75a..f813d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"] [tool.mypy] python_version = "3.11" -# check_untyped_defs = True +# check_untyped_defs = true ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true diff --git a/tests/test_cli.py b/tests/test_cli.py index a5cb351..f6d3328 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,7 @@ runner = CliRunner() -def test_plaintext_cli(tmpdir): +def test_plaintext_cli(tmpdir, first_lwm_plaintext_json_dict): """Test running `plaintext` file export via `cli`.""" result = runner.invoke( cli, @@ -26,14 +26,18 @@ def test_plaintext_cli(tmpdir): tmpdir / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json" ) assert exported_json[0]["model"] == "fulltext.fulltext" - assert "NEW TREDEGAR & BARGOED" in exported_json[0]["fields"]["text"] + # assert "DRAPER & OUTFITTER" in exported_json[0]["fields"]["text"] + assert ( + exported_json[0]["fields"]["text"] + == first_lwm_plaintext_json_dict["fields"]["text"] + ) assert ( exported_json[0]["fields"]["path"] - == "tests/bl_lwm/extracted/0003548/1904/0630/0003548_19040630_art0002.txt" + == first_lwm_plaintext_json_dict["fields"]["path"] ) assert ( exported_json[0]["fields"]["compressed_path"] - == "tests/bl_lwm/0003548-test_plaintext.zip" + == first_lwm_plaintext_json_dict["fields"]["compressed_path"] ) assert ( exported_json[0]["fields"]["updated_at"] From 35abcf4e3f5a48629b6907ec5abcbd8ec2b52561 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Sun, 27 Aug 2023 17:41:39 -0400 Subject: [PATCH 08/23] feat(plaintext): add `compress_info` and `info_table` --- .gitignore | 1 + alto2txt2fixture/plaintext.py | 244 +++++++++++++++++++-------- alto2txt2fixture/utils.py | 84 ++++++++- conftest.py | 13 +- pyproject.toml | 4 + tests/test_cli.py | 4 +- tests/test_create_adjacent_tables.py | 2 + 7 files changed, 276 insertions(+), 76 deletions(-) diff --git a/.gitignore b/.gitignore index d4656fe..9586354 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ debugger.py .vscode .DS_Store tmp* +data diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 309c058..8fc05ae 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -7,6 +7,7 @@ from typing import Final, Generator, TypedDict from zipfile import ZipFile, ZipInfo +from rich.table import Table from tqdm.rich import tqdm from .settings import NEWSPAPER_DATA_PROVIDER_CODE_DICT @@ -16,10 +17,12 @@ PlaintextFixtureFieldsDict, ) from .utils import ( + ZIP_FILE_EXTENSION, DiskUsageTuple, console, free_hd_space_in_GB, path_globs_to_tuple, + paths_with_newlines, save_fixture, valid_compression_files, ) @@ -31,7 +34,6 @@ DEFAULT_EXTRACTED_SUBDIR: Final[PathLike] = Path("extracted") FULLTEXT_FILE_NAME_SUFFIX: Final[str] = "_plaintext" -ZIP_FILE_EXTENSION: Final[str] = "zip" FULLTEXT_DEFAULT_PLAINTEXT_ZIP_GLOB_REGEX: Final[ str ] = f"*{FULLTEXT_FILE_NAME_SUFFIX}.{ZIP_FILE_EXTENSION}" @@ -138,6 +140,17 @@ class PlainTextFixture: ... path='tests/bl_lwm', ... compressed_glob_regex="*_plaintext.zip", ... ) + >>> plaintext_bl_lwm.info() + PlainTextFixture for 2 'bl_lwm' files + ┌─────────────────────┬───────────────────────────────────────────┐ + │ Path │ 'tests/bl_lwm' │ + │ Compressed Files │ 'tests/bl_lwm/0003079-test_plaintext.zip' │ + │ │ 'tests/bl_lwm/0003548-test_plaintext.zip' │ + │ Extract Path │ 'str(self.extract_path)' │ + │ Uncompressed Files │ │ + │ Data Provider │ Living with Machines │ + │ Initial Primary Key │ 1 │ + └─────────────────────┴───────────────────────────────────────────┘ >>> plaintext_bl_lwm >>> str(plaintext_bl_lwm) @@ -148,28 +161,15 @@ class PlainTextFixture: (PosixPath('tests/bl_lwm/0003079-test_plaintext.zip'), PosixPath('tests/bl_lwm/0003548-test_plaintext.zip')) >>> plaintext_bl_lwm.extract_compressed() - [...] Extract path:...tests/bl_lwm/extracted... - ...Extracting:...tests/bl_lwm/0003079-test_plaintext.zip ... - ...Extracting:...tests/bl_lwm/0003548-test_plaintext.zip ... - ...%...[...] + + ...Extract path:...'tests/bl_lwm/extracted'... + ...Extracting:...'tests/bl_lwm/0003079-test_plaintext.zip' ... + ...Extracting:...'tests/bl_lwm/0003548-test_plaintext.zip' ... + ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleteing all files in: tests/bl_lwm/extracted + Deleting all files in: 'tests/bl_lwm/extracted' ``` - tests/bl_lwm/0003079-test_plaintext.zip - Todo: - Work through lines below to conclude `doctest` - - ```python - plain_text_hmd.newspaper_publication_paths - plain_text_hmd.issues_paths - plain_text_hmd.items_paths - plain_text_hmd.summary - plain_text_hmd.output_paths - plain_text_hmd.export_to_json() - plain_text_hmd.output_paths - plain_text_hmd.compress_json() - ``` """ path: PathLike @@ -204,21 +204,60 @@ def __len__(self) -> int: return len(self.files) if self.files else 0 def __str__(self) -> str: - """Return summary with `DataProvider` if available.""" + """Return class name with count and `DataProvider` if available.""" return ( f"{type(self).__name__} " f"for {len(self)} " - f"{self._data_provider_name_quoted_with_trailing_space}files" + f"{self._data_provider_code_quoted_with_trailing_space}files" ) def __repr__(self) -> str: - """Return summary with `DataProvider` if available.""" + """Return `class` name with `path` attribute.""" return f"<{type(self).__name__}(path='{self.path}')>" @property - def _data_provider_name_quoted_with_trailing_space(self) -> str | None: + def _data_provider_code_quoted_with_trailing_space(self) -> str | None: """Return `self.data_provider` `code` attributre with trailing space or `None`.""" - return f"'{self.data_provider_name}' " if self.data_provider_name else None + return f"'{self.data_provider_code}' " if self.data_provider_code else None + + @property + def info_table(self) -> str: + """Generate a `rich.ltable.Table` of config information. + + Example: + ```pycon + >>> hmd_plaintext_fixture = PlainTextFixture( + ... path=".", + ... data_provider_code="bl_hmd") + >>> table = hmd_plaintext_fixture.info_table + >>> table.title + "PlainTextFixture for 0 'bl_hmd' files" + + ``` + + """ + table: Table = Table(title=str(self), show_header=False) + table.add_row("Path", f"'{self.path}'") + table.add_row("Compressed Files", self._compressed_file_names) + table.add_row("Extract Path", f"'str(self.extract_path)'") + table.add_row("Uncompressed Files", self._provided_uncompressed_file_names) + table.add_row("Data Provider", str(self.data_provider_name)) + table.add_row("Initial Primary Key", str(self.initial_pk)) + return table + + def info(self) -> None: + """Print `self.info_table` to the `console`.""" + console.print(self.info_table) + + @property + def _compressed_file_names(self) -> str: + """`self.compressed_files` `paths` separated by `\n`.""" + return paths_with_newlines(self.compressed_files) + + @property + def _provided_uncompressed_file_names(self) -> str: + """`self.plaintext_provided_uncompressed` `paths` separated by `\n`.""" + return paths_with_newlines(self.plaintext_provided_uncompressed) @property def data_provider_name(self) -> str | None: @@ -226,8 +265,37 @@ def data_provider_name(self) -> str | None: Todo: * Add check without risk of recursion for `self.data_provider_code` + + Example: + ```pycon + >>> bl_hmd = PlainTextFixture( + ... path=".", + ... data_provider_code="bl_hmd") + >>> bl_hmd.data_provider_name + 'Heritage Made Digital' + >>> bl_lwm = PlainTextFixture( + ... path='.', + ... data_provider=NEWSPAPER_DATA_PROVIDER_CODE_DICT['bl_lwm'], + ... ) + >>> bl_lwm.data_provider_name + 'Living with Machines' + >>> plaintext_fixture = PlainTextFixture( + ... path=".") + + ...`.data_provider` and `.data_provider_code` are 'None'... + ...in ... + >>> plaintext_fixture.data_provider_name + + ``` + + ` """ - return self.data_provider_code if self.data_provider_code else None + if self.data_provider and "name" in self.data_provider["fields"]: + return self.data_provider["fields"]["name"] + elif self.data_provider_code: + return self.data_provider_code + else: + return None @property def free_hd_space_in_GB(self) -> float: @@ -285,13 +353,8 @@ def extract_path(self) -> Path: """Path any compressed files would be extracted to.""" if Path(self.path).is_file(): return Path(self.path).parent / self.extract_subdir - elif Path(self.path).is_dir(): - return Path(self.path) / self.extract_subdir else: - raise ValueError( - f"`extract_path` only valid if `self.path` is a " - f"`file` or `dir`: {self.path}" - ) + return Path(self.path) / self.extract_subdir @property def compressed_files(self) -> tuple[PathLike, ...]: @@ -323,7 +386,7 @@ def zipinfo(self) -> Generator[list[ZipInfo], None, None]: Example: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') - >>> zipfile_info_list = list(plaintext_bl_lwm.zipinfo) + >>> zipfile_info_list: list[ZipInfo] = list(plaintext_bl_lwm.zipinfo) Getting zipfile info from >>> zipfile_info_list[0][-1].filename '0003079/1898/0204/0003079_18980204_sect0001.txt' @@ -353,25 +416,25 @@ def extract_compressed(self) -> None: >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...tests/bl_lwm/extracted... + ...Extract path:...'tests/bl_lwm/extracted'... >>> plaintext_bl_lwm._uncompressed_source_file_dict[ ... Path('tests/bl_lwm/extracted/0003079/1898/' ... '0204/0003079_18980204_sect0001.txt') ... ] PosixPath('tests/bl_lwm/0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleteing all files in: tests/bl_lwm/extracted + Deleting all files in: 'tests/bl_lwm/extracted' ``` """ self.extract_path.mkdir(parents=True, exist_ok=True) - console.log(f"Extract path: {self.extract_path}") + console.log(f"Extract path: '{self.extract_path}'") for compressed_file in tqdm( self.compressed_files, total=len(self.compressed_files), ): - logger.info(f"Extracting: {compressed_file} ...") + logger.info(f"Extracting: '{compressed_file}' ...") unpack_archive(compressed_file, self.extract_path) for path in sorted(self.extract_path.glob(self.plaintext_glob_regex)): if path not in self._uncompressed_source_file_dict: @@ -449,7 +512,7 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) Compressed configs :...%.../...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() - Deleteing all files in: tests/.../extracted + Deleting all files in: 'tests/.../extracted' ``` """ @@ -498,8 +561,14 @@ def export_to_json_fixtures( >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=tmpdir) Compressed configs...%...[...] + >>> len(plaintext_bl_lwm._exported_json_paths) + 1 + >>> plaintext_bl_lwm._exported_json_paths + (...Path(...plaintext_fixture-1.json...),) >>> import json - >>> exported_json = json.load(tmpdir/'plaintext_fixture-1.json') + >>> exported_json = json.loads( + ... plaintext_bl_lwm._exported_json_paths[0].read_text() + ... ) >>> exported_json[0]['pk'] == first_lwm_plaintext_json_dict['pk'] True >>> exported_json[0]['model'] == first_lwm_plaintext_json_dict['model'] @@ -520,7 +589,6 @@ def export_to_json_fixtures( True ``` - """ output_path = self.export_directory if not output_path else output_path prefix = self.saved_fixture_prefix if not prefix else prefix @@ -530,12 +598,32 @@ def export_to_json_fixtures( output_path=output_path, add_created=True, ) + self._exported_json_paths = tuple( + Path(path) for path in sorted(Path(output_path).glob(f"**/{prefix}*.json")) + ) # def delete_compressed(self, index: int | str | None = None) -> None: def delete_decompressed(self, ignore_errors: bool = True) -> None: - """Remove all uncompressed files.""" - console.print(f"Deleteing all files in: {self.extract_path}") - rmtree(self.extract_path, ignore_errors=ignore_errors) + """Remove all files in `self.extract_path`. + + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') + + ...Extract path:...'tests/bl_lwm/extracted'... + >>> plaintext_bl_lwm.delete_decompressed() + Deleting all files in:... + >>> plaintext_bl_lwm.delete_decompressed() + + ...Extract path empty: 'tests/bl_lwm/extracted'... + + ```` + """ + if self.extract_path.exists(): + console.print(f"Deleting all files in: '{self.extract_path}'") + rmtree(self.extract_path, ignore_errors=ignore_errors) + else: + console.log(f"Extract path empty: '{self.extract_path}'") def _check_and_set_files_attr(self, force: bool = False) -> None: """Check and populate attributes from `self.path` and `self.files`. @@ -584,34 +672,50 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: ) def _check_and_set_data_provider(self, force: bool = False) -> None: - """Set `self.data_provider` and check `self.data_provider_code`.""" - if self.data_provider_code: - if self.data_provider: - if self.data_provider["fields"]["code"] != self.data_provider_code: - raise ValueError( - f"`self.data_provider_code` {self.data_provider_code} " - f"!= {self.data_provider} (`self.data_provider`)." - ) - else: - logger.debug( - f"{repr(self)} `self.data_provider['fields']['code']` " - f"== `self.data_provider_code`" - ) + """Set `self.data_provider` and check `self.data_provider_code`. + + Example: + ```pycon + >>> plaintext_fixture = PlainTextFixture(path=".") + + ...`.data_provider` and `.data_provider_code` are 'None' in... + ...... + + ``` + """ + if self.data_provider: + data_provider_fields_code: str = self.data_provider["fields"]["code"] + if not self.data_provider_code: + self.data_provider_code = data_provider_fields_code + elif self.data_provider_code == data_provider_fields_code: + logger.debug( + f"{repr(self)} `self.data_provider['fields']['code']` " + f"== `self.data_provider_code`" + ) + elif force: + logger.warning( + f"Forcing {repr(self)} `data_provider_code` to " + f"{self.data_provider['fields']['code']}\n" + f"Orinal `data_provider_code`: {self.data_provider_code}" + ) + self.data_provider_code = data_provider_fields_code else: - if self.data_provider_code in self.data_provider_code_dict: - self.data_provider = self.data_provider_code_dict[ - self.data_provider_code - ] - else: - raise ValueError( - f"`self.data_provider_code` {self.data_provider_code} " - f"not included in `self.data_provider_code_dict`." - f"Available `codes`: {self.data_provider_code_dict.keys()}" - ) - elif self.data_provider: - self.data_provider_code = self.data_provider["fields"]["code"] + raise ValueError( + f"`self.data_provider_code` {self.data_provider_code} " + f"!= {self.data_provider} (`self.data_provider`)." + ) + elif self.data_provider_code: + if self.data_provider_code in self.data_provider_code_dict: + self.data_provider = self.data_provider_code_dict[ + self.data_provider_code + ] + else: + raise ValueError( + f"`self.data_provider_code` {self.data_provider_code} " + f"not in `self.data_provider_code_dict`." + f"Available `codes`: {self.data_provider_code_dict.keys()}" + ) else: logger.debug( - f"Neither `self.data_provider` nor " - f"`self.data_provider_code` provided; both are `None` for {repr(self)}" + f"`.data_provider` and `.data_provider_code` are 'None' in {repr(self)}" ) diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 22f46b0..aeb4972 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -3,9 +3,9 @@ import json import logging from collections import OrderedDict -from os import PathLike, getcwd +from os import PathLike, chdir, getcwd from pathlib import Path -from shutil import disk_usage, get_unpack_formats +from shutil import disk_usage, get_unpack_formats, make_archive from typing import ( Any, Final, @@ -55,6 +55,10 @@ BYTES_PER_GIGABYTE: Final[int] = 1024 * 1024 * 1024 NewspaperElements: Final[TypeAlias] = Literal["newspaper", "issue", "item"] +JSON_FILE_EXTENSION: str = "json" +ZIP_FILE_EXTENSION: Final[str] = "zip" + +JSON_FILE_GLOB_STRING: str = f"**/*{JSON_FILE_EXTENSION}" def get_now(as_str: bool = False) -> datetime.datetime | str: @@ -1047,3 +1051,79 @@ def valid_compression_files(files: Sequence[PathLike]) -> list[PathLike]: for file in files if "".join(Path(file).suffixes) in VALID_COMPRESSION_FORMATS ] + + +def compress_fixture( + path: PathLike, + output_path: PathLike | str = settings.OUTPUT, + suffix: str = "", + format: str = ZIP_FILE_EXTENSION, +) -> None: + """Compress exported `fixtures` files using `make_archive`. + + Args: + path: + `Path` to file to compress + + fixture_glob: + A `glob` string for matching fxitures to compress within `path` + + output_path: + Compressed file name (without extension specified from `format`). + + format: + A `str` of one of the registered compression formats. + `Python` provides `zip`, `tar`, `gztar`, `bztar`, and `xztar` + + suffix: + `str` to add to comprssed filename saved. + For example: if `path = plaintext_fixture-1.json` and + `suffix=_compressed`, then the saved file might be called + `plaintext_fixture_compressed-1.json.zip` + + fixture_extension: + What `str` to glob files within `path` for compression. + + delete_source: + Whether to delete the `path` file after compression. + + Example: + ```pycon + >>> tmpdir: Path = getfixture("tmpdir") + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_json_export') + + ...Compressed configs...%...[...] + >>> compress_fixture( + ... path=plaintext_bl_lwm._exported_json_paths[0], + ... output_path=tmpdir) + Compressing.../plaintext_fixture-1.json to 'zip' + >>> from zipfile import ZipFile, ZipInfo + >>> zipfile_info_list: list[ZipInfo] = ZipFile( + ... tmpdir/'plaintext_fixture-1.json.zip' + ... ).infolist() + >>> len(zipfile_info_list) + 1 + >>> Path(zipfile_info_list[0].filename).name + 'plaintext_fixture-1.json' + + ``` + """ + chdir(str(Path(path).parent)) + save_path: Path = Path(output_path) / f"{path}{suffix}" + console.print(f"Compressing {path} to '{format}'") + make_archive(str(save_path), format=format, base_dir=path) + + +def paths_with_newlines(paths: Iterable[PathLike]) -> str: + """Return a `str` of `paths` separated by \n. + + Example: + ```pycon + >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') + >>> print(paths_with_newlines(plaintext_bl_lwm.compressed_files)) + 'tests/bl_lwm/0003079-test_plaintext.zip' + 'tests/bl_lwm/0003548-test_plaintext.zip' + + ``` + """ + return "\n".join(f"'{f}'" for f in paths) diff --git a/conftest.py b/conftest.py index dcfaacc..ab17d6d 100644 --- a/conftest.py +++ b/conftest.py @@ -18,12 +18,10 @@ BADGE_PATH: Path = Path("docs") / "img" / "coverage.svg" +LWM_PLAINTEXT_FIXTURE: Final[Path] = Path("tests") / "bl_lwm" # HMD_PLAINTEXT_FIXTURE: Path = ( # Path("tests") / "bl_hmd" # ) # "0002645_plaintext.zip" -LWM_PLAINTEXT_FIXTURE: Final[Path] = Path("tests") / "bl_lwm" -# LWM_PLAINTEXT_FIXTURE_extension: Final[str] = - # @pytest.fixture # l def hmd_metadata_fixture() -> Path: @@ -83,6 +81,15 @@ def bl_lwm_plaintext_extracted( yield bl_lwm_plaintext +@pytest.fixture +def bl_lwm_plaintext_json_export( + bl_lwm_plaintext_extracted, + tmpdir, +) -> Generator[PlainTextFixture, None, None]: + bl_lwm_plaintext_extracted.export_to_json_fixtures(output_path=tmpdir) + yield bl_lwm_plaintext_extracted + + @pytest.fixture def first_lwm_plaintext_json_dict() -> PlaintextFixtureDict: return PlaintextFixtureDict( diff --git a/pyproject.toml b/pyproject.toml index f813d3e..178e848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,10 @@ markers = [ "download: requires downloading (deselect with '-m \"not download\"')", ] doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"] +testpaths = [ + "tests", + "alto2txt2fixture" +] [tool.mypy] python_version = "3.11" diff --git a/tests/test_cli.py b/tests/test_cli.py index f6d3328..48f3bf4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ import json +import pytest from typer.testing import CliRunner from alto2txt2fixture.cli import cli @@ -8,6 +9,7 @@ runner = CliRunner() +@pytest.mark.slow def test_plaintext_cli(tmpdir, first_lwm_plaintext_json_dict): """Test running `plaintext` file export via `cli`.""" result = runner.invoke( @@ -21,7 +23,7 @@ def test_plaintext_cli(tmpdir, first_lwm_plaintext_json_dict): ], ) assert result.exit_code == 0 - assert "Extract path: tests/bl_lwm/extracted" in result.stdout + assert "Extract path: 'tests/bl_lwm/extracted'" in result.stdout exported_json: list[FixtureDict] = json.load( tmpdir / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json" ) diff --git a/tests/test_create_adjacent_tables.py b/tests/test_create_adjacent_tables.py index f77ee22..fa7a911 100644 --- a/tests/test_create_adjacent_tables.py +++ b/tests/test_create_adjacent_tables.py @@ -33,6 +33,7 @@ def test_admin_counties_config() -> RemoteDataFilesType: } +@pytest.mark.slow @pytest.mark.download def test_download_custom_folder( uncached_folder, test_admin_counties_config, capsys @@ -44,6 +45,7 @@ def test_download_custom_folder( ) +@pytest.mark.slow @pytest.mark.download def test_local_result_paths(adjacent_data_run_results) -> None: """Test `Mitchells` and `Gazetteer` `json` and `csv` results.""" From afc418b914ddb2f2aa0ea141bd730f00544f4790 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 29 Aug 2023 21:48:45 -0400 Subject: [PATCH 09/23] fix(ci): refactor test fixtures for GitHub actions --- alto2txt2fixture/create_adjacent_tables.py | 7 +- alto2txt2fixture/plaintext.py | 128 ++++++++++++--------- alto2txt2fixture/utils.py | 97 ++++++++++++++-- conftest.py | 69 ++++++----- tests/test_cli.py | 23 ++-- tests/test_create_adjacent_tables.py | 17 +-- tests/test_newspaper.py | 1 - tests/test_newspaper.py.orig | 25 ++++ tests/test_utils.py | 2 +- 9 files changed, 254 insertions(+), 115 deletions(-) create mode 100644 tests/test_newspaper.py.orig diff --git a/alto2txt2fixture/create_adjacent_tables.py b/alto2txt2fixture/create_adjacent_tables.py index 4a4b644..ff7651f 100755 --- a/alto2txt2fixture/create_adjacent_tables.py +++ b/alto2txt2fixture/create_adjacent_tables.py @@ -300,7 +300,7 @@ def run( saved: list[PathLike] = SAVED, time_stamp: str = "", output_path: Path = OUTPUT, -) -> None: +) -> list[PathLike]: """Download, process and link ``files_dict`` to `json` and `csv`. Note: @@ -322,6 +322,7 @@ def run( output_path.mkdir(exist_ok=True, parents=True) # Read all the Wikidata Q values from Mitchells + assert "local" in files_dict["mitchells"] mitchells_df = pd.read_csv(files_dict["mitchells"]["local"], index_col=0) mitchell_wikidata_mentions = sorted( list(mitchells_df.PLACE_PUB_WIKI.unique()), @@ -330,6 +331,7 @@ def run( # Set up wikidata_gazetteer gaz_cols = ["wikidata_id", "english_label", "latitude", "longitude", "geonamesIDs"] + assert "local" in files_dict["wikidata_gazetteer_selected_columns"] wikidata_gazetteer = pd.read_csv( files_dict["wikidata_gazetteer_selected_columns"]["local"], usecols=gaz_cols ) @@ -758,10 +760,11 @@ def run( # ###### NOW WE CAN EASILY CREATE JSON files_dict for csv_file_path in output_path.glob("*.csv"): - csv2json_list(csv_file_path) + csv2json_list(csv_file_path, output_path=output_path) print("Finished - saved files:") print("- " + "\n- ".join([str(x) for x in saved])) + return saved if __name__ == "__main__": diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 8fc05ae..7f0ba5a 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -24,6 +24,7 @@ path_globs_to_tuple, paths_with_newlines, save_fixture, + truncate_path_str, valid_compression_files, ) @@ -134,40 +135,36 @@ class PlainTextFixture: Example: ```pycon - >>> from pprint import pprint + >>> path = getfixture('bl_lwm') >>> plaintext_bl_lwm = PlainTextFixture( ... data_provider_code='bl_lwm', - ... path='tests/bl_lwm', + ... path=path, ... compressed_glob_regex="*_plaintext.zip", ... ) - >>> plaintext_bl_lwm.info() - PlainTextFixture for 2 'bl_lwm' files - ┌─────────────────────┬───────────────────────────────────────────┐ - │ Path │ 'tests/bl_lwm' │ - │ Compressed Files │ 'tests/bl_lwm/0003079-test_plaintext.zip' │ - │ │ 'tests/bl_lwm/0003548-test_plaintext.zip' │ - │ Extract Path │ 'str(self.extract_path)' │ - │ Uncompressed Files │ │ - │ Data Provider │ Living with Machines │ - │ Initial Primary Key │ 1 │ - └─────────────────────┴───────────────────────────────────────────┘ >>> plaintext_bl_lwm - - >>> str(plaintext_bl_lwm) - "PlainTextFixture for 2 'bl_lwm' files" + + >>> plaintext_bl_lwm.info() + + ...PlainTextFixture for 2 'bl_lwm' files... + ┌─────────────────────┬─────────────────────────────────────────...┐ + │ Path │ '/.../bl_lwm' ...│ + │ Compressed Files │ '/.../bl_lwm/0003079-test_plaintext.zip'...│ + │ │ '/.../bl_lwm/0003548-test_plaintext.zip'...│ + │ Extract Path │ '/.../bl_lwm/extracted' ...│ + │ Uncompressed Files │ None ...│ + │ Data Provider │ 'Living with Machines' ...│ + │ Initial Primary Key │ 1 ...│ + └─────────────────────┴─────────────────────────────────────────...┘ >>> plaintext_bl_lwm.free_hd_space_in_GB > 1 True - >>> pprint(plaintext_bl_lwm.compressed_files) - (PosixPath('tests/bl_lwm/0003079-test_plaintext.zip'), - PosixPath('tests/bl_lwm/0003548-test_plaintext.zip')) >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'tests/bl_lwm/extracted'... - ...Extracting:...'tests/bl_lwm/0003079-test_plaintext.zip' ... - ...Extracting:...'tests/bl_lwm/0003548-test_plaintext.zip' ... + ...Extract path:...'/.../bl_lwm/extracted'... + ...Extracting:...'/.../bl_lwm/0003079-test_...zip' ... + ...Extracting:...'/.../bl_lwm/0003548-test_...zip' ... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: 'tests/bl_lwm/extracted' + Deleting all files in: '/.../bl_lwm/extracted' ``` """ @@ -188,6 +185,7 @@ class PlainTextFixture: max_plaintext_per_fixture_file: int = DEFAULT_MAX_PLAINTEXT_PER_FIXTURE_FILE saved_fixture_prefix: str = DEFAULT_PLAINTEXT_FILE_NAME_PREFIX export_directory: PathLike = DEFAULT_PLAINTEXT_FIXTURE_OUTPUT + empty_info_default_str: str = "None" def __post_init__(self) -> None: """Manage populating additional attributes if necessary.""" @@ -236,12 +234,22 @@ def info_table(self) -> str: ``` """ + compressed_file_names: str = ( + self._compressed_file_names(truncate=True, tail_paths=2) + ) or self.empty_info_default_str + uncompressed_file_names: str = ( + self._provided_uncompressed_file_names(truncate=True, tail_paths=2) + ) or self.empty_info_default_str + extract_path: str = ( + f"'{truncate_path_str(self.extract_path, tail_paths=2)}'" + or self.empty_info_default_str + ) table: Table = Table(title=str(self), show_header=False) - table.add_row("Path", f"'{self.path}'") - table.add_row("Compressed Files", self._compressed_file_names) - table.add_row("Extract Path", f"'str(self.extract_path)'") - table.add_row("Uncompressed Files", self._provided_uncompressed_file_names) - table.add_row("Data Provider", str(self.data_provider_name)) + table.add_row("Path", f"'{truncate_path_str(self.path)}'") + table.add_row("Compressed Files", compressed_file_names) + table.add_row("Extract Path", extract_path) + table.add_row("Uncompressed Files", uncompressed_file_names) + table.add_row("Data Provider", f"'{str(self.data_provider_name)}'") table.add_row("Initial Primary Key", str(self.initial_pk)) return table @@ -249,15 +257,23 @@ def info(self) -> None: """Print `self.info_table` to the `console`.""" console.print(self.info_table) - @property - def _compressed_file_names(self) -> str: + def _compressed_file_names( + self, truncate: bool = False, tail_paths: int = 1 + ) -> str: """`self.compressed_files` `paths` separated by `\n`.""" - return paths_with_newlines(self.compressed_files) + return paths_with_newlines( + self.compressed_files, truncate=truncate, tail_paths=tail_paths + ) - @property - def _provided_uncompressed_file_names(self) -> str: + def _provided_uncompressed_file_names( + self, truncate: bool = False, tail_paths: int = 1 + ) -> str: """`self.plaintext_provided_uncompressed` `paths` separated by `\n`.""" - return paths_with_newlines(self.plaintext_provided_uncompressed) + return paths_with_newlines( + self.plaintext_provided_uncompressed, + truncate=truncate, + tail_paths=tail_paths, + ) @property def data_provider_name(self) -> str | None: @@ -282,8 +298,8 @@ def data_provider_name(self) -> str | None: >>> plaintext_fixture = PlainTextFixture( ... path=".") - ...`.data_provider` and `.data_provider_code` are 'None'... - ...in ... + ...`.data_provider` and `.data_provider_code`... + ...are 'None'...in ... >>> plaintext_fixture.data_provider_name ``` @@ -387,7 +403,7 @@ def zipinfo(self) -> Generator[list[ZipInfo], None, None]: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> zipfile_info_list: list[ZipInfo] = list(plaintext_bl_lwm.zipinfo) - Getting zipfile info from + Getting zipfile info from >>> zipfile_info_list[0][-1].filename '0003079/1898/0204/0003079_18980204_sect0001.txt' >>> zipfile_info_list[-1][-1].filename @@ -416,14 +432,18 @@ def extract_compressed(self) -> None: >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'tests/bl_lwm/extracted'... + ...Extract path:...'/.../bl_lwm/extracted'... + >>> filter_sect1_txt: list[str] = [txt_file for txt_file in + ... plaintext_bl_lwm._uncompressed_source_file_dict.keys() + ... if txt_file.name.endswith('204_sect0001.txt')] + >>> len(filter_sect1_txt) + 1 >>> plaintext_bl_lwm._uncompressed_source_file_dict[ - ... Path('tests/bl_lwm/extracted/0003079/1898/' - ... '0204/0003079_18980204_sect0001.txt') + ... filter_sect1_txt[0] ... ] - PosixPath('tests/bl_lwm/0003079-test_plaintext.zip') + PosixPath('/.../bl_lwm/0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: 'tests/bl_lwm/extracted' + Deleting all files in: '/.../bl_lwm/extracted' ``` @@ -449,7 +469,7 @@ def plaintext_paths( ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/bl_lwm/extracted... + ...Extract path:.../.../bl_lwm/extracted... >>> plaintext_paths = plaintext_bl_lwm.plaintext_paths() >>> first_path_fixture_dict = next(iter(plaintext_paths)) >>> first_path_fixture_dict['path'].name @@ -508,11 +528,11 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/bl_lwm/extracted... + ...Extract path:.../.../bl_lwm/extracted... >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) Compressed configs :...%.../...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: 'tests/.../extracted' + Deleting all files in: '/.../.../extracted' ``` """ @@ -551,14 +571,14 @@ def export_to_json_fixtures( Example: ```pycon - >>> tmpdir: Path = getfixture("tmpdir") + >>> bl_lwm: Path = getfixture("bl_lwm") >>> first_lwm_plaintext_json_dict: PlaintextFixtureDict = ( ... getfixture("first_lwm_plaintext_json_dict") ... ) >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...tests/bl_lwm/extracted... - >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=tmpdir) + ...Extract path:.../.../bl_lwm/extracted... + >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=bl_lwm / "output") Compressed configs...%...[...] >>> len(plaintext_bl_lwm._exported_json_paths) @@ -577,10 +597,10 @@ def export_to_json_fixtures( ... first_lwm_plaintext_json_dict['fields']['text']) True >>> (exported_json[0]['fields']['path'] == - ... first_lwm_plaintext_json_dict['fields']['path']) + ... str(first_lwm_plaintext_json_dict['fields']['path'])) True >>> (exported_json[0]['fields']['compressed_path'] == - ... first_lwm_plaintext_json_dict['fields']['compressed_path']) + ... str(first_lwm_plaintext_json_dict['fields']['compressed_path'])) True >>> exported_json[0]['fields']['created_at'] '20...' @@ -610,12 +630,12 @@ def delete_decompressed(self, ignore_errors: bool = True) -> None: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...'tests/bl_lwm/extracted'... + ...Extract path:...'/.../bl_lwm/extracted'... >>> plaintext_bl_lwm.delete_decompressed() Deleting all files in:... >>> plaintext_bl_lwm.delete_decompressed() - ...Extract path empty: 'tests/bl_lwm/extracted'... + ...Extract path empty:...'/.../bl_lwm/extracted'... ```` """ @@ -646,7 +666,7 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: ...DEBUG...No changes from... ...>> plaintext_lwm.path = ( - ... 'tests/bl_lwm/0003079-test_plaintext.zip') + ... plaintext_lwm.path / '0003079-test_plaintext.zip') >>> plaintext_lwm._check_and_set_files_attr() Traceback (most recent call last): ... @@ -656,7 +676,7 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> plaintext_lwm._check_and_set_files_attr(force=True) DEBUG...Force change to...>> plaintext_lwm.files - ('tests/bl_lwm/0003079-test_plaintext.zip',) + (...('/.../bl_lwm/0003079-test_plaintext.zip'),) >>> len(plaintext_lwm) 1 diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index aeb4972..0d141bc 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -16,6 +16,7 @@ NamedTuple, Sequence, TypeAlias, + overload, ) import pytz @@ -60,6 +61,19 @@ JSON_FILE_GLOB_STRING: str = f"**/*{JSON_FILE_EXTENSION}" +MAX_TRUNCATE_PATH_STR_LEN: Final[int] = 30 +INTERMEDIATE_PATH_TRUNCATION_STR: Final[str] = "." + + +@overload +def get_now(as_str: Literal[True]) -> str: + ... + + +@overload +def get_now(as_str: Literal[False]) -> datetime.datetime: + ... + def get_now(as_str: bool = False) -> datetime.datetime | str: """ @@ -972,13 +986,14 @@ def path_globs_to_tuple( Example: ```pycon + >>> bl_lwm = getfixture("bl_lwm") >>> from pprint import pprint - >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*text.zip')) - (PosixPath('tests/bl_lwm/0003079-test_plaintext.zip'), - PosixPath('tests/bl_lwm/0003548-test_plaintext.zip')) - >>> pprint(path_globs_to_tuple('tests/bl_lwm', '*.txt')) - (PosixPath('tests/bl_lwm/0003079_18980121_sect0001.txt'), - PosixPath('tests/bl_lwm/0003548_19040707_art0037.txt')) + >>> pprint(path_globs_to_tuple(bl_lwm, '*text.zip')) + (PosixPath('/.../bl_lwm/0003079-test_plaintext.zip'), + PosixPath('/.../bl_lwm/0003548-test_plaintext.zip')) + >>> pprint(path_globs_to_tuple(bl_lwm, '*.txt')) + (PosixPath('/.../bl_lwm/0003079_18980121_sect0001.txt'), + PosixPath('/.../bl_lwm/0003548_19040707_art0037.txt')) ``` @@ -1096,7 +1111,7 @@ def compress_fixture( >>> compress_fixture( ... path=plaintext_bl_lwm._exported_json_paths[0], ... output_path=tmpdir) - Compressing.../plaintext_fixture-1.json to 'zip' + Compressing.../plain...t_fixture-1.json to 'zip' >>> from zipfile import ZipFile, ZipInfo >>> zipfile_info_list: list[ZipInfo] = ZipFile( ... tmpdir/'plaintext_fixture-1.json.zip' @@ -1114,16 +1129,76 @@ def compress_fixture( make_archive(str(save_path), format=format, base_dir=path) -def paths_with_newlines(paths: Iterable[PathLike]) -> str: +def paths_with_newlines( + paths: Iterable[PathLike], truncate: bool = False, **kwargs +) -> str: """Return a `str` of `paths` separated by \n. Example: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> print(paths_with_newlines(plaintext_bl_lwm.compressed_files)) - 'tests/bl_lwm/0003079-test_plaintext.zip' - 'tests/bl_lwm/0003548-test_plaintext.zip' + '/.../bl_lwm/0003079-test_plaintext.zip' + '/.../bl_lwm/0003548-test_plaintext.zip' + >>> print( + ... paths_with_newlines(plaintext_bl_lwm.compressed_files, + ... truncate=True) + ... ) + '/..././0003079-test_plaintext.zip' + '/..././0003548-test_plaintext.zip' + + ``` + """ + if truncate: + return "\n".join(f"'{truncate_path_str(f, **kwargs)}'" for f in paths) + else: + return "\n".join(f"'{f}'" for f in paths) + + +def truncate_path_str( + path: PathLike, + max_length: int = MAX_TRUNCATE_PATH_STR_LEN, + folder_filler_str: str = INTERMEDIATE_PATH_TRUNCATION_STR, + tail_paths: int = 1, +) -> str: + """If `len(text) > max_length` return `text` followed by `trail_str`. + + Args: + text: `str` to truncate + max_length: maximum length of `text` to allow, anything belond truncated + folder_filler_str: what to fill intermediate path names with + + Returns: + `text` truncated to `max_length` (if longer than `max_length`), + with with `folder_filler_str` for intermediate folder names + + Example: + ```pycon + >>> love_shadows: Path = ( + ... Path('Standing') / 'in' / 'the' / 'shadows'/ 'of' / 'love.') + >>> truncate_path_str(love_shadows) + 'Standing/././././love.' + >>> truncate_path_str(love_shadows, max_length=100) + 'Standing/in/the/shadows/of/love.' + >>> truncate_path_str(love_shadows, folder_filler_str="*") + 'Standing/*/*/*/*/love.' + >>> truncate_path_str(Path('/') / love_shadows, folder_filler_str="*") + '/Standing/*/*/*/*/love.' + >>> truncate_path_str(Path('/') / love_shadows, + ... folder_filler_str="*", tail_paths=3) + '/Standing/*/*/shadows/of/love.' ``` """ - return "\n".join(f"'{f}'" for f in paths) + if len(str(path)) > max_length: + path_parts: tuple[str] = Path(path).parts + first_folder_name_index: int = 1 if Path(path).is_absolute() else 0 + paths_str: str = "/".join( + part + if i == 0 or i >= len(path_parts) - first_folder_name_index - tail_paths + else folder_filler_str + for i, part in enumerate(path_parts[first_folder_name_index:]) + ) + return "/" + paths_str if first_folder_name_index == 1 else paths_str + else: + return str(path) diff --git a/conftest.py b/conftest.py index ab17d6d..a5a38ee 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,11 @@ from pathlib import Path +from shutil import copytree, rmtree from typing import Final, Generator import pytest from coverage_badge.__main__ import main as gen_cov_badge -from alto2txt2fixture.create_adjacent_tables import OUTPUT, run +from alto2txt2fixture.create_adjacent_tables import run from alto2txt2fixture.plaintext import ( DEFAULT_INITIAL_PK, FULLTEXT_DJANGO_MODEL, @@ -18,7 +19,10 @@ BADGE_PATH: Path = Path("docs") / "img" / "coverage.svg" -LWM_PLAINTEXT_FIXTURE: Final[Path] = Path("tests") / "bl_lwm" +LWM_PLAINTEXT_FIXTURE_FOLDER: Final[Path] = Path("bl_lwm") +LWM_PLAINTEXT_FIXTURE: Final[Path] = ( + MODULE_PATH / "tests" / LWM_PLAINTEXT_FIXTURE_FOLDER +) # HMD_PLAINTEXT_FIXTURE: Path = ( # Path("tests") / "bl_hmd" # ) # "0002645_plaintext.zip" @@ -35,42 +39,53 @@ # return HMD_PLAINTEXT_FIXTURE -@pytest.fixture -def uncached_folder(monkeypatch, tmpdir) -> None: - """Change local path to avoid using pre-cached data.""" - monkeypatch.chdir(tmpdir) - - -@pytest.fixture(autouse=True) -def package_path(monkeypatch) -> None: - monkeypatch.chdir(MODULE_PATH) +# @pytest.fixture +# def uncached_folder(monkeypatch, tmp_path) -> None: +# """Change local path to avoid using pre-cached data.""" +# monkeypatch.chdir(tmp_path) +# +# +# @pytest.fixture(autouse=True) +# def package_path(monkeypatch) -> None: +# monkeypatch.chdir(MODULE_PATH) @pytest.mark.downloaded @pytest.fixture(scope="session") -def adjacent_data_run_results() -> None: +def adjacent_data_run_results(tmp_path_factory) -> Generator[Path, None, None]: """Test `create_adjacent_tables.run`, using `cached` data if available. This fixture provides the results of `create_adjacent_tables.run` for tests to compare with. Include it as a parameter for tests that need those files downloaded locally to run. """ - run() + temp_adjacent_run_path = tmp_path_factory.mktemp("OUTPUT") + run(output_path=temp_adjacent_run_path) + yield temp_adjacent_run_path +@pytest.mark.downloaded @pytest.fixture(scope="session") -def all_create_adjacent_tables_json_results() -> Generator[list, None, None]: +def all_create_adjacent_tables_json_results( + adjacent_data_run_results, +) -> Generator[list, None, None]: """Return a list of `json` results from `adjacent_data_run_results`.""" - yield load_multiple_json(Path(OUTPUT)) + yield load_multiple_json(adjacent_data_run_results) + + +@pytest.fixture +def bl_lwm(tmp_path) -> Generator[Path, None, None]: + yield copytree(LWM_PLAINTEXT_FIXTURE, tmp_path / LWM_PLAINTEXT_FIXTURE_FOLDER) + rmtree(tmp_path / LWM_PLAINTEXT_FIXTURE_FOLDER) @pytest.fixture -def bl_lwm_plaintext() -> Generator[PlainTextFixture, None, None]: - bl_lwm: PlainTextFixture = PlainTextFixture( - path=LWM_PLAINTEXT_FIXTURE, data_provider_code="bl_lwm" +def bl_lwm_plaintext(bl_lwm) -> Generator[PlainTextFixture, None, None]: + bl_lwm_fixture: PlainTextFixture = PlainTextFixture( + path=bl_lwm, data_provider_code="bl_lwm" ) - yield bl_lwm - bl_lwm.delete_decompressed() + yield bl_lwm_fixture + bl_lwm_fixture.delete_decompressed() @pytest.fixture @@ -84,21 +99,23 @@ def bl_lwm_plaintext_extracted( @pytest.fixture def bl_lwm_plaintext_json_export( bl_lwm_plaintext_extracted, - tmpdir, + tmp_path, ) -> Generator[PlainTextFixture, None, None]: - bl_lwm_plaintext_extracted.export_to_json_fixtures(output_path=tmpdir) + bl_lwm_plaintext_extracted.export_to_json_fixtures(output_path=tmp_path) yield bl_lwm_plaintext_extracted @pytest.fixture -def first_lwm_plaintext_json_dict() -> PlaintextFixtureDict: +def first_lwm_plaintext_json_dict(bl_lwm) -> PlaintextFixtureDict: return PlaintextFixtureDict( pk=DEFAULT_INITIAL_PK, model=FULLTEXT_DJANGO_MODEL, fields=PlaintextFixtureFieldsDict( - text="billel\n\nB. RANNS,\n\nDRAPER & OUTFITTER,\nSTATION ROAD,\nCHAPELTOWN,\nu NNW SWIM I • LUSA LIMIT\nOF MI\n\n' NE'TEST Gi\n\n110111 TEM SIMON.\n", - path="tests/bl_lwm/extracted/0003079/1898/0107/0003079_18980107_art0001.txt", - compressed_path="tests/bl_lwm/0003079-test_plaintext.zip", + text="billel\n\nB. RANNS,\n\nDRAPER & OUTFITTER,\nSTATION ROAD," + "\nCHAPELTOWN,\nu NNW SWIM I • LUSA LIMIT\nOF MI\n\n' " + "NE'TEST Gi\n\n110111 TEM SIMON.\n", + path=bl_lwm / "extracted/0003079/1898/0107/0003079_18980107_art0001.txt", + compressed_path=bl_lwm / "0003079-test_plaintext.zip", errors=None, ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 48f3bf4..c44a201 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,22 +10,23 @@ @pytest.mark.slow -def test_plaintext_cli(tmpdir, first_lwm_plaintext_json_dict): +def test_plaintext_cli(bl_lwm, first_lwm_plaintext_json_dict): """Test running `plaintext` file export via `cli`.""" result = runner.invoke( cli, [ - "tests/bl_lwm/", + str(bl_lwm), "--save-path", - tmpdir / "test-cli-plaintext-fixture", + bl_lwm / "test-cli-plaintext-fixture", "--data-provider-code", "bl_lwm", ], ) assert result.exit_code == 0 - assert "Extract path: 'tests/bl_lwm/extracted'" in result.stdout - exported_json: list[FixtureDict] = json.load( - tmpdir / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json" + for message in ("Extract path:", "bl_lwm/extracted"): + assert message in result.stdout + exported_json: list[FixtureDict] = json.loads( + (bl_lwm / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json").read_text() ) assert exported_json[0]["model"] == "fulltext.fulltext" # assert "DRAPER & OUTFITTER" in exported_json[0]["fields"]["text"] @@ -33,13 +34,11 @@ def test_plaintext_cli(tmpdir, first_lwm_plaintext_json_dict): exported_json[0]["fields"]["text"] == first_lwm_plaintext_json_dict["fields"]["text"] ) - assert ( - exported_json[0]["fields"]["path"] - == first_lwm_plaintext_json_dict["fields"]["path"] + assert exported_json[0]["fields"]["path"] == str( + first_lwm_plaintext_json_dict["fields"]["path"] ) - assert ( - exported_json[0]["fields"]["compressed_path"] - == first_lwm_plaintext_json_dict["fields"]["compressed_path"] + assert exported_json[0]["fields"]["compressed_path"] == str( + first_lwm_plaintext_json_dict["fields"]["compressed_path"] ) assert ( exported_json[0]["fields"]["updated_at"] diff --git a/tests/test_create_adjacent_tables.py b/tests/test_create_adjacent_tables.py index fa7a911..98ce244 100644 --- a/tests/test_create_adjacent_tables.py +++ b/tests/test_create_adjacent_tables.py @@ -24,25 +24,26 @@ def dict_admin_counties() -> dict[str, list[str]]: @pytest.fixture() -def test_admin_counties_config() -> RemoteDataFilesType: +def test_admin_counties_config(tmpdir) -> RemoteDataFilesType: return { "dict_admin_counties": { "remote": "https://zooniversedata.blob.core.windows.net/downloads/Gazetteer-files/dict_admin_counties.json", - "local": Path("cache/extra/path/dict_admin_counties.json"), + "local": tmpdir / "dict_admin_counties.json", } } @pytest.mark.slow @pytest.mark.download -def test_download_custom_folder( - uncached_folder, test_admin_counties_config, capsys -) -> None: +def test_download_custom_folder(test_admin_counties_config, capsys) -> None: download_data(test_admin_counties_config) captured: CaptureResult = capsys.readouterr() - assert captured.out.startswith( - f"Downloading {test_admin_counties_config['dict_admin_counties']['local']}\n100%" - ) + assert captured.out.startswith(f"Downloading") + similar_path_parts: list[tuple[str, bool]] = [] + for part in Path(test_admin_counties_config["dict_admin_counties"]["local"]).parts: + similar_path_parts.append((part, part in captured.out)) + assert sum(int(not matches) for part, matches in similar_path_parts) < 2 + assert "100%" in captured.out @pytest.mark.slow diff --git a/tests/test_newspaper.py b/tests/test_newspaper.py index 56eaa4d..cc090bb 100644 --- a/tests/test_newspaper.py +++ b/tests/test_newspaper.py @@ -44,7 +44,6 @@ def test_newspaper_help(help_param: str, capsys: pytest.LogCaptureFixture) -> No ) def test_run_without_local_or_blobfuse( local_args: list | None, - uncached_folder: None, monkeypatch: pytest.MonkeyPatch, capsys: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/test_newspaper.py.orig b/tests/test_newspaper.py.orig new file mode 100644 index 0000000..5aadd1f --- /dev/null +++ b/tests/test_newspaper.py.orig @@ -0,0 +1,25 @@ +from alto2txt2fixture.__main__ import run + +import pytest + + +def test_run_without_local_or_blobfuse(capsys) -> None: + """Test error mesages from `alto2txt2fixture.run` cli. + + Todo: + This currently fails if --pdb option is used because + that alters the behavious of `capsys` + """ + error_message: str = ( + "The mountpoint provided for alto2txt does not exist. " + "Either create a local copy or blobfuse it to" + ) + with pytest.raises(SystemExit) as e_info: + run() + # Check the error is raised where expected +<<<<<<< HEAD + assert e_info.traceback[1].path.name == "run.py" +======= + assert e_info.traceback[1].path.name == "__main__.py" +>>>>>>> origin/main + assert error_message in capsys.readouterr().out diff --git a/tests/test_utils.py b/tests/test_utils.py index 62ff93d..d7d3d58 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -24,7 +24,7 @@ def test_json_results_ordering(all_create_adjacent_tables_json_results: list) -> @pytest.mark.slow @pytest.mark.download -def test_download(uncached_folder) -> None: +def test_download() -> None: """Assuming intenet connectivity, test downloading needed files.""" download_data() From 0dcafd3410fdd5b19105dc5373508c5b33a8eaa4 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 29 Aug 2023 22:11:25 -0400 Subject: [PATCH 10/23] fix(ci): alter `docstring` tests with ellipses following `act` `GitHub` term width --- alto2txt2fixture/plaintext.py | 12 ++++++------ alto2txt2fixture/utils.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 7f0ba5a..81dff12 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -160,11 +160,11 @@ class PlainTextFixture: >>> plaintext_bl_lwm.extract_compressed() ...Extract path:...'/.../bl_lwm/extracted'... - ...Extracting:...'/.../bl_lwm/0003079-test_...zip' ... - ...Extracting:...'/.../bl_lwm/0003548-test_...zip' ... + ...Extracting:...'/.../bl_lwm/0003079...zip' ... + ...Extracting:...'/.../bl_lwm/0003548...zip' ... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: '/.../bl_lwm/extracted' + Deleting all files in: '/.../bl_lwm/..tracted' ``` """ @@ -532,7 +532,7 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) Compressed configs :...%.../...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: '/.../.../extracted' + Deleting all files in: '/.../.../...tracted' ``` """ @@ -664,7 +664,7 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> plaintext_lwm._check_and_set_files_attr() ...DEBUG...No changes from... - ...>> plaintext_lwm.path = ( ... plaintext_lwm.path / '0003079-test_plaintext.zip') >>> plaintext_lwm._check_and_set_files_attr() @@ -698,7 +698,7 @@ def _check_and_set_data_provider(self, force: bool = False) -> None: ```pycon >>> plaintext_fixture = PlainTextFixture(path=".") - ...`.data_provider` and `.data_provider_code` are 'None' in... + ...`.data_provider` and `.data_provider_code`...'None' in... ...... ``` diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 0d141bc..6b4faa8 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -1111,7 +1111,7 @@ def compress_fixture( >>> compress_fixture( ... path=plaintext_bl_lwm._exported_json_paths[0], ... output_path=tmpdir) - Compressing.../plain...t_fixture-1.json to 'zip' + Compressing.../plain...-1.json to 'zip' >>> from zipfile import ZipFile, ZipInfo >>> zipfile_info_list: list[ZipInfo] = ZipFile( ... tmpdir/'plaintext_fixture-1.json.zip' From 497679a840d06eb93d1c96e8018161edb31b7975 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 29 Aug 2023 22:25:05 -0400 Subject: [PATCH 11/23] fix(ci): tweak `plaintext` `docstring` `ellipses` following `act` GitHub Actions text --- alto2txt2fixture/plaintext.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 81dff12..16da1d6 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -164,7 +164,7 @@ class PlainTextFixture: ...Extracting:...'/.../bl_lwm/0003548...zip' ... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: '/.../bl_lwm/..tracted' + Deleting all files in:...'/.../bl_lwm/...tracted' ``` """ @@ -443,7 +443,7 @@ def extract_compressed(self) -> None: ... ] PosixPath('/.../bl_lwm/0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: '/.../bl_lwm/extracted' + Deleting all files in:...'/.../bl_lwm/...tracted' ``` From a78c2dd5e5f0f000752085da8d4947eea93efc5a Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 08:33:12 -0400 Subject: [PATCH 12/23] fix(ci): add missing `plaintext.txt` test files and tweak `plaintext` `docstring` tests --- alto2txt2fixture/plaintext.py | 12 +- tests/bl_lwm/0003079_18980121_sect0001.txt | 1792 ++++++++++++++++++++ tests/bl_lwm/0003548_19040707_art0037.txt | 136 ++ 3 files changed, 1934 insertions(+), 6 deletions(-) create mode 100644 tests/bl_lwm/0003079_18980121_sect0001.txt create mode 100644 tests/bl_lwm/0003548_19040707_art0037.txt diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 16da1d6..ce605fe 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -145,7 +145,7 @@ class PlainTextFixture: >>> plaintext_bl_lwm.info() - ...PlainTextFixture for 2 'bl_lwm' files... + ...PlainTextFixture for 2 'bl_lwm' files... ┌─────────────────────┬─────────────────────────────────────────...┐ │ Path │ '/.../bl_lwm' ...│ │ Compressed Files │ '/.../bl_lwm/0003079-test_plaintext.zip'...│ @@ -159,12 +159,12 @@ class PlainTextFixture: True >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'/.../bl_lwm/extracted'... - ...Extracting:...'/.../bl_lwm/0003079...zip' ... - ...Extracting:...'/.../bl_lwm/0003548...zip' ... + ...Extract path:...'/.../bl_lwm/extracted... + ...Extracting:...'/.../bl_lwm/00030... + ...Extracting:...'/.../bl_lwm/00035... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in:...'/.../bl_lwm/...tracted' + Deleting all files in:...'/.../bl_lwm...tracted' ``` """ @@ -443,7 +443,7 @@ def extract_compressed(self) -> None: ... ] PosixPath('/.../bl_lwm/0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in:...'/.../bl_lwm/...tracted' + Deleting all files in:...'/.../bl_lwm...tracted' ``` diff --git a/tests/bl_lwm/0003079_18980121_sect0001.txt b/tests/bl_lwm/0003079_18980121_sect0001.txt new file mode 100644 index 0000000..0e70ec4 --- /dev/null +++ b/tests/bl_lwm/0003079_18980121_sect0001.txt @@ -0,0 +1,1792 @@ +. . +I 4,1 trLovirw-tr I 11.0%.• 7 +. • +- ' 'Ai It. +tt • -, - 6-1 PT +, . +t 6 .. +W LEY 0. Gli + +FURNISHING DISP•RTNINT. +COLE BROTHERS +Are sini eggnoglM folkorlsig +SPECIAL PURCII•51111. +KO PALES OF WHITE AND IMMLI +LACE CURTAINS. MIME PLC= +AND VARIOUS SIZES. 10 PER CENT. +UNDER YAWL +A /LANUFACTURRES STOCE. OF +SAMMIE AND ODD LOYD OP WHITE +SAM. TOIL. AND PRINTED +QUILTS, • FEW IMMIX STAIN- +ED. EDT OTILERWIWI PIIMPECF +GOODS. WILL IR SOLD AT SI PER +CENT. MOW OUR REGUIAR PRICE +POE TEEM 000011 +CARPET DEPARTMENT. +DIROONTINITED PATTERNS OF +ERIMMILL AXILINSTMI, AND +OTWER CARPETS. SOILED RMS. +triMARNII.OALL REMIAI. ANIIIOIOIP +CLOTS& AT +GREATLY REDUCED PRICES. +COLE BROTHERS +causes nom AND FA ROAM +D. +=WM +_ + +IF YOU WANT A +GOOD-FITTING +SERVICEABLE SUIT, +ONE THAT WILL GIVE ATISFACTION AT A +REASONABLE PRICE. +YOU SHOULD GO DIBEC't TO +HENRY WOODWARD, +Practical Tailor and Draper, +38, SHEFFIELD ROAD, +HOYLAND COMMON. +GEN TLEMEN'S HOSIERY. +FIRST. CLASS TAILOR AND OUTFITTER. + +T. B. & W. ...CYCICAYNE +Bun TO ANNOUNCE +THAT THEIR +ANNUAL SALE +SURPLUS STOCK +IN EVERY DMARTNENT +WILL coiniENcr +ON +MONDAY, JANUARY 24th. +AND BE CONTINUED DURING THE +MONTH OF FEBRUARY. +ALL GOOD DELIVERED FREE BY +OUR OWN VANS IN TOWN AND +NEIGHBOURHOOD. +GOODS OVER CI IN VALUE CARRIAGE +FREE TO ANY RAILWAY +STATION. +ANGEL STREET. +SHEFFIELD. +iate9 + +You can get the Best Value for year Berney +BY PIIRCD ARNO YOUR +WATCHES, CLOCKS, 'JEWELLERY AND +ELECTRO-PLATED GOODS +FROM +IRVING NASH, +PRACTICAL WATCHMAKER ,:,v surntrnow), +81, WELLGATE, ROTH E ItHAM, +SPECIAL ATTENTION GIVEN TO REPAIRS. A TRIAL SOLICITED. +SATISFACTION th CA RAN I EU). + +M""a. ANDERSON & KNOWLES 9 +9 HIGH STRICZT, ROTHERHAM, +AUTUMN MILLINERY. +WI All 11110111 NO ALL TIE UMW AUTUMN PASEIONS ni: +LADIES MDT I.LDI AUTUMN NONIINTS, +WNW TNIIIIIIMAINATS. 01111 TllMxram +LURES TRENIKND TORNADOP/ GM/ MANTIS/ +LAMBS TRIMIIID PIM RAM ALL COLOIIIII 11/011 +...A LABOR STOCK 01 /SNOT MAIMS PURIM AND COWIE. +UN OUR NNW AUTUMN TWO= lIILLDIIRT FOR 111719 AND CIOLDIRIL +CRULDIIMIN OM= IN AND MURES. +WANTS °owns COMPLIrLIJI STOOL +TIN MURAT= S. AND S. OMITS IN BLACK AND COLOURS.PRO muo TO +11/11 PIM PALL +lADINS CID Cl/Mumonr ALMS lINIDI/010TIMOS. + +PRIVATE CHRISTMAS CARDS. +STYLISH, FASHIONABLE, +LARGEST SELECTION AT +J. B. WINDER'S, +ART STATIONER. +ARCADE, BARNSLEY. + +\ +- -a.. +i - - + +HARVEY'S +QUARTS. +A NOTH ER NRW DwARTURI. +IRISH WHISKEY . +IRISH WHISKEY +IRISH WHISKEY.. +ft- PER QUART. +IRISH WHISKEY ... +IRISH WHIMSY ... +IRISH WHISKEY! . +3/- PER QUART. +Kum +• • -- ." +3,- PER QUART. +RUM +Rum . :*. +Ws BUY BIGHT. YOU BWHIFIT. +ws smss OUR mown wilt OUR +ROYAL OAK, +QUEEN ST., BARNSLEY. +ROO +The Gramell old flsmsdy +*be. Ohmmot Dlimbawms. +Is ass bees Us pear ISM +CONCREVEs +Tlill +iplerNe sidlelee Is la owl Wads& +IN CAW OP os aPPahmair et Oa NM +mum vvvjaida +CMIIIIIII4Z swar.= +=ies +BISMCINAIL +.11111.1•11011 WINOINEII +the o +ak swill. 40* +dos sof ovii &Mg& 1.• • • MM. +And ONOIR +• • sof r Garb Ps ....lir ""6.P.4.07.= +Sem{ armaiid brriDatzi +eaviewass alaaVa., +11A11=1". K 40 war datiramdrarant +..vra=',..errzerA +atr...7.1.4...4 pi Turin +era& +=.7.:4ll4isarismAip srs dl +OtraParil=tl—..7.--t/Wl/I"6"lrliairailM +Totem WM* at la. I. It, It IL. mg km* +NI us ow en + +, - +, • 1, +• , - +-- ....._.... Or , +... .. ~ ._... +A +\w • k +~ 7 i 1 i. , ~.,, +Ai • , +i \ . +.. - +ER. [razz BY POST 111 • +WIZ QUAIWIL +, + +R TI + +LOCAL RAILWAY GUIDE +FOR JARIILIY. +'4‘l +Mr Pi Adam GuidAlKtri:So pro Mt Aro it r comet al prible. sumo gel feria rat arm re =lbis +S tat teetreris At +IS air le irko It as scrpler + +0 1173:70` :7 +g*A.-- . rt9D_A9kg +18240fg-Z422."-wM k +co 8 +itMitten A +Irit-**-2°•°?-5.1.--:sa; 1 +10±._s_I..k, •r.), +112X"•281Zil ...$ 1 +I il +4131e322 IR 1 +1 )71-../kr.-... - - go— 1 +ID +54:12Lii 2g 12:111716 +tri El:411'411101i I +u-.4.ualFglicil 1- +9 rail : ------- +N +-Ammeuly-ft +tda...... +m Orovvvy • +E-2-11-6.- t. +'4-1.6:fi1l 1 +4"-"44-ggir IS +1817.7:25242"•2421r- +-1 : : :A :ii ::3; : : f! +;411.1114 ! +thilli i_ il +....—"S 7 li .1 • +I j +clUittzialk azic 1 + +,••• + +• as le to +g 3 + +2'42 +_gl.4 r fi. +IER az.:;;;;-x..... + +_ +111102,0, +:32X"2 +ji=6 74^52i2 1 +Ipm.mmooloo., +lESM4422**I + +6013"21°' + +I. + +Loo. +EL' +;P*MtVAVISII2 +•tto_owe W WWW +E —.:4122e7 +1. •• 1 • •A • +r 43 IZI +ft +D andliiii•Gt. C. it + +Screrrin.o . +Darnall (for H.l +Woodhouse .1 +Kinston Park . +( err +Wossoor.. +Whltwell +Mansfield W,' +ltuurnmo + +•• +• • +• • +•• +•• +• • +• • +• • +• • +• • + +alm. nu% r +..m p +acri +a 71.. :: i 1 41.. ti :: +SI .. .. 2 1 St .. +59. .. .. I 1 1/1 +dl.. .. 2 .. +.. +.. +SOi •• .. B I .. + +•• at;' +W MI +.. 7 +a: , +loassor.. +alp. 3111 +Ake .. .. 11 +AD Park .. .. 11 +(macaw .. .. V +---.11 (for H. . I + +_•! + +st 4 +•• +1 +A + +.-,--- --2,--.... +I 'ill. •• • al . +.pmes: : : : I : : • I +1— - li. .a : : •• : .. i +~.. 211A.11f. ' 11111 +SMICIN• • • !• • • "ter ....." w0..,6": • +... +0.... : +..":1214. +.ta6:• :: ii:_it2"-• '4• Z-Lti. .{.• 4:II +4 +3IMIE •-... ..„, +- - !"-' -v e : ff: :• • +• • -9.5 fe . + +..sztnattzsgami +: : :• . +tstrizzo +Oto COO JR, +•• • • • ezz.tate_ ..am•palts +O. 10 V+ tka enalr +1113 +ttrarjr.; • — +e6.VrAVq: +• Fig!..l lazz.ts +lard& am.a. 1 +emtsktra./.• +...... +... : if +8 tr.::::.,•tstits +; +• +, I .,stttss +--ef fort!! S. • • • kt +- + +• +4 +14 + + +ILIUDOULLIITIZIL ete.---11 lady, harbor triad In I +vain every advert:led remedy. has et lam die. +covered • Simple Cum which will ad la • few +bourl. no matter bow stubborn the ease. Bcowide +sawn can get it Free of Charge by weetwieg +It__ envelope. Drat experonant with Wee*. +irs Wis. Mixtures, 0:01111, and other latjwione new +trona. Writ' as am with full Hope lad Cenfigma +kr Yrs. N. L sr. mak ftalbamoka IN= +17...leirra. para. + +S. ISBN) RADTORD. +IL IT. WM lOW, +Moe WI wisitto Applitatices 6. +1,616060. Sob Nambh. sid Dotp• mu be +Abbot +SOW TO MD Tow 11•0011.—Oos OWT-Wi +Tis Tomb. tab tho Sok owl your plio WIN gel +14 owl wilif to MS to WV So tint Gm well +ollteut slot Wom amnia* Tooodoo • +tattoo, Wilma to tbo gaiter swat St wow; +pealed t!itl tho MIS oat taboot ol tit maw% ad +simian!, pobrbodoo, bis se marim• +lout flik + +TO LDTIRMaII. +TNXI IkJID0 +AL 40 SIIIMINInyTirWCIS. +110118 +MOAT WI +AT LAM! OR +TIM Tim cameo? as ovaiws- +Taw AT WM. +rarrAlD NAM +Tins Ws +Ows 41.15. Owls +wow swim arillirier .- II s._i 0 ... a 0 +ear omds . 1 • ... II 4 4 • +a a . ... ,lI—II • 1 • +. . I 4 4 4 —NI • +- + +ELLIS AINLEY, +pwrovors sALoos +—AI D +Frairruss woursouszs, +58 "" 609 11"" STRIZT. +PARRO•TI. +Damon POICIAMNI TO IN- +s MCI OP PIANOS, MOANS. AND +AND 001IPARS micas moo +"PALM MORI SUMO. +suserrerrox °mamma +11 TIAM RIANNANT" OITOI wns +AMR lINITMIMIT. +rA AT NUMMI PIANOS surruzo AT +LONER" rommas COST. +PM CIDIT. MOW= FOR CAM +O TIS INVIIMMINTS TO SALK? PIM +MIT TEMA AIRMORD. +COLLARD PIANOS. trout I Guiana. +J. NAINIURAUS PIANOS. hien AI Osispos, +,A. WAWA AND to. Is PIANOS, Die I +DITIOIZTE"Il PIANOS soy be bed tar Na. es +r +PriAND MLD PIANOS me- be beg he Na. +sfor. mesa. +Aid Mot sollisees mho graft les. +111.1boo-"P`ombay sooll.•-esel: +trof•Dl. at aft W "AMR allsbP +D +pss Ca. sol ease bedbug melon. +FURNITURE. +inn 1111011001 SVITIO TO INUACT PAWL +AM Pismo Dom SA las to AA +SOL SAIDIWOOD WITS. Ihsvolbad PIM% wig +• =WI Will AU Us. +ovu sirsoom Ems in +ors MMus. trete I. lidsien. se AU M. +Don ird....Daarneasoom suns. DI. +RAST MUM a. lloleseay Torigaw +tom Rs. +%DWI WORM le Noir Solis* Ss. +WM illivelied Phi.Diell as +INT %AWL" No. +CURVETS AND CAST SSIVANM boo +V synods. +Roos or ALL MIDS. +INOLWRIS AND FLOOR CUMIN IN GMT +vAaurrr. +ensoisa swims. trews Ss. +ZS AND NAIL CAS to at Prim to Suit +C ou2NlAGDayero. +IL OBIT Is. suarsoig rAwerrrs +.m CORIUM'S. RTC. +SPIONO NAITRINSIM Imo 10/11. +WOOL merrusits, from U/L +"MIMI= aid IPLOCZ REDS ALL PGRITIRD +E a. Loran 1111012/IALZ PIKS& +ArVIRILINIMA rig LOONINO GLUM Is.. +9.1 so SSW Owe RI se be slowed billbelbs +• wet. + +- - - +dm +WO MAIMS us MIMICS Daze. lk. +sZlire eve Ilastla Lormnesor +tinkApitri. ginntellties".!#.7 +G. 12. 00,121111 +ailkapiNaitams& +Clin°MOM +11 +IMMO: aorigas. +At Ai Ililabliemat ~WA le Om bb +111=. IlavaA Pledol r +DU4II I ell be ramoiroom lith. +mama CLAM. +Oesiorted Njasl. P. C. WOODS. +DAMWIDIMILMT mod 11a1VIDAT. +CUM +• Douri,,r Clan sommile ea hammy MX +11110 +Ile se arm lessiA : 'Dr WidigOos Pook. +OA TOMB Moro IN offshassisi. +Fat ANA, .1111111 i 5517 i• He WNW. +MMINIMION& Whit MI am" +at Dowd balmliamarl lam rims +Itopmalis bur Ilmaimilma sal Saub. +Ilhartird awl Dammliaa taaabt—lPaa• +Tempaawa..-/le. V. IL lootamig in a +Simi kr Sep sa Ilibume Nam. lailowile +'NEL Um al Ueda lON + +NM +MUD IVIINEL +L ESSONS 11l PIANOFORTR•Ie. +?KIM aammouna. +Quo ampoutA sniff. +?AMINO. +MitTOW' IMAM 611112T,111113kY. +s kr • tiled Noir of P. + +7711VA71 Immo ACM:WM +TlCll=rallisitimi. Wok +lasswartia +PitoleZirm. C.T.. 1K LI. +LS* loinaml alert in Ms& +velem ONO Wed Wrier tit do West Mpg +omit C. he... se tionsosiot brolidge +aslailkepaneg=iMe do sibs santral +• ilompons. litigate sod ledldinel whim *pay +▪ laka. Nem= awl hultus. a. say soilis +Oak mormines. maimso Is the 111117 +llEssiistiisa Pr air +lationy • It = +Climb + +Wm L. Lx..72121,11:-.• me- +Prehessit of the Oepw lissaks4s. Nog*. +Illsrartay. Oseeteresiet, Iletient Chetpeeitiee. lhie., +will ememesee the rat them's Owes +et lame es +NON DAY, JANUARY Mi. lOW +S. Mem Timm. Illsatettafft. we + +" COCK CY THE NORTH." +La maim a as wow& debt +Pot Ileittaish olorior moo +Lot plows ploy with Ohdr id* +Is Ammo of kids ME. +lot pm at Moo bolons sod dd. +Di Imo to +WithWb Mit +170171.011 CMS =II 'lll/ILLT-00101. +ITP." ido MIR 111101bY Mown for += +4"1171 COMBS 2 +. COLIW. 110MCWItit +owl 18. If pus toy k pors we. wo liii +artais. soodolood to dhow. +• TOIOMMUL• DOCTOR up- —Romp +Coo. Law. ilollpOoso4l • it ootd got. +it wood ow WI • Ind Mod of +ltr. tWVOI Choothi, Coollotord:—M +Aide mi ono. +odor pow +to ho• food wow mom et Wow +sad lossos.' +Mr. J. C. WWITIIIAW We Ilhowlot et do +Monk who • lal*Conolld bww dewed +tombola, to do Is atm if dila NA ammo +Omit* sod Ooldi. met +od lot mod do moo +poi fooddig * mo +loiroZsmit othontho hoe los:=6 +000011 mamma, -aripc.- +14,- is odd I. old IL%Moo * .ICbmoddo isl Wow elm Ikaat Dow Ow. + +_ ALL +ALL 0007111VOT10110. awl YAW, ibe +aymmtume yrrrblessi pkb 5.... +IlksaxaltLlisemair Arm tinse the +dr ea frokfiwyerber• as rosepli II at +a amp, by I.?. TOWLI sad 00.. Illamthrberen, +Serbs Siarri. sottlaglom +Ibmws of isateither. *Wm sal wears. + +REAL GENUINE BARGAINS +AT +JONATHAN WHITAKER'S, +THE PEOPLE'S CLOTHIER, +PAWNBROKER. JEWELLER AND OUTFITTER. +64,06 & 68, BANK ST., & WATH RD. +MEXBOROUGH. +ESL DimirAn."o27 + +"WALTMAN LETlBL—Wortrad la porton +VT ISá lifer. Thseawkaor's trod; SAI is. +La at Wriskor look Orr. sr War +QILVI2II MIDA2/1.-16111 Mari AlOa later +6:7 Erg" Art 1/11 I.e.loothall arm or +Ad 96 spoor +aprior.—Wlitakr Dar Orr sad +War Sr& Nlsorwok. +OPERA ADD V 1 GL*.— Law* mai +WA Mr fa llabortar t. riot from 2/11 +tt. 12/2-11111ilarr. Irk Strook larkonasts. +101 Is. SL-1111111111 WALTMAN LIM, aril to +AP ma: sore &Me. Xs. 104117; wararod +2 ram Warli parrata Drk +atm& +WIACTIO•PLATED 4-2196 r Dior Orr: oaly +IVA a irw lull. faiS orb to akar: Ira dostr— +Wlsitakar. Desk Ilatot. larkoragl. + +• -- +AND INL—Nmedoloss Tspeofty +.bsliuL3by3: wood pit- +Later* oww. ai Wilears. Nei Strest, Nes- +CI4SUNIIINVOS, abourtell sebum Imp +.7., bum sp. TarsAry Nye le all the awed +piles awl wieues. fres 3NL at DA above lam& + +- +SPlClAL—Topostry loam Corpet, geed searlft +14 am& to clear, oaly a fur lilt.- +--t.— +IMbilaLl"k Mask and. likaborougi. +DIT TIMITIMMIL at 141: ell am; beet Wye Is +Whitakim, Peak amt. art +Wadi lbsd. Iftearsvols. + +WEITAXIMIS NOY= NUM ban 1/11: +fall dr. limed Wad Aim. mlollomrs, war. +mood ar.k.M siu—ama lUD* awl Watb +nest +MONIT Admiral all Idris of Valuable Pro. +MK party: 14)11 SATS Cl MUM +at Mask Stroat. Modaraysk. +Qlll7ll Made to Meatom. from ea.: Trauma to +Maromm. trim VI. all tie saw potions far +=Whom from Mt 11•11 lIIIMMISIA at +Dia Illnat sad Wolb Moak + +SHAW'S GREA +}-IAILAM v +I. Ffimbsser.. ire Zit; +helm. wilbia IS MISS reeed Come be de +thty sad tee Or hergabe elhorod. yew have • dors +•Yersent, rot ewe towthlede of yaw wpm= la +your purdrese. +cap.—STY/MOW MERU. the Welt lluical +OKI Is.. beeetifelly Weld. made at Walnut. sad +Pep tlhe Mewing tame:—lamirs law las MI- +As@ de Cenreille. lAtit hale imam Say me levet: +het est peobbp, Oh Pry. a.y bary. Whew that +&Iles. The Queue or the Damask Print* Tams* +As All. the SW. The py teegit. Sera. meet +beer. Oh Daisy I. Tbe mei It the arm Is. +of the Hemp Oirt, A millet wad • mei. Geed day my +Mode. sed Await Kerb& +1 Q-1111.—Starli Renee. es potheed alseeds, 111141• +bqh. mealy mid et a..; tole pin +Shaw. J. IL. The laser. +011. 11d. awl vprank—llleyf law Meter Pah. +AS meet be dewed: aloe • let el Rept Coed Mier +TROILISM. hem ISd. te ie. IL. plc + +eipmls.--lores' Trier 15-Cant Oeld Come WATCH. +0.11•1 raatifelly decorated. sir lifilt • perfect ire- +keeper rel reliable. Note aidrest : J. & 12/4 +Tim Moor. The burr dealer la el kr& of rid +iostrimato la tie city. +1.01. SL-4.aá.' Neer Herr WATCII sad Yrs +Tear ALBERT. • saitrie porsal. tray +leo to deer. bat to may Winn pest bah at retert +,el postal erases for =seat r edwrial; emelt +tar isigare.-111ro. 111. The Igor, + +A & Id. Sesh.—Breed NIV awl Breed SOUS. +62 The Used 14iive have s minim levey beadle. +plea Iketed, epleedid iremes Osiseeie +Wm; smote wills the Dread Farb; liergalse; +als lett,-11esw. oppeeit• neveller's lest. +mil.—Oseen KNIVES aid /OM& eery seed ere +SP steal bides; steel leeks, Wm& Wale Madge. +—l2l. The Neer. + +IL 3L-11Z1111, t .d. Ma. moo +1 is tit Its& Grosiells. Momok e=4.— +lbw. MA Me Mom OA and Mok. +011. mum, sok, toll Mid, +All Mak Mkt sod Mot. M. pod Mlrto tor +Pt.ussr.—Solo. 1111. 1.11. Mk Mar. +oss 114,—Ouly h. WI. 0111110041 fl mot be +Mond. A dffisullor 'Mr mt. +610. la to ass—amoinoms! MILOMONIII +101.00110/01-11kmase of kr !argot +dais bt frose b. M. to +Ma. Al tbe Mak t=s =mole isowomulto. +Any Nod i.a.t I. ropsitsi In 11. obortoot +arum at J. B. 11111AWM. Masi us. TM Mar. +seyS. et —Mow MAIM. woliks M. Is foie. +I nod • kost.'• strom Mkt With t 0,., +WATCH, bold Dial. Cooped Mortmai., hay Jowl- +Isl. maw Jole Peos., Lowboa, Cloossmotor +Maar to Ms Admiralty. M. I.l7.—Mks, +Mac + +Led 164.--114441 Meld CONCIIIITINA. Nadi* 411 +I arobi. ormed arie: weld east MS yaw. la +are-411how. dr Old &ladin +LD g Pagrebrelmr. +OGOLD, •attiquer Artielw. OW Moo, Ivortw, boogla for era or rodwagod. no wry highest +pric• 41011111 for mac or pow to say tones brim* +reek Artois' to J. B. Ilbso. ISS. ne *sir. +cimuomerrs, LibsitNoel, Weft +tala tams. IS bays, pada 0 temp boy, sod a tbo +latest wortorlie for • good imltromaL + +Cbresoette CONCERTINA, awe- +lAN astir nobs, steel Kula by Losienal. in wood +eser—Shaw, !swanlbw, fhelleld. +eftb.advarthed trades nog Is bad +an yea* psynosela. Ile ems drum +768.1h1d..-.1•01:i Ororaumut Stamped ea evg. +aught our I eroomoigno. opal le lIISW,4IIIBW. The +Pswabreior. +loym&—SEArni Clebkingle RISS feest.'al. sot 7 +EU lorry, waitabErnionomt.—Nele AMP" M. +no Maar. +otui M.--KNIVIS, baßlionin. IrgaiZir ball. +J bees donor( Isivos,_Xylosite +A.S.- 1111.-- . +-Gant 's Bun Silver Curb ALBERT; +veduboHo. le 'iv colas. Note prim; +• bargaist.—.l. B. Ilk:w7121, TM Naar aid ki +Stasopio. an every EA Intl Um boa +mlL—Oold Et made is' • Owit.'s +.IAP NINO, aarodeetned pledge. +_ +368.---11EARBLE- TIMEPIECE., in sake ardor, Rs- +redemed *doe. with mambo sari& pillaro, +10 intim Web. NO wide: a boinplor, it to +Wilds sad Sen. Near. +optEl- 11.1;4ABLE SPOONS, bulf-tionolkurn +• Damn /poem and balf.dasea Tea.Spooes, +adokle elhoor.--la The N. oIL +S ALK TIDISDAY—a—Viriad—ILPT +. STMTS. BLANKETS, BEDEW +LINEN. rte., rossonseaise at 1 o'clock sharp. +DEL—Diesoond and Opal LOCKET. mks eider +dho P. Brooch. or Pandas'. nil is • guanine +Bargain, sad words double. +IrIirbIL—SALAD BOWL. veryeld Aa- tiger Mae, +lUMLF raeinsard with a fliker-platod Ria an Copps, +Rim. with two Spessia. +2 1 —OOIIP,IIOI POT, beautifully Ilagraved, pure +Nickel Wow tbrourbout. Viredeonad Pledge. +TOAST RACK vary old. Ts. EiL CANDLESTICKS +is Couper, 70. Sd. 1P1N44 + +Leeb 311.--11M11 COVINA nand prim. 11111 +411.81 Plated as tie bed /WM Wm Worth +Ihrodemod SeMom isst vItIL-118. +DO 10.--11111 mil MD. Comhbatios bet, DM +mod. A Ilempla.-Alhow. I,War. +omB.-Veirst PM TABLE CLOTH. beeistihil Pit- +'U tem. large two yards sids,-AI +1141.-OPSRA GLUM lierbay Chit, is men. +11.11114141011•11111 +!ROOM Nomelod is +, QR. AL-Cameo RAR MOIL Mies* Odd, mese& +a ad Muth the ahem IlissAL-Ilisw. +R.it-lbeetro-Platol 1-141-PoTT—ifert +ii +- +et It -mot swims* of Ihimi•-• Art, is hro +midi& of ea Italia& *Om the Dec =c..d +sie halls lady • Dm. igh Mom re moo litedihe.-Ma., Mac +modeimodOSRAHUIII, Mohtshiled is +4511a1t0 Gersol. tory ramble Jim imitate for +• Weddle Mum TM Padiag estitied. +12Mlbs Nom Tory rare to la +SHMTIII, nil. The Mawr. her Jo bid solosioa or +Mod Lobo Oeseerlime +br hooka made • wpoislll7 be lesinsmis for mem +domeghly mbentmit thew MR My for irk sr +oil at • 11110811111411 sommimies. + +(gasairs +Thalia . sal dis mad a. or Mita- +OLDsiao) Wean kap. UIE MOhorls; Ib. +toy far ama +or." 41.—aar Wake, I.*mat Glad sum +• samlauster. brautitally wild& dal, +magnolia ist perfect going order. id ma Moo +law about sight, roams; this is • +laird pledae, ass sew rubbish as aissrilied largely +ia what Jaurnals. + +r-01•1111 en large Aimee Acme Diereeed ENG. +Pell el Ire. est be Slane Geld. • Dleaced far +she widen met via. +Id.. N.. end la RACE. +GENUS GOLINPLATID ALEUTS. FANCY +I Nernam, LAIKEBEIZE CORM. SNALIARZE +MERL Nei SNAKE PATTERN. +Re bin. lOW IS Ore. rod Ray mert be sill. +SENT TO ANY ADDERS POST PRIX + +- +r, Adwisk reed. Ma- + +okpubrusi.l4 u.ismik. Osage- +Wormel eau fee +6114012. L Misr Aria- +&auk t.ous•kespnb.l PSerwais. +Arai : I es IL 4. WWI +•votneoliii Walked tor Ina family is +A Ileakerbra. TI. rst Okt. +Good booso.—Kro. a Is. Book Mho, +bar +EXPIIMINCID Omsk Wired ear lotreboat. +Ord referrer W art to 1118.-116 b +Boulimb 060•1•1 L.,iUI.Mos .ak. +wOILLING Ilbriooper W. to IS. 3.. d +ebroster sad aide to take arrersola of +boor Wages atooraw to skilly.-11.4. + +rerly am; +o OM. Craft. + +&Tr., + +....ewe AMISS BMW - +Id.:m. Camidgee, li. 11. SS; 'We Ow +A." IL_ 311.; Almelo. helm °WOW IL 3d.; +Ames Thew ; lbeWleg, sad Ipert- +ag Sepilies; Who Cwt. peld.--Jela= +We, Ikallevalit, Sweldwd. +stoat PISS ke ku.tiwe SOL ark; dee awe +amorgris 011.2.—AesIf Cietaw Tr. rpm. +eee. Owiebre.. mans +WMmoz.n.—urior or:sa +. tat wart Wry his. geed se wr; +Skt-Llies sad Wien. pMae= +"VG- SAIL COMP =A VEIL SUSINZIS is Kw +.A? Wu* Se sewiles. 0111,0 thbeg grew +tad. Valved= leoweble seer Wend. +"Oweol.lhebewell. 'WII3 +MD be SOLL-Asife reemetee BICYCLI, is +1 reed easaiew--Apply. Dee Ifisatea. + +QZLNICT asourm.—lmifiss aspirin lanasta +OU and lllarsasta litsaalets all IIU7I he gotta +St Sim Juthis If* IL Awits raid, +A N asaristsol Tam Mao nab Stuatias—as +11 Basra. Is NaN=Ltliblety sow.--Saply, +O. St. Asia nod. + +DON'T DI WITIOUT +£lO TO £2.000 +Lb:4l Mimelaiely is awy pet d Dial Flat +dem. fee Meg er Imre perieda weelby +al ea a NOM OF WAND AU dime +Fem, ausimam Diewdelp• aml edam* Law +0•14.61,0117 amine Wmmis IemOFFIb. lamp +Umeet LOW Tba eimeme Mesweed may +Is mesa by may imealweall eit dm mil el • +eased Maw TY newsy me awe be Isztawerb +mabbreeel d weelliel wied. *ma Fag. Fb• elds• +mt "Mesa gomeememL AN Mleemelbw plee• bit +SR ll•ewealled selm. be +boa. bow Oman, amweem. WO" Odom +IIAW7IIIII. Fiesmea Ilbagbeemea cab Feeldemys, +Weiser, Olds, +yea imam Ilebeelmemeere. 11•=81.7 +le lm Terme, weeleis. Mai yam awl +it am MM./ yaw Mee we pee +art rt de la Werabara emil apply be the actual +▪ peleMpal leader. +HENRY LWIFION, +L LOT FlLZADlLlirwriuxtbe Perla Clam*, +arwdlol + +VipANTIO, Ikessog Oesertil, fer Pa& +V is Beibicfros• wages Os. my looks re% +=try Na. holism. 6. Woo. Moule• +WANT= bi Ow +v V vilibisO Ws Mimi lo Os. +--4 ply Na. IlkistA 114 W; 112.4 Dakar. +boa +IUrANTID. Ouralo. Us ismillu.ll +OW I?NIaMMN.Na +shaft Oa"I. Wolgosi an* +IigUANTID kr salloki OhombselaCipeog +OO ago Las swam* isham,_. woo OL +yroisk.—AppOr Na. I, Wavle Novak + +- +Aiui.Kies l'aioseelf fifecomer as +'• , Sarrairts.—Been-1 le 18. 11 . Liniu= +I:W.A. and I-110 to 5= as, Oaf. Ifedire- +ha." Tboredaye =copied). Xs pelilleasomi. +abOD GENTAAL WaMid, far Ifisgby. Incer +family. No aliddron. 1114 le Mt Moot love +good referencen—kliii Peneeekk leglotry. +(1001[4312411LU. Wined, fur beermaid +IL. kept, goad blow MS woo; reforocaa +—lapply Atka Perrock, Oollore Street. +el.an= + +• mums= int. +BIRKBECK BANKI +.......,............., Li" Lath" w.o. +WNW hail - - J 18,000,000 +amber fr eassimis. 711,4111. +...D•airD•A•_l.ll.....,ALt pa: +.Mknrraasre 'Homed as +'WF—buIPF. :am -r-onovlres. cm the nisi +Min Worm WIND oat awn balm, moo. +NO. gm ANIIIITUNI o.•hemi sod Nha +et +lvTille• DINPASTIMIT. +lemll Neolvid, mad Iroarest Wand nesiblr on +ash +pia • 'AMAMI& eft Prtillekis.lK+l OM +Tthithoss: Sem= & am. BAnumat +Tedir•Ak Amidnvr: "Biaitions.Leirs• •.. + +WANTED, o roigamiablo thesral Servant. +VT for family of tine. LOT by letter, stating +aye wed wogs required. so Timor Ma. nil4llo +WITANTED. capable Oargol, Coat-General, sad +VT Hourogra—ilko Nobse, Oa, Norfolk law, +Ilbserld. agnrbt7l.l +WATlVlD.—llasbeissa Oseratal. aspbol lam and +Iry red rrors. bre slOto •11.—nrs. Alford, Or +=rem Simla, Darby. writ= + +- +TMOUTH TONKIN'S& DISOWN? AND +IN entWINT COMPANY. minim +oarricm—aung aim ounzus, +NOTHERILUL +Miami mode wpm all Mode ofpo& meeeity, +Pradsmiry No" WA of imbue% eV, as Immo +met Ilsereows. +APPLY TO MS MANAGIN +INTWNIN TES NOUNS Oil AND? P.M. +Ow Moodem. Weilloble, and +11.1.—Tem °mow Mo as ommotioolisliek= +M bor ad a lwast mom slab +M0N1T,—.4401117 bo ememet to be leat as +Ims se ion pilea le ail proem moray el +meat. Nagy repoyormle. Nmeemble WWI& No +Ismery hoe MI IMemoillm bom—AgelimiSori. +Udell@ IMMIng aul IMMO Omormy, +Dm* ONIA IS,MIN Mom& WomblesA as Mar +by WI% from 4be "um mar +WHY PAY PIO +IE7IIOIII ES UPWARDS ADVANCED, +On Simple Signally* of Deflower. +To limmeicildore. Shopkeepers, Farmers. Cowliemers, +HMSonette Proprietor., and others. in town +or Country. on the cloy of application. +I bare a reputation for lioneety sod atraigtitforward +dealing. and, owing to my eztainive connection. out +make advances on tonne that defy competition. Call +penenally or write, elating your requunsents. and +I WILL CALL ON YOU FM OF CHARGE. +its... Note my only Addams— +/11MT SHIM, St. PITT STREET +Peeleguarel. Darleciel. +UOXIY Admmool to Itamon. Trodemneil +elloom—A.bo moldeme, YO +DIINCOUNT H. New Sweet, York; +Bat Chombere. Lame Domemor; ma WA +iml Climeme, loNsooloms. Pim pm and. Amid +sa brow HER +'I,OIFIT.-- A LeasSl NUN Si sommorl +MR =Ms AttesseA eNli or irlibmul Imprin" +MI,. nark—Allis. H. Nol" Ildime Nem, Wbolor +Nome. MAMA rns +WS" Wu OMB MON.'S TO TEM +=.OON APPLICANTS OWN +IT NOTE. +AINIOLUTSLY WITHOUT MINNTIMS. +ADVANCIS are mode at a low he Raise to +Cimilewm Tomlesoom +Ilembeme._ SloopM=MthissZ +emosora mel el tioNI Nkle se Pomolo). +Noy be Noma boy y Ile MsslbM PaywarhAor as +eutokniipsy=osobableb.p4= +=es Memel end elleleipoll Adviser +to them Mom We Pon ee Amllmliee Charm& +troomesoolly, 10 Mil eram= et any +All WSW +OHIMPRID +IS. ma num mom= + +respostabis Boy, 14 to 15. to +AN Iris meal tredr.—Apnly, Orin. JawUr, +10. M. Stou.NezberougL ntle3 +A OURS Wasted in thir awl the +.EL teem, to start IWINIONS ANSOCIAmmrIIOI4 +toe eir supply el goad, mend, sad syllable artielar +at shderate prima Tarns liberal. Partisans end +LlVlpost fros.—J. W. BENSON. Ltd., (11 and +Alll MILL, LONDON. LC. aalolll + +T AMES regales wwwwwW, sod aermata situation, +appl le Na. T. D. Towsor's lsgWiy, Wadi +Scott 11 Wasted Cooks. HOVlsirealiiii, aid +Oesernia. +10IRST4'1A8S SHOPS wasted. in beet war** +A position., in principal towns. Littera "Ptsswi• +mei," 14. Madura Grave, East Dulwich. Lurks, 11,11. + +tinormo. G Co +TV Cook.—Mise Alfred, 76, Omon +MITANTID, Chmaral wages about 1,23; oe wadi. +VT its: vary goad baar.—Yra. Booth. 116. Dlas. +..gbsoo Ida..!Bradford. + +'mem Atverail. trim word& Ile +ZUL Wm; irettot orbs, 'ilk sr without Nos; +mit--Apply orosorili •• by War, to N. NAM +n. nom Wasid. +"sway Adlllllllllll whims lierkoma.-9.3 MG +In. to Amor algamilues d ParftwerSlMl7 PI +rta. No rosatimi• Am donut. No liar +II the Itammiet amorik Nom er tratilt er—a—fg +*pros it more to pay • tort sr • mirth. lota +Nit wry roderatt. isparorte to sok llorrowi +saw toonoitor.--Ap*y, and be oesrated, to The +119911111. 19. Giro Raw Ur, •CliiU Stmt. +N•orwoogb. +MOSBY' NONNIfiI +£3 to MOO ADVANCED PIOIIPTLY owl Pi& +VAINLY to motrible moms Oar or +to NOVI Or ZAM ALONI. N. easiir iss= +•9 11• Is. /say rem +or repayrootr—Apyly yea +by +raft Wry to +J. 110071INOYD rid 00., +art% Ilralaap. Weal *mak Doottorr. +011•• rms. 9 rot. to II p.a. + +011T.-0o Jaargy Sib. Sam lin. between %tit +.11.4 awl Illtiehonteilt. lividar will be rewarded es +Miring dr raw to L. Zara. Tows Mod. +atime-Dearrt. ml4ll. +FMND. • White awl Drown Croekeed Terrier +llitelt. If olit owned in 7 &ye will be sold.— +T. Tarter. 112. Ptiwl. treat. awake. oilsoo +ItlWAlltD.—Lcot. gip Tiemedey. Jammer, 13th. +If haulms Ilezhoreogli mil Welt Mottos. • inelf +Wee Fur. Tinder will he reitardid se +I our to tee nimeir Moe. 11emberetteb. .1507 + +THE REST LOCAL ALHANACIL +TEM +ME XBORO. & SWINTON TIMES +ALMANAOK +FOR 11117. +UNEQUALLED FOR ITS LOCAL. DISTRICT. +AND GENERAL INFORMATION. +griONTAINS, in addition to or- +dieary calendar matter. +VIJ • full dironoiogy of the sea ia loud events of +tll, ;Aar : particulars remedies rates. tam, stamps, +. measierek hurs. feasts. festivals. etc.. ate. +Illurps awry ether Alumnae& published in the +&Met fee the fulasia. variety, and conspletessem of +its itiennatises +BE BURR TO G- +ET IT. +TRICE ID. +May be obtaiged front any Newsagent in Town or +Coquetry. +Alevady • large demaid.—Neersegents not already +hiwiend Meseta order immediately. + +SIDNEY HAMILTON, +Meer aid Undertaker, +THURNSCOE. +Ihreems:—LOOCWOOD LAM eamesairet +WIZ IMO. W II + +- +(100ILS. HOUSEMAIDS. NURSES, GIINIIRALS +V Wanted daily, Exadlent attains, with high +wages.--Apply, City Registry. 72, Piaster Sorest, +Shellheld. nierstdl24ll +I IaiIISPECTASLE General Servant wanted ifforadi- +Kt Maly, road places, referesees muted. Shirai +w—Ay. Ifraira=" Illerventa' legrape_y_, +Us, Rymn narwhESSl +1011TAN1RD Wary mid Cannirioa Avesta for +TV Pieklas Vinegars._ Swasea. Statiowery.—Apply, +Worrell Brothers, a laisserinry Street, Leiagtaa. +Leedom. orwlef7l + +WANTED. Coeductor for the liezborotigh ago- +• V motto Crocertios Haat Prortire from 10 to +112 flueday seorrisg.—Aoply, vOotleg terns. to K. N. +Wit.., 15. Illioltoe West, Iferkestrosta. rolisll +grIONPRTSPIT Genera Morreet wooed, aged to +ra. mei he thorolityats +sehes and reliable.— +Ay, IC Bohr &net. + +IaprANTID Doom vide we of Pismo +TV ins dog pat wash. is good asighliondiood.— +Aptly. stating tanr. to lb. A. T. Tisch, 3S, Green +Lae, assnwesh. +A GOOD situation own to mimetic man with +..(% gum& business sWity sad good riderencas. +Address. Mee of this J 0...). + +WANTED, remeetable Nano GM, ahem 111 er 17. +• —Apply to llre. Lliediorgeniamen, Hprimield 11m +ram Manisa, near 11 +COMFORTABLE Apartments wanted by man +• men (pernument). Ileniereagli of Ilirlaten.— +Beate terry at are. ITS. V& Risk Greet, Hex- +borough. ne1503 +WANTRD, Haney Cooper at ome, before Wed. +amity newt : 24d. • doses. Hurry vp.—A. +• juNdar. 12. High West. Ilerborongb. ml5Ol +WANTED. Honeentaid-Waitrees sad General for +TV Hotel: Coolkieseral and Oemetale for private. +—Belmont Registry, !Guarani. +WANTED 'Mations in the neighbourhood for +TV Gemini, ern 17 sad 15. sad Homesesids, +age. 19 and 17.—Behnont ragirAry. limberattet. +ia1.5011 + +WANTED, dam Masa Carla, GM as General, +about 20. Good Mon ittarantead.-= +Yrs. Darns. William street. Serinta mars,haw. 5a1512 +CENTRAL 111201912 T. +T ADITS 'SQUIRM SSEVANTS and MI- +LA YANTIS SITUATIONS apply /Ira N. Tliamp- +am. 40, Craidand Street, Ihriatoe, Mit lotheritara. + +LIANTED, clean respectable Gemara!: age about +V • 17 years. Charades ressired.—Apply. 91. Ms.- +baredgb greet, Namberorgb. swerb2796 +EN9IIOI9IIC Man Waimea ip Sell Papers es PS- +day. Geed swat —Apply, 'Timer Moe. +WANTED respectable Notrebeeper, ass hotlines +•• 30 ead 40 cheat-ter reepared.—Apply, Jobe +Burrell, 4 Choppst Street, Birdsall. b 417 +leLeL + +fIOMPOZTABLI LODGINGS may be °Wird et +11, Peek zee& Nestbeeeestt. _ =LIM +Pib Sessit-, leelft epos ILO +Inter-Wile%at red &Hetes lied. seeettek +Ceseeete. iseeetatemeeta. leatmee. Wee. etc; +meat antra +Neskenseeta. wi—l.-Apply. 41. ne Arced.. +WWI + +rro LET, a Laelt-tip o,in Beelinens. Road, +eniatosi.-41111. H. Imp. 5e1504 +be LET, &aye Brine Tana is tie— Towe- +-1 ship of astoweess-Aaghtea. eenisidni 111 nem +oe them-Wan. now in the asenpatinn et= +Hint. For particulars apply to +and Elliott. &Baton. &eßeld,trbiNesit New +man ana Bond. Bolinton, No. S. &WM Welk Berun +' ley. =MOW + +ti. RH RLDOti, +AUCTIONEER, VALUER, AND LAND AGENT +MIDLAND CNAMBERS, IROTHF.REAM. +VALUER of HOTELS aad LICENSED ROUSES. +REFEREE *ad UMPIRE. +SAILIVIP, by SPECIAL APPOINTMENT. or +dor tie Agricultural Holdiap Act, I$M to lan +Distrusts Re Root Is sag put of Engload sad +Naha +Mr. SHELDON'S 10111111111•111 pracOos Indio Ai +Act emblem bin to trauma Tab amscaaso +waren all buitoom oatasaid to + +ROTHIRHANI CATTLI KARIM. +or MONDAY NPxy at ELEVEN Mind& +Worm WITHIN° and TURNER eel INN +AUCTION. a I,rp Is, of PAT +and MOM from Tannin In the weitht• wire& +Owe roenvod ao Warr lowsa re Akhrefie +Pita.. keteodey. willbs Ingit new air einem +and forwarded to tte Marlon is Scaday. +Hawn, (by *peatl appointment t • Sneer +Jades Ellison) ander lb. Annnoltors, 11,11.01 P +Act. to I•vy I)tettowe for Rent in .ny part +of Eatgleisit sod Warn +Central Auction Nut, + +VINCI Olt W• 101, TiIiATNIL +NIMIMNIIWIAH +lids Nimeamma.... T. C. LIVNIZY. +Friday sad Ilaamiay. au. VA and lea& +boa Imo alslias at +`'LADY SLAYEY." +MONDAY, JANIIANY Nth, +"THE GOLDEN LADDER." +-. +MONDAY, JANUARY Oak +PANTOMIME. "CINDERELLA." +-.- +MONDAY, IPENDUARY nit, +Rains Vida al WILLIAM ORBIT'S +" THE SIGN OF THE CROSS.' +- +TIMM AND MOM AS USUAL. +UNION INN, MIXBOROUGII. +LINDLOND Mr. ONO. NEWELL +IMMO mg ammo ova, liwiss. +et Womb weekly. +IA Ong* marg's Mean Nam shop be @vandal +faalltiss +aim al the dolma lamis. Mlliats whil Nagaiiila. +1 --- +113.-100 Y OLIN way woultimL WIMP + +A GRAND ORGAN sierra, Inc., +Will he elves in the +pIUUTIVE WITRODIST CRAM, +NRIBOROUGH. +ON 72117119 DAY EVICNING Nerr, +JANUARY VG), UK +kr +MR. HARRY FLITCIIIII3 +(itematly Organist at Ottawa Catbalkal)- +- +80140113173. +Mr. J. 1.. RAWERWOMMI (Teem) +and +Nita GER= WAR (Contralto). +Doors 09r11 at 7 o'clock, to commence at 7.30 +SILVER OOLLECTION.• +• GRAND TREAT NAY RR RIPRCYRD. + +MEXBOROUGII uninnuusr warm +LIM ASCADI HALL. +Nest eimis- M 1-30 aad +Dim=los bribed. Collemetious. + +MEIROROUGH PARISH CHURCH. +3rd SUNDAY All= EPIPHANY. +SUNDAY, January Md. UM. +... +NM, Matins. +SPECIAL CHORAL !SERVICE AT EVENSONG. +S.M. +•"BERL YE THE LORD." +Solo Mr. J. E. H. DAUM& + +QALE Of MOBIL maid of Bt. Nies Catholic +Bolscials. at Doipton Chambers. Market Place. +Denrooter, next y and Priday afternoons, Iffth +sad 11111 k leaver', tea 2.30 to 10 &dock. To be +snowed by EM Warship Ms Mayor. All are cordially +Invited. a 1.5011 +PUBLIC NOTICE. +Is to eve No that I, Jobs Mancrorth, +ass be reopensible for any debt +or &Ms my wife, Bestaiee Shustrarth, may systract +as or after this isitt.—Oligesd), JOHN lITANWONTH +Jaào. Cut*" Mast, 111Alest, Jantrery 110t1i, +11/L adfios + +nipoiswer TO DARDS:SAW FLORISTS. AND +OTHERS. +OFPOSITS 11A130NIF ARMS. IaBBOROUGH. +. BARNSTT bee receive:l installations es OW +TV by Meths, on Ileaday. Jemmy MS. INIR +400 VALUABLE LOTS OF 'symptom +p.a.a Pow Dwarf 1n.... Clan)!wit Bone +Isaias IBMs, Leanne, see., ote. End, lot i. +sorted. +SALE AT It P.N. +The Arsikraser begs to mil Special Attestion te +eds Sate. the Renee and Inergleses being of AM- +Clew quaky. wan + +OPPOINTS MASONS ARKS, NEXBOROUGH. +IL W. BARNETT will Bell by Auction. on We- +DI day. January mu. LW tbe iellowian valuable +HOUBEEOLD TURNITURB. VII • +2 Iron Bediftwade, I Bening llattramee, miry go 4 +Feather Bed, New Illabepny Dramas Table and Wank +Stand. Itie Beek and Mar.' Nal Bold Walnut +Crewmen Ownek, Gent.a and Indyb itaaLtnsasca, +2.7 in Timid Plunk, mat 110; Talk. +" y awn Daswera, Gaul.'. Kairosetad +70 Taiii7lr, 4 Lather Chalet, Lep Osise Birds= +CS; 2 pairs Oil Paintiap, Tapestry Carpts, Sawing +Machine, Asnarkan Oegaa, very good latest. style +Carriage, nadir sad Tidy, Wringing Machine. Patent +Waebw. 4 liteben Make, Tbwel Rail, Creckery Wait, +Wicket Clair, Butte,* Bar*. etc. +BALE AT O'CLOCK raomPT. + +'VOLD THE FOLLOWING PEASE NOTICES, +MIS ; +LINCOLN LEADER, Jas. Id. +"The great popularity of DORDNYS ALNANACK +Is well maintained by the ISIS or (the SIG)- +It is the Klondike warm Mamaseeke—a very goldbeld +of interest and informatice. At the modest price of +Threepence it must have an enonnous isle to pay; +and its appearance year after year with improve- +mots every tine is proof that it does pay." +LINDSEY NEAR, Jan. Wk. +"The Alesmandk (Durdefs) is better thee sear," +ROTIIIIRNAN ADVENDISKR, Jan. 111.1 e. +"If amass mama • rod and reliable reference ea- +r mai, mad at the as taste get pieuty for his money. +be cannot do better than purchase " DURDEYB +•LJLINACK" (J. . Ifinstertom. Gainehro': +For., 3d.) It is wsudful how the enterprising pub- +lisher tan give a much for so little. This is the +twentieth year of publication, and as long as we can +ramameaber it, ita emits have increased +.emy twelve- +month of its MIMEO/. In this year sum the beet +of the aid features are continued, and others are added, +the reedit/ matter being both instructive sod enter- +GUARDIAN. Jan. 7th. +"DURDETS ALNIANACK (J. Durdey, Niesterloo. +win 3d.) will soon attain its 'majority. and that it +n Moyne a healthy sod vigorous period of Moles- +ems n evident from the 110th bass just to bend. +The lees) information is good sad exhaustive, and the +Meting wed illmetrations wet sad elm. • capital +nary is included in the both: bet the reeding matter +as no doubt the principal feature. This ie made up +of a Meoughly entertaining mileallany at prose sad +verve from ValiOUS sources. The several poem are +each sound and full of that mob of Muni that makes +we winds were his; and of the Erne meE be mew +unload God. Gentlesmen" by the Rev. R. E. Welsh, +N.A. "OrMseuther's Uri Nrom "Lneolambire +'ani sod • plentiful welectioe of morel, monis, +mad immeructive items. Dureley's •Issumek has already +gained wide reputation. and the proem issue will +certainly add to its good name. It es • capital mama +from start to Spin." +Brigg. latency 17th. 111.311. +Dear have ma your Alenansek. and most +say it is • marvellous specimen of good work, good +judgment, and good iek, combined with bemuse." +Yours truly, G. R. JACKSON. Bookseller. +From E. CHAPNAN and SON, Wintertoe. +Dec. 15th.—Will you Emily oblige by eroding us +6 doe, of your Aimee... The at your earliest, as we /UM +already a great demand for these.. I assure you they +rev mark appreciated in this district. +Jon. lab.—We have mused with mot pleasure +and satisfaction "DOIRD" Annual for MI, sad +vm- pleased to notice it le thoroughly up to date, +and, in ow opine., exactly meets the requirements +of the public. Reny is Nonesher our customers +tit to min *Urn' for "Derdey's Almanac:lC +• we have no doubt that if it Is cenisd cie. ea the +same lam in the futon' *tit has been mined on in +the pest, it will be "A 1" Now r as elective adver- +timer; also as • Mal alamneek." +Agents: —llezberouo, Timer Swine, Jackson; +Barnsley Haigh; Rothnima, Dawes. mad Standee : +Candieraugh, Lowcoek ; Ileffirs. Water, and +Wilkenon• Raley. Post Offilee; Domeier, News. +Blatt, Robinson (Whoinale and Retail), Boma, +VERY INTERESTING! VERY wrrry! +VERY PROFITABLE!! +VERY 1117NOUROINS AND FUNNY! +DIIRDEY'S ALMANACK, +11168. PRICE, 3D. +- + +SALE BY Mk E. ADAMSON. +STATION YARD, EIBECAIL +XI'S. Z. ADAMSON will Sell by Auctics. eft +KEL TUESDAY, Justify 11Sib, • Quantity of Deals, +Bawds, Balsa Mae Boards, Me. +SALE 11) COMMENCE AT t O'CLOCK._ + +WELLINGTON HOTEL YARD, DONCASTER, +MHOS. SHEARMAN willB.tl by Ascalon, ea Thum +day Mit, from 30 to 40 HOME, of miens +ere sad sixes, including wend geed Oert NOM& +Several New Trips. suitable for "Idsimes, sad as- +sertment of Harms. +Entries received Op to tins a +SALE AT ONE O'CLOCK. +nal4oo + +ROW BOOM PARK +(Two Miles from itionergion gad Pinning bay Stational. +Important Bele of 10 Norm, 211 Boaato, 37 &ore +Pori. Poultry, llairgolds, Potatoes. • genntity of +Linton Delano, Peso in lota; oral Good Aamooineol +of Agricultural Carriegn, Implements. and Goering. +part nearly now, for working • Moore faro. +IGIZARMAN will Sell by Auction, on +..a. FRIDAY .at, 'anew SM. 111111. cm the +• es shore, in the oniaapetire of Mr. J. Oer- +te +' +SAM AT 111 GOLOCIL +Ilteedniaa and Sae, of Other &root. Doter, will +ram a waggonette' in the. for ilea Sale. +Auctioneer, Oriental Clienbore, Danceetar. _ + +- - - +HENRY WADDINGTON, +• AMMONIA= . ALVIN +...anowoues. +y. .11 Dodo et AM INfr an to awl +woke do AoloOlued NA lA. +el NO= Amnion& AO. +Oak Marivio Mos sad NAll +MOAN. +W. DAVIS, +AMIN= AND TAM; +011 TIMID IMF! UNDER TH3 MIMI +AMIDNINT LOT. +win yr., WATOON- += +Is prossed Is Nava Ms +oporbill boa INT + +H. TYA____t_S +AUCTIONEER AND Tawas. +THE +GRO MRIDO IL +A IL LINDE OF SUM AND VALUATIONS +A. ATTENDED TO ON MODERATE =MIL +IRITIMMENT ON DAT OE SALE ADVANCED +MADE IS NROMMAIT. + +SHEFFIELD, YORAM= AND DEHEYNIMIL +NICHOLSON. GREAVES, RAMIE nal HAWING' +(Foams el the Auelksewe• Isolassite). +AUCTION +IMnii VALIMMTEMATOES, +AND M, +Stedil Ewen Tiro he +Mai is Illemslits Deswery +end ellee +THE MEM Amnon t=ist mem +MEM EilliffELD; Zed +Tin man REFOOFIOZT. +CAMS HILL, 1111111 MILD. +The mai epreittlii=etMly &tate Sit Bean +1. 1. tbe e eestrerailais le the di, sid +be Nearred every ke Fiesbold end +LaaNield =as and litres teverasery +Isteresi els. +The sidelly aimed isrkeee sta Sàcu( le +pis +esd Goma Arkin- ea be y tee +Sir .1 13.. Art res, Feriae% Da +lisreissaiNes, +eirl Wow Demi Mt be bell et mislay +Moils +Vabeise Eike d all demelipalina +• dessieb ati +Etn4:3. +14setaiser. +el/ a, mg ammo aase, +, Tandy. 1.1.11.1.13. Mid Tammany a asai +min& +Pitaert :- +1 JOSEPH J. OILIAVIII, +I. H. DAUM Jim, PAL +ALLAN HAZITNOi +Teisreise: Nitholeoms, Aveiro% + +DKARD BR•IDLRY, +AUCTIONS= AND YAIDIR, +IFFINGRAM Ammar KART, INFINGKAM +KYRIZT, itanniamt. +EDWARD BRAIDIXT. Be; wrist +mot of Ilia Beam Jake W mar *be= +Armament Ani, lary in sap pert et +&Owl ead Woks +EU= OF SALO INFOSCIID, AND FITINITURR +/1017611 T TO ANT AMOUNT. +Ifitiolsrafri dors kr Trilbeisuem es Um km* +poi mem +*km= arm Mike Mari rod +mem fr emert. +Dw putout, +•UCTIONESS AID VALUER. +BAILIFF BY ILKYLKSKIDINT. +lak FBILDIRICL STMT. BOTIIKBHAM. +AUCTION MART: 11, WIT STRZST. +areosaamml IP•mitere hennebt w..y moose. +Primp% £ - TI NOS + +I +...-. + +Gni +hi +liii +U. +(Tv +via +a +i + +sei- +el +es +at +eh + +funeral Cards Printed ofi ivhile you wail. Posters Printed at two hours notice. ' +Tenders given for r.nu Class of work. +BILLS POSTED ON OCR OWN PRIVATE POSTING STATIONS IN AND ROUND +CHAPELTOWN DISTRICT. + +=Tele 101 Id 14 ems m• balm:: +, eft • Wail* or hot. Re +WINE Its mode tke rem* to °baby "Yee The lila +to losash +lir %Lab UM= khokailekCad a , . +uov... +wax..., +Sam* +e It was biomes be (Brace) mid +id be le the N +a Mobile outside. soil bY i +Mk. Hkimsott : Have my of the eagineen . gelMs m +116 you +Ilse 11.? yea ad i +modullY +Are thins not in.. semalhee eunier +th. smbsse union present with you in wort? that inus +llllSemet No, sir. +MAW !as -ailled, and ate beim si Id Poo +by tko amermia, said that Bruce was.the wone rhea, did +drink. hoot Mil +The mogistrate retired, and on disk mhos hovel( t' +theCiwillmacase, sadamaithtehrthtnadethyterlerec4r.iwatillYo( ' . 11 win. +61111 Loa +evidence to being the detendaine within the +al the AM odor which they had been charged, • Waled +they therefore Ilismiseei the ors match a +, Wild It +Moe ti +visitors' +zalii= who hod +4. +1 ow +. _ . _ • • . 0/1/91MTIP SPIPII,O +... hilushal +TM vim +' +--- .. +'.'.':, i. . -,- • 71".':' 0 +• +gook ti +- - .4-Kdillii, +A1tr..„.:,..••• •••• +'4lllllolr IN +1 *..AifirMl +~.. . . -....• . 4,..i...,....04 . a +sad WI +mnries, +i ‘ 4kll, +Ills 1 +, ribs +• ' -7' . - 'ir' -els' ' -'.713 ifigagitaatßlV'3 -r- +. ' ' ! - +- -- wogs wi +T. CHARLES, Who +'Mita +CARRIAGE BUILDER. ROTHERHAM. MIMIC +IMO in +MAKES • SPECIALITY OF GROCZINT AND DRAPERS CARTS AND VANS. +THE EAST AND CHEAP= DI YORISRINE. Pooh +United +11101 CLAIM TWITIMONIALS FROM FIRER CLASS BOUM Derby' +iodic +@vizi DESCRIPTION 01 MILOS BUILT a,/ ORDER. AND WARRANTED 10111 MONIIII wwain +WRITE FOR DESIGNS AND PRICES 's 4: Worts +• °MARL'S, GPO. +CARRIAGE EUILDEIt. +RUTHENIAN Bet +So moo +_____ maybe +• At.. +Mita +• psi +ameba +'UMW +. +THE Wary +Dir +mar.; +I.:PUBLIC BENEFIT BURNISHING ba. ~,,,, +..... +Into +Aden +COMPANY. one. +Th +_ faint +to th +ing I +FURNISH ON OUR EASY PAYMENT SYSTEM. tho t +Mile +• elei +foe I +....__. +NO LONDON hUBBISH OLD HERE. Chill +mod +EVERY ARTICLE GUARANTEED. the +NO LARGE DEPOSITS ARE PRESSED FOR +_ Vi +• is +BEING GENUINE MAKERS, CAN SUPPLY THE PUBLIC AT LOWEST PR! RE. hluv +sod +..----- V +loot +CALL IN AND SEE THE MANAGER. war +by I +No one will know your Business. He will make the terms to mil you. +I ALL GOODS DELIVIIRED ON PRIVATE DRAYS. +. . +i' I +MIN ....... +EASIEST TERMS IN THE TRADE. • ii. +.• on +£3 worth 3s. down, is, per neek, am +1 +ts „ 359. 79 2s. 91 ibl +• 1.• +C4l. +ft* „ tos. „ Ss. 6d. „ +. a +.C2O ~ 205., 4s. 6(1. ~ I +i.. +£3O „ 308. „ Is. 91 th +C +tither amounts In proportion. g +II +a +MONTHLY OR QUARTERLY P tYMENTS TAKEN. Dill:lNQ ILLNESS OR 11 +NON-EMPLOYMkNT EVERY INDULGENCE IS 61.10WN. +CATALOGUES FREE. II +a +i +-- I +CAREFULLY REMEMIsdR OUR NAME iND ADDRES 4- +4 +I +PUBLIC BENEFIT FURNISHING , +, +, +COMPANY ~ +(Opposite Finpire lisle Hall). ' +88, PINSTONE STIZET:7 , SHEFFIELD. +Beware. r s P.llle !ra!' sh.rs are trying to copy us. +_ +iLL COMMtICTC ATIONS MUST BE ADDIIEEFED 10 11. BART. MANAGER.' + +NOTICE OF REMOVAL! +MEORS. +D. GLASGOW HUXLEY & CO*** +CHEMISTS AND SPECIALISTS, +MASONIC BUILDINGS, ROTHERHAM, +Beg to notify their Patron* and the Public generally, that +ON WEDNESDAY. THE 29th DECEMBER. 1897. +THEY WILL +open their hew Premises and Consulting Isms, +IN THE +" ODDFEL LOWS' BUILDINGS,' +(OLD mum COURT BUILDINGS), +Opposite the Gomel Pent °floe, +WESTGATE, BOTHERHAM +WITS • !UM SUPPLY OF THE +Fames isglo-isseelem lethal Medicines and Preparations. +los D. HUXLEY WILL ATTEND AS USUAL TO GIVE +ADVICE FREE ON ALL DISEASES. +I._ .9 + +LADIES. +AIIerOLVTSI; thorns who êsW. IN DiSPINSABLn. +(179 LATES I +reliable sad assiderrisas rarady for certain +OBIMIIIMONS sad InREOULARITIEB, a S. +ms=ii +isr=s= ..,:z, +i........... Tim += allMiL NI I.= 6.1.06 .... +bil ihnisberniusas alLtaveleillr ea +"I =fflr ' aseler +rAmmills briarriass. +li.biritaarZTAßgef IrialiVellt +ffsairaft.bies.fter lirs.zosizil.. Ds N.= =re +arrsele serlers rebarbeint imiresar +linsca—Thls remereal era* eargrar dr bar +as Isa cis la is ararsaiaise sr dear re +aely Menem micro bled Or lair sr +_..e_rili parrs b re era an sir nerds +,so ser=biLesdas arrer Iles +E by Gas +r. 1•741 all +Kra jr Ira roe pr is I sell NM= +iii al per b rare N. pre Om • +Drab traft=tariu.arts doll liirrszs.fri, rettisardesimsee +: eis riereal so sermrse +=l4l.dia.raser era =a Orr Ihrbar. +Irses ma 3M x Insierse era +MO rms. e +Do rt r bit err Nara Wordy la +MADAME 4 FRAINE_ +NOW eirereb Ibliee lt. lasilim sa. +fowler obindrir camas - + +ii, • the liagesem 0510,1 +Rana /Ml 4. be& al +--eyUI al Pala +ike led. IL I awe 5•4. 410 +/I. Ir. 13. ft dr Yam% tee Ms*, +rot Mar" 714 +ad MX Ptl.S. Geese* +N-13161111.-laramy 11. Gawp lhibwt +T 54•4434. • 151108444 emel 5164 lbw Mesh +Mrllll-11TIRL-Jereari 4 • &a +Paola Moak. Selma IWsl* +Ile/m4 • boa =Mak MYER 4 lbliala +Yiedhamege. +111,43111014-4•11111.-elawar7 111, ea, do Maabare +Peril nasal 11111•111111 St iamb AAss. +WI • Ibid. +311. al go +2411118, 094 S 11r. MEW Ikeelesa to 111 e +11144104aa. a= et lawasele. +DIME& +JAI 114/54d. Clisitelillmr 4 We* WY. +aid bp belapred et Wadi Oinsisey s.ltsftsg. +Wt. =BR +CIIOSIL4S D.-0a Jamaay 13 • Wenel. ROA e• +ids etwi Onislowl. sad Air de=l +Gel.. Mr. 110 e. Lamm ammo wok +• rum- Die* amperded and iamb Napa +- +84111111.--Jawaez=. Wasedre, 1.1 W.. +dr Weber 4 kb +Thenaley Nes& mai al yes +-Jemmy 14, at &bead SIN* Waillaame. +Tama Iheber, egad 111 yam. +PAGAN-C. &mem 14, • bead Mks hail& +P. miLter= +s lmam +UI M Weedheas. Wm, Peraka. +sea at Miami IMAM OW disc aged 15 magela. +DItIVIII.--dammll. • Weedesere NAL allked +Sames Meer. am • Owed River. seal Warr. +wry +.I=. +15, • Weellean, Arear +Calla lola Nee • Well. liefeh. lawdasee aserk +13&.4.2114 +J lia .-warsy 1. • selawards. Mob +W era Tr 14444 +Millia-Uurn Si. Si /derlseads, 1d bb. +Atari 111 mew +sa 11•1412" Dup. +ad, Orbs Ihmbas. aid Imiathi. +1111.115.-Jamm7 II • Gam land lab MOM +_ 11 Illeadia +MUM.-Jermarryl.earieberma lareue, beiy 11eaa. +DVINII.-=1) IT. at Cam. Ma tepee. add +U. • 411. Meares Deed. Welt +Masa. Wain Ilearals. p. 40 rem +0081L-Jamary 11. ea* Sae* Itenea. +ireannmanaged +14. at IL &Mgt Ninet. awSt. +1... +ritil=ged 11 +s yam. +11. • 14. Pt..Ain laspgeo. agal I reepele. +MIL-J 44•41 14, •4, %mei Ilireg. +Wilfred NM/ay ligekee. aged 11 rasalha +-11arrary 14, at & arks OM* Nub. +a...aged 111 faffli. +a's•-•l4.ffan 14. • 3 Warm farm Saha +Ma lb.NuJasins, Egad 3 aeralhe. +1111011011.--demary 13, ea IS, Qom Sareat, adobe, +poWswillaselak.„4:l wierks. +IX at 4. Omar lam +Wolk Nene Pegglesel. aged I yeen. +1111.121111011 it • /1/4 Sisk *sr* Mgr +oCl!4mtr."em 11111iesea. aged mac +le, • Wall Nos* lladaei. +llarket Oaehaad. mod 15 +111115E-Jamory . lbsbeameed4 +15 • +Meer W. p. 4 14 +Wl.-37 31 • =we. AM= &leak +.Aval paw. +.-430 Jar 714.014. DavasaMit Mad. +Dna - 3ama Matz aged 45 jean. +lam Ofteet dais. +sod 0 Jora +sir +i;ilagoLfl-J=lfi 11,2rdi Thu...... +*U- .y • POW Yard. +GIMP AMIEIW4. ud 3 media +.-karaary tr. St owe Ossibormigh, +kora Pers. mod 11 +mg =kr Road. 0e14154,44 +C1=1381r."11. aged 1 assall. +I& • Oboe& 11111e1*, /kabare•gte. +Paedlernallbusl, eged +14511.-Jameara 11 at J Jame Apse. +& at Oftiobarsergle. Clede4 bola +Jur +autzammiyara. +WA. at 110aNaeallearae. Nada +likada. aged S vesolaa. +1111,1111T.-drme7 11 • Diaake. Marry Iteek. +ae Orinkoremi., Pori +Amer. aged ores diff --git a/tests/bl_lwm/0003548_19040707_art0037.txt b/tests/bl_lwm/0003548_19040707_art0037.txt new file mode 100644 index 0000000..0b441e7 --- /dev/null +++ b/tests/bl_lwm/0003548_19040707_art0037.txt @@ -0,0 +1,136 @@ +MEETING AT CAERPHILLY. + +LEAVE CARDTEF. I Lunt WESTON. , +1-8-15, 9-30,1,10-30, 11-45; 9-15, 10-30. *1145 am, +am, 5-30, 6-30, 7-30, 5-15, 6-30, 7-30, 8-45, +8-30 pm ' 9-30 pm On Monday there was a Temperance +2-8-15, 9-30, 10-30 am, 9-15, rlO-20, 11-30 am meeting held at the Market Hall to hear +12-30, 6, 8 pm 6,7, 9,9-15 pm +a farewell addres from Mrs Harrison +4-8-15, 11-30 9-11.p30,1k,i4,0.1.,081,16350am +am, 1-30, 7, Bpm Rev J. la. Thomas presided. +5-7-80, 8-30 , 9-30, 10-30, 8-20, 9-3 0, 10 -3 0. At the advertised time for commence- +Lee. The +11-30, am, 2, 7-45, 8-30, 44,11-10 am, 12-30, 7, . , , . +.. , +*9-40 pm 8.45 9.30, 10.20 pm /n-tit. there was but a sparse audience, +6-8-30, 9-30, 10-30 am, '10.30, rll -30 am but later the hall presented a well- +7jattended appearance. The majority of +,1*01.30-15,2m-4.51,28455, +101.-3015, .1-2:17113°3438aM194455, , +8-1104,51.1:302.1a5m, 33.534p)73-15 2.15a,m3,, ei +29:15. pi0n12.30, those present were ladies. The Rev - +well was called upon to lead prayers, +4, 4-15 ptu +and these having been concluded, +11.13-0143p.Wm The Chairman, in hailing the ladies +9-25:3405: +41-30, +6-a1m5,p,1.2-30 11,5715,,152:3ti., +11-'6-30, 7-45 am, 1-30, -7-15. am, 145, 3-30, with joy, said he and their brethren had +2-30, 4-30, 6-30 pm ' 5-30, 7-10, 7-30 pm , , . +~ +12-.745, 8-30 am, 2.30,1 8 pm, 2-15, .4,30, 8.15, fears or oeir% ousted by the women +3-30, 5-15, 7-15 pm i 845, 830 workers in the e.ause. The present was +3-'B, 9-30 am, 3-30, 4.30,! am, 3-15, 4-30,540 a most opportune time to fight the drink +5-30, 6-30, 7-30 pm 6-30, 8-45, 9 pm +14-8-15,10 am, 4-30,'5-30,1 9-15. am, 4, 5-30, *645, trade-or traffic, to be more explicit. +8-30,7-15, 8-30 Pm i 7-30, 9-15, 9.30 The House of Commons was the most +15-8-30, 9-30. II am, 5-15,i 10-30 am, 3,6-15, God-forsaken place on the earth, but +6-15, 7-15, 8-15 pm i 7-15, 9, 9-15 +16-8-15, 9-30, 10-20, 11-45 9-15, 10-30, 11-20 am, there was one worse - the House of Lords. +am. 5-45, 7,8, '9 pm 1 5-45,6-45. 8,9-30,9.45 He favoured compensation that would +18-8.15, 10-15, 11 45 am,j 9-15, 11-159-30 ant, +9-4a1246, +favour the widows and orphans and the +, +19-7-30. 8-30, 9-30,11 am,; 8-20, 9-30,1'10-30 am, lightening of the burden of the rate- +-12-30, 2, 8.30, 9-15 pm , 12 n, 1-30, 7-15, 8-15, payers, who were entitled to considera- +I 9-30. 10-20 pm +20,-8.30, 9.33,10.30 am, 1, 9-30, 4,•10-30, 11-30 am tion. Nine out of every ten criminal +2-45, 9-15 pm 2,8, 8-45, 10 15 pm cases were attributed to strong drink. +21-9-30, 1010, 1110 am,l 10-300, f44.1-30a_ a 9-30 m , He knew the brewers: their appearance +, +22-10-30, 11 am, 14-30, 3, 11.30 am, 1-30, 4-15, denoted their calling. Six days in the +5.15 pm 4-30, 10-30 pm week they damned souls and on the +-6. 11-45 am, 1,2, 3, 4 11--15 am 4, a 45, 2,3, seventh they piously went to the chapels. +l 530, 5-5 pm +25-'6-45, 8-10 am, 2,3, 5, *7-30 am, 1-45, 4,6, Compensation to brewers, indeed !-the +7pm 7-4.5, Bpm cause of poverty nod misery. They saw +21;-7-15 9 am, 2.45, 5-30, 8.15 am, 2-15, 4-30, +5-30, 7-30 6-30. 8-30, 8-45 the nation awakening, and they went to +27-8, 9-40 am, 1-30, 4-30. 9 am, 3, *4-30, 5-30, a man on the point of drowning, who got +*545, 6-30, 7,8-30 pm *6. 7-3% 9-15, 1") their help, and he gave them his in re- +-28-8-15, 9-45, sin,4. 5,0, 9-15 am, 3-45, 5,6, 7, +7.8. *9 pm 8,9-30. 9-45 pm turn. The great sin of the age was drink. +29-8-30, *9-30, 10-45 am, 9-30, *lO-15 am, 4-15, They were determined it shall be killed. +4-49 5 +*1 5 +, pm The public was asked what right had +30-8, -30. 0-1, 11-30, 9, 10-30, *ll am, 4, „ , +am, 5-15, 7-15, 9-15 1 *6:15, 845, 10-15 pm they to interfere with their business. +"lka-6 not Call at Fesarth. t via Clevetton_ He quoted Mary Barton on the battle- +_ field, whose good works were questioned. +1 That was the work of God. Balfour and +all the powers-the Evil One included- +would be defeated. +Mrs Harrison Lee said she KOLS sorry +the right people were not present. All +things were coming to pass. Their work +resembled that of laying the last stone of +the amethyst. They should hasten to +put the stone in the New Jerusalem, and +by so doing they were hastening the day. +Years ago, in a little American village, +there was a woman who was the wife of +a drunkard, She took the Bible to the +village saloon (the one her husband fre- +qpitiiecnkteedd )t, +haendcontislenfreeectofof +thheerpruebaidicitamg +. William Seward summoned Edward +to such an extent that he closed the William Thomas for wilful damage to +saloon. Her husband was of a shutter of a house occupied by Thomas.. +opinion +that such a result was beyond the powers The damage was estimated at 10a. +of his wife, but the publican stuck to the He had renovated the house just aitor' +promise he made to the wife that her has. Thomas took it. A remark from the +band would he served there no more, Magisterial quarter to the effect that it +The latter was enabled to give his son a was a case for soother Court saw +University .:nueation, who tarried out to , Seward, acting on the suggestion of the +be one of America's greatest, doctors (Dr Clerk, get out a summons under another +Lewi-). He said the *mama wo ild be head. + +9-30. + +9-30 + +8-45 + +9-30, + +The Chairman wished Mrs Harrison +Lee God-speed in her voyage to Australia, +and acknowledged a vote of thanks, pro +posed by Mr Salathiel and seconded by +Mr Phillips. +_ _ + +The Benediction was pronounced, and +the meeting concluded. + +Ilfracombe ec Lynmouth. +LEAVE CARDIFF. ' LEAVE ILFRACOMBE. +1-10410 16-9-30 am! 1-4-30 16-445 pm +2-9-30 18-9-30 aml 2-5-45 18-6-30 pm +4-9-30 19-11 am 4-5-30 19-8 pm +5-11-30 20-9-30 arni 5-41 120-7 pm +6-9-30 21-9-30 am; 6-5 21-6-30 pm +7-10 22-10-15aml 7-41 22-7-30 pm +8-10 25-8-30 am 8-7-15 25-4 pm +11—.17-43 26-9 am 11-12-30 26-2-15 pm +12-8-43 27-10 am 12-2 27-3-45 pm +13-9-30 28-1045am:13-3 28-3-30 pm +14-10-15 29-9.30 am:l4-3-30 29-6- :5 pm +15-10-45 30-9-30 am'ls —4 30-5-15 pm +t Does not call off Lyntaoutli. From 978e3dd0172a2ba53480744cbd53373a9ebbc369 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 08:56:42 -0400 Subject: [PATCH 13/23] fix(ci): enable `download` flagged tests for CI and tweak `plaintext` tests for macOS --- .github/workflows/ci.yaml | 2 +- alto2txt2fixture/plaintext.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a87cea4..fadf4c8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: - name: Run pre-commit uses: pre-commit/action@main - name: Run pytest - run: poetry run pytest -n auto -m "not download" + run: poetry run pytest -n auto - name: Archive coverage svg uses: actions/upload-artifact@v3 with: diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index ce605fe..f1f50b6 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -145,16 +145,16 @@ class PlainTextFixture: >>> plaintext_bl_lwm.info() - ...PlainTextFixture for 2 'bl_lwm' files... - ┌─────────────────────┬─────────────────────────────────────────...┐ - │ Path │ '/.../bl_lwm' ...│ - │ Compressed Files │ '/.../bl_lwm/0003079-test_plaintext.zip'...│ - │ │ '/.../bl_lwm/0003548-test_plaintext.zip'...│ - │ Extract Path │ '/.../bl_lwm/extracted' ...│ - │ Uncompressed Files │ None ...│ - │ Data Provider │ 'Living with Machines' ...│ - │ Initial Primary Key │ 1 ...│ - └─────────────────────┴─────────────────────────────────────────...┘ + ...PlainTextFixture for 2 'bl_lwm' files... + ┌─────────────────────┬────────────────────────────────...┐ + │ Path │ '/.../bl_lwm' ...│ + │ Compressed Files │ '/.../bl_lwm/0003079-test_plain...│ + │ │ '/.../bl_lwm/0003548-test_plain...│ + │ Extract Path │ '/.../bl_lwm/extracted' ...│ + │ Uncompressed Files │ None ...│ + │ Data Provider │ 'Living with Machines' ...│ + │ Initial Primary Key │ 1 ...│ + └─────────────────────┴────────────────────────────────...┘ >>> plaintext_bl_lwm.free_hd_space_in_GB > 1 True >>> plaintext_bl_lwm.extract_compressed() From 0983ebfe6b8aa28145b1cfddde57eb134d9b0c39 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 09:02:20 -0400 Subject: [PATCH 14/23] fix(ci): skip `download` flagged tests in GitHub Actions --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fadf4c8..a87cea4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: - name: Run pre-commit uses: pre-commit/action@main - name: Run pytest - run: poetry run pytest -n auto + run: poetry run pytest -n auto -m "not download" - name: Archive coverage svg uses: actions/upload-artifact@v3 with: From 7b9e3ca30181d66413a0963767c306a15ce773fd Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 09:12:27 -0400 Subject: [PATCH 15/23] fix(ci): add `--doctest-continue-on-failure` to `pytest` config in `pyproject.toml` --- alto2txt2fixture/plaintext.py | 6 +++--- pyproject.toml | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index f1f50b6..b651e3d 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -159,9 +159,9 @@ class PlainTextFixture: True >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'/.../bl_lwm/extracted... - ...Extracting:...'/.../bl_lwm/00030... - ...Extracting:...'/.../bl_lwm/00035... + ...Extract path:...'/...lwm/extracted... + ...Extracting:...'/...lwm/00030... + ...Extracting:...'/...lwm/00035... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() Deleting all files in:...'/.../bl_lwm...tracted' diff --git a/pyproject.toml b/pyproject.toml index 178e848..52b0309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ addopts = """ --cov-report=term:skip-covered --pdbcls=IPython.terminal.debugger:TerminalPdb --doctest-modules +--doctest-continue-on-failure --durations=3 """ markers = [ From 730f76b181caa0b2ee07470ff6a91d26ce045798 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 14:33:53 -0400 Subject: [PATCH 16/23] fix(ci): replace `/` with `...` in test paths for windows compatibility --- alto2txt2fixture/plaintext.py | 46 +++++++++++++++++------------------ alto2txt2fixture/utils.py | 41 ++++++++++++++++--------------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index b651e3d..ecbabd9 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -142,15 +142,15 @@ class PlainTextFixture: ... compressed_glob_regex="*_plaintext.zip", ... ) >>> plaintext_bl_lwm - + >>> plaintext_bl_lwm.info() ...PlainTextFixture for 2 'bl_lwm' files... ┌─────────────────────┬────────────────────────────────...┐ - │ Path │ '/.../bl_lwm' ...│ - │ Compressed Files │ '/.../bl_lwm/0003079-test_plain...│ - │ │ '/.../bl_lwm/0003548-test_plain...│ - │ Extract Path │ '/.../bl_lwm/extracted' ...│ + │ Path │ '...bl_lwm' ...│ + │ Compressed Files │ '...bl_lwm...0003079-test_plain...│ + │ │ '...bl_lwm...0003548-test_plain...│ + │ Extract Path │ '...bl_lwm...extracted' ...│ │ Uncompressed Files │ None ...│ │ Data Provider │ 'Living with Machines' ...│ │ Initial Primary Key │ 1 ...│ @@ -159,12 +159,12 @@ class PlainTextFixture: True >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'/...lwm/extracted... - ...Extracting:...'/...lwm/00030... - ...Extracting:...'/...lwm/00035... + ...Extract path:...'...lwm...extracted... + ...Extracting:...'...lwm...00030... + ...Extracting:...'...lwm...00035... ...%...[...]... >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in:...'/.../bl_lwm...tracted' + Deleting all files in:...'...bl_lwm...tracted' ``` """ @@ -403,11 +403,11 @@ def zipinfo(self) -> Generator[list[ZipInfo], None, None]: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> zipfile_info_list: list[ZipInfo] = list(plaintext_bl_lwm.zipinfo) - Getting zipfile info from + Getting zipfile info from >>> zipfile_info_list[0][-1].filename - '0003079/1898/0204/0003079_18980204_sect0001.txt' + '0003079...1898...0204...0003079_18980204_sect0001.txt' >>> zipfile_info_list[-1][-1].filename - '0003548/1904/0707/0003548_19040707_art0059.txt' + '0003548...1904...0707...0003548_19040707_art0059.txt' >>> zipfile_info_list[0][-1].file_size 70192 >>> zipfile_info_list[0][-1].compress_size @@ -432,7 +432,7 @@ def extract_compressed(self) -> None: >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> plaintext_bl_lwm.extract_compressed() - ...Extract path:...'/.../bl_lwm/extracted'... + ...Extract path:...'...bl_lwm...extracted'... >>> filter_sect1_txt: list[str] = [txt_file for txt_file in ... plaintext_bl_lwm._uncompressed_source_file_dict.keys() ... if txt_file.name.endswith('204_sect0001.txt')] @@ -441,9 +441,9 @@ def extract_compressed(self) -> None: >>> plaintext_bl_lwm._uncompressed_source_file_dict[ ... filter_sect1_txt[0] ... ] - PosixPath('/.../bl_lwm/0003079-test_plaintext.zip') + PosixPath('...bl_lwm...0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in:...'/.../bl_lwm...tracted' + Deleting all files in:...'...bl_lwm...tracted' ``` @@ -469,7 +469,7 @@ def plaintext_paths( ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:.../.../bl_lwm/extracted... + ...Extract path:...bl_lwm...extracted... >>> plaintext_paths = plaintext_bl_lwm.plaintext_paths() >>> first_path_fixture_dict = next(iter(plaintext_paths)) >>> first_path_fixture_dict['path'].name @@ -528,11 +528,11 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:.../.../bl_lwm/extracted... + ...Extract path:...bl_lwm...extracted... >>> paths_dict = list(plaintext_bl_lwm.plaintext_paths_to_dicts()) - Compressed configs :...%.../...[ ... it/s ] + Compressed configs :...%...[ ... it/s ] >>> plaintext_bl_lwm.delete_decompressed() - Deleting all files in: '/.../.../...tracted' + Deleting all files in: '...tracted' ``` """ @@ -577,7 +577,7 @@ def export_to_json_fixtures( ... ) >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:.../.../bl_lwm/extracted... + ...Extract path:...bl_lwm...extracted... >>> plaintext_bl_lwm.export_to_json_fixtures(output_path=bl_lwm / "output") Compressed configs...%...[...] @@ -630,12 +630,12 @@ def delete_decompressed(self, ignore_errors: bool = True) -> None: ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') - ...Extract path:...'/.../bl_lwm/extracted'... + ...Extract path:...'...bl_lwm...extracted'... >>> plaintext_bl_lwm.delete_decompressed() Deleting all files in:... >>> plaintext_bl_lwm.delete_decompressed() - ...Extract path empty:...'/.../bl_lwm/extracted'... + ...Extract path empty:...'...bl_lwm...extracted'... ```` """ @@ -676,7 +676,7 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> plaintext_lwm._check_and_set_files_attr(force=True) DEBUG...Force change to...>> plaintext_lwm.files - (...('/.../bl_lwm/0003079-test_plaintext.zip'),) + (...('...bl_lwm...0003079-test_plaintext.zip'),) >>> len(plaintext_lwm) 1 diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 6b4faa8..af6226d 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -322,7 +322,8 @@ def write_json( Example: ```pycon - >>> path = 'test-write-json/example.json' + >>> tmp_path: Path = getfixture('tmp_path') + >>> path: Path = tmp_path / 'test-write-json-example.json' >>> write_json(p=path, ... o=NEWSPAPER_COLLECTION_METADATA, ... add_created=True) @@ -786,9 +787,10 @@ def save_fixture( Example: ```pycon + >>> tmp_path: Path = getfixture('tmp_path') >>> save_fixture(NEWSPAPER_COLLECTION_METADATA, - ... prefix='test', output_path='tests/') - >>> imported_fixture = load_json('tests/test-1.json') + ... prefix='test', output_path=tmp_path) + >>> imported_fixture = load_json(tmp_path / 'test-1.json') >>> imported_fixture[1]['pk'] 2 >>> imported_fixture[1]['fields'][DATA_PROVIDER_INDEX] @@ -862,10 +864,11 @@ def fixtures_dict2csv( Example: ```pycon + >>> tmp_path: Path = getfixture('tmp_path') >>> from pandas import read_csv >>> fixtures_dict2csv(NEWSPAPER_COLLECTION_METADATA, - ... prefix='test', output_path='tests/') - >>> imported_fixture = read_csv('tests/test-1.csv') + ... prefix='test', output_path=tmp_path) + >>> imported_fixture = read_csv(tmp_path / 'test-1.csv') >>> imported_fixture.iloc[1]['pk'] 2 >>> imported_fixture.iloc[1][DATA_PROVIDER_INDEX] @@ -989,11 +992,11 @@ def path_globs_to_tuple( >>> bl_lwm = getfixture("bl_lwm") >>> from pprint import pprint >>> pprint(path_globs_to_tuple(bl_lwm, '*text.zip')) - (PosixPath('/.../bl_lwm/0003079-test_plaintext.zip'), - PosixPath('/.../bl_lwm/0003548-test_plaintext.zip')) + (PosixPath('...bl_lwm...0003079-test_plaintext.zip'), + PosixPath('...bl_lwm...0003548-test_plaintext.zip')) >>> pprint(path_globs_to_tuple(bl_lwm, '*.txt')) - (PosixPath('/.../bl_lwm/0003079_18980121_sect0001.txt'), - PosixPath('/.../bl_lwm/0003548_19040707_art0037.txt')) + (PosixPath('...bl_lwm...0003079_18980121_sect0001.txt'), + PosixPath('...bl_lwm...0003548_19040707_art0037.txt')) ``` @@ -1111,7 +1114,7 @@ def compress_fixture( >>> compress_fixture( ... path=plaintext_bl_lwm._exported_json_paths[0], ... output_path=tmpdir) - Compressing.../plain...-1.json to 'zip' + Compressing...plain...-1.json to 'zip' >>> from zipfile import ZipFile, ZipInfo >>> zipfile_info_list: list[ZipInfo] = ZipFile( ... tmpdir/'plaintext_fixture-1.json.zip' @@ -1138,14 +1141,14 @@ def paths_with_newlines( ```pycon >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext') >>> print(paths_with_newlines(plaintext_bl_lwm.compressed_files)) - '/.../bl_lwm/0003079-test_plaintext.zip' - '/.../bl_lwm/0003548-test_plaintext.zip' + '...bl_lwm...0003079-test_plaintext.zip' + '...bl_lwm...0003548-test_plaintext.zip' >>> print( ... paths_with_newlines(plaintext_bl_lwm.compressed_files, ... truncate=True) ... ) - '/..././0003079-test_plaintext.zip' - '/..././0003548-test_plaintext.zip' + '...0003079-test_plaintext.zip' + '...0003548-test_plaintext.zip' ``` """ @@ -1177,16 +1180,16 @@ def truncate_path_str( >>> love_shadows: Path = ( ... Path('Standing') / 'in' / 'the' / 'shadows'/ 'of' / 'love.') >>> truncate_path_str(love_shadows) - 'Standing/././././love.' + 'Standing...love.' >>> truncate_path_str(love_shadows, max_length=100) - 'Standing/in/the/shadows/of/love.' + 'Standing...in...the...shadows...of...love.' >>> truncate_path_str(love_shadows, folder_filler_str="*") - 'Standing/*/*/*/*/love.' + 'Standing...*...*...*...*...love.' >>> truncate_path_str(Path('/') / love_shadows, folder_filler_str="*") - '/Standing/*/*/*/*/love.' + '...Standing...*...*...*...*...love.' >>> truncate_path_str(Path('/') / love_shadows, ... folder_filler_str="*", tail_paths=3) - '/Standing/*/*/shadows/of/love.' + '...Standing...*...*...shadows...of...love.' ``` """ From 7c90e14c4dbdfe9685dd4c843a6b944712202bf9 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Wed, 30 Aug 2023 20:15:13 -0400 Subject: [PATCH 17/23] fix(ci): add `sep` to `truncate_path_str` solve Widows `Path` issue --- alto2txt2fixture/plaintext.py | 6 +++++- alto2txt2fixture/utils.py | 18 +++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index ecbabd9..4e6b4c7 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -441,7 +441,8 @@ def extract_compressed(self) -> None: >>> plaintext_bl_lwm._uncompressed_source_file_dict[ ... filter_sect1_txt[0] ... ] - PosixPath('...bl_lwm...0003079-test_plaintext.zip') + + ...Path('...bl_lwm...0003079-test_plaintext.zip') >>> plaintext_bl_lwm.delete_decompressed() Deleting all files in:...'...bl_lwm...tracted' @@ -526,6 +527,9 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None Example: ```pycon + >>> import sys, pytest + >>> if sys.platform.startswith('win'): + ... pytest.skip('current decompression does not work on Windows') >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') ...Extract path:...bl_lwm...extracted... diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index af6226d..0fe7612 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -3,7 +3,7 @@ import json import logging from collections import OrderedDict -from os import PathLike, chdir, getcwd +from os import PathLike, chdir, getcwd, sep from pathlib import Path from shutil import disk_usage, get_unpack_formats, make_archive from typing import ( @@ -992,11 +992,11 @@ def path_globs_to_tuple( >>> bl_lwm = getfixture("bl_lwm") >>> from pprint import pprint >>> pprint(path_globs_to_tuple(bl_lwm, '*text.zip')) - (PosixPath('...bl_lwm...0003079-test_plaintext.zip'), - PosixPath('...bl_lwm...0003548-test_plaintext.zip')) + (...Path('...bl_lwm...0003079-test_plaintext.zip'), + ...Path('...bl_lwm...0003548-test_plaintext.zip')) >>> pprint(path_globs_to_tuple(bl_lwm, '*.txt')) - (PosixPath('...bl_lwm...0003079_18980121_sect0001.txt'), - PosixPath('...bl_lwm...0003548_19040707_art0037.txt')) + (...Path('...bl_lwm...0003079_18980121_sect0001.txt'), + ...Path('...bl_lwm...0003548_19040707_art0037.txt')) ``` @@ -1185,9 +1185,9 @@ def truncate_path_str( 'Standing...in...the...shadows...of...love.' >>> truncate_path_str(love_shadows, folder_filler_str="*") 'Standing...*...*...*...*...love.' - >>> truncate_path_str(Path('/') / love_shadows, folder_filler_str="*") + >>> truncate_path_str(Path(sep) / love_shadows, folder_filler_str="*") '...Standing...*...*...*...*...love.' - >>> truncate_path_str(Path('/') / love_shadows, + >>> truncate_path_str(Path(sep) / love_shadows, ... folder_filler_str="*", tail_paths=3) '...Standing...*...*...shadows...of...love.' @@ -1196,12 +1196,12 @@ def truncate_path_str( if len(str(path)) > max_length: path_parts: tuple[str] = Path(path).parts first_folder_name_index: int = 1 if Path(path).is_absolute() else 0 - paths_str: str = "/".join( + paths_str: str = sep.join( part if i == 0 or i >= len(path_parts) - first_folder_name_index - tail_paths else folder_filler_str for i, part in enumerate(path_parts[first_folder_name_index:]) ) - return "/" + paths_str if first_folder_name_index == 1 else paths_str + return sep + paths_str if first_folder_name_index == 1 else paths_str else: return str(path) From 9cf4f02d8d9ae6764a458ac0c49b02f5bbc62dc0 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Thu, 31 Aug 2023 16:33:59 -0400 Subject: [PATCH 18/23] fix(ci): address more `sep` `truncate_path_str` Windows issues --- alto2txt2fixture/plaintext.py | 3 +++ alto2txt2fixture/utils.py | 26 +++++++++++++++++++++----- tests/test_cli.py | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 4e6b4c7..eae6313 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -575,6 +575,9 @@ def export_to_json_fixtures( Example: ```pycon + >>> import sys, pytest + >>> if sys.platform.startswith('win'): + ... pytest.skip('current decompression does not work on Windows') >>> bl_lwm: Path = getfixture("bl_lwm") >>> first_lwm_plaintext_json_dict: PlaintextFixtureDict = ( ... getfixture("first_lwm_plaintext_json_dict") diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 0fe7612..1bda2f6 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -6,6 +6,7 @@ from os import PathLike, chdir, getcwd, sep from pathlib import Path from shutil import disk_usage, get_unpack_formats, make_archive +from sys import platform from typing import ( Any, Final, @@ -1163,6 +1164,9 @@ def truncate_path_str( max_length: int = MAX_TRUNCATE_PATH_STR_LEN, folder_filler_str: str = INTERMEDIATE_PATH_TRUNCATION_STR, tail_paths: int = 1, + path_sep: str = sep, + _posix_path_start_index: int = 1, + _win_path_start_index: int = 2, ) -> str: """If `len(text) > max_length` return `text` followed by `trail_str`. @@ -1185,9 +1189,10 @@ def truncate_path_str( 'Standing...in...the...shadows...of...love.' >>> truncate_path_str(love_shadows, folder_filler_str="*") 'Standing...*...*...*...*...love.' - >>> truncate_path_str(Path(sep) / love_shadows, folder_filler_str="*") + >>> root_love_shadows: Path = Path(sep) / love_shadows + >>> truncate_path_str(root_love_shadows, folder_filler_str="*") '...Standing...*...*...*...*...love.' - >>> truncate_path_str(Path(sep) / love_shadows, + >>> truncate_path_str(root_love_shadows, ... folder_filler_str="*", tail_paths=3) '...Standing...*...*...shadows...of...love.' @@ -1195,13 +1200,24 @@ def truncate_path_str( """ if len(str(path)) > max_length: path_parts: tuple[str] = Path(path).parts - first_folder_name_index: int = 1 if Path(path).is_absolute() else 0 - paths_str: str = sep.join( + path_start_index = ( + _win_path_start_index + if platform.startswith("win") + else _posix_path_start_index + ) + first_folder_name_index: int = ( + path_start_index if Path(path).is_absolute() else 0 + ) + paths_str: str = path_sep.join( part if i == 0 or i >= len(path_parts) - first_folder_name_index - tail_paths else folder_filler_str for i, part in enumerate(path_parts[first_folder_name_index:]) ) - return sep + paths_str if first_folder_name_index == 1 else paths_str + return ( + path_sep + paths_str + if first_folder_name_index == path_start_index + else paths_str + ) else: return str(path) diff --git a/tests/test_cli.py b/tests/test_cli.py index c44a201..c1bccfb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ def test_plaintext_cli(bl_lwm, first_lwm_plaintext_json_dict): ], ) assert result.exit_code == 0 - for message in ("Extract path:", "bl_lwm/extracted"): + for message in ("Extract path:", "bl_lwm", "extracted"): assert message in result.stdout exported_json: list[FixtureDict] = json.loads( (bl_lwm / "test-cli-plaintext-fixture" / "plaintext_fixture-1.json").read_text() From 06c21df25fe5a160e7ae79e9f5abb71069c3a047 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Mon, 4 Sep 2023 21:19:30 -0400 Subject: [PATCH 19/23] fix(ci): address more `sep` `truncate_path_str` Windows issues --- alto2txt2fixture/plaintext.py | 17 +++--- alto2txt2fixture/utils.py | 106 ++++++++++++++++++++-------------- conftest.py | 15 ++++- docs/gen_ref_pages.py | 7 ++- tests/test_cli.py | 8 ++- tests/test_utils.py | 61 ++++++++++++++++++- 6 files changed, 156 insertions(+), 58 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index eae6313..60c882e 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -235,13 +235,13 @@ def info_table(self) -> str: """ compressed_file_names: str = ( - self._compressed_file_names(truncate=True, tail_paths=2) + self._compressed_file_names(truncate=True, tail_parts=2) ) or self.empty_info_default_str uncompressed_file_names: str = ( - self._provided_uncompressed_file_names(truncate=True, tail_paths=2) + self._provided_uncompressed_file_names(truncate=True, tail_parts=2) ) or self.empty_info_default_str extract_path: str = ( - f"'{truncate_path_str(self.extract_path, tail_paths=2)}'" + f"'{truncate_path_str(self.extract_path, tail_parts=2)}'" or self.empty_info_default_str ) table: Table = Table(title=str(self), show_header=False) @@ -258,21 +258,21 @@ def info(self) -> None: console.print(self.info_table) def _compressed_file_names( - self, truncate: bool = False, tail_paths: int = 1 + self, truncate: bool = False, tail_parts: int = 1 ) -> str: """`self.compressed_files` `paths` separated by `\n`.""" return paths_with_newlines( - self.compressed_files, truncate=truncate, tail_paths=tail_paths + self.compressed_files, truncate=truncate, tail_parts=tail_parts ) def _provided_uncompressed_file_names( - self, truncate: bool = False, tail_paths: int = 1 + self, truncate: bool = False, tail_parts: int = 1 ) -> str: """`self.plaintext_provided_uncompressed` `paths` separated by `\n`.""" return paths_with_newlines( self.plaintext_provided_uncompressed, truncate=truncate, - tail_paths=tail_paths, + tail_parts=tail_parts, ) @property @@ -681,7 +681,8 @@ def _check_and_set_files_attr(self, force: bool = False) -> None: >>> len(plaintext_lwm) 2 >>> plaintext_lwm._check_and_set_files_attr(force=True) - DEBUG...Force change to... + ...DEBUG...Force change to...>> plaintext_lwm.files (...('...bl_lwm...0003079-test_plaintext.zip'),) >>> len(plaintext_lwm) diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 1bda2f6..40429af 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -4,9 +4,9 @@ import logging from collections import OrderedDict from os import PathLike, chdir, getcwd, sep -from pathlib import Path +from os.path import normpath +from pathlib import Path, PureWindowsPath from shutil import disk_usage, get_unpack_formats, make_archive -from sys import platform from typing import ( Any, Final, @@ -16,6 +16,7 @@ Literal, NamedTuple, Sequence, + Type, TypeAlias, overload, ) @@ -900,9 +901,6 @@ def fixtures_dict2csv( df: DataFrame = DataFrame.from_records(lst) df.to_csv(Path(f"{output_path}/{prefix}-{counter}.csv"), index=index) - return - save_fixture(records, prefix=f"test-{table_name}", output_path=path) - def export_fixtures( fixture_tables: dict[str, Sequence[FixtureDict]], @@ -1084,15 +1082,12 @@ def compress_fixture( path: `Path` to file to compress - fixture_glob: - A `glob` string for matching fxitures to compress within `path` - output_path: Compressed file name (without extension specified from `format`). format: - A `str` of one of the registered compression formats. - `Python` provides `zip`, `tar`, `gztar`, `bztar`, and `xztar` + A `str` of one of the registered compression formats. + `Python` provides `zip`, `tar`, `gztar`, `bztar`, and `xztar` suffix: `str` to add to comprssed filename saved. @@ -1100,12 +1095,6 @@ def compress_fixture( `suffix=_compressed`, then the saved file might be called `plaintext_fixture_compressed-1.json.zip` - fixture_extension: - What `str` to glob files within `path` for compression. - - delete_source: - Whether to delete the `path` file after compression. - Example: ```pycon >>> tmpdir: Path = getfixture("tmpdir") @@ -1148,6 +1137,8 @@ def paths_with_newlines( ... paths_with_newlines(plaintext_bl_lwm.compressed_files, ... truncate=True) ... ) + + ...Adding 1... '...0003079-test_plaintext.zip' '...0003548-test_plaintext.zip' @@ -1163,21 +1154,32 @@ def truncate_path_str( path: PathLike, max_length: int = MAX_TRUNCATE_PATH_STR_LEN, folder_filler_str: str = INTERMEDIATE_PATH_TRUNCATION_STR, - tail_paths: int = 1, + head_parts: int = 1, + tail_parts: int = 1, path_sep: str = sep, - _posix_path_start_index: int = 1, - _win_path_start_index: int = 2, + _force_type: Type[Path] | Type[PureWindowsPath] = Path, ) -> str: """If `len(text) > max_length` return `text` followed by `trail_str`. Args: - text: `str` to truncate - max_length: maximum length of `text` to allow, anything belond truncated - folder_filler_str: what to fill intermediate path names with + path: + `PathLike` object to truncate + max_length: + maximum length of `path` to allow, anything belond truncated + folder_filler_str: + what to fill intermediate path names with + head_parts: + how many parts of `path` from the root to keep. + These must be `int` >= 0 + tail_parts: + how many parts from the `path` tail the root to keep. + These must be `int` >= 0 + path_sep: + what `str` to replace `path` parts with if over `max_length` Returns: `text` truncated to `max_length` (if longer than `max_length`), - with with `folder_filler_str` for intermediate folder names + with with `folder_filler_str` for intermediate folder names Example: ```pycon @@ -1191,33 +1193,51 @@ def truncate_path_str( 'Standing...*...*...*...*...love.' >>> root_love_shadows: Path = Path(sep) / love_shadows >>> truncate_path_str(root_love_shadows, folder_filler_str="*") + + ...Adding 1... '...Standing...*...*...*...*...love.' >>> truncate_path_str(root_love_shadows, - ... folder_filler_str="*", tail_paths=3) - '...Standing...*...*...shadows...of...love.' + ... folder_filler_str="*", tail_parts=3) + + ...Adding 1... + '...Standing...*...*...shadows...of...love.'... ``` """ + path = _force_type(normpath(path)) if len(str(path)) > max_length: - path_parts: tuple[str] = Path(path).parts - path_start_index = ( - _win_path_start_index - if platform.startswith("win") - else _posix_path_start_index - ) - first_folder_name_index: int = ( - path_start_index if Path(path).is_absolute() else 0 - ) - paths_str: str = path_sep.join( - part - if i == 0 or i >= len(path_parts) - first_folder_name_index - tail_paths - else folder_filler_str - for i, part in enumerate(path_parts[first_folder_name_index:]) + try: + assert not (head_parts < 0 or tail_parts < 0) + except AssertionError: + logger.error( + f"Both index params for `truncate_path_str` must be >=0: " + f"(head_parts={head_parts}, tail_parts={tail_parts})" + ) + return str(path) + if path.drive or path.is_absolute(): + logger.debug( + f"Adding 1 to `head_parts`: {head_parts} " f"to truncate: '{path}'" + ) + head_parts += 1 + original_path_parts: tuple[str] = path.parts + try: + assert head_parts + tail_parts < len(str(original_path_parts)) + except AssertionError: + logger.error( + f"Returning untruncated. Params " + f"(head_parts={head_parts}, tail_parts={tail_parts}) " + f"not valid to truncate: '{path}'" + ) + return str(path) + tail_index: int = len(original_path_parts) - tail_parts + replaced_path_parts: tuple[str] = tuple( + part if (i < head_parts or i >= tail_index) else folder_filler_str + for i, part in enumerate(original_path_parts) ) - return ( - path_sep + paths_str - if first_folder_name_index == path_start_index - else paths_str + replaced_start_str: str = "".join(replaced_path_parts[:head_parts]) + replaced_end_str: str = path_sep.join( + path for path in replaced_path_parts[head_parts:] ) + return path_sep.join((replaced_start_str, replaced_end_str)) else: return str(path) diff --git a/conftest.py b/conftest.py index a5a38ee..9658a80 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path, PureWindowsPath from shutil import copytree, rmtree from typing import Final, Generator @@ -121,6 +121,19 @@ def first_lwm_plaintext_json_dict(bl_lwm) -> PlaintextFixtureDict: ) +@pytest.fixture +def win_root_shadow_path() -> PureWindowsPath: + return PureWindowsPath( + Path("S:") / "Standing" / "in" / "the" / "shadows" / "of" / "love." + ) + + +@pytest.fixture +def correct_win_path_trunc_str() -> str: + """Correct truncated `str` for `win_root_shadow_path`.""" + return "S:Standing\\*\\*\\*\\*\\love." + + def pytest_sessionfinish(session, exitstatus): """Generate badges for docs after tests finish.""" if exitstatus == 0: diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index 09630f9..cc1ed3b 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -12,10 +12,15 @@ PACKAGE_PATH: str = "." DOCS_PATH_NAME: str = "docs" TESTS_PATH_NAME: str = "tests" +TESTS_CONF_FILE: str = "conftest.py" for path in sorted(Path(PACKAGE_PATH).rglob("*.py")): - if DOCS_PATH_NAME in str(path) or TESTS_PATH_NAME in str(path): + if ( + DOCS_PATH_NAME in str(path) + or TESTS_PATH_NAME in str(path) + or TESTS_CONF_FILE in str(path) + ): continue module_path = path.relative_to(PACKAGE_PATH).with_suffix("") doc_path = path.relative_to(PACKAGE_PATH).with_suffix(".md") diff --git a/tests/test_cli.py b/tests/test_cli.py index c1bccfb..97028a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +from sys import platform import pytest from typer.testing import CliRunner @@ -37,9 +38,10 @@ def test_plaintext_cli(bl_lwm, first_lwm_plaintext_json_dict): assert exported_json[0]["fields"]["path"] == str( first_lwm_plaintext_json_dict["fields"]["path"] ) - assert exported_json[0]["fields"]["compressed_path"] == str( - first_lwm_plaintext_json_dict["fields"]["compressed_path"] - ) + if not platform.startswith("win"): + assert exported_json[0]["fields"]["compressed_path"] == str( + first_lwm_plaintext_json_dict["fields"]["compressed_path"] + ) assert ( exported_json[0]["fields"]["updated_at"] == exported_json[0]["fields"]["updated_at"] diff --git a/tests/test_utils.py b/tests/test_utils.py index d7d3d58..2a36d36 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path, PureWindowsPath import pytest @@ -8,7 +8,10 @@ TableOutputConfigType, download_data, ) -from alto2txt2fixture.utils import check_newspaper_collection_configuration +from alto2txt2fixture.utils import ( + check_newspaper_collection_configuration, + truncate_path_str, +) @pytest.mark.download @@ -37,3 +40,57 @@ def test_check_newspaper_collection_config(capsys) -> None: unmatched: set[str] = check_newspaper_collection_configuration(["cat", "dog"]) assert unmatched == {"cat", "dog"} assert correct_log_prefix in capsys.readouterr().out + + +@pytest.mark.parametrize( + "head_parts, tail_parts", ((500, 0), (0, 500), (-1, 50), (50, -1)) +) +def test_bad_head_tail_logging( + head_parts: int, + tail_parts: int, + win_root_shadow_path: PureWindowsPath, + caplog, +) -> None: + """Test invalid indexing options.""" + test_result: str = truncate_path_str( + path=win_root_shadow_path, + head_parts=head_parts, + tail_parts=tail_parts, + folder_filler_str="*", + path_sep="\\", + max_length=10, + _force_type=PureWindowsPath, + ) + assert PureWindowsPath(test_result) == win_root_shadow_path + if head_parts < 0 or tail_parts < 0: + index_params_error_log: str = ( + "Both index params for `truncate_path_str` must be >=0: " + f"(head_parts={head_parts}, tail_parts={tail_parts})" + ) + assert index_params_error_log in caplog.text + else: + drive_or_absolute_log: str = ( + f"Adding 1 to `head_parts`: {head_parts} to truncate: " + f"'{str(win_root_shadow_path)[:10]}" + ) + truncate_error_log: str = ( + f"Returning untruncated. Params " + f"(head_parts={head_parts + 1}, tail_parts={tail_parts}) " + f"not valid to truncate: '{str(win_root_shadow_path)[:10]}" + ) + assert drive_or_absolute_log in caplog.text + assert truncate_error_log in caplog.text + + +def test_windows_root_path_truncate( + win_root_shadow_path: PureWindowsPath, correct_win_path_trunc_str: str +) -> None: + """Test `truncate_path_str` for a root directory on `PureWindowsPath`.""" + short_root: str = truncate_path_str( + win_root_shadow_path, + folder_filler_str="*", + path_sep="\\", + max_length=10, + _force_type=PureWindowsPath, + ) + assert short_root == correct_win_path_trunc_str From 814159eb2baa4b524cf49fded1bffe82e628f838 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 5 Sep 2023 00:19:20 -0400 Subject: [PATCH 20/23] fix(ci): skip decompress section of `test_cli` for windows for `char` encoding error. --- tests/test_cli.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 97028a5..ace4525 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,17 +31,17 @@ def test_plaintext_cli(bl_lwm, first_lwm_plaintext_json_dict): ) assert exported_json[0]["model"] == "fulltext.fulltext" # assert "DRAPER & OUTFITTER" in exported_json[0]["fields"]["text"] - assert ( - exported_json[0]["fields"]["text"] - == first_lwm_plaintext_json_dict["fields"]["text"] - ) + if not platform.startswith("win"): + assert ( + exported_json[0]["fields"]["text"] + == first_lwm_plaintext_json_dict["fields"]["text"] + ) assert exported_json[0]["fields"]["path"] == str( first_lwm_plaintext_json_dict["fields"]["path"] ) - if not platform.startswith("win"): - assert exported_json[0]["fields"]["compressed_path"] == str( - first_lwm_plaintext_json_dict["fields"]["compressed_path"] - ) + assert exported_json[0]["fields"]["compressed_path"] == str( + first_lwm_plaintext_json_dict["fields"]["compressed_path"] + ) assert ( exported_json[0]["fields"]["updated_at"] == exported_json[0]["fields"]["updated_at"] From ff98859f4d5bee57bcd93194827a01fc757d2ee4 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 5 Sep 2023 01:13:20 -0400 Subject: [PATCH 21/23] fix(ci): update GitHub Actions checkout to `v4.0.0` --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a87cea4..39f5a85 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,7 +22,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: 3.x From ce2eee84f3b430e67b4e1cce6a764d75a891c325 Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 5 Sep 2023 09:05:55 -0400 Subject: [PATCH 22/23] fix(utils): add iteration for `truncate_path_str` to better handle windows paths --- alto2txt2fixture/plaintext.py | 2 +- alto2txt2fixture/utils.py | 16 ++++++++++++---- conftest.py | 6 ++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 60c882e..49a5009 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -299,7 +299,7 @@ def data_provider_name(self) -> str | None: ... path=".") ...`.data_provider` and `.data_provider_code`... - ...are 'None'...in ... + ...are 'None'...in...... >>> plaintext_fixture.data_provider_name ``` diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index 40429af..c2cb9d1 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -1214,12 +1214,20 @@ def truncate_path_str( f"(head_parts={head_parts}, tail_parts={tail_parts})" ) return str(path) - if path.drive or path.is_absolute(): + original_path_parts: tuple[str] = path.parts + head_index_fix: int = 0 + if path.is_absolute() or path.drive: + head_index_fix += 1 + for part in original_path_parts[head_parts + head_index_fix :]: + if not part: + head_index_fix += 1 + else: + break logger.debug( - f"Adding 1 to `head_parts`: {head_parts} " f"to truncate: '{path}'" + f"Adding {head_index_fix} to `head_parts`: {head_parts} " + f"to truncate: '{path}'" ) - head_parts += 1 - original_path_parts: tuple[str] = path.parts + head_parts += head_index_fix try: assert head_parts + tail_parts < len(str(original_path_parts)) except AssertionError: diff --git a/conftest.py b/conftest.py index 9658a80..d04242f 100644 --- a/conftest.py +++ b/conftest.py @@ -123,15 +123,13 @@ def first_lwm_plaintext_json_dict(bl_lwm) -> PlaintextFixtureDict: @pytest.fixture def win_root_shadow_path() -> PureWindowsPath: - return PureWindowsPath( - Path("S:") / "Standing" / "in" / "the" / "shadows" / "of" / "love." - ) + return PureWindowsPath("S:\\\\Standing\\in\\the\\shadows\\of\\love.") @pytest.fixture def correct_win_path_trunc_str() -> str: """Correct truncated `str` for `win_root_shadow_path`.""" - return "S:Standing\\*\\*\\*\\*\\love." + return "S:\\Standing\\*\\*\\*\\*\\love." def pytest_sessionfinish(session, exitstatus): From 5d9396ac5e76b15626cd316c592113d4fd43640c Mon Sep 17 00:00:00 2001 From: Dr Griffith Rees Date: Tue, 5 Sep 2023 14:53:02 -0400 Subject: [PATCH 23/23] fix(test): skip windows on part of `truncate_path_str` via `doctest_auto_fixtures` --- alto2txt2fixture/create_adjacent_tables.py | 6 +++--- alto2txt2fixture/plaintext.py | 6 ++---- alto2txt2fixture/utils.py | 10 +++++----- conftest.py | 16 ++++++++++++++++ tests/test_create_adjacent_tables.py | 4 ++-- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/alto2txt2fixture/create_adjacent_tables.py b/alto2txt2fixture/create_adjacent_tables.py index ff7651f..43d7c1a 100755 --- a/alto2txt2fixture/create_adjacent_tables.py +++ b/alto2txt2fixture/create_adjacent_tables.py @@ -72,7 +72,6 @@ def get_outpaths_dict(names: Sequence[str], module_name: str) -> TableOutputConf Example: ```pycon - >>> from pprint import pprint >>> pprint(get_outpaths_dict(MITCHELLS_TABELS, "mitchells")) {'Entry': {'csv': 'mitchells.Entry.csv', 'json': 'mitchells.Entry.json'}, 'Issue': {'csv': 'mitchells.Issue.csv', 'json': 'mitchells.Issue.json'}, @@ -237,8 +236,9 @@ def download_data( Example: ```pycon - >>> tmp: Path = getfixture('tmpdir') - >>> set_path: Path = tmp.chdir() + >>> from os import chdir + >>> tmp_path: Path = getfixture('tmp_path') + >>> set_path: Path = chdir(tmp_path) >>> download_data(exclude=["mitchells", "Newspaper-1", "linking"]) Excluding mitchells... Excluding Newspaper-1... diff --git a/alto2txt2fixture/plaintext.py b/alto2txt2fixture/plaintext.py index 49a5009..fc3e0f8 100644 --- a/alto2txt2fixture/plaintext.py +++ b/alto2txt2fixture/plaintext.py @@ -527,8 +527,7 @@ def plaintext_paths_to_dicts(self) -> Generator[PlaintextFixtureDict, None, None Example: ```pycon - >>> import sys, pytest - >>> if sys.platform.startswith('win'): + >>> if is_platform_win: ... pytest.skip('current decompression does not work on Windows') >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_extracted') @@ -575,8 +574,7 @@ def export_to_json_fixtures( Example: ```pycon - >>> import sys, pytest - >>> if sys.platform.startswith('win'): + >>> if is_platform_win: ... pytest.skip('current decompression does not work on Windows') >>> bl_lwm: Path = getfixture("bl_lwm") >>> first_lwm_plaintext_json_dict: PlaintextFixtureDict = ( diff --git a/alto2txt2fixture/utils.py b/alto2txt2fixture/utils.py index c2cb9d1..0d5e54d 100644 --- a/alto2txt2fixture/utils.py +++ b/alto2txt2fixture/utils.py @@ -483,7 +483,6 @@ def filter_json_fields( Example: ```pycon - >>> from pprint import pprint >>> entry_fixture: dict = [ ... {"pk": 4889, "model": "mitchells.entry", ... "fields": {"title": "BIRMINGHAM POST .", @@ -989,7 +988,6 @@ def path_globs_to_tuple( Example: ```pycon >>> bl_lwm = getfixture("bl_lwm") - >>> from pprint import pprint >>> pprint(path_globs_to_tuple(bl_lwm, '*text.zip')) (...Path('...bl_lwm...0003079-test_plaintext.zip'), ...Path('...bl_lwm...0003548-test_plaintext.zip')) @@ -1097,17 +1095,17 @@ def compress_fixture( Example: ```pycon - >>> tmpdir: Path = getfixture("tmpdir") + >>> tmp_path: Path = getfixture("tmp_path") >>> plaintext_bl_lwm = getfixture('bl_lwm_plaintext_json_export') ...Compressed configs...%...[...] >>> compress_fixture( ... path=plaintext_bl_lwm._exported_json_paths[0], - ... output_path=tmpdir) + ... output_path=tmp_path) Compressing...plain...-1.json to 'zip' >>> from zipfile import ZipFile, ZipInfo >>> zipfile_info_list: list[ZipInfo] = ZipFile( - ... tmpdir/'plaintext_fixture-1.json.zip' + ... tmp_path / 'plaintext_fixture-1.json.zip' ... ).infolist() >>> len(zipfile_info_list) 1 @@ -1196,6 +1194,8 @@ def truncate_path_str( ...Adding 1... '...Standing...*...*...*...*...love.' + >>> if is_platform_win: + ... pytest.skip('see current issues with Windows root paths') >>> truncate_path_str(root_love_shadows, ... folder_filler_str="*", tail_parts=3) diff --git a/conftest.py b/conftest.py index d04242f..991aa4f 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,6 @@ +import sys from pathlib import Path, PureWindowsPath +from pprint import pprint from shutil import copytree, rmtree from typing import Final, Generator @@ -132,6 +134,20 @@ def correct_win_path_trunc_str() -> str: return "S:\\Standing\\*\\*\\*\\*\\love." +@pytest.fixture() +def is_platform_win() -> bool: + """Check if `sys.platform` is windows.""" + return sys.platform.startswith("win") + + +@pytest.fixture(autouse=True) +def doctest_auto_fixtures(doctest_namespace: dict, is_platform_win: bool) -> None: + """Elements to add to default `doctest` namespace.""" + doctest_namespace["is_platform_win"] = is_platform_win + doctest_namespace["pprint"] = pprint + doctest_namespace["pytest"] = pytest + + def pytest_sessionfinish(session, exitstatus): """Generate badges for docs after tests finish.""" if exitstatus == 0: diff --git a/tests/test_create_adjacent_tables.py b/tests/test_create_adjacent_tables.py index 98ce244..6e14c15 100644 --- a/tests/test_create_adjacent_tables.py +++ b/tests/test_create_adjacent_tables.py @@ -24,11 +24,11 @@ def dict_admin_counties() -> dict[str, list[str]]: @pytest.fixture() -def test_admin_counties_config(tmpdir) -> RemoteDataFilesType: +def test_admin_counties_config(tmp_path) -> RemoteDataFilesType: return { "dict_admin_counties": { "remote": "https://zooniversedata.blob.core.windows.net/downloads/Gazetteer-files/dict_admin_counties.json", - "local": tmpdir / "dict_admin_counties.json", + "local": tmp_path / "dict_admin_counties.json", } }