diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index e5c105cc..211d0309 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -36,7 +36,12 @@ from shelfmark.core.settings_registry import ( register_on_save, register_settings, ) -from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS, DEFAULT_EBOOK_FORMATS +from shelfmark.core.utils import ( + ARCHIVE_FORMATS, + AUDIOBOOK_FORMATS, + DEFAULT_AUDIOBOOK_FORMATS, + DEFAULT_EBOOK_FORMATS, +) _DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT = 60 _DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX = 3600 @@ -503,7 +508,7 @@ def general_settings() -> list[SettingsField]: label="Supported Audiobook Formats", description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.", options=_AUDIOBOOK_FORMAT_OPTIONS, - default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS], + default=list(DEFAULT_AUDIOBOOK_FORMATS), ), ] diff --git a/shelfmark/core/formats.py b/shelfmark/core/formats.py new file mode 100644 index 00000000..52ea7104 --- /dev/null +++ b/shelfmark/core/formats.py @@ -0,0 +1,35 @@ +"""Which file formats are enabled, by content type. + +The one place that reads the Supported Book Formats and Supported Audiobook Formats +settings. It sits in ``core`` and imports nothing heavy, so release sources and +post-processing can both use it without an import cycle. +""" + +from __future__ import annotations + +import shelfmark.core.config as core_config +from shelfmark.core.utils import ( + DEFAULT_AUDIOBOOK_FORMATS, + DEFAULT_EBOOK_FORMATS, + is_audiobook, + normalize_format_list, +) + + +def get_supported_formats() -> list[str]: + """Get current supported book formats from the config singleton.""" + formats = core_config.config.get("SUPPORTED_FORMATS", None) + return normalize_format_list(formats, DEFAULT_EBOOK_FORMATS) + + +def get_supported_audiobook_formats() -> list[str]: + """Get current supported audiobook formats from the config singleton.""" + formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", None) + return normalize_format_list(formats, DEFAULT_AUDIOBOOK_FORMATS) + + +def get_supported_formats_for(content_type: str | None) -> list[str]: + """Supported formats for a task's content type: audiobook formats or book formats.""" + if is_audiobook(content_type): + return get_supported_audiobook_formats() + return get_supported_formats() diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 3c4e8342..8522c7e2 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -146,6 +146,10 @@ ARCHIVE_FORMATS = ("zip", "rar") # registry, post-processing and every release source fall back to this one list. DEFAULT_EBOOK_FORMATS = ("epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr") +# What the Supported Audiobook Formats setting holds until someone changes it: every audio +# format, and the archives a multi-file audiobook usually comes in. Same rule as above. +DEFAULT_AUDIOBOOK_FORMATS = (*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS) + def normalize_format_list(value: object, default: tuple[str, ...] | list[str]) -> list[str]: """Read a configured format list, or a comma or space separated string of them. diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 5653c28a..c30ca0fa 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -123,7 +123,7 @@ class _CompletedPathResolution: retryable: bool -def _coerce_completed_path_timeout_seconds(value: object, default: float) -> float: +def coerce_completed_path_timeout_seconds(value: object, default: float) -> float: if isinstance(value, bool) or value is None: return default if isinstance(value, (int, float)): @@ -332,7 +332,7 @@ class ExternalClientHandler(DownloadHandler, ABC): """Total time to wait for completed files to appear on disk.""" fallback = self._completed_path_retry_interval() * self._completed_path_max_attempts() configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback) - return _coerce_completed_path_timeout_seconds(configured, fallback) + return coerce_completed_path_timeout_seconds(configured, fallback) def _refresh_download_request_after_add_failure( self, diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py index f9bd5892..bbca866b 100644 --- a/shelfmark/download/postprocess/policy.py +++ b/shelfmark/download/postprocess/policy.py @@ -17,7 +17,11 @@ circular imports (`archive` is used by the pipeline). from __future__ import annotations import shelfmark.core.config as core_config -from shelfmark.core.utils import DEFAULT_EBOOK_FORMATS, normalize_format_list +from shelfmark.core.formats import ( + get_supported_audiobook_formats, + get_supported_formats, + get_supported_formats_for, +) def _config_text(value: object) -> str: @@ -26,17 +30,11 @@ def _config_text(value: object) -> str: return "" -def get_supported_formats() -> list[str]: - """Get current supported formats from config singleton.""" - formats = core_config.config.get("SUPPORTED_FORMATS", None) - return normalize_format_list(formats, DEFAULT_EBOOK_FORMATS) - - -def get_supported_audiobook_formats() -> list[str]: - """Get current supported audiobook formats from config singleton.""" - default_formats = ["m4b", "mp3"] - formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", default_formats) - return normalize_format_list(formats, default_formats) +__all__ = [ + "get_supported_audiobook_formats", + "get_supported_formats", + "get_supported_formats_for", +] def get_file_organization(*, is_audiobook: bool) -> str: diff --git a/shelfmark/download/postprocess/scan.py b/shelfmark/download/postprocess/scan.py index a8fa1b7a..143f76b4 100644 --- a/shelfmark/download/postprocess/scan.py +++ b/shelfmark/download/postprocess/scan.py @@ -13,10 +13,7 @@ from shelfmark.download.archive import ArchiveExtractionError, extract_archive, from shelfmark.download.fs import run_blocking_io from shelfmark.download.permissions_debug import log_path_permission_context from shelfmark.download.postprocess.policy import ( - get_supported_audiobook_formats, -) -from shelfmark.download.postprocess.policy import ( - get_supported_formats as get_book_formats, + get_supported_formats_for as get_supported_formats, ) from shelfmark.download.staging import build_staging_dir @@ -28,13 +25,6 @@ if TYPE_CHECKING: logger = setup_logger("shelfmark.download.postprocess.pipeline") -def get_supported_formats(content_type: str | None = None) -> list[str]: - """Return supported file extensions for the requested content type.""" - if check_audiobook(content_type): - return get_supported_audiobook_formats() - return get_book_formats() - - def _format_not_supported_error(rejected_files: list[Path], task: DownloadTask) -> str: content_type = task.content_type file_type_label = "audiobook" if check_audiobook(content_type) else "book" diff --git a/shelfmark/release_sources/direct_download/common.py b/shelfmark/release_sources/direct_download/common.py index f68d77e7..9c720d4b 100644 --- a/shelfmark/release_sources/direct_download/common.py +++ b/shelfmark/release_sources/direct_download/common.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable from bs4 import BeautifulSoup, Tag -from shelfmark.core.config import config +from shelfmark.core.formats import get_supported_formats as _configured_book_formats from shelfmark.core.languages import language_alias_map from shelfmark.release_sources import BrowseRecord, SourceUnavailableError @@ -40,7 +40,7 @@ def coerce_str_list(value: object) -> list[str]: def get_supported_formats() -> list[str]: """Return configured supported formats as a clean string list.""" - return coerce_str_list(config.SUPPORTED_FORMATS) + return _configured_book_formats() def html_response_text(response: str | tuple[str, str]) -> str: diff --git a/shelfmark/release_sources/irc/parser.py b/shelfmark/release_sources/irc/parser.py index 18b27115..009bbe57 100644 --- a/shelfmark/release_sources/irc/parser.py +++ b/shelfmark/release_sources/irc/parser.py @@ -8,14 +8,9 @@ import zipfile from dataclasses import dataclass from typing import TYPE_CHECKING -from shelfmark.core.config import config +from shelfmark.core.formats import get_supported_formats_for from shelfmark.core.logger import setup_logger -from shelfmark.core.utils import ( - ARCHIVE_FORMATS, - AUDIOBOOK_FORMATS, - DEFAULT_EBOOK_FORMATS, - normalize_format_list, -) +from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS from shelfmark.core.utils import is_audiobook as check_audiobook if TYPE_CHECKING: @@ -58,10 +53,7 @@ ALL_RECOGNIZED_FORMATS = tuple( def _get_supported_formats(content_type: str | None = None) -> set[str]: """Get the supported formats for the requested content type.""" - if check_audiobook(content_type): - formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", None) - return set(normalize_format_list(formats, ["m4b", "mp3"])) - return set(normalize_format_list(config.get("SUPPORTED_FORMATS", None), DEFAULT_EBOOK_FORMATS)) + return set(get_supported_formats_for(content_type)) # Regex to parse result lines diff --git a/shelfmark/release_sources/newznab/source.py b/shelfmark/release_sources/newznab/source.py index 43425406..2bdf8012 100644 --- a/shelfmark/release_sources/newznab/source.py +++ b/shelfmark/release_sources/newznab/source.py @@ -38,7 +38,7 @@ from shelfmark.release_sources.prowlarr.source import ( # Re-use the Prowlarr source helpers — they operate on generic result dicts. from shelfmark.release_sources.prowlarr.source import ( _detect_content_type_from_categories, - _parse_size, + parse_size, ) logger = setup_logger(__name__) @@ -246,7 +246,7 @@ def _newznab_result_to_release( title=raw_title, format=None, language=None, - size=_parse_size(size_bytes), + size=parse_size(size_bytes), size_bytes=size_bytes, download_url=None, info_url=info_url, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 024c07bc..c419f223 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -15,12 +15,13 @@ if TYPE_CHECKING: from shelfmark.core.author_match import AUTHOR_UNKNOWN, author_affinity from shelfmark.core.config import config +from shelfmark.core.formats import get_supported_formats from shelfmark.core.languages import normalize_language from shelfmark.core.logger import setup_logger from shelfmark.core.request_helpers import coerce_int, normalize_optional_text from shelfmark.core.search_plan import ReleaseSearchVariant from shelfmark.core.utils import AUDIOBOOK_FORMATS as CORE_AUDIOBOOK_FORMATS -from shelfmark.core.utils import DEFAULT_EBOOK_FORMATS, normalize_format_list, normalize_http_url +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources import ( ColumnAlign, ColumnColorHint, @@ -309,7 +310,7 @@ def _is_over_size(result: dict, max_bytes: int | None) -> bool: return size > max_bytes -def _parse_size(size_bytes: int | None) -> str | None: +def parse_size(size_bytes: int | None) -> str | None: """Convert bytes to human-readable size string.""" if size_bytes is None or size_bytes <= 0: return None @@ -395,8 +396,7 @@ def _unsupported_title_extension(title: str, content_type: str) -> str | None: extension = match.group(1) if extension not in _KNOWN_FILE_EXTENSIONS: return None - supported = normalize_format_list(config.get("SUPPORTED_FORMATS", None), DEFAULT_EBOOK_FORMATS) - if extension in supported: + if extension in get_supported_formats(): return None return extension @@ -720,7 +720,7 @@ def _prowlarr_result_to_release( title=title, format=format_detected, language=language_detected, - size=_parse_size(size_bytes), + size=parse_size(size_bytes), size_bytes=size_bytes, download_url=None, info_url=result.get("infoUrl") or result.get("guid"), diff --git a/shelfmark/release_sources/slskd/handler.py b/shelfmark/release_sources/slskd/handler.py index 46413f83..52431476 100644 --- a/shelfmark/release_sources/slskd/handler.py +++ b/shelfmark/release_sources/slskd/handler.py @@ -35,7 +35,7 @@ from shelfmark.download.clients.base_handler import ( ) from shelfmark.download.clients.base_handler import ( COMPLETED_PATH_TIMEOUT_SETTING, - _coerce_completed_path_timeout_seconds, + coerce_completed_path_timeout_seconds, ) from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.packs import PackFile @@ -426,7 +426,7 @@ class SlskdHandler(DownloadHandler): def _completed_path_timeout_seconds(self) -> float: fallback = self._completed_path_retry_interval() * COMPLETED_PATH_MAX_ATTEMPTS - return _coerce_completed_path_timeout_seconds( + return coerce_completed_path_timeout_seconds( config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback), fallback ) diff --git a/shelfmark/release_sources/slskd/source.py b/shelfmark/release_sources/slskd/source.py index 3c8b6dd4..c1018248 100644 --- a/shelfmark/release_sources/slskd/source.py +++ b/shelfmark/release_sources/slskd/source.py @@ -18,12 +18,10 @@ if TYPE_CHECKING: from shelfmark.metadata_providers import BookMetadata from shelfmark.core.config import config +from shelfmark.core.formats import get_supported_formats_for from shelfmark.core.logger import setup_logger from shelfmark.core.utils import ( ARCHIVE_FORMATS, - AUDIOBOOK_FORMATS, - DEFAULT_EBOOK_FORMATS, - normalize_format_list, normalize_http_url, ) from shelfmark.core.utils import is_audiobook as check_audiobook @@ -43,7 +41,7 @@ from shelfmark.release_sources import ( SourceUnavailableError, register_source, ) -from shelfmark.release_sources.prowlarr.source import _parse_size +from shelfmark.release_sources.prowlarr.source import parse_size from shelfmark.release_sources.slskd.api import SlskdClient logger = setup_logger(__name__) @@ -60,7 +58,6 @@ MAX_QUERY_VARIANTS = 3 # Whole-book audio files: one file holds the entire book, unlike chapter files. CONTAINER_FORMATS = frozenset({"m4b", "m4a"}) -_DEFAULT_AUDIOBOOK_FORMATS = [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS] @dataclass(frozen=True) @@ -133,13 +130,7 @@ def make_source_id(username: str, remote_path: str) -> str: def get_supported_formats(content_type: str | None) -> set[str]: """Return the configured formats for the requested content type.""" - if check_audiobook(content_type): - return set( - normalize_format_list( - config.get("SUPPORTED_AUDIOBOOK_FORMATS", None), _DEFAULT_AUDIOBOOK_FORMATS - ) - ) - return set(normalize_format_list(config.get("SUPPORTED_FORMATS", None), DEFAULT_EBOOK_FORMATS)) + return set(get_supported_formats_for(content_type)) def _coerce_int(value: object) -> int: @@ -232,7 +223,7 @@ def file_release(peer_file: PeerFile, response: dict[str, Any], content_type: st title=_strip_extension(peer_file.basename), format=peer_file.extension or None, language=None, - size=_parse_size(peer_file.size), + size=parse_size(peer_file.size), size_bytes=peer_file.size or None, download_url=None, info_url=None, @@ -272,7 +263,7 @@ def directory_release( title=f"{directory_name} ({len(files)} files)", format=formats[0] if formats else None, language=None, - size=_parse_size(total_size), + size=parse_size(total_size), size_bytes=total_size or None, download_url=None, info_url=None, diff --git a/tests/core/test_format_defaults.py b/tests/core/test_format_defaults.py index d97bfadf..2569ce38 100644 --- a/tests/core/test_format_defaults.py +++ b/tests/core/test_format_defaults.py @@ -3,7 +3,12 @@ import pytest from shelfmark.config import settings as app_settings -from shelfmark.core.utils import DEFAULT_EBOOK_FORMATS, normalize_format_list +from shelfmark.core.formats import get_supported_formats_for +from shelfmark.core.utils import ( + DEFAULT_AUDIOBOOK_FORMATS, + DEFAULT_EBOOK_FORMATS, + normalize_format_list, +) from shelfmark.download.postprocess import policy DEFAULT = ["epub", "mobi"] @@ -44,3 +49,49 @@ def test_post_processing_falls_back_to_the_shared_default(monkeypatch): monkeypatch.setattr("shelfmark.core.config.config", _Config()) assert policy.get_supported_formats() == list(DEFAULT_EBOOK_FORMATS) + + +def test_the_audiobook_registry_default_is_the_shared_default(): + field = next( + f + for f in app_settings.general_settings() + if getattr(f, "key", "") == "SUPPORTED_AUDIOBOOK_FORMATS" + ) + + assert field.default == list(DEFAULT_AUDIOBOOK_FORMATS) + + +class TestEveryReaderFallsBackToTheSameDefaults: + """Post-processing and the sources each read the formats; none may keep its own default.""" + + @pytest.fixture(autouse=True) + def unset_config(self, monkeypatch): + class _Config: + def get(self, key, default=None, user_id=None): + return default + + monkeypatch.setattr("shelfmark.core.config.config", _Config()) + + def test_post_processing(self): + assert policy.get_supported_formats() == list(DEFAULT_EBOOK_FORMATS) + assert policy.get_supported_audiobook_formats() == list(DEFAULT_AUDIOBOOK_FORMATS) + + @pytest.mark.parametrize( + ("content_type", "expected"), + [ + (None, DEFAULT_EBOOK_FORMATS), + ("book (fiction)", DEFAULT_EBOOK_FORMATS), + ("audiobook", DEFAULT_AUDIOBOOK_FORMATS), + ], + ) + def test_the_lookup_follows_the_content_type(self, content_type, expected): + assert get_supported_formats_for(content_type) == list(expected) + + def test_irc_and_slskd(self): + from shelfmark.release_sources.irc.parser import _get_supported_formats + from shelfmark.release_sources.slskd.source import get_supported_formats + + assert _get_supported_formats("audiobook") == set(DEFAULT_AUDIOBOOK_FORMATS) + assert get_supported_formats("audiobook") == set(DEFAULT_AUDIOBOOK_FORMATS) + assert _get_supported_formats("ebook") == set(DEFAULT_EBOOK_FORMATS) + assert get_supported_formats("ebook") == set(DEFAULT_EBOOK_FORMATS) diff --git a/tests/download/test_postprocess_scan_archives.py b/tests/download/test_postprocess_scan_archives.py index 496e172b..29222d27 100644 --- a/tests/download/test_postprocess_scan_archives.py +++ b/tests/download/test_postprocess_scan_archives.py @@ -2,13 +2,14 @@ from __future__ import annotations import zipfile +from shelfmark.core import formats as formats_mod from shelfmark.core.models import DownloadTask -from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS +from shelfmark.core.utils import DEFAULT_AUDIOBOOK_FORMATS from shelfmark.download import archive as archive_mod from shelfmark.download.postprocess import scan as scan_mod # The shipped default for SUPPORTED_AUDIOBOOK_FORMATS, which includes archives. -_DEFAULT_AUDIOBOOK_FORMATS = [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS] +_DEFAULT_AUDIOBOOK_FORMATS = list(DEFAULT_AUDIOBOOK_FORMATS) def _audiobook_task() -> DownloadTask: @@ -17,7 +18,7 @@ def _audiobook_task() -> DownloadTask: def _use_default_audiobook_formats(monkeypatch, tmp_path) -> None: monkeypatch.setattr( - scan_mod, "get_supported_audiobook_formats", lambda: list(_DEFAULT_AUDIOBOOK_FORMATS) + formats_mod, "get_supported_audiobook_formats", lambda: list(_DEFAULT_AUDIOBOOK_FORMATS) ) monkeypatch.setattr( archive_mod, "get_supported_audiobook_formats", lambda: list(_DEFAULT_AUDIOBOOK_FORMATS) diff --git a/tests/irc/test_parser.py b/tests/irc/test_parser.py index ade17f38..c0d44da9 100644 --- a/tests/irc/test_parser.py +++ b/tests/irc/test_parser.py @@ -1,5 +1,6 @@ import pytest +from shelfmark.core.config import config from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS from shelfmark.release_sources.irc import parser @@ -13,9 +14,7 @@ _DEFAULT_CONFIG = { @pytest.fixture def default_formats(monkeypatch): """Filter with the shipped default format lists.""" - monkeypatch.setattr( - parser.config, "get", lambda key, default=None: _DEFAULT_CONFIG.get(key, default) - ) + monkeypatch.setattr(config, "get", lambda key, default=None: _DEFAULT_CONFIG.get(key, default)) def test_audiobook_archives_are_found_with_default_settings(default_formats): @@ -98,7 +97,7 @@ def test_parse_results_file_uses_audiobook_format_settings(monkeypatch): "SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"], } - monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default)) + monkeypatch.setattr(config, "get", lambda key, default=None: values.get(key, default)) content = "\n".join( [ @@ -119,7 +118,7 @@ def test_parse_results_file_uses_book_format_settings_for_ebooks(monkeypatch): "SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"], } - monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default)) + monkeypatch.setattr(config, "get", lambda key, default=None: values.get(key, default)) content = "\n".join( [ diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index c661b256..cfac7b41 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -22,11 +22,11 @@ from shelfmark.release_sources.prowlarr.source import ( _extract_mam_language, _fetch_indexer_seed_settings, _last_known_seed_settings, - _parse_size, _release_identity, _result_dedup_key, _search_budget_seconds, _unsupported_title_extension, + parse_size, ) from shelfmark.release_sources.prowlarr.utils import ( build_source_id, @@ -50,45 +50,45 @@ def source_available_by_default(monkeypatch): class TestParseSize: - """Tests for the _parse_size function.""" + """Tests for the parse_size function.""" def test_parse_size_bytes(self): """Test parsing small byte sizes.""" - assert _parse_size(100) == "100 B" - assert _parse_size(512) == "512 B" + assert parse_size(100) == "100 B" + assert parse_size(512) == "512 B" def test_parse_size_kilobytes(self): """Test parsing kilobyte sizes.""" - assert _parse_size(1024) == "1.0 KB" - assert _parse_size(2048) == "2.0 KB" - assert _parse_size(1536) == "1.5 KB" + assert parse_size(1024) == "1.0 KB" + assert parse_size(2048) == "2.0 KB" + assert parse_size(1536) == "1.5 KB" def test_parse_size_megabytes(self): """Test parsing megabyte sizes.""" - assert _parse_size(1048576) == "1.0 MB" - assert _parse_size(5242880) == "5.0 MB" - assert _parse_size(1572864) == "1.5 MB" + assert parse_size(1048576) == "1.0 MB" + assert parse_size(5242880) == "5.0 MB" + assert parse_size(1572864) == "1.5 MB" def test_parse_size_gigabytes(self): """Test parsing gigabyte sizes.""" - assert _parse_size(1073741824) == "1.0 GB" - assert _parse_size(2147483648) == "2.0 GB" + assert parse_size(1073741824) == "1.0 GB" + assert parse_size(2147483648) == "2.0 GB" def test_parse_size_terabytes(self): """Test parsing terabyte sizes.""" - assert _parse_size(1099511627776) == "1.0 TB" + assert parse_size(1099511627776) == "1.0 TB" def test_parse_size_none(self): """Test that None returns None.""" - assert _parse_size(None) is None + assert parse_size(None) is None def test_parse_size_zero(self): """Test that zero returns None.""" - assert _parse_size(0) is None + assert parse_size(0) is None def test_parse_size_negative(self): """Test that negative values return None.""" - assert _parse_size(-100) is None + assert parse_size(-100) is None class TestExtractFormat: