Compare commits

...

18 Commits

Author SHA1 Message Date
904561fb8e Merge branch 'pyicu' into develop 2022-09-10 21:48:04 -07:00
be6b71dec7 Put unix specific commands in OS specific blocks 2022-09-10 21:11:48 -07:00
63b654a173 Update ci to install pyicu 2022-09-10 19:51:26 -07:00
bc25acde9f Fix sorting
Switch natsort to use os_sorted
Remove directories when returning a list of files in a comic
Update tests to account for '!cover.jpg'
2022-09-10 19:48:50 -07:00
03677ce4b8 Fix renaming
Make ComicArchive.path always absolute
Fix unique_file not preserving the extension
Fix incorrect output when renaming in CLI mode
Fix handling of platform when renaming
2022-08-19 20:20:37 -07:00
535afcb4c6 Fix replacements 2022-08-19 19:59:58 -07:00
06255f7848 Perform replacements on literal text and format values 2022-08-18 13:48:23 -07:00
00e649bb4c Move colon handling when renaming to the MetadataFormatter class
Fixes #356
2022-08-17 16:16:38 -07:00
078f569ec6 Fix codeblock in README.md 2022-08-14 10:51:08 -07:00
315cf7d920 Merge pull request #355 from Xav83/patch-1
Adds the Chocolatey package as a way to install ComicTagger
2022-08-14 10:47:24 -07:00
e9cc6a16a8 Note that @Xav83 is the maintainer of the chocolatey package
Co-authored-by: Xavier Jouvenot <x.jouvenot@gmail.com>
2022-08-14 10:45:51 -07:00
26eb6985fe Adds the Chocolatey package as a way to install ComicTagger
Adds the Chocolatey package in the list of possibilities to install ComicTagger
2022-08-13 11:52:09 +02:00
be983c61bc Fix #353
The two primary cases fixed are:
Ms. Marvel
spider-man/deadpool

The first issue removed 'Ms.' which is a problem as many comics have
series that the only difference in the title is the
designation/honorific.

The second issue is that the '/' was removed and not replaced with
anything causing a search for 'mandeadpool' which will not show useful
results.

Consequently all designations/honorifics are now untouched
All punctuation is replaced with a space
2022-08-12 07:10:36 -07:00
77a53a6834 Update dependencies
Includes changes from pyupgrade
2022-08-10 20:55:46 -07:00
860a3147d2 Construct URL correctly 2022-08-10 16:33:40 -07:00
8ecb87fa26 Install all optional dependencies in CI 2022-08-08 19:10:57 -07:00
f17f560705 Fix tests on windows
Make the speedup dependency to thefuzz optional it requires a C compiler
2022-08-08 19:03:25 -07:00
aadeb07c49 Fix issues
Refactor add_to_path with tests
Fix type hints for titles_match
Use casefold in get_language
Fix using the recursive flag in cli mode
Add http status code to ComicVine exceptions
Fix parenthesis getting removed when renaming
Add more tests
2022-08-08 18:05:06 -07:00
26 changed files with 298 additions and 119 deletions

View File

@ -80,11 +80,25 @@ jobs:
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Install macos dependencies
run: |
brew install icu4c pkg-config
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=:pyicu: pyicu
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=:pyicu: pyicu
if: runner.os == 'Linux'
- name: Build and install PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[GUI,CBR]"
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
- name: build
run: |

View File

@ -41,12 +41,25 @@ jobs:
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Install macos dependencies
run: |
brew install icu4c pkg-config
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=:pyicu: pyicu
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=:pyicu: pyicu
if: runner.os == 'Linux'
- name: Build, Install and Test PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[GUI,CBR]"
echo "CT_FULL_NAME=$(python setup.py --fullname)" >> $GITHUB_ENV
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
python -m flake8
python -m pytest
@ -69,4 +82,4 @@ jobs:
draft: false
files: |
dist/!(*Linux).zip
dist/*.whl
dist/*${{ fromJSON('["never", ""]')[runner.os == 'Linux'] }}.whl

View File

@ -1,7 +1,7 @@
exclude: ^scripts
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -10,7 +10,7 @@ repos:
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.1
rev: v2.0.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/isort
@ -19,12 +19,12 @@ repos:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/pyupgrade
rev: v2.32.1
rev: v2.37.3
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/psf/black
rev: 22.3.0
rev: 22.6.0
hooks:
- id: black
- repo: https://github.com/PyCQA/autoflake
@ -33,12 +33,12 @@ repos:
- id: autoflake
args: [-i]
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-eradicate, flake8-length, flake8-print]
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.960
rev: v0.971
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests]

View File

@ -2,6 +2,7 @@
[![GitHub release (latest by date)](https://img.shields.io/github/downloads/comictagger/comictagger/latest/total)](https://github.com/comictagger/comictagger/releases/latest)
[![PyPI](https://img.shields.io/pypi/v/comictagger)](https://pypi.org/project/comictagger/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/comictagger)](https://pypistats.org/packages/comictagger)
[![Chocolatey package](https://img.shields.io/chocolatey/dt/comictagger?color=blue&label=chocolatey)](https://community.chocolatey.org/packages/comictagger)
[![PyPI - License](https://img.shields.io/pypi/l/comictagger)](https://opensource.org/licenses/Apache-2.0)
[![GitHub Discussions](https://img.shields.io/github/discussions/comictagger/comictagger)](https://github.com/comictagger/comictagger/discussions)
@ -48,6 +49,12 @@ A pip package is provided, you can install it with:
There are two optional dependencies GUI and CBR. You can install the optional dependencies by specifying one or more of `GUI`,`CBR` or `all` in braces e.g. `comictagger[CBR,GUI]`
### Chocolatey installation (Windows only)
A [Chocolatey package](https://community.chocolatey.org/packages/comictagger), maintained by @Xav83, is provided, you can install it with:
```powershell
choco install comictagger
```
### From source
1. Ensure you have python 3.9 installed

View File

@ -114,7 +114,7 @@ class SevenZipArchiver(UnknownArchiver):
return False
def read_file(self, archive_file: str) -> bytes:
data = bytes()
data = b""
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
data = zf.read(archive_file)[archive_file].read()
@ -148,7 +148,7 @@ class SevenZipArchiver(UnknownArchiver):
def get_filename_list(self) -> list[str]:
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
namelist: list[str] = zf.getnames()
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
return namelist
except (py7zr.Bad7zFile, OSError) as e:
@ -248,7 +248,7 @@ class ZipArchiver(UnknownArchiver):
def get_filename_list(self) -> list[str]:
try:
with zipfile.ZipFile(self.path, mode="r") as zf:
namelist = zf.namelist()
namelist = [file.filename for file in zf.infolist() if not file.is_dir()]
return namelist
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error listing files in zip archive [%s]: %s", e, self.path)
@ -422,7 +422,7 @@ class RarArchiver(UnknownArchiver):
rarc = self.get_rar_obj()
if rarc is None:
return bytes()
return b""
tries = 0
while tries < 7:
@ -665,7 +665,7 @@ class FolderArchiver(UnknownArchiver):
class ComicArchive:
logo_data = bytes()
logo_data = b""
class ArchiveType:
SevenZip, Zip, Rar, Folder, Pdf, Unknown = list(range(6))
@ -683,7 +683,7 @@ class ComicArchive:
self._has_cbi: bool | None = None
self._has_cix: bool | None = None
self._has_comet: bool | None = None
self.path = pathlib.Path(path)
self.path = pathlib.Path(path).absolute()
self.page_count: int | None = None
self.page_list: list[str] = []
@ -853,13 +853,13 @@ class ComicArchive:
return retcode
def get_page(self, index: int) -> bytes:
image_data = bytes()
image_data = b""
filename = self.get_page_name(index)
if filename:
try:
image_data = self.archiver.read_file(filename) or bytes()
image_data = self.archiver.read_file(filename) or b""
except Exception:
logger.error("Error reading in page %d. Substituting logo page.", index)
image_data = ComicArchive.logo_data
@ -934,7 +934,7 @@ class ComicArchive:
# seems like some archive creators are on Windows, and don't know about case-sensitivity!
if sort_list:
files = cast(list[str], natsort.natsorted(files, alg=natsort.ns.IC | natsort.ns.I | natsort.ns.U))
files = cast(list[str], natsort.os_sorted(files))
# make a sub-list of image files
self.page_list = []
@ -1033,7 +1033,7 @@ class ComicArchive:
raw_cix = self.archiver.read_file(self.ci_xml_filename) or b""
except Exception as e:
logger.error("Error reading in raw CIX! for %s: %s", self.path, e)
raw_cix = bytes()
raw_cix = b""
return raw_cix
def write_cix(self, metadata: GenericMetadata) -> bool:

View File

@ -24,7 +24,8 @@ import logging
import os
import re
from operator import itemgetter
from typing import Callable, Match, TypedDict
from re import Match
from typing import Callable, TypedDict
from urllib.parse import unquote
from text2digits import text2digits
@ -67,17 +68,10 @@ class FileNameParser:
# replace any name separators with spaces
tmpstr = self.fix_spaces(filename)
found = False
match = re.search(r"(?<=\sof\s)\d+(?=\s)", tmpstr, re.IGNORECASE)
match = re.search(r"(?:\s\(?of\s)(\d+)(?: |\))", tmpstr, re.IGNORECASE)
if match:
count = match.group()
found = True
if not found:
match = re.search(r"(?<=\(of\s)\d+(?=\))", tmpstr, re.IGNORECASE)
if match:
count = match.group()
count = match.group(1)
return count.lstrip("0")
@ -180,10 +174,12 @@ class FileNameParser:
if "--" in filename:
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
filename = re.sub(r"--.*", self.repl, filename)
# never happens
elif "__" in filename:
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
filename = re.sub(r"__.*", self.repl, filename)
# never happens
filename = filename.replace("+", " ")
tmpstr = self.fix_spaces(filename, remove_dashes=False)
@ -425,6 +421,7 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
likely_year = False
if p.firstItem and p.first_is_alt:
p.alt = True
p.firstItem = False
return parse_issue_number
# The issue number should hopefully not be in parentheses
@ -440,9 +437,6 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
# Series has already been started/parsed,
# filters out leading alternate numbers leading alternate number
if len(p.series_parts) > 0:
# Unset first item
if p.firstItem:
p.firstItem = False
return parse_issue_number
else:
p.operator_rejected.append(item)
@ -506,9 +500,6 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
p.series_parts.append(item)
p.used_items.append(item)
# Unset first item
if p.firstItem:
p.firstItem = False
p.get()
return parse_series
@ -590,6 +581,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
if series_append:
p.series_parts.append(item)
p.used_items.append(item)
if p.firstItem:
p.firstItem = False
return parse_series
# We found text, it's probably the title or series
@ -602,6 +595,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
# Usually the word 'of' eg 1 (of 6)
elif item.typ == filenamelexer.ItemType.InfoSpecifier:
if p.firstItem:
p.firstItem = False
return parse_info_specifier
# Operator is a symbol that acts as some sort of separator eg - : ;
@ -622,9 +617,13 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
p.irrelevant.extend([item, p.input[p.pos], p.get()])
else:
p.backup()
if p.firstItem:
p.firstItem = False
return parse_series
# This is text that just happens to also be a month/day
else:
if p.firstItem:
p.firstItem = False
return parse_series
# Specifically '__' or '--', no further title/series parsing is done to keep compatibility with wiki
@ -853,10 +852,11 @@ def resolve_year(p: Parser) -> None:
p.used_items.append(vol)
# Remove volume from series and title
if selected_year in p.series_parts:
p.series_parts.remove(selected_year)
if selected_year in p.title_parts:
p.title_parts.remove(selected_year)
# note: this never happens
if vol in p.series_parts:
p.series_parts.remove(vol)
if vol in p.title_parts:
p.title_parts.remove(vol)
# Remove year from series and title
if selected_year in p.series_parts:

View File

@ -93,7 +93,7 @@ class GenericMetadata:
comments: str | None = None # use same way as Summary in CIX
volume_count: int | None = None
critical_rating: float | None = None # rating in cbl; CommunityRating in CIX
critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
country: str | None = None
alternate_series: str | None = None

View File

@ -19,11 +19,11 @@ import json
import logging
import os
import pathlib
import re
import unicodedata
from collections import defaultdict
from collections.abc import Mapping
from shutil import which # noqa: F401
from typing import Any, Mapping
from typing import Any
import pycountry
import thefuzz.fuzz
@ -57,22 +57,20 @@ def get_recursive_filelist(pathlist: list[str]) -> list[str]:
if os.path.isdir(p):
filelist.extend(x for x in glob.glob(f"{p}{os.sep}/**", recursive=True) if not os.path.isdir(x))
else:
filelist.append(p)
elif str(p) not in filelist:
filelist.append(str(p))
return filelist
def add_to_path(dirname: str) -> None:
if dirname is not None and dirname != "":
if dirname:
dirname = os.path.abspath(dirname)
paths = [os.path.normpath(x) for x in os.environ["PATH"].split(os.pathsep)]
# verify that path doesn't already contain the given dirname
tmpdirname = re.escape(dirname)
pattern = r"(^|{sep}){dir}({sep}|$)".format(dir=tmpdirname, sep=os.pathsep)
match = re.search(pattern, os.environ["PATH"])
if not match:
os.environ["PATH"] = dirname + os.pathsep + os.environ["PATH"]
if dirname not in paths:
paths.insert(0, dirname)
os.environ["PATH"] = os.pathsep.join(paths)
def xlate(data: Any, is_int: bool = False, is_float: bool = False) -> Any:
@ -123,13 +121,9 @@ def remove_articles(text: str) -> str:
"the",
"the",
"with",
"ms",
"mrs",
"mr",
"dr",
]
new_text = ""
for word in text.split(" "):
for word in text.split():
if word not in articles:
new_text += word + " "
@ -141,19 +135,16 @@ def remove_articles(text: str) -> str:
def sanitize_title(text: str, basic: bool = False) -> str:
# normalize unicode and convert to ascii. Does not work for everything eg ½ to 12 not 1/2
text = unicodedata.normalize("NFKD", text).casefold()
if basic:
# comicvine keeps apostrophes a part of the word
text = text.replace("'", "")
text = text.replace('"', "")
else:
# comicvine keeps apostrophes a part of the word
text = text.replace("'", "")
text = text.replace('"', "")
if not basic:
# comicvine ignores punctuation and accents
# remove all characters that are not a letter, separator (space) or number
# replace any "dash punctuation" with a space
# makes sure that batman-superman and self-proclaimed stay separate words
text = "".join(
c if not unicodedata.category(c) in ("Pd",) else " "
for c in text
if unicodedata.category(c)[0] in "LZN" or unicodedata.category(c) in ("Pd",)
c if unicodedata.category(c)[0] not in "P" else " " for c in text if unicodedata.category(c)[0] in "LZNP"
)
# remove extra space and articles and all lower case
text = remove_articles(text).strip()
@ -161,10 +152,10 @@ def sanitize_title(text: str, basic: bool = False) -> str:
return text
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> int:
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
sanitized_search = sanitize_title(search_title)
sanitized_record = sanitize_title(record_title)
ratio = thefuzz.fuzz.ratio(sanitized_search, sanitized_record)
ratio: int = thefuzz.fuzz.ratio(sanitized_search, sanitized_record)
logger.debug(
"search title: %s ; record title: %s ; ratio: %d ; match threshold: %d",
search_title,
@ -176,12 +167,12 @@ def titles_match(search_title: str, record_title: str, threshold: int = 90) -> i
def unique_file(file_name: pathlib.Path) -> pathlib.Path:
name = file_name.name
name = file_name.stem
counter = 1
while True:
if not file_name.exists():
return file_name
file_name = file_name.with_name(name + " (" + str(counter) + ")")
file_name = file_name.with_stem(name + " (" + str(counter) + ")")
counter += 1
@ -205,6 +196,7 @@ def get_language_from_iso(iso: str | None) -> str | None:
def get_language(string: str | None) -> str | None:
if string is None:
return None
string = string.casefold()
lang = get_language_from_iso(string)
@ -217,8 +209,6 @@ def get_language(string: str | None) -> str | None:
def get_publisher(publisher: str) -> tuple[str, str]:
if publisher is None:
return ("", "")
imprint = ""
for pub in publishers.values():

View File

@ -165,13 +165,13 @@ def post_process_matches(
def cli_mode(opts: argparse.Namespace, settings: ComicTaggerSettings) -> None:
if len(opts.files) < 1:
if len(opts.file_list) < 1:
logger.error("You must specify at least one filename. Use the -h option for more info")
return
match_results = OnlineMatchResults()
for f in opts.files:
for f in opts.file_list:
process_file_cli(f, opts, settings, match_results)
sys.stdout.flush()
@ -212,7 +212,7 @@ def create_local_metadata(opts: argparse.Namespace, ca: ComicArchive, settings:
def process_file_cli(
filename: str, opts: argparse.Namespace, settings: ComicTaggerSettings, match_results: OnlineMatchResults
) -> None:
batch_mode = len(opts.files) > 1
batch_mode = len(opts.file_list) > 1
ca = ComicArchive(filename, settings.rar_exe_path, ComicTaggerSettings.get_graphic("nocover.png"))
@ -472,7 +472,7 @@ def process_file_cli(
match_results.good_matches.append(str(ca.path.absolute()))
elif opts.rename:
original_path = ca.path
msg_hdr = ""
if batch_mode:
msg_hdr = f"{ca.path}: "
@ -529,7 +529,7 @@ def process_file_cli(
else:
suffix = " (dry-run, no change)"
print(f"renamed '{os.path.basename(ca.path)}' -> '{new_name}' {suffix}")
print(f"renamed '{original_path.name}' -> '{new_name}' {suffix}")
elif opts.export_to_zip:
msg_hdr = ""

View File

@ -184,8 +184,8 @@ class ComicVineTalker:
def get_url_content(self, url: str, params: dict[str, Any]) -> Any:
# connect to server:
# if there is a 500 error, try a few more times before giving up
# any other error, just bail
# if there is a 500 error, try a few more times before giving up
# any other error, just bail
for tries in range(3):
try:
resp = requests.get(url, params=params, headers={"user-agent": "comictagger/" + ctversion.version})
@ -199,10 +199,15 @@ class ComicVineTalker:
break
except requests.exceptions.RequestException as e:
self.write_log(str(e) + "\n")
self.write_log(f"{e}\n")
raise ComicVineTalkerException(ComicVineTalkerException.Network, "Network Error!") from e
except json.JSONDecodeError as e:
self.write_log(f"{e}\n")
raise ComicVineTalkerException(ComicVineTalkerException.Unknown, "ComicVine did not provide json")
raise ComicVineTalkerException(ComicVineTalkerException.Unknown, "Error on Comic Vine server")
raise ComicVineTalkerException(
ComicVineTalkerException.Unknown, f"Error on Comic Vine server: {resp.status_code}"
)
def search_for_series(
self,
@ -735,7 +740,7 @@ class ComicVineTalker:
)
self.nam.finished.connect(self.async_fetch_issue_cover_url_complete)
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(issue_url)))
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(issue_url.geturl())))
def async_fetch_issue_cover_url_complete(self, reply: QtNetwork.QNetworkReply) -> None:
# read in the response

View File

@ -113,7 +113,7 @@ class CoverImageWidget(QtWidgets.QWidget):
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = bytes()
self.imageData = b""
self.btnLeft.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("left.png")))
self.btnRight.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("right.png")))
@ -136,7 +136,7 @@ class CoverImageWidget(QtWidgets.QWidget):
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = bytes()
self.imageData = b""
def clear(self) -> None:
self.reset_widget()

View File

@ -20,10 +20,9 @@ import logging
import os
import pathlib
import string
import sys
from typing import Any, cast
from typing import Any, NamedTuple, cast
from pathvalidate import sanitize_filename
from pathvalidate import Platform, normalize_platform, sanitize_filename
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
@ -32,6 +31,16 @@ from comicapi.issuestring import IssueString
logger = logging.getLogger(__name__)
class Replacements(NamedTuple):
literal_text: list[tuple[str, str]]
format_value: list[tuple[str, str]]
REPLACEMENTS = Replacements(
literal_text=[(": ", " - "), (":", "-")], format_value=[(": ", " - "), (":", "-"), ("/", "-"), ("\\", "-")]
)
def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path:
folder = ca.path.parent.absolute()
if rename_dir is not None:
@ -42,16 +51,24 @@ def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> p
class MetadataFormatter(string.Formatter):
def __init__(self, smart_cleanup: bool = False, platform: str = "auto") -> None:
def __init__(
self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = REPLACEMENTS
) -> None:
super().__init__()
self.smart_cleanup = smart_cleanup
self.platform = platform
self.platform = normalize_platform(platform)
self.replacements = replacements
def format_field(self, value: Any, format_spec: str) -> str:
if value is None or value == "":
return ""
return cast(str, super().format_field(value, format_spec))
def handle_replacements(self, string: str, replacements: list[tuple[str, str]]) -> str:
for f, r in replacements:
string = string.replace(f, r)
return string
def _vformat(
self,
format_string: str,
@ -72,6 +89,8 @@ class MetadataFormatter(string.Formatter):
if lstrip:
literal_text = literal_text.lstrip("-_)}]#")
if self.smart_cleanup:
if self.platform in [Platform.UNIVERSAL, Platform.WINDOWS]:
literal_text = self.handle_replacements(literal_text, self.replacements.literal_text)
lspace = literal_text[0].isspace() if literal_text else False
rspace = literal_text[-1].isspace() if literal_text else False
literal_text = " ".join(literal_text.split())
@ -109,19 +128,29 @@ class MetadataFormatter(string.Formatter):
# format the object and append to the result
fmt_obj = self.format_field(obj, format_spec)
if fmt_obj == "" and len(result) > 0 and self.smart_cleanup and literal_text:
lstrip = True
if fmt_obj == "" and result and self.smart_cleanup and literal_text:
if self.str_contains(result[-1], "({["):
lstrip = True
if result:
if " " in result[-1]:
result[-1], _, _ = result[-1].rpartition(" ")
result[-1], _, _ = result[-1].rstrip().rpartition(" ")
result[-1] = result[-1].rstrip("-_({[#")
if self.smart_cleanup:
if self.platform in [Platform.UNIVERSAL, Platform.WINDOWS]:
# colons and slashes get special treatment
fmt_obj = self.handle_replacements(fmt_obj, self.replacements.format_value)
fmt_obj = " ".join(fmt_obj.split())
fmt_obj = str(sanitize_filename(fmt_obj, platform=self.platform))
result.append(fmt_obj)
return "".join(result), False
def str_contains(self, chars: str, string: str) -> bool:
for char in chars:
if char in string:
return True
return False
class FileRenamer:
def __init__(self, metadata: GenericMetadata | None, platform: str = "auto") -> None:
@ -172,13 +201,6 @@ class FileRenamer:
new_basename = ""
for component in pathlib.PureWindowsPath(template).parts:
if (
self.platform.casefold() in ["universal", "windows"] or sys.platform.casefold() in ["windows"]
) and self.smart_cleanup:
# colons get special treatment
component = component.replace(": ", " - ")
component = component.replace(":", "-")
new_basename = str(
sanitize_filename(fmt.vformat(component, args=[], kwargs=Default(md_dict)), platform=self.platform)
).strip()

View File

@ -97,14 +97,14 @@ class ImageFetcher:
# if we found it, just emit the signal asap
if image_data:
ImageFetcher.image_fetch_complete(QtCore.QByteArray(image_data))
return bytes()
return b""
# didn't find it. look online
self.nam.finished.connect(self.finish_request)
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
# we'll get called back when done...
return bytes()
return b""
def finish_request(self, reply: QtNetwork.QNetworkReply) -> None:
# read in the image data
@ -159,10 +159,10 @@ class ImageFetcher:
row = cur.fetchone()
if row is None:
return bytes()
return b""
filename = row[0]
image_data = bytes()
image_data = b""
try:
with open(filename, "rb") as f:

View File

@ -157,7 +157,7 @@ class IssueIdentifier:
cropped_im = im.crop((int(w / 2), 0, w, h))
except Exception:
logger.exception("cropCover() error")
return bytes()
return b""
output = io.BytesIO()
cropped_im.save(output, format="PNG")

View File

@ -405,6 +405,8 @@ def parse_cmd_line() -> argparse.Namespace:
opts.copy = opts.copy[0]
if opts.recursive:
opts.file_list = utils.get_recursive_filelist(opts.file_list)
opts.file_list = utils.get_recursive_filelist(opts.files)
else:
opts.file_list = opts.files
return opts

View File

@ -22,7 +22,8 @@ import pathlib
import platform
import sys
import uuid
from typing import Iterator, TextIO, no_type_check
from collections.abc import Iterator
from typing import TextIO, no_type_check
from comicapi import utils

View File

@ -26,7 +26,8 @@ import pprint
import re
import sys
import webbrowser
from typing import Any, Callable, Iterable, cast
from collections.abc import Iterable
from typing import Any, Callable, cast
from urllib.parse import urlparse
import natsort
@ -1854,7 +1855,7 @@ Have fun!
logger.error("Failed to load metadata for %s: %s", ca.path, e)
image_data = ca.get_page(cover_idx)
self.atprogdialog.set_archive_image(image_data)
self.atprogdialog.set_test_image(bytes())
self.atprogdialog.set_test_image(b"")
QtCore.QCoreApplication.processEvents()
if self.atprogdialog.isdone:

1
requirements-speedup.txt Normal file
View File

@ -0,0 +1 @@
thefuzz[speedup]>=0.19.0

View File

@ -2,11 +2,12 @@ beautifulsoup4 >= 4.1
importlib_metadata
natsort>=8.1.0
pathvalidate
pillow>=4.3.0
pillow>=9.1.0
py7zr
pycountry
pyicu; sys_platform == 'linux' or sys_platform == 'darwin'
requests==2.*
text2digits
thefuzz[speedup]>=0.19.0
thefuzz>=0.19.0
typing_extensions
wordninja

View File

@ -114,6 +114,7 @@ imprints = [
("marvel", ("", "Marvel")),
("marvel comics", ("", "Marvel")),
("aircel", ("Aircel Comics", "Marvel")),
("nothing", ("", "nothing")),
]
additional_imprints = [

Binary file not shown.

View File

@ -733,6 +733,20 @@ fnames = [
},
True,
),
(
"Cory Doctorow's Futuristic Tales of the Here and Now: Anda's Game #001 (2007).cbz",
"full-date, issue in parenthesis",
{
"issue": "1",
"series": "Cory Doctorow's Futuristic Tales of the Here and Now",
"title": "Anda's Game",
"volume": "",
"year": "2007",
"remainder": "",
"issue_count": "",
},
True,
),
]
rnames = [
@ -743,6 +757,13 @@ rnames = [
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
),
(
"{series} #{issue} - {title} {volume:02} ({year})", # Ensure format specifier works
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game 01 (2007).cbz",
does_not_raise(),
),
(
"{series} #{issue} - {title} ({year})({price})", # price should be none, test no space between ')('
False,
@ -764,6 +785,20 @@ rnames = [
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
),
(
"{title} {web_link}", # Ensure colon is replaced in metadata
False,
"universal",
"Anda's Game https---comicvine.gamespot.com-cory-doctorows-futuristic-tales-of-the-here-and-no-4000-140529-.cbz",
does_not_raise(),
),
(
"{series}:{title} #{issue} ({year})", # on windows the ':' is replaced
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now-Anda's Game #001 (2007).cbz",
does_not_raise(),
),
(
"{series}: {title} #{issue} ({year})", # on windows the ':' is replaced
False,
@ -795,7 +830,7 @@ rnames = [
(
r"{publisher}\ {series} #{issue} - {title} ({year})", # backslashes separate directories
False,
"universal",
"Linux",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
),
@ -807,10 +842,10 @@ rnames = [
does_not_raise(),
),
(
"{series} # {issue} - {locations} ({year})",
"{series} #{issue} - {locations} ({year})",
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now # 001 - lonely cottage (2007).cbz",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - lonely cottage (2007).cbz",
does_not_raise(),
),
(
@ -848,6 +883,20 @@ rnames = [
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game {test} #001 (2007).cbz",
does_not_raise(),
),
(
"{series} - {title} #{issue} ({year} {price})", # Test null value in parenthesis with a non-null value
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz",
does_not_raise(),
),
(
"{series} - {title} #{issue} (of {price})", # null value with literal text in parenthesis
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001.cbz",
does_not_raise(),
),
(
"{series} - {title} {1} #{issue} ({year})", # Test numeric key
False,

View File

@ -15,6 +15,8 @@ def test_getPageNameList():
pageNameList = c.get_page_name_list()
assert pageNameList == [
"!cover.jpg",
"00.jpg",
"page0.jpg",
"Page1.jpeg",
"Page2.png",

View File

@ -5,7 +5,8 @@ import datetime
import io
import shutil
import unittest.mock
from typing import Any, Generator
from collections.abc import Generator
from typing import Any
import pytest
import requests

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import os
import pytest
import comicapi.utils
@ -22,13 +24,16 @@ def test_recursive_list_with_file(tmp_path) -> None:
temp_txt = tmp_path / "info.txt"
temp_txt.write_text("this is here")
expected_result = {str(foo_png), str(temp_cbr), str(temp_file), str(temp_txt)}
result = set(comicapi.utils.get_recursive_filelist([tmp_path]))
temp_txt2 = tmp_path / "info2.txt"
temp_txt2.write_text("this is here")
expected_result = {str(foo_png), str(temp_cbr), str(temp_file), str(temp_txt), str(temp_txt2)}
result = set(comicapi.utils.get_recursive_filelist([str(temp_txt2), tmp_path]))
assert result == expected_result
values = [
xlate_values = [
({"data": "", "is_int": False, "is_float": False}, None),
({"data": None, "is_int": False, "is_float": False}, None),
({"data": None, "is_int": True, "is_float": False}, None),
@ -52,6 +57,70 @@ values = [
]
@pytest.mark.parametrize("value, result", values)
@pytest.mark.parametrize("value, result", xlate_values)
def test_xlate(value, result):
assert comicapi.utils.xlate(**value) == result
language_values = [
("en", "English"),
("EN", "English"),
("En", "English"),
("", None),
(None, None),
]
@pytest.mark.parametrize("value, result", language_values)
def test_get_language(value, result):
assert result == comicapi.utils.get_language(value)
def test_unique_file(tmp_path):
file = tmp_path / "test.cbz"
assert file == comicapi.utils.unique_file(file)
file.mkdir()
assert (tmp_path / "test (1).cbz") == comicapi.utils.unique_file(file)
def test_add_to_path(monkeypatch):
monkeypatch.setenv("PATH", os.path.abspath("/usr/bin"))
comicapi.utils.add_to_path("/bin")
assert os.environ["PATH"] == (os.path.abspath("/bin") + os.pathsep + os.path.abspath("/usr/bin"))
comicapi.utils.add_to_path("/usr/bin")
comicapi.utils.add_to_path("/usr/bin/")
assert os.environ["PATH"] == (os.path.abspath("/bin") + os.pathsep + os.path.abspath("/usr/bin"))
titles = [
(("", ""), True),
(("Conan el Barbaro", "Conan el Bárbaro"), True),
(("鋼の錬金術師", "鋼の錬金術師"), True),
(("钢之炼金术师", "鋼の錬金術師"), False),
(("batmans grave", "The Batman's Grave"), True),
(("batman grave", "The Batman's Grave"), True),
(("bats grave", "The Batman's Grave"), False),
]
@pytest.mark.parametrize("value, result", titles)
def test_titles_match(value, result):
assert comicapi.utils.titles_match(value[0], value[1]) == result
titles_2 = [
("", ""),
("鋼の錬金術師", "鋼の錬金術師"),
("Conan el Bárbaro", "Conan el Barbaro"),
("The Batman's Grave", "batmans grave"),
("A+X", "ax"),
("ms. marvel", "ms marvel"),
("spider-man/deadpool", "spider man deadpool"),
]
@pytest.mark.parametrize("value, result", titles_2)
def test_sanitize_title(value, result):
assert comicapi.utils.sanitize_title(value) == result.casefold()