Merge branch 'metadataOverride' into develop

This commit is contained in:
Timmy Welch 2024-04-12 14:12:27 -07:00
commit af059b8775
6 changed files with 179 additions and 50 deletions

View File

@ -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"])

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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=[])),
)