diff --git a/darkseid/metadata/base_handler.py b/darkseid/metadata/base_handler.py
index bff82e6..41c7f76 100644
--- a/darkseid/metadata/base_handler.py
+++ b/darkseid/metadata/base_handler.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
import xml.etree.ElementTree as ET
from abc import ABC, abstractmethod
from datetime import UTC, date, datetime
@@ -11,10 +12,15 @@
from defusedxml.ElementTree import parse
+from darkseid.metadata.data_classes import validate_language_tag
+
if TYPE_CHECKING:
from darkseid.metadata.data_classes import Metadata
+logger = logging.getLogger(__name__)
+
+
class XmlError(Exception):
"""Class for an XML error."""
@@ -214,6 +220,23 @@ def _parse_int(value: str | None) -> int | None:
"""
return int(value) if value and value.isdigit() else None
+ @staticmethod
+ def _parse_language(value: str | None) -> str | None:
+ """Safely parse a language tag or name to a normalized BCP 47 tag.
+
+ Args:
+ value: Language value to parse.
+
+ Returns:
+ Normalized language tag, or None if the value is empty or not a known language.
+
+ """
+ try:
+ return validate_language_tag(value)
+ except ValueError:
+ logger.warning("Dropping unknown language: %s", value)
+ return None
+
@staticmethod
def _parse_date(value: str | None) -> date | None:
"""Safely parse string to a date.
diff --git a/darkseid/metadata/comicinfo.py b/darkseid/metadata/comicinfo.py
index 5c20262..eaced76 100644
--- a/darkseid/metadata/comicinfo.py
+++ b/darkseid/metadata/comicinfo.py
@@ -440,7 +440,7 @@ def _convert_xml_to_metadata(self, tree: ET.ElementTree) -> Metadata: # noqa: C
md.genres = self._string_to_resource(self._get_text_content(root, "Genre"))
md.web_link = self._parse_urls(self._get_text_content(root, "Web"))
- md.series.language = self._get_text_content(root, "LanguageISO")
+ md.series.language = self._parse_language(self._get_text_content(root, "LanguageISO"))
md.series.format = self._get_text_content(root, "Format")
md.manga = self._get_text_content(root, "Manga")
md.characters = self._string_to_resource(self._get_text_content(root, "Characters"))
diff --git a/darkseid/metadata/data_classes.py b/darkseid/metadata/data_classes.py
index d3a6a76..172882f 100644
--- a/darkseid/metadata/data_classes.py
+++ b/darkseid/metadata/data_classes.py
@@ -17,8 +17,11 @@
if TYPE_CHECKING:
from datetime import date, datetime
+ from pycountry.db import Data
+
import logging
+import re
import pycountry
@@ -28,6 +31,7 @@
MAX_UPC = 17
MAX_ISBN = 13
COUNTRY_LEN = 2
+LANGUAGE_ALPHA_3_LEN = 3
CURRENCY_LEN = 3
YEAR_LEN = 4
@@ -50,6 +54,11 @@
_COUNTRY_TO_CURRENCY: dict[str, str] = {v: k for k, v in _CURRENCY_TO_COUNTRY.items()}
+# BCP 47 subtags that may follow the primary language subtag
+_SCRIPT_SUBTAG_RE = re.compile(r"[a-zA-Z]{4}")
+_REGION_SUBTAG_RE = re.compile(r"[a-zA-Z]{2}|\d{3}")
+_VARIANT_SUBTAG_RE = re.compile(r"[a-zA-Z0-9]{5,8}|\d[a-zA-Z0-9]{3}")
+
def currency_to_country(currency_code: str) -> str | None:
"""Convert an ISO 4217 currency code to an ISO 3166-1 alpha-2 country code.
@@ -77,6 +86,98 @@ def country_to_currency(country_code: str) -> str | None:
return _COUNTRY_TO_CURRENCY.get(country_code)
+def _get_language(code: str) -> Data | None:
+ """Get a language by its ISO 639-1, ISO 639-3 or ISO 639-2/B code."""
+ if len(code) == COUNTRY_LEN:
+ return pycountry.languages.get(alpha_2=code)
+ if len(code) == LANGUAGE_ALPHA_3_LEN:
+ return pycountry.languages.get(alpha_3=code) or pycountry.languages.get(bibliographic=code)
+ return None
+
+
+def _normalize_language_subtags(subtags: list[str]) -> list[str] | None:
+ """Validate and normalize the subtags following the primary language subtag.
+
+ Subtags must be in BCP 47 order: an optional script, an optional region, then any
+ variants. Scripts and alphabetic regions must be known ISO 15924 and ISO 3166-1 codes.
+
+ Returns:
+ The subtags in their canonical case, or None if they are malformed.
+
+ """
+ normalized = []
+ remaining = iter(subtags)
+ subtag = next(remaining, None)
+ if subtag and _SCRIPT_SUBTAG_RE.fullmatch(subtag):
+ if pycountry.scripts.get(alpha_4=subtag) is None:
+ return None
+ normalized.append(subtag.title())
+ subtag = next(remaining, None)
+ if subtag and _REGION_SUBTAG_RE.fullmatch(subtag):
+ if subtag.isalpha() and pycountry.countries.get(alpha_2=subtag) is None:
+ return None
+ normalized.append(subtag.upper())
+ subtag = next(remaining, None)
+ while subtag is not None:
+ if not _VARIANT_SUBTAG_RE.fullmatch(subtag):
+ return None
+ normalized.append(subtag.lower())
+ subtag = next(remaining, None)
+ return normalized
+
+
+def validate_language_tag(value: str | None) -> str | None:
+ """Validate and normalize a BCP 47 language tag.
+
+ The value is first looked up as a language code or name (e.g. "ger", "English", "Ai-Cham"),
+ then parsed as a tag. The primary language subtag is normalized to its ISO 639-1 code,
+ falling back to the ISO 639-3 code for languages without one (e.g. "fil"). Script, region
+ and variant subtags (e.g. "pt-BR", "zh-Hant") are normalized to their canonical case.
+ Extended language, extension and private use subtags aren't supported.
+
+ Args:
+ value: The language tag or name to validate.
+
+ Returns:
+ The normalized language tag, or None if the value is empty.
+
+ Raises:
+ ValueError: Raised when the language cannot be found or the subtags are malformed.
+
+ """
+ if not value:
+ return None
+ value = value.strip()
+
+ # Codes are checked before lookup(), which can match a language name equal to another
+ # language's code (e.g. "En" is a language name, not just English's ISO 639-1 code).
+ try:
+ obj = _get_language(value) or pycountry.languages.lookup(value)
+ subtags = []
+ except LookupError:
+ primary, *rest = value.split("-")
+ obj = _get_language(primary)
+ subtags = _normalize_language_subtags(rest) if rest else []
+ if obj is None or subtags is None:
+ msg = f"Couldn't find language {value}"
+ raise ValueError(msg)
+
+ code = getattr(obj, "alpha_2", None) or obj.alpha_3
+ return "-".join([code, *subtags])
+
+
+def _validate_year(value: int | None, label: str) -> int | None:
+ """Validate a four-digit year, using the label in the error message."""
+ if not value:
+ return None
+
+ if len(str(value)) == YEAR_LEN:
+ return value
+
+ msg = f"{label}: {value} length must be {YEAR_LEN}"
+ raise ValueError(msg)
+
+
class Validations:
"""A base class for data validation in dataclasses.
@@ -264,7 +365,7 @@ class AlternativeNames(Basic, Validations):
Attributes:
name (str): The alternative name for a series.
id_ (int | None): The ID associated with the alternative name, defaults to None.
- language (str | None): The 2-letter ISO code of the language, defaults to None.
+ language (str | None): The BCP 47 language tag, defaults to None.
Static Methods:
validate_language(value: str, **_: any) -> str | None: Validates a language value.
@@ -277,38 +378,18 @@ class AlternativeNames(Basic, Validations):
def validate_language(value: str, **_: any) -> str | None:
"""Validate a language value.
- If the value is empty, it returns None. Otherwise, it strips any leading or trailing
- whitespace from the value. If the length of the value is 2, it tries to find the
- language object using the alpha-2 code. Otherwise, it tries to look up the language
- object using the value. If the language object is not found, it raises a ValueError.
-
Args:
- value (str): The language value to validate.
+ value (str): The BCP 47 language tag or language name to validate.
**_ (any): Additional keyword arguments (ignored).
Returns:
- Optional[str]: The validated language code, or None if the value is empty.
+ Optional[str]: The normalized language tag, or None if the value is empty.
Raises:
- ValueError: Raised when the language object cannot be found.
+ ValueError: Raised when the language cannot be found.
"""
- if not value:
- return None
- value = value.strip()
-
- if len(value) == COUNTRY_LEN:
- obj = pycountry.languages.get(alpha_2=value)
- else:
- try:
- obj = pycountry.languages.lookup(value)
- except LookupError as e:
- msg = f"Couldn't find language {value}"
- raise ValueError(msg) from e
- if obj is None:
- msg = f"Couldn't find language {value}"
- raise ValueError(msg)
- return obj.alpha_2
+ return validate_language_tag(value)
@dataclass
@@ -325,7 +406,10 @@ class Series(Basic, Validations):
issue_count (Optional[int]): The count of issues.
volume_count (int | None): The count of volumes.
alternative_names: list[AlternativeNames]: A list of alternative names for series.
- language (str | None): The 2-letter ISO code of the language, defaults to None.
+ language (str | None): The BCP 47 language tag, defaults to None.
+ end_year (int | None): The year that the series ended in. A 4 digit value.
+ status (str | None): The publication status of the series (e.g. Ongoing, Completed).
+ summary (str | None): A description of the series.
Static Methods:
validate_language(value: str, **_: any) -> str | None: Validates a language value.
@@ -339,7 +423,10 @@ class Series(Basic, Validations):
issue_count: int | None = None
volume_count: int | None = None
alternative_names: list[AlternativeNames] = field(default_factory=list)
- language: str | None = None # 2-letter iso code
+ language: str | None = None # BCP 47 language tag
+ end_year: int | None = None
+ status: str | None = None
+ summary: str | None = None
@staticmethod
def validate_start_year(value: int, **_: any) -> int | None:
@@ -359,51 +446,56 @@ def validate_start_year(value: int, **_: any) -> int | None:
ValueError: Raised when the year is not exactly four digits.
"""
- if not value:
- return None
+ return _validate_year(value, "Start year")
- if len(str(value)) == YEAR_LEN:
- return value
+ @staticmethod
+ def validate_end_year(value: int, **_: any) -> int | None:
+ """Validate an end year value.
- msg = f"Year: {value} length must be {YEAR_LEN}"
- raise ValueError(msg)
+ This method checks if the provided value is a valid four-digit year. If the value is not present,
+ it returns None. If the value is not exactly four digits, it raises a ValueError.
+
+ Args:
+ value (int): The year value to validate.
+ **_ (any): Additional keyword arguments (ignored).
+
+ Returns:
+ Optional[int]: The validated year value, or None if the value is not present.
+
+ Raises:
+ ValueError: Raised when the year is not exactly four digits.
+
+ """
+ return _validate_year(value, "End year")
+
+ def __post_init__(self: Series) -> None:
+ """Validate the series fields and check the end year doesn't precede the start year.
+
+ Raises:
+ ValueError: Raised when a field is invalid or the end year precedes the start year.
+
+ """
+ super().__post_init__()
+ if self.start_year and self.end_year and self.end_year < self.start_year:
+ msg = f"End year: {self.end_year} can't precede start year: {self.start_year}"
+ raise ValueError(msg)
@staticmethod
def validate_language(value: str, **_: any) -> str | None:
"""Validate a language value.
- If the value is empty, it returns None. Otherwise, it strips any leading or trailing
- whitespace from the value. If the length of the value is 2, it tries to find the
- language object using the alpha-2 code. Otherwise, it tries to look up the language
- object using the value. If the language object is not found, it raises a ValueError.
-
Args:
- value (str): The language value to validate.
+ value (str): The BCP 47 language tag or language name to validate.
**_ (any): Additional keyword arguments (ignored).
Returns:
- Optional[str]: The validated language code, or None if the value is empty.
+ Optional[str]: The normalized language tag, or None if the value is empty.
Raises:
- ValueError: Raised when the language object cannot be found.
+ ValueError: Raised when the language cannot be found.
"""
- if not value:
- return None
- value = value.strip()
-
- if len(value) == COUNTRY_LEN:
- obj = pycountry.languages.get(alpha_2=value)
- else:
- try:
- obj = pycountry.languages.lookup(value)
- except LookupError as e:
- msg = f"Couldn't find language {value}"
- raise ValueError(msg) from e
- if obj is None:
- msg = f"Couldn't find language {value}"
- raise ValueError(msg)
- return obj.alpha_2
+ return validate_language_tag(value)
@dataclass
@@ -1015,7 +1107,8 @@ def __str__(self: Metadata) -> str: # noqa: PLR0912
if self.series.volume:
series_info += f" (v{self.series.volume})"
if self.series.start_year:
- series_info += f" [{self.series.start_year}]"
+ end_year = f"-{self.series.end_year}" if self.series.end_year else ""
+ series_info += f" [{self.series.start_year}{end_year}]"
lines.append(f"{indent}Series: {series_info}")
if self.issue:
diff --git a/darkseid/metadata/metroninfo.py b/darkseid/metadata/metroninfo.py
index 3f121ef..8abdddb 100644
--- a/darkseid/metadata/metroninfo.py
+++ b/darkseid/metadata/metroninfo.py
@@ -36,6 +36,7 @@
)
if TYPE_CHECKING:
+ from collections.abc import Mapping
from datetime import date
# Constants
@@ -151,6 +152,14 @@
"Trade Paperback": frozenset({"trade paperback", "tpb", "trade paper back"}),
}
+# Series status mappings
+STATUS_MAPPINGS = {
+ "Cancelled": frozenset({"cancelled"}),
+ "Completed": frozenset({"completed"}),
+ "Hiatus": frozenset({"hiatus"}),
+ "Ongoing": frozenset({"ongoing"}),
+}
+
class MetronInfo(BaseMetadataHandler):
"""A class to manage comic metadata and its MetronInfo XML representation.
@@ -162,7 +171,7 @@ class MetronInfo(BaseMetadataHandler):
def __init__(self) -> None:
"""Initialize the MetronInfo instance."""
self._schema_path = (
- Path(__file__).parent.parent / "schemas" / "MetronInfo" / "v1_1" / "MetronInfo.xsd"
+ Path(__file__).parent.parent / "schemas" / "MetronInfo" / "v1_2" / "MetronInfo.xsd"
)
def metadata_from_string(self, xml_string: str) -> Metadata:
@@ -278,13 +287,13 @@ def _get_root(xml_bytes: bytes | None) -> ET.Element:
}
root.attrib["xmlns:metroninfo"] = (
- "https://metron-project.github.io/docs/metroninfo/schemas/v1.1"
+ "https://metron-project.github.io/docs/metroninfo/schemas/v1.2"
)
root.attrib["xmlns:xsd"] = "http://www.w3.org/2001/XMLSchema"
root.attrib["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib["xsi:schemaLocation"] = (
- "https://metron-project.github.io/docs/metroninfo/schemas/v1.1 "
- "https://raw.githubusercontent.com/Metron-Project/metroninfo/refs/heads/master/schema/v1.1/MetronInfo.xsd"
+ "https://metron-project.github.io/docs/metroninfo/schemas/v1.2 "
+ "https://raw.githubusercontent.com/Metron-Project/metroninfo/refs/heads/master/schema/v1.2/MetronInfo.xsd"
)
return root
@@ -337,6 +346,27 @@ def _build_id_attrib(id_: int | str | None) -> dict[str, str]:
"""
return {"id": str(id_)} if id_ else {}
+ @staticmethod
+ def _match_mapping(value: str | None, mappings: Mapping[str, frozenset[str]]) -> str | None:
+ """Match a value case-insensitively against a mapping of canonical values to synonyms.
+
+ Args:
+ value: Value to match.
+ mappings: Mapping of canonical values to their lowercase synonyms.
+
+ Returns:
+ The canonical value whose synonyms include the value, or None.
+
+ """
+ if not value:
+ return None
+
+ lower_val = value.strip().lower()
+ return next(
+ (canonical for canonical, synonyms in mappings.items() if lower_val in synonyms),
+ None,
+ )
+
@staticmethod
def _normalize_age_rating(age_rating: AgeRatings | None) -> str | None:
"""Normalize age rating to valid MetronInfo format.
@@ -358,13 +388,7 @@ def _normalize_age_rating(age_rating: AgeRatings | None) -> str | None:
else age_rating.metron_info
)
- if age_rating.comic_rack:
- lower_val = age_rating.comic_rack.lower()
- for rating, synonyms in RATING_MAPPINGS.items():
- if lower_val in synonyms:
- return rating
-
- return None
+ return MetronInfo._match_mapping(age_rating.comic_rack, RATING_MAPPINGS)
@staticmethod
def _normalize_series_format(format_str: str | None) -> str | None:
@@ -377,14 +401,20 @@ def _normalize_series_format(format_str: str | None) -> str | None:
Normalized format or None.
"""
- if not format_str:
- return None
+ return MetronInfo._match_mapping(format_str, FORMAT_MAPPINGS)
- lower_val = format_str.strip().lower()
- return next(
- (fmt for fmt, synonyms in FORMAT_MAPPINGS.items() if lower_val in synonyms),
- None,
- )
+ @staticmethod
+ def _normalize_series_status(status: str | None) -> str | None:
+ """Normalize series status to valid MetronInfo status.
+
+ Args:
+ status: Status string to normalize.
+
+ Returns:
+ Normalized status or None.
+
+ """
+ return MetronInfo._match_mapping(status, STATUS_MAPPINGS)
@staticmethod
def _set_date_element(root: ET.Element, tag: str, date_val: date | None = None) -> None:
@@ -505,10 +535,18 @@ def _add_series(self, root: ET.Element, series: Series | None) -> None: # noqa:
if series_format is not None:
ET.SubElement(series_node, "Format").text = series_format
- if series.start_year:
- ET.SubElement(series_node, "StartYear").text = str(series.start_year)
- elif series.volume is not None and series.volume >= VOLUME_THRESHOLD:
- ET.SubElement(series_node, "StartYear").text = str(series.volume)
+ start_year = series.start_year
+ if not start_year and series.volume is not None and series.volume >= VOLUME_THRESHOLD:
+ start_year = series.volume
+ if start_year:
+ ET.SubElement(series_node, "StartYear").text = str(start_year)
+
+ status = self._normalize_series_status(series.status)
+ # The schema rejects an EndYear before the StartYear, or on an ongoing series.
+ if series.end_year and status != "Ongoing" and series.end_year >= (start_year or 0):
+ ET.SubElement(series_node, "EndYear").text = str(series.end_year)
+ if status is not None:
+ ET.SubElement(series_node, "Status").text = status
if series.issue_count:
ET.SubElement(series_node, "IssueCount").text = str(series.issue_count)
@@ -525,6 +563,9 @@ def _add_series(self, root: ET.Element, series: Series | None) -> None: # noqa:
attrib["lang"] = alt_name.language
ET.SubElement(alt_names_node, "AlternativeName", attrib=attrib).text = alt_name.name
+ if series.summary:
+ ET.SubElement(series_node, "Summary").text = series.summary
+
def _add_info_sources(self, root: ET.Element, info_sources: list[InfoSources]) -> None:
"""Add information sources to XML.
@@ -824,15 +865,22 @@ def _parse_series_element(self, series_element: ET.Element | None) -> Series | N
series = Series(DEFAULT_SERIES_NAME)
series.id_ = self._get_id_from_attrib(series_element.attrib)
- series.language = series_element.attrib.get("lang")
+ series.language = self._parse_language(series_element.attrib.get("lang"))
# Define mapping for simple text elements
- text_mappings = {"Name": "name", "SortName": "sort_name", "Format": "format"}
+ text_mappings = {
+ "Name": "name",
+ "SortName": "sort_name",
+ "Format": "format",
+ "Status": "status",
+ "Summary": "summary",
+ }
# Define mapping for integer elements
int_mappings = {
"Volume": "volume",
"StartYear": "start_year",
+ "EndYear": "end_year",
"IssueCount": "issue_count",
"VolumeCount": "volume_count",
}
@@ -850,7 +898,7 @@ def _parse_series_element(self, series_element: ET.Element | None) -> Series | N
alt_name = AlternativeNames(
name_elem.text,
self._get_id_from_attrib(name_elem.attrib),
- name_elem.attrib.get("lang"),
+ self._parse_language(name_elem.attrib.get("lang")),
)
alt_names.append(alt_name)
series.alternative_names = alt_names
diff --git a/darkseid/schemas/MetronInfo/v1_2/MetronInfo.xsd b/darkseid/schemas/MetronInfo/v1_2/MetronInfo.xsd
new file mode 100644
index 0000000..8db7cac
--- /dev/null
+++ b/darkseid/schemas/MetronInfo/v1_2/MetronInfo.xsd
@@ -0,0 +1,374 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/darkseid/validate.py b/darkseid/validate.py
index 5b4bca7..1d2372f 100644
--- a/darkseid/validate.py
+++ b/darkseid/validate.py
@@ -50,6 +50,7 @@ class SchemaVersion(Enum):
for validation priority.
"""
+ METRON_INFO_V1_2 = auto()
METRON_INFO_V1_1 = auto()
COMIC_INFO_V2 = auto()
COMIC_INFO_V1 = auto()
@@ -87,11 +88,18 @@ class ValidateMetadata:
"file_name": "MetronInfo.xsd",
"schema_class": XMLSchema11,
},
+ SchemaVersion.METRON_INFO_V1_2: {
+ "module_path": "darkseid.schemas.MetronInfo.v1_2",
+ "file_name": "MetronInfo.xsd",
+ "schema_class": XMLSchema11,
+ },
}
- # Validation order: newest/most specific schemas first
+ # Validation order: MetronInfo before ComicInfo, newest ComicInfo first. MetronInfo v1.2 is a
+ # superset of v1.1, so v1.1 is checked first to report the oldest version a document conforms to.
_VALIDATION_ORDER: ClassVar[list[SchemaVersion]] = [
SchemaVersion.METRON_INFO_V1_1,
+ SchemaVersion.METRON_INFO_V1_2,
SchemaVersion.COMIC_INFO_V2,
SchemaVersion.COMIC_INFO_V1,
]
@@ -216,13 +224,15 @@ def _is_valid(self, schema_version: SchemaVersion) -> bool:
return True
def validate(self) -> SchemaVersion:
- """Determine and return the highest valid schema version for the XML metadata.
+ """Determine and return the schema version for the XML metadata.
This method checks the XML against all supported schema versions in order
- of preference (newest first) and returns the first valid match.
+ of preference and returns the first valid match. ComicInfo is checked newest
+ first; MetronInfo is checked oldest first, since each version is a superset of
+ the previous one.
Returns:
- The highest valid schema version found, or SchemaVersion.UNKNOWN
+ The first valid schema version found, or SchemaVersion.UNKNOWN
if no valid schema is found.
"""
diff --git a/tests/test_comicinfo.py b/tests/test_comicinfo.py
index 429f7ac..883c9ff 100644
--- a/tests/test_comicinfo.py
+++ b/tests/test_comicinfo.py
@@ -395,3 +395,20 @@ def test_review_parsed_from_xml_string() -> None:
)
md = ComicInfo().metadata_from_string(xml)
assert md.review == "A fantastic underwater adventure."
+
+
+@pytest.mark.parametrize(
+ ("language", "expected"),
+ [("pt-br", "pt-BR"), ("English", "en"), ("unknown", None)],
+)
+def test_language_iso_normalized_on_read(language: str, expected: str | None) -> None:
+ """Test LanguageISO is normalized on read, and an unknown language is dropped."""
+ xml = (
+ ''
+ ""
+ "Aquaman"
+ f"{language}"
+ ""
+ )
+ md = ComicInfo().metadata_from_string(xml)
+ assert md.series.language == expected
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index 0a19f5b..adc4f84 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -149,6 +149,78 @@ def test_series_invalid_language():
Series("Foo Bar", language="xyz")
+@pytest.mark.parametrize(
+ ("language", "expected"),
+ [
+ ("en", "en"),
+ (" de ", "de"),
+ ("English", "en"),
+ ("eng", "en"),
+ ("fil", "fil"),
+ ("yue", "yue"),
+ ("pt-BR", "pt-BR"),
+ ("pt-br", "pt-BR"),
+ ("EN-us", "en-US"),
+ ("zh-Hant", "zh-Hant"),
+ ("zh-hant-tw", "zh-Hant-TW"),
+ ("fil-PH", "fil-PH"),
+ ("es-419", "es-419"),
+ ("de-CH-1901", "de-CH-1901"),
+ ("sl-ROZAJ", "sl-rozaj"),
+ ("ger", "de"),
+ ("ger-DE", "de-DE"),
+ ("fre-CA", "fr-CA"),
+ ("chi-Hant", "zh-Hant"),
+ ("Ai-Cham", "aih"),
+ ("", None),
+ (None, None),
+ ],
+)
+def test_series_bcp47_language(language, expected):
+ """Test Series accepts BCP 47 language tags, normalizing the primary language subtag."""
+ assert Series("Foo Bar", language=language).language == expected
+
+
+@pytest.mark.parametrize(
+ "language",
+ [
+ "xyz",
+ "zz-BR",
+ "pt-",
+ "pt-toolongsubtag",
+ "pt-B_R",
+ "Na-Dene",
+ "zh-min-nan",
+ "en-XX",
+ "zh-Abcd",
+ "pt-BR-BR",
+ "en-US-x-private",
+ ],
+)
+def test_series_invalid_bcp47_language(language):
+ """Test Series rejects unknown languages and malformed subtags."""
+ with pytest.raises(ValueError, match="Couldn't find language"):
+ Series("Foo Bar", language=language)
+
+
+def test_series_end_year_validation():
+ """Test Series end_year validation."""
+ assert Series("Superman", start_year=1938, end_year=2011).end_year == 2011
+ assert Series("Superman", end_year=None).end_year is None
+
+ with pytest.raises(ValueError, match="End year: 99 length must be 4"):
+ Series("Superman", end_year=99)
+
+
+def test_series_end_year_before_start_year():
+ """Test Series rejects an end year before the start year."""
+ assert Series("Superman", start_year=1938, end_year=1938).end_year == 1938
+ assert Series("Superman", end_year=1938).end_year == 1938
+
+ with pytest.raises(ValueError, match="End year: 1937 can't precede start year: 1938"):
+ Series("Superman", start_year=1938, end_year=1937)
+
+
def test_series_start_year_validation():
"""Test Series start_year validation."""
# Valid year
@@ -160,7 +232,7 @@ def test_series_start_year_validation():
assert series_none.start_year is None
# Invalid year length
- with pytest.raises(ValueError, match=r"Year: .* length must be 4"):
+ with pytest.raises(ValueError, match=r"Start year: .* length must be 4"):
Series("Superman", start_year=38)
diff --git a/tests/test_metroninfo.py b/tests/test_metroninfo.py
index d2284ad..ef0d45e 100644
--- a/tests/test_metroninfo.py
+++ b/tests/test_metroninfo.py
@@ -253,6 +253,8 @@ def test_series_format_edge_cases(metron_info):
(AgeRatings(comic_rack="PG"), "Teen", "comic_rack_pg"),
(AgeRatings(comic_rack="R18+"), "Mature", "comic_rack_r18"),
(AgeRatings(comic_rack="M"), "Mature", "comic_rack_m"),
+ (AgeRatings(comic_rack=" pg "), "Teen", "comic_rack_whitespace"),
+ (AgeRatings(comic_rack="NotARating"), None, "unknown_comic_rack_rating"),
(AgeRatings(metron_info=""), None, "empty_metron_rating"),
(AgeRatings(comic_rack=""), None, "empty_comic_rack_rating"),
(AgeRatings(), None, "empty_ratings_object"),
@@ -323,6 +325,116 @@ def test_community_rating_missing_rating_count(metron_info):
assert result.rating_count is None
+# Series EndYear, Status & Summary (v1.2) tests
+def test_series_v1_2_fields_round_trip(metron_info, tmp_path):
+ """EndYear, Status and Summary validate against the schema and survive a round trip."""
+ metadata = Metadata()
+ metadata.series = Series(
+ name="Justice League",
+ start_year=1970,
+ end_year=1975,
+ status="completed",
+ summary="The original run.",
+ )
+
+ filename = tmp_path / "MetronInfo.xml"
+ metron_info.write_xml(filename, metadata)
+ result = metron_info.metadata_from_string(filename.read_text())
+
+ assert result.series.end_year == 1975
+ assert result.series.status == "Completed"
+ assert result.series.summary == "The original run."
+
+
+@pytest.mark.parametrize(
+ ("start_year", "end_year", "status"),
+ [
+ (1975, 1970, None), # EndYear before StartYear
+ (1970, 1975, "Ongoing"), # Ongoing series can't have an EndYear
+ ],
+)
+def test_series_end_year_omitted_when_invalid(metron_info, tmp_path, start_year, end_year, status):
+ """An EndYear the schema would reject is dropped rather than failing the write."""
+ metadata = Metadata()
+ metadata.series = Series(name="Test Series", start_year=start_year, status=status)
+ # Set after construction, which Series doesn't validate (e.g. when parsed from XML)
+ metadata.series.end_year = end_year
+
+ filename = tmp_path / "MetronInfo.xml"
+ metron_info.write_xml(filename, metadata)
+ root = ET.fromstring(filename.read_text()) # noqa: S314
+
+ assert root.find("Series/EndYear") is None
+ assert root.find("Series/StartYear").text == str(start_year)
+
+
+def test_series_end_year_checked_against_volume_start_year(metron_info):
+ """A volume used as the StartYear is also used to check the EndYear."""
+ metadata = Metadata()
+ metadata.series = Series(name="Test Series", volume=2016, end_year=2015)
+
+ root = metron_info._convert_metadata_to_xml(metadata).getroot()
+
+ assert root.find("Series/StartYear").text == "2016"
+ assert root.find("Series/EndYear") is None
+
+
+@pytest.mark.parametrize(
+ ("status", "expected"),
+ [
+ ("Ongoing", "Ongoing"),
+ (" hiatus ", "Hiatus"),
+ ("CANCELLED", "Cancelled"),
+ ("Unknown", None),
+ ("", None),
+ (None, None),
+ ],
+)
+def test_normalize_series_status(metron_info, status, expected):
+ """Series status is matched case-insensitively and unknown values are dropped."""
+ assert metron_info._normalize_series_status(status) == expected
+
+
+def test_series_bcp47_language_round_trip(metron_info, tmp_path):
+ """BCP 47 language tags validate against the schema and survive a round trip."""
+ metadata = Metadata()
+ metadata.series = Series(
+ name="Test Series",
+ language="pt-BR",
+ alternative_names=[
+ AlternativeNames("Ang Kagila-gilalas", language="fil"),
+ AlternativeNames("蜘蛛人", language="zh-Hant"),
+ ],
+ )
+
+ filename = tmp_path / "MetronInfo.xml"
+ metron_info.write_xml(filename, metadata)
+ result = metron_info.metadata_from_string(filename.read_text())
+
+ assert result.series.language == "pt-BR"
+ assert [n.language for n in result.series.alternative_names] == ["fil", "zh-Hant"]
+
+
+def test_series_languages_normalized_on_read(metron_info, caplog):
+ """Series and alternative name languages are normalized, and unknown ones dropped."""
+ xml = """
+
+
+ Test Series
+
+ 蜘蛛人
+ Unknown
+
+
+ """
+
+ result = metron_info.metadata_from_string(xml)
+
+ assert result.series.language == "pt-BR"
+ assert [n.language for n in result.series.alternative_names] == ["zh-Hant", None]
+ assert "Dropping unknown language: zz" in caplog.text
+
+
# XML Conversion Tests
def test_convert_complex_metadata_to_xml(metron_info, complex_metadata):
"""Test XML conversion with complex metadata."""
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 1794133..135b5f2 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -44,6 +44,7 @@ def mock_schema_path(tmp_path):
def test_schema_version_enum_values():
"""Test that SchemaVersion enum has expected values."""
expected_values = [
+ "METRON_INFO_V1_2",
"METRON_INFO_V1_1",
"COMIC_INFO_V2",
"COMIC_INFO_V1",
@@ -57,6 +58,7 @@ def test_schema_version_string_representation():
"""Test SchemaVersion string representation."""
assert str(SchemaVersion.COMIC_INFO_V1) == "Comic Info V1"
assert str(SchemaVersion.COMIC_INFO_V2) == "Comic Info V2"
+ assert str(SchemaVersion.METRON_INFO_V1_2) == "Metron Info V1.2"
assert str(SchemaVersion.METRON_INFO_V1_1) == "Metron Info V1.1"
assert str(SchemaVersion.UNKNOWN) == "Unknown"
@@ -128,15 +130,15 @@ def test_get_schema_path_comic_info_v1(mock_as_file, mock_files):
@patch("darkseid.validate.files")
@patch("darkseid.validate.as_file")
-def test_get_schema_path_metron_info_v1_1(mock_as_file, mock_files):
- """Test _get_schema_path for MetronInfo v1.1."""
+def test_get_schema_path_metron_info_v1_2(mock_as_file, mock_files):
+ """Test _get_schema_path for MetronInfo v1.2."""
mock_path = Mock()
mock_files.return_value.joinpath.return_value = mock_path
mock_as_file.return_value.__enter__.return_value = Path("/fake/path/MetronInfo.xsd")
- result = ValidateMetadata._get_schema_path(SchemaVersion.METRON_INFO_V1_1)
+ result = ValidateMetadata._get_schema_path(SchemaVersion.METRON_INFO_V1_2)
- mock_files.assert_called_once_with("darkseid.schemas.MetronInfo.v1_1")
+ mock_files.assert_called_once_with("darkseid.schemas.MetronInfo.v1_2")
mock_files.return_value.joinpath.assert_called_once_with("MetronInfo.xsd")
assert result == Path("/fake/path/MetronInfo.xsd")
@@ -253,7 +255,8 @@ def test_validate_all_returns_all_results(valid_xml_bytes):
validator = ValidateMetadata(valid_xml_bytes)
expected_results = {
- SchemaVersion.METRON_INFO_V1_1: True,
+ SchemaVersion.METRON_INFO_V1_1: False,
+ SchemaVersion.METRON_INFO_V1_2: True,
SchemaVersion.COMIC_INFO_V2: False,
SchemaVersion.COMIC_INFO_V1: True,
}
@@ -263,7 +266,7 @@ def test_validate_all_returns_all_results(valid_xml_bytes):
results = validator.validate_all()
assert results == expected_results
- assert len(results) == 3
+ assert len(results) == 4
# get_validation_errors method tests
@@ -313,6 +316,7 @@ def test_get_validation_errors_no_errors(valid_xml_bytes):
(SchemaVersion.COMIC_INFO_V1, "darkseid.schemas.ComicInfo.v1", "ComicInfo.xsd"),
(SchemaVersion.COMIC_INFO_V2, "darkseid.schemas.ComicInfo.v2", "ComicInfo.xsd"),
(SchemaVersion.METRON_INFO_V1_1, "darkseid.schemas.MetronInfo.v1_1", "MetronInfo.xsd"),
+ (SchemaVersion.METRON_INFO_V1_2, "darkseid.schemas.MetronInfo.v1_2", "MetronInfo.xsd"),
],
)
def test_schema_config_mapping(schema_version, expected_module, expected_file):
@@ -329,6 +333,7 @@ def test_schema_config_mapping(schema_version, expected_module, expected_file):
(SchemaVersion.COMIC_INFO_V1, XMLSchema10),
(SchemaVersion.COMIC_INFO_V2, XMLSchema10),
(SchemaVersion.METRON_INFO_V1_1, XMLSchema11),
+ (SchemaVersion.METRON_INFO_V1_2, XMLSchema11),
],
)
def test_schema_class_mapping(schema_version, expected_class):
@@ -342,6 +347,7 @@ def test_validation_order_consistency():
"""Test that validation order is consistent with class definition."""
expected_order = [
SchemaVersion.METRON_INFO_V1_1,
+ SchemaVersion.METRON_INFO_V1_2,
SchemaVersion.COMIC_INFO_V2,
SchemaVersion.COMIC_INFO_V1,
]
@@ -413,3 +419,50 @@ def test_get_validation_errors_reports_entities(xml):
def test_validate_accepts_documents_without_entities(xml):
"""Test defusing doesn't reject ordinary documents."""
assert ValidateMetadata(xml).validate() == SchemaVersion.COMIC_INFO_V2
+
+
+@pytest.mark.parametrize(
+ ("series_xml", "expected"),
+ [
+ (
+ (
+ 'Foo1970'
+ "1975Completed"
+ "A series."
+ ),
+ SchemaVersion.METRON_INFO_V1_2,
+ ),
+ (
+ ("Foo19751970"),
+ SchemaVersion.UNKNOWN,
+ ),
+ (
+ "Foo1975Ongoing",
+ SchemaVersion.UNKNOWN,
+ ),
+ ],
+)
+def test_validate_metron_info_v1_2_series(series_xml, expected):
+ """Test validation of MetronInfo v1.2 series elements against the bundled schema."""
+ xml = f"{series_xml}"
+ assert ValidateMetadata(xml.encode()).validate() == expected
+
+
+@pytest.mark.parametrize(
+ ("series_xml", "expected"),
+ [
+ (
+ 'Foo1970',
+ SchemaVersion.METRON_INFO_V1_1,
+ ),
+ (
+ "Foo19701975",
+ SchemaVersion.METRON_INFO_V1_2,
+ ),
+ ('Foo', SchemaVersion.METRON_INFO_V1_2),
+ ],
+)
+def test_validate_reports_oldest_metron_info_version(series_xml, expected):
+ """Test MetronInfo documents report the oldest schema version they conform to."""
+ xml = f"{series_xml}"
+ assert ValidateMetadata(xml.encode()).validate() == expected