diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py index 01326fc..cc6760b 100644 --- a/comicapi/genericmetadata.py +++ b/comicapi/genericmetadata.py @@ -25,17 +25,27 @@ import copy import dataclasses import logging from collections.abc import Sequence -from typing import Any, TypedDict +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, TypedDict, Union from typing_extensions import NamedTuple, Required from comicapi import utils +from comicapi._url import Url, parse_url -from ._url import Url, parse_url +if TYPE_CHECKING: + Union logger = logging.getLogger(__name__) +class __remove(Enum): + REMOVE = auto() + + +REMOVE = __remove.REMOVE + + class PageType: """ These page info classes are exactly the same as the CIX scheme, since @@ -213,11 +223,15 @@ class GenericMetadata: def assign(cur: str, new: Any) -> None: if new is not None: - if isinstance(new, str) and len(new) == 0: + if new is REMOVE: if isinstance(getattr(self, cur), (list, set)): getattr(self, cur).clear() else: setattr(self, cur, None) + return + + if isinstance(new, str) and len(new) == 0: + setattr(self, cur, None) elif isinstance(new, (list, set)) and len(new) == 0: pass else: @@ -281,8 +295,9 @@ class GenericMetadata: assign("_alternate_images", new_md._alternate_images) def overlay_credits(self, new_credits: list[Credit]) -> None: - if isinstance(new_credits, str) and len(new_credits) == 0: + if new_credits is REMOVE: self.credits = [] + return for c in new_credits: primary = bool("primary" in c and c["primary"]) diff --git a/comicapi/utils.py b/comicapi/utils.py index b7fdb4d..e74248a 100644 --- a/comicapi/utils.py +++ b/comicapi/utils.py @@ -33,7 +33,8 @@ from comicfn2dict import comicfn2dict import comicapi.data from comicapi import filenamelexer, filenameparser -from ._url import Url, parse_url +from ._url import Url as Url +from ._url import parse_url as parse_url try: import icu diff --git a/comictaggerlib/ctsettings/commandline.py b/comictaggerlib/ctsettings/commandline.py index e34335f..3920f3c 100644 --- a/comictaggerlib/ctsettings/commandline.py +++ b/comictaggerlib/ctsettings/commandline.py @@ -109,7 +109,7 @@ def register_runtime(parser: settngs.Manager) -> None: "--metadata", default=GenericMetadata(), type=parse_metadata_from_string, - help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""", + help="""Explicitly define some tags to be used in YAML syntax. Use @file.yaml to read from a file. e.g.:\n"series: Plastic Man, publisher: Quality Comics, year: "\n"series: 'Kickers, Inc.', issue: '1', year: 1986"\nIf you want to erase a tag leave the value blank.\nSome names that can be used: series, issue, issue_count, year,\npublisher, title\n\n""", file=False, ) parser.add_setting( diff --git a/comictaggerlib/ctsettings/types.py b/comictaggerlib/ctsettings/types.py index 668248f..f693a0d 100644 --- a/comictaggerlib/ctsettings/types.py +++ b/comictaggerlib/ctsettings/types.py @@ -2,12 +2,95 @@ from __future__ import annotations import argparse import pathlib +import sys +import types +import typing +from collections.abc import Collection, Mapping +from typing import Any +import yaml from appdirs import AppDirs from comicapi import utils from comicapi.comicarchive import metadata_styles -from comicapi.genericmetadata import GenericMetadata +from comicapi.genericmetadata import REMOVE, GenericMetadata + +if sys.version_info < (3, 10): + + @typing.no_type_check + def get_type_hints(obj, globalns=None, localns=None, include_extras=False): + if getattr(obj, "__no_type_check__", None): + return {} + # Classes require a special treatment. + if isinstance(obj, type): + hints = {} + for base in reversed(obj.__mro__): + if globalns is None: + base_globals = getattr(sys.modules.get(base.__module__, None), "__dict__", {}) + else: + base_globals = globalns + ann = base.__dict__.get("__annotations__", {}) + if isinstance(ann, types.GetSetDescriptorType): + ann = {} + base_locals = dict(vars(base)) if localns is None else localns + if localns is None and globalns is None: + # This is surprising, but required. Before Python 3.10, + # get_type_hints only evaluated the globalns of + # a class. To maintain backwards compatibility, we reverse + # the globalns and localns order so that eval() looks into + # *base_globals* first rather than *base_locals*. + # This only affects ForwardRefs. + base_globals, base_locals = base_locals, base_globals + for name, value in ann.items(): + if value is None: + value = type(None) + if isinstance(value, str): + if "|" in value: + value = "Union[" + value.replace(" |", ",") + "]" + value = typing.ForwardRef(value, is_argument=False, is_class=True) + value = typing._eval_type(value, base_globals, base_locals) + hints[name] = value + return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()} + + if globalns is None: + if isinstance(obj, types.ModuleType): + globalns = obj.__dict__ + else: + nsobj = obj + # Find globalns for the unwrapped object. + while hasattr(nsobj, "__wrapped__"): + nsobj = nsobj.__wrapped__ + globalns = getattr(nsobj, "__globals__", {}) + if localns is None: + localns = globalns + elif localns is None: + localns = globalns + hints = getattr(obj, "__annotations__", None) + if hints is None: + # Return empty annotations for something that _could_ have them. + if isinstance(obj, typing._allowed_types): + return {} + else: + raise TypeError("{!r} is not a module, class, method, " "or function.".format(obj)) + hints = dict(hints) + for name, value in hints.items(): + if value is None: + value = type(None) + if isinstance(value, str): + if "|" in value: + value = "Union[" + value.replace(" |", ",") + "]" + # class-level forward refs were handled above, this must be either + # a module-level annotation or a function argument annotation + value = typing.ForwardRef( + value, + is_argument=not isinstance(obj, types.ModuleType), + is_class=False, + ) + hints[name] = typing._eval_type(value, globalns, localns) + return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()} + +else: + from typing import get_type_hints class ComicTaggerPaths(AppDirs): @@ -84,50 +167,78 @@ def metadata_type(types: str) -> list[str]: def parse_metadata_from_string(mdstr: str) -> GenericMetadata: - """The metadata string is a comma separated list of name-value pairs - The names match the attributes of the internal metadata struct (for now) - The caret is the special "escape character", since it's not common in - natural language text - example = "series=Kickers^, Inc. ,issue=1, year=1986" - """ + def get_type(key: str, tt: Any = get_type_hints(GenericMetadata)) -> Any: + t: Any = tt.get(key, None) + if t is None: + return None + if getattr(t, "__origin__", None) is typing.Union and len(t.__args__) == 2 and t.__args__[1] is type(None): + t = t.__args__[0] + elif isinstance(t, types.GenericAlias) and issubclass(t.mro()[0], Collection): + t = t.mro()[0], t.__args__[0] - escaped_comma = "^," - escaped_equals = "^=" - replacement_token = "<_~_>" + if isinstance(t, tuple) and issubclass(t[1], dict): + return (t[0], dict) + if isinstance(t, type) and issubclass(t, dict): + return dict + return t + + def convert_value(t: type, value: Any) -> Any: + if not isinstance(value, t): + if isinstance(value, (Mapping)): + value = t(**value) + elif not isinstance(value, str) and isinstance(value, (Collection)): + value = t(*value) + else: + try: + if t is utils.Url and isinstance(value, str): + value = utils.parse_url(value) + else: + value = t(value) + except (ValueError, TypeError): + raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'") + return value md = GenericMetadata() - # First, replace escaped commas with with a unique token (to be changed back later) - mdstr = mdstr.replace(escaped_comma, replacement_token) - tmp_list = utils.split(mdstr, ",") - md_list = [] - for item in tmp_list: - item = item.replace(replacement_token, ",") - md_list.append(item) + if not mdstr: + return md + if mdstr[0] == "@": + p = pathlib.Path(mdstr[1:]) + if not p.is_file(): + raise argparse.ArgumentTypeError("Invalid filepath") + mdstr = p.read_text() + if mdstr[0] != "{": + mdstr = "{" + mdstr + "}" - # Now build a nice dict from the list - md_dict = {} - for item in md_list: - # Make sure to fix any escaped equal signs - i = item.replace(escaped_equals, replacement_token) - key, _, value = i.partition("=") - value = value.replace(replacement_token, "=").strip() - key = key.strip() - if key.casefold() == "credit": - cred_attribs = utils.split(value, ":") - role = cred_attribs[0] - person = cred_attribs[1] if len(cred_attribs) > 1 else "" - primary = len(cred_attribs) > 2 - md.add_credit(person.strip(), role.strip(), primary) - else: - md_dict[key] = value + md_dict = yaml.safe_load(mdstr) + empty = True # Map the dict to the metadata object for key, value in md_dict.items(): - if not hasattr(md, key): - raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name") - else: - md.is_empty = False + if hasattr(md, key): + t = get_type(key) + if value is None: + value = REMOVE + elif isinstance(t, tuple): + if value == "" or value is None: + value = t[0]() + else: + if isinstance(value, str): + values: list[Any] = value.split("::") + if not isinstance(value, Collection): + raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'") + values = list(value) + for idx, v in enumerate(values): + if not isinstance(v, t[1]): + values[idx] = convert_value(t[1], v) + value = t[0](values) + elif value is not None: + value = convert_value(t, value) + + empty = False setattr(md, key, value) + else: + raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name") + md.is_empty = empty return md diff --git a/setup.cfg b/setup.cfg index 2575b76..43919b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,7 @@ install_requires = pathvalidate pillow>=9.1.0,<10 pyrate-limiter>=2.6,<3 + pyyaml rapidfuzz>=2.12.0 requests==2.* settngs==0.10.0 @@ -317,6 +318,7 @@ disallow_incomplete_defs = true disallow_untyped_defs = true warn_redundant_casts = true warn_unused_ignores = true +disable_error_code = import-untyped [mypy-testing.*] disallow_untyped_defs = false diff --git a/tests/ctsettings_test.py b/tests/ctsettings_test.py index 59d69b0..c1f1293 100644 --- a/tests/ctsettings_test.py +++ b/tests/ctsettings_test.py @@ -7,13 +7,13 @@ import comictaggerlib.ctsettings.types md_strings = ( ("", comicapi.genericmetadata.md_test.replace()), - ("year=", comicapi.genericmetadata.md_test.replace(year=None)), - ("year=2009", comicapi.genericmetadata.md_test.replace(year="2009")), - ("series=", comicapi.genericmetadata.md_test.replace(series=None)), - ("series_aliases=", comicapi.genericmetadata.md_test.replace(series_aliases=set())), - ("black_and_white=", comicapi.genericmetadata.md_test.replace(black_and_white=None)), - ("credits=", comicapi.genericmetadata.md_test.replace(credits=[])), - ("story_arcs=", comicapi.genericmetadata.md_test.replace(story_arcs=[])), + ("year:", comicapi.genericmetadata.md_test.replace(year=None)), + ("year: 2009", comicapi.genericmetadata.md_test.replace(year=2009)), + ("series:", comicapi.genericmetadata.md_test.replace(series=None)), + ("series_aliases:", comicapi.genericmetadata.md_test.replace(series_aliases=set())), + ("black_and_white:", comicapi.genericmetadata.md_test.replace(black_and_white=None)), + ("credits:", comicapi.genericmetadata.md_test.replace(credits=[])), + ("story_arcs:", comicapi.genericmetadata.md_test.replace(story_arcs=[])), )