Improve commandline metadata override
Change parse_metadata_from_string to yaml syntax Add a special value to remove existing values when metadata is overlayed
This commit is contained in:
parent
de084ffff9
commit
8bcd51f49b
@ -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"])
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,73 @@ 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] != "{":
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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=[])),
|
||||
)
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user