Compare commits

...
32 Commits
Author SHA1 Message Date
legonzaur 45da4c11f4 fix: allow comi.number to be a float 2025-12-30 15:25:50 +01:00
legonzaur 1e23fad6b5 fix: allow Comic.number to be a float 2025-12-30 15:25:46 +01:00
legonzaur 4ebee2a6cf chore: formatting 2025-12-30 15:25:19 +01:00
legonzaur ef4eff4868 fix pyproject toml to work with uv 2025-12-30 15:24:47 +01:00
hyugogirubato d37be87c2f Merge pull request #29 from hyugogirubato/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-11-30 14:59:33 +01:00
dependabot[bot] 0b011a7efc Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 15:58:42 +00:00
hyugogirubato 62b8cf5739 simplify suffix check 2025-11-04 00:00:46 +01:00
hyugogirubato 871716971a release v3.4.5 2025-10-19 19:48:27 +02:00
hyugogirubato 597cb41b00 more flexible suffix check 2025-10-19 19:48:13 +02:00
hyugogirubato a489ae17fb check invalid file content 2025-10-19 19:47:52 +02:00
hyugogirubato abc69d4673 Linking content check 2025-10-19 19:47:27 +02:00
hyugogirubato 6a1111a6bb release v3.4.4 2025-09-21 10:41:42 +02:00
hyugogirubato 220f19fe46 add additional image format 2025-09-20 19:43:52 +02:00
hyugogirubato bbab9ba3b3 Merge pull request #24 from hyugogirubato/dependabot/github_actions/actions/setup-python-6
Bump actions/setup-python from 5 to 6
2025-09-17 21:20:51 +02:00
hyugogirubato 5824a8428a add thrid-party plugins licences 2025-09-17 21:19:50 +02:00
hyugogirubato 23f7e37561 load pillow extra plugins 2025-09-17 21:16:40 +02:00
hyugogirubato 5cc4732238 add pillow extra plugins 2025-09-17 21:16:27 +02:00
hyugogirubato 9d5d539311 force sort archive filenames 2025-09-17 20:48:20 +02:00
dependabot[bot] f8274ae26b Bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 14:42:19 +00:00
hyugogirubato c045c548de release v3.4.4 2025-08-31 21:21:53 +02:00
hyugogirubato 7355e5edc0 Merge pull request #23 from hyugogirubato/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-31 21:14:36 +02:00
hyugogirubato 5eee972d75 Merge pull request #22 from tssujt/single
Fix ComicInfo XML parsing for single page comic
2025-08-31 21:14:07 +02:00
dependabot[bot] 3604a4e98f Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 23:05:20 +00:00
tssujt 19c1b01ea6 fix ComicInfo XML parsing for single page comic 2025-08-01 14:27:05 +08:00
hyugogirubato 7e599a1adb release v3.4.2 2025-07-26 17:21:02 +02:00
hyugogirubato 312eb2da13 Merge pull request #21 from hyugogirubato/dependabot/pip/pytest-gte-7.4.0-and-lt-9.0.0
Update pytest requirement from >=7.4.0,<8.0.0 to >=7.4.0,<9.0.0
2025-07-26 17:19:39 +02:00
hyugogirubato b7d1b631b9 fixing typo 2025-07-26 17:17:49 +02:00
hyugogirubato fe43024656 format 2025-07-26 17:17:28 +02:00
hyugogirubato 58a5256dc1 moved example to test dir 2025-07-26 17:17:13 +02:00
dependabot[bot] 7d0f004345 Update pytest requirement from >=7.4.0,<8.0.0 to >=7.4.0,<9.0.0
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/7.4.0...8.3.5)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.3.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-26 14:45:37 +00:00
hyugogirubato 6aeabc2047 Merge pull request #20 from tssujt/tests
ci: Add testing framework setup
2025-07-26 16:44:30 +02:00
tssujt 0c4aa34f8f ci: Add testing framework setup 2025-07-10 12:08:28 +08:00
27 changed files with 954 additions and 181 deletions
+11
View File
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+27
View File
@@ -0,0 +1,27 @@
name: Tests
on: [ push, pull_request ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ]
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install poetry
poetry install --no-interaction --no-root --with dev
- name: Run tests with pytest
run: |
poetry run pytest tests/ -v --cov=cbz --cov-report=xml --cov-report=term-missing
+37
View File
@@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.4.5] - 2025-10-19
### Changed
- More flexible suffix checking to support complex archive paths.
- Improved validation in `PageInfo.load()` to handle empty or invalid data correctly.
## [3.4.4] - 2025-09-21
### Added
- Added `[pillow]` extension to support `.jxl` and `.avif` images.
### Fixed
- Files in the input archives are now sorted alphabetically.
## [3.4.3] - 2025-08-31
### Changed
- Bump actions/checkout from 4 to 5.
### Fixed
- Fix ComicInfo XML parsing for single page comic.
## [3.4.2] - 2025-07-26
### Added
- Adding CI tests.
## [3.4.1] - 2025-07-11
### Fixed
@@ -199,6 +232,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Initial release.
[3.4.5]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.5
[3.4.4]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.4
[3.4.3]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.3
[3.4.2]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.2
[3.4.1]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.1
[3.4.0]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.0
[3.3.7]: https://github.com/hyugogirubato/cbz/releases/tag/v3.3.7
+3 -1
View File
@@ -216,10 +216,12 @@ This project uses the following third-party libraries:
- **[langcodes](https://pypi.org/project/langcodes/)** - MIT License
- **[Pillow](https://pypi.org/project/Pillow/)** - HPND License
- **[pillow-avif-plugin](https://pypi.org/project/pillow-avif-plugin/)** - MIT License
- **[pillow-jxl-plugin](https://pypi.org/project/pillow-jxl-plugin/)** - MIT License
- **[pypdf](https://pypi.org/project/pypdf/)** - BSD License
- **[rarfile](https://pypi.org/project/rarfile/)** - ISC License
- **[xmltodict](https://pypi.org/project/xmltodict/)** - MIT License
* * *
---
© hyugogirubato 2025
+7
View File
@@ -1,2 +1,9 @@
from .comic import ComicInfo
from .page import PageInfo
# Register optional Pillow plugins
for _plugin in ('pillow_avif', 'pillow_jxl'):
try:
__import__(_plugin)
except ImportError:
pass
+19 -13
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import zipfile
from datetime import datetime, timezone
@@ -156,12 +157,12 @@ class ComicInfo(ComicModel):
ComicInfo: An instance of ComicInfo.
"""
pages = []
names = archive_file.namelist()
names = sorted(archive_file.namelist())
comic_info = {}
if XML_NAME in names:
with archive_file.open(XML_NAME, 'r') as f:
comic_info = xmltodict.parse(f.read()).get('ComicInfo', {})
comic_info = xmltodict.parse(f.read(), force_list=('Page',)).get('ComicInfo', {})
names.remove(XML_NAME)
comic = ComicInfo.__extract_info(
@@ -172,17 +173,22 @@ class ComicInfo(ComicModel):
pages_info = comic_info.get('Pages', {}).get('Page', [])
for i, name in enumerate(names):
suffix = Path(name).suffix
if suffix:
assert suffix in IMAGE_FORMAT, f'Unsupported image format: {suffix}'
with archive_file.open(name, 'r') as f:
page_info = {}
if i < len(pages_info):
page_info = ComicInfo.__extract_info(
items=pages_info[i],
fields=PAGE_FIELDS
)
page_info['name'] = Path(name).name
pages.append(PageInfo.loads(data=f.read(), **page_info))
# Some archives may contain folders or hidden files (e.g., ".extra/" or "__MACOSX/.DS_Store")
# that could corrupt the suffix detection and cause invalid image handling.
if suffix not in IMAGE_FORMAT:
logging.warning(f'Skipping unsupported or invalid file: {name!r} (suffix={suffix!r})')
continue
with archive_file.open(name, 'r') as f:
page_info = {}
if i < len(pages_info):
page_info = ComicInfo.__extract_info(
items=pages_info[i],
fields=PAGE_FIELDS
)
page_info['name'] = Path(name).name
pages.append(PageInfo.loads(data=f.read(), **page_info))
return ComicInfo.from_pages(pages=pages, **comic)
+140 -134
View File
@@ -3,189 +3,195 @@ from typing import Union
from langcodes import Language
XML_NAME = 'ComicInfo.xml'
XML_NAME = "ComicInfo.xml"
IMAGE_FORMAT = {
'.jpeg', '.jpg', # Joint Photographic Experts Group
'.png', # Portable Network Graphics
'.gif', # Graphics Interchange Format
'.bmp', # Bitmap Image File
'.tiff', '.tif', # Tagged Image File Format
'.webp' # Web Picture Format
".jpeg",
".jpg", # JPEG (Joint Photographic Experts Group)
".png", # PNG (Portable Network Graphics)
".gif", # GIF (Graphics Interchange Format)
".bmp", # BMP (Bitmap Image File)
".tiff",
".tif", # TIFF (Tagged Image File Format)
".webp", # WebP (Web Picture Format, by Google)
".jxl", # JPEG XL (Next-generation JPEG format)
".avif", # AVIF (AV1 Image File Format, based on AV1 codec)
}
class YesNo(Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
UNKNOWN = "Unknown"
NO = "No"
YES = "Yes"
class Manga(Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
RIGHT_LEFT = 'YesAndRightToLeft'
UNKNOWN = "Unknown"
NO = "No"
YES = "Yes"
RIGHT_LEFT = "YesAndRightToLeft"
class PageType(Enum):
FRONT_COVER = 'FrontCover'
INNER_COVER = 'InnerCover'
ROUNDUP = 'Roundup'
STORY = 'Story'
ADVERTISEMENT = 'Advertisement'
EDITORIAL = 'Editorial'
LETTERS = 'Letters'
PREVIEW = 'Preview'
BACK_COVER = 'BackCover'
OTHER = 'Other'
DELETED = 'Deleted'
FRONT_COVER = "FrontCover"
INNER_COVER = "InnerCover"
ROUNDUP = "Roundup"
STORY = "Story"
ADVERTISEMENT = "Advertisement"
EDITORIAL = "Editorial"
LETTERS = "Letters"
PREVIEW = "Preview"
BACK_COVER = "BackCover"
OTHER = "Other"
DELETED = "Deleted"
class AgeRating(Enum):
UNKNOWN = 'Unknown'
ADULTS18 = 'Adults Only 18+'
CHILDHOOD = 'Early Childhood'
EVERYONE = 'Everyone'
EVERYONE10 = 'Everyone 10+'
G = 'G'
KIDS = 'Kids to Adults'
M = 'M'
MA15 = 'MA15+'
MATURE17 = 'Mature 17+'
PG = 'PG'
R18 = 'R18+'
PENDING = 'Rating Pending'
TEEN = 'Teen'
X18 = 'X18+'
UNKNOWN = "Unknown"
ADULTS18 = "Adults Only 18+"
CHILDHOOD = "Early Childhood"
EVERYONE = "Everyone"
EVERYONE10 = "Everyone 10+"
G = "G"
KIDS = "Kids to Adults"
M = "M"
MA15 = "MA15+"
MATURE17 = "Mature 17+"
PG = "PG"
R18 = "R18+"
PENDING = "Rating Pending"
TEEN = "Teen"
X18 = "X18+"
class Format(Enum):
UNKNOWN = 'Unknown'
UNKNOWN = "Unknown"
# ONE_SHOT = '1 Shot'
# ONE_SHOT = '1/2',
# ONE_SHOT = '1-Shot'
ANNOTATION = 'Annotation'
ANNOTATION = "Annotation"
# ANNOTATIONS = 'Annotations'
ANNUAL = 'Annual'
ANTHOLOGY = 'Anthology'
ANNUAL = "Annual"
ANTHOLOGY = "Anthology"
# BLACK_WHITE = 'B&W'
# BLACK_WHITE = 'B/W'
# BLACK_WHITE = 'B&&W'
BLACK_WHITE = 'Black & White'
BLACK_WHITE = "Black & White"
# BOX_SET = 'Box Set'
BOX_SET = 'Box-Set'
CROSSOVER = 'Crossover'
DIRECTOR_CUT = 'Director\'s Cut'
EPILOGUE = 'Epilogue'
EVENT = 'Event'
FCBD = 'FCBD'
FLYER = 'Flyer'
BOX_SET = "Box-Set"
CROSSOVER = "Crossover"
DIRECTOR_CUT = "Director's Cut"
EPILOGUE = "Epilogue"
EVENT = "Event"
FCBD = "FCBD"
FLYER = "Flyer"
# GIANT_SIZE = 'Giant'
# GIANT_SIZE = 'Giant Size'
GIANT_SIZE = 'Giant-Size'
GRAPHIC_NOVEL = 'Graphic Novel'
GIANT_SIZE = "Giant-Size"
GRAPHIC_NOVEL = "Graphic Novel"
# HARDCOVER = 'Hardcover'
HARDCOVER = 'Hard-Cover'
HARDCOVER = "Hard-Cover"
# KING_SIZE = 'King'
# KING_SIZE = 'King Size'
KING_SIZE = 'King-Size'
LIMITED_SERIES = 'Limited Series'
MAGAZINE = 'Magazine'
NSFW = 'NSFW'
KING_SIZE = "King-Size"
LIMITED_SERIES = "Limited Series"
MAGAZINE = "Magazine"
NSFW = "NSFW"
# ONE_SHOT = 'One Shot'
ONE_SHOT = 'One-Shot'
POINT1 = 'Point 1'
PREVIEW = 'Preview'
PROLOGUE = 'Prologue'
REFERENCE = 'Reference'
REVIEW = 'Review'
REVIEWED = 'Reviewed'
SCANLATION = 'Scanlation'
SCRIPT = 'Script'
SERIES = 'Series'
SKETCH = 'Sketch'
SPECIAL = 'Special'
ONE_SHOT = "One-Shot"
POINT1 = "Point 1"
PREVIEW = "Preview"
PROLOGUE = "Prologue"
REFERENCE = "Reference"
REVIEW = "Review"
REVIEWED = "Reviewed"
SCANLATION = "Scanlation"
SCRIPT = "Script"
SERIES = "Series"
SKETCH = "Sketch"
SPECIAL = "Special"
# TRADE_PAPER_BACK = 'TPB'
TRADE_PAPER_BACK = 'Trade Paper Back'
TRADE_PAPER_BACK = "Trade Paper Back"
# WEB_COMIC = 'WebComic'
WEB_COMIC = 'Web Comic'
WEB_COMIC = "Web Comic"
# YEAR_ONE = 'Year 1'
YEAR_ONE = 'Year One'
YEAR_ONE = "Year One"
class Rating(float):
def __new__(cls, value: Union[int, float] = -1) -> float:
assert -1 <= float(value) <= 5, f'Rating must be between 0 and 5, input {value}'
assert -1 <= float(value) <= 5, f"Rating must be between 0 and 5, input {value}"
return super().__new__(cls, value)
class LanguageISO(str):
def __new__(cls, value: str = '') -> str:
assert not value or Language.get(str(value)).is_valid(), f'Invalid {value} language'
def __new__(cls, value: str = "") -> str:
assert (
not value or Language.get(str(value)).is_valid()
), f"Invalid {value} language"
return super().__new__(cls, value)
COMIC_FIELDS = {
'title': ('Title', str),
'series': ('Series', str),
'number': ('Number', int),
'count': ('Count', int),
'volume': ('Volume', int),
'alternate_series': ('AlternateSeries', str),
'alternate_number': ('AlternateNumber', int),
'alternate_count': ('AlternateCount', int),
'summary': ('Summary', str),
'notes': ('Notes', str),
'year': ('Year', int),
'month': ('Month', int),
'day': ('Day', int),
'writer': ('Writer', str),
'penciller': ('Penciller', str),
'inker': ('Inker', str),
'colorist': ('Colorist', str),
'letterer': ('Letterer', str),
'cover_artist': ('CoverArtist', str),
'editor': ('Editor', str),
'translator': ('Translator', str),
'publisher': ('Publisher', str),
'imprint': ('Imprint', str),
'genre': ('Genre', str),
'tags': ('Tags', str),
'web': ('Web', str),
'format': ('Format', Format),
'ean': ('EAN', str),
'black_white': ('BlackAndWhite', YesNo),
'manga': ('Manga', Manga),
'characters': ('Characters', str),
'teams': ('Teams', str),
'locations': ('Locations', str),
'scan_information': ('ScanInformation', str),
'story_arc': ('StoryArc', str),
'story_arc_number': ('StoryArcNumber', int),
'series_group': ('SeriesGroup', str),
'age_rating': ('AgeRating', AgeRating),
'main_character_or_team': ('MainCharacterOrTeam', str),
'review': ('Review', str),
'language_iso': ('LanguageISO', LanguageISO),
'community_rating': ('CommunityRating', Rating),
'added': ('Added', str),
'released': ('Released', str),
'file_size': ('FileSize', int),
'file_modified_time': ('FileModifiedTime', str),
'file_creation_time': ('FileCreationTime', str),
'book_price': ('BookPrice', str),
'custom_values_store': ('CustomValuesStore', str)
"title": ("Title", str),
"series": ("Series", str),
"number": ("Number", float),
"count": ("Count", int),
"volume": ("Volume", int),
"alternate_series": ("AlternateSeries", str),
"alternate_number": ("AlternateNumber", int),
"alternate_count": ("AlternateCount", int),
"summary": ("Summary", str),
"notes": ("Notes", str),
"year": ("Year", int),
"month": ("Month", int),
"day": ("Day", int),
"writer": ("Writer", str),
"penciller": ("Penciller", str),
"inker": ("Inker", str),
"colorist": ("Colorist", str),
"letterer": ("Letterer", str),
"cover_artist": ("CoverArtist", str),
"editor": ("Editor", str),
"translator": ("Translator", str),
"publisher": ("Publisher", str),
"imprint": ("Imprint", str),
"genre": ("Genre", str),
"tags": ("Tags", str),
"web": ("Web", str),
"format": ("Format", Format),
"ean": ("EAN", str),
"black_white": ("BlackAndWhite", YesNo),
"manga": ("Manga", Manga),
"characters": ("Characters", str),
"teams": ("Teams", str),
"locations": ("Locations", str),
"scan_information": ("ScanInformation", str),
"story_arc": ("StoryArc", str),
"story_arc_number": ("StoryArcNumber", int),
"series_group": ("SeriesGroup", str),
"age_rating": ("AgeRating", AgeRating),
"main_character_or_team": ("MainCharacterOrTeam", str),
"review": ("Review", str),
"language_iso": ("LanguageISO", LanguageISO),
"community_rating": ("CommunityRating", Rating),
"added": ("Added", str),
"released": ("Released", str),
"file_size": ("FileSize", int),
"file_modified_time": ("FileModifiedTime", str),
"file_creation_time": ("FileCreationTime", str),
"book_price": ("BookPrice", str),
"custom_values_store": ("CustomValuesStore", str),
}
PAGE_FIELDS = {
'image': ('@Image', int),
'type': ('@Type', PageType),
'double': ('@DoublePage', bool),
'key': ('@Key', str),
'bookmark': ('@Bookmark', str),
'image_size': ('@ImageSize', int),
'image_width': ('@ImageWidth', int),
'image_height': ('@ImageHeight', int)
"image": ("@Image", int),
"type": ("@Type", PageType),
"double": ("@DoublePage", bool),
"key": ("@Key", str),
"bookmark": ("@Bookmark", str),
"image_size": ("@ImageSize", int),
"image_width": ("@ImageWidth", int),
"image_height": ("@ImageHeight", int),
}
+30 -5
View File
@@ -1,5 +1,19 @@
from cbz.constants import COMIC_FIELDS, PAGE_FIELDS, Format, YesNo, Manga, AgeRating, LanguageISO, Rating, PageType
from cbz.constants import (
COMIC_FIELDS,
PAGE_FIELDS,
Format,
YesNo,
Manga,
AgeRating,
LanguageISO,
Rating,
PageType,
)
from cbz.utils import verify_attr, default_attr
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
class BaseModel:
@@ -21,7 +35,7 @@ class BaseModel:
# Set default values for each attribute based on its type
setattr(self, key, kwargs.get(key, default_attr(field_type)))
def __setattr__(self, key: str, value: any) -> None:
def __setattr__(self, key: str, value: "Any") -> None:
"""
Sets the value of an attribute and verifies its type.
@@ -39,6 +53,9 @@ class BaseModel:
value = field_type(value)
# Verify that the assigned value matches the expected type
verify_attr(field_type, key, value)
if isinstance(value, float):
if value.is_integer():
value = int(value)
except (AttributeError, KeyError):
pass
super().__setattr__(key, value)
@@ -50,9 +67,15 @@ class BaseModel:
Returns:
str: A string representation of the object, displaying its class name and attribute key-value pairs.
"""
return '{name}({items})'.format(
return "{name}({items})".format(
name=self.__class__.__name__,
items=', '.join([f'{k}={repr(v)}' for k, v in self.__dict__.items() if not k.startswith('_')])
items=", ".join(
[
f"{k}={repr(v)}"
for k, v in self.__dict__.items()
if not k.startswith("_")
]
),
)
@@ -60,9 +83,10 @@ class ComicModel(BaseModel):
"""
Model for representing comic book metadata.
"""
title: str
series: str
number: int
number: float
count: int
volume: int
alternate_series: str
@@ -124,6 +148,7 @@ class PageModel(BaseModel):
"""
Model for representing comic book pages.
"""
type: PageType
double: bool
image_size: int
+3 -1
View File
@@ -75,6 +75,8 @@ class PageInfo(PageModel):
data = base64.b64decode(data)
if not isinstance(data, bytes):
raise ValueError(f'Expecting Bytes or Base64 input, got {data!r}')
if not data.strip():
raise ValueError(f'Empty or null data provided (length={len(data)})')
return cls(data, **kwargs)
@classmethod
@@ -98,7 +100,7 @@ class PageInfo(PageModel):
path = Path(path)
with path.open(mode='rb') as f:
kwargs.setdefault('name', path.name)
return cls(f.read(), **kwargs)
return cls.loads(f.read(), **kwargs)
def show(self) -> None:
"""
+34 -26
View File
@@ -1,16 +1,20 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
[project]
name = "cbz"
version = "3.4.1"
version = "3.4.5"
description = "CBZ simplifies creating, managing, and viewing comic book files in CBZ, CBR, and PDF formats, offering seamless packaging, metadata handling and built-in viewing capabilities."
license = "MIT"
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
authors = [{name="hyugogirubato", email="65763543+hyugogirubato@users.noreply.github.com"}]
readme = "README.md"
repository = "https://github.com/hyugogirubato/cbz"
keywords = ["python", "cbz", "cbr", "pdf", "ebooks", "manga", "comics", "webtoons"]
keywords = [
"python",
"cbz",
"cbr",
"pdf",
"ebooks",
"manga",
"comics",
"webtoons",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
@@ -20,31 +24,35 @@ classifiers = [
"Topic :: Utilities",
"Topic :: Software Development :: Libraries :: Python Modules",
]
include = [
{ path = "CHANGELOG.md", format = "sdist" },
{ path = "README.md", format = "sdist" },
{ path = "LICENSE", format = "sdist" }
requires-python = ">=3.8,<4.0"
dependencies= [
"langcodes>=3.4.0",
"Pillow>=10.4.0",
"pypdf >=5.7.0",
"rarfile>=4.2",
"xmltodict>=0.14.2"
]
[tool.poetry.urls]
"Issues" = "https://github.com/hyugogirubato/cbz/issues"
"Changelog" = "https://github.com/hyugogirubato/cbz/blob/main/CHANGELOG.md"
[tool.poetry.dependencies]
python = ">=3.8,<4.0"
langcodes = ">=3.4.0"
Pillow = ">=10.4.0"
pypdf = ">=5.7.0"
rarfile = ">=4.2"
xmltodict = ">=0.14.2"
[tool.poetry.scripts]
cbzplayer = "cbz.__main__:main"
[project.optional-dependencies]
pillow = ["pillow-avif-plugin>=1.5.2", "pillow-jxl-plugin>=1.3.4"]
[[tool.poetry.source]]
name = "localpypi"
url = "https://pypi.org/simple/"
priority = "primary"
[dependency-groups]
dev = ["pytest>=7.4.0,<9.0.0", "pytest-cov>=5.0.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--cov=cbz",
]
[certificates]
localpypi = { cert = false }
View File
+55
View File
@@ -0,0 +1,55 @@
from pathlib import Path
import pytest
from cbz.comic import ComicInfo
from cbz.page import PageInfo
from cbz.constants import PageType
@pytest.fixture
def fixtures_dir() -> Path:
"""Fixture that provides the path to the test fixtures directory."""
return Path(__file__).parent / 'fixtures'
@pytest.fixture
def images_dir(fixtures_dir: Path) -> Path:
"""Fixture that provides the path to the test images directory."""
return fixtures_dir / 'images'
@pytest.fixture
def sample_image_path(images_dir: Path) -> Path:
"""Fixture that provides a sample image path."""
return images_dir / 'page-000.jpg'
@pytest.fixture
def sample_cbz_file(tmp_path: Path, images_dir: Path) -> Path:
"""Fixture that creates a sample CBZ file for testing."""
# Load sample pages
image_paths = sorted(list(images_dir.iterdir()))[:3] # Use first 3 images
pages = []
for i, path in enumerate(image_paths):
page_type = PageType.FRONT_COVER if i == 0 else PageType.STORY
page = PageInfo.load(path=path, type=page_type)
pages.append(page)
# Create comic from pages
comic = ComicInfo.from_pages(
pages=pages,
title='Test Comic',
series='Test Series',
number=1,
volume=1,
year=2024
)
# Save to temporary file
cbz_path = tmp_path / 'test_comic.cbz'
cbz_content = comic.pack()
cbz_path.write_bytes(cbz_content)
return cbz_path
+1 -1
View File
@@ -7,7 +7,7 @@ from cbz.page import PageInfo
PARENT = Path(__file__).parent
if __name__ == '__main__':
paths = list((PARENT / 'images').iterdir())
paths = list((PARENT / 'fixtures' / 'images').iterdir())
# Load each page from the 'images' folder into a list of PageInfo objects
pages = [

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 619 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 592 KiB

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 680 KiB

Before

Width:  |  Height:  |  Size: 590 KiB

After

Width:  |  Height:  |  Size: 590 KiB

Before

Width:  |  Height:  |  Size: 492 KiB

After

Width:  |  Height:  |  Size: 492 KiB

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 479 KiB

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 549 KiB

+179
View File
@@ -0,0 +1,179 @@
import tempfile
from pathlib import Path
from typing import List
from cbz.comic import ComicInfo
from cbz.page import PageInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
class TestComicInfo:
"""Test cases for ComicInfo class."""
def test_from_pages_creation(self, images_dir: Path) -> None:
"""Test creating ComicInfo from pages."""
# Load sample pages
image_paths = sorted(list(images_dir.iterdir()))[:3] # Use first 3 images
pages: List[PageInfo] = []
for i, path in enumerate(image_paths):
page_type = PageType.FRONT_COVER if i == 0 else PageType.STORY
page = PageInfo.load(path=path, type=page_type)
pages.append(page)
# Create comic from pages
comic = ComicInfo.from_pages(
pages=pages,
title='Test Comic',
series='Test Series',
number=1,
volume=1,
year=2024
)
assert comic.title == 'Test Comic'
assert comic.series == 'Test Series'
assert comic.number == 1
assert comic.volume == 1
assert comic.year == 2024
assert len(comic.pages) == 3
assert comic.pages[0].type == PageType.FRONT_COVER
assert comic.pages[1].type == PageType.STORY
def test_from_cbz_file(self, sample_cbz_file: Path) -> None:
"""Test loading ComicInfo from CBZ file."""
comic = ComicInfo.from_cbz(sample_cbz_file)
assert comic is not None
assert hasattr(comic, 'pages')
assert len(comic.pages) > 0
assert all(isinstance(page, PageInfo) for page in comic.pages)
def test_pack_cbz(self, images_dir: Path) -> None:
"""Test packing comic into CBZ format."""
# Create a simple comic
image_paths = sorted(list(images_dir.iterdir()))[:2]
pages = [PageInfo.load(path=path) for path in image_paths]
comic = ComicInfo.from_pages(
pages=pages,
title='Pack Test',
series='Test Series'
)
# Pack to CBZ
cbz_content = comic.pack()
assert isinstance(cbz_content, bytes)
assert len(cbz_content) > 0
def test_pack_with_rename(self, images_dir: Path) -> None:
"""Test packing comic with page renaming."""
image_paths = sorted(list(images_dir.iterdir()))[:2]
pages = [PageInfo.load(path=path) for path in image_paths]
comic = ComicInfo.from_pages(
pages=pages,
title='Rename Test'
)
# Pack with rename option
cbz_content = comic.pack(rename=True)
assert isinstance(cbz_content, bytes)
assert len(cbz_content) > 0
def test_comic_metadata_properties(self, images_dir: Path) -> None:
"""Test comic metadata properties."""
image_paths = sorted(list(images_dir.iterdir()))[:1]
pages = [PageInfo.load(path=path) for path in image_paths]
comic = ComicInfo.from_pages(
pages=pages,
title='Metadata Test',
series='Test Series',
number=5,
count=10,
volume=2,
summary='Test summary',
year=2023,
month=6,
day=15,
writer='Test Writer',
penciller='Test Penciller',
inker='Test Inker',
colorist='Test Colorist',
letterer='Test Letterer',
cover_artist='Test Cover Artist',
editor='Test Editor',
publisher='Test Publisher',
imprint='Test Imprint',
genre='Test Genre',
language_iso='en',
format=Format.SERIES,
black_white=YesNo.NO,
manga=Manga.RIGHT_LEFT,
age_rating=AgeRating.TEEN,
community_rating=4
)
assert comic.title == 'Metadata Test'
assert comic.series == 'Test Series'
assert comic.number == 5
assert comic.count == 10
assert comic.volume == 2
assert comic.summary == 'Test summary'
assert comic.year == 2023
assert comic.month == 6
assert comic.day == 15
assert comic.writer == 'Test Writer'
assert comic.penciller == 'Test Penciller'
assert comic.inker == 'Test Inker'
assert comic.colorist == 'Test Colorist'
assert comic.letterer == 'Test Letterer'
assert comic.cover_artist == 'Test Cover Artist'
assert comic.editor == 'Test Editor'
assert comic.publisher == 'Test Publisher'
assert comic.imprint == 'Test Imprint'
assert comic.genre == 'Test Genre'
assert comic.language_iso == 'en'
assert comic.format == Format.SERIES
assert comic.black_white == YesNo.NO
assert comic.manga == Manga.RIGHT_LEFT
assert comic.age_rating == AgeRating.TEEN
assert comic.community_rating == 4
def test_page_count_property(self, images_dir: Path) -> None:
"""Test that page count returns correct count."""
image_paths = sorted(list(images_dir.iterdir()))[:4]
pages = [PageInfo.load(path=path) for path in image_paths]
comic = ComicInfo.from_pages(pages=pages, title='Count Test')
assert len(comic.pages) == 4
def test_empty_pages_list(self) -> None:
"""Test creating comic with empty pages list."""
comic = ComicInfo.from_pages(pages=[], title='Empty Test')
assert comic.title == 'Empty Test'
assert len(comic.pages) == 0
def test_single_page_comic_load(self, images_dir):
"""Test loading comic with a single page."""
image_paths = sorted(list(images_dir.iterdir()))[:1]
pages = [PageInfo.load(path=path) for path in image_paths]
comic = ComicInfo.from_pages(pages=pages, title="Single Page Test")
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir) / "single_page.cbz"
data = comic.pack()
with open(temp_path, "wb") as f:
f.write(data)
assert temp_path.exists()
comic_loaded = ComicInfo.from_cbz(temp_path)
assert comic_loaded.title == "Single Page Test"
assert len(comic_loaded.pages) == 1
+265
View File
@@ -0,0 +1,265 @@
from cbz.models import BaseModel, ComicModel, PageModel
from cbz.constants import Format, YesNo, Manga, AgeRating, PageType
class TestBaseModel:
"""Test cases for BaseModel class."""
def test_base_model_creation(self) -> None:
"""Test creating BaseModel with fields."""
test_fields = {
'test_str': ('Test String', str),
'test_int': ('Test Integer', int),
'test_bool': ('Test Boolean', bool)
}
model = BaseModel(fields=test_fields)
# Check default values are set
assert hasattr(model, 'test_str')
assert hasattr(model, 'test_int')
assert hasattr(model, 'test_bool')
def test_base_model_with_kwargs(self) -> None:
"""Test creating BaseModel with keyword arguments."""
test_fields = {
'title': ('Title', str),
'number': ('Number', int),
'published': ('Published', bool)
}
model = BaseModel(
fields=test_fields,
title='Test Title',
number=42,
published=True
)
assert model.title == 'Test Title'
assert model.number == 42
assert model.published
def test_attribute_type_verification(self) -> None:
"""Test that attribute types are verified on assignment."""
test_fields = {
'count': ('Count', int),
'name': ('Name', str)
}
model = BaseModel(fields=test_fields)
# Valid assignments
model.count = 10
model.name = 'Test'
assert model.count == 10
assert model.name == 'Test'
def test_repr_method(self) -> None:
"""Test string representation of BaseModel."""
test_fields = {
'title': ('Title', str)
}
model = BaseModel(fields=test_fields, title='Test')
repr_str = repr(model)
assert isinstance(repr_str, str)
assert 'BaseModel' in repr_str
class TestComicModel:
"""Test cases for ComicModel class."""
def test_comic_model_creation(self) -> None:
"""Test creating ComicModel with default values."""
model = ComicModel()
# Check that comic-specific attributes exist
assert hasattr(model, 'title')
assert hasattr(model, 'series')
assert hasattr(model, 'number')
assert hasattr(model, 'volume')
assert hasattr(model, 'year')
assert hasattr(model, 'month')
assert hasattr(model, 'day')
def test_comic_model_with_values(self) -> None:
"""Test creating ComicModel with specific values."""
model = ComicModel(
title='Test Comic',
series='Test Series',
number=1,
volume=1,
year=2024,
month=6,
day=15,
writer='Test Writer',
publisher='Test Publisher',
language_iso='en',
format=Format.SERIES,
black_white=YesNo.NO,
manga=Manga.RIGHT_LEFT,
age_rating=AgeRating.EVERYONE
)
assert model.title == 'Test Comic'
assert model.series == 'Test Series'
assert model.number == 1
assert model.volume == 1
assert model.year == 2024
assert model.month == 6
assert model.day == 15
assert model.writer == 'Test Writer'
assert model.publisher == 'Test Publisher'
assert model.language_iso == 'en'
assert model.format == Format.SERIES
assert model.black_white == YesNo.NO
assert model.manga == Manga.RIGHT_LEFT
assert model.age_rating == AgeRating.EVERYONE
def test_comic_model_enum_properties(self) -> None:
"""Test that enum properties work correctly."""
model = ComicModel()
# Test format enum
model.format = Format.PREVIEW
assert model.format == Format.PREVIEW
# Test yes/no enum
model.black_white = YesNo.YES
assert model.black_white == YesNo.YES
# Test manga enum
model.manga = Manga.RIGHT_LEFT
assert model.manga == Manga.RIGHT_LEFT
# Test age rating enum
model.age_rating = AgeRating.TEEN
assert model.age_rating == AgeRating.TEEN
def test_comic_model_metadata_fields(self) -> None:
"""Test comic metadata fields."""
model = ComicModel(
summary='Test summary',
notes='Test notes',
genre='Adventure',
web='http://example.com',
ean='1234567890123',
community_rating=5,
main_character_or_team='Hero',
characters='Hero, Villain',
teams='Justice League',
locations='Metropolis',
scan_information='Scanned by Test',
story_arc='Origin Story',
series_group='DC Comics',
alternate_series='Alternate Universe',
alternate_number=2,
alternate_count=10
)
assert model.summary == 'Test summary'
assert model.notes == 'Test notes'
assert model.genre == 'Adventure'
assert model.web == 'http://example.com'
assert model.ean == '1234567890123'
assert model.community_rating == 5
assert model.main_character_or_team == 'Hero'
assert model.characters == 'Hero, Villain'
assert model.teams == 'Justice League'
assert model.locations == 'Metropolis'
assert model.scan_information == 'Scanned by Test'
assert model.story_arc == 'Origin Story'
assert model.series_group == 'DC Comics'
assert model.alternate_series == 'Alternate Universe'
assert model.alternate_number == 2
assert model.alternate_count == 10
class TestPageModel:
"""Test cases for PageModel class."""
def test_page_model_creation(self) -> None:
"""Test creating PageModel with default values."""
model = PageModel()
# Check that page-specific attributes exist
assert hasattr(model, 'image')
assert hasattr(model, 'type')
assert hasattr(model, 'double')
assert hasattr(model, 'image_size')
assert hasattr(model, 'key')
assert hasattr(model, 'bookmark')
assert hasattr(model, 'image_width')
assert hasattr(model, 'image_height')
assert hasattr(model, 'image_size')
# Note: format is not a base field in PageModel
def test_page_model_with_values(self) -> None:
"""Test creating PageModel with specific values."""
model = PageModel(
type=PageType.FRONT_COVER,
double=True,
image_size=1024000,
key='cover',
bookmark='Chapter 1',
image_width=800,
image_height=1200,
)
assert model.type == PageType.FRONT_COVER
assert model.double
assert model.image_size == 1024000
assert model.key == 'cover'
assert model.bookmark == 'Chapter 1'
assert model.image_width == 800
assert model.image_height == 1200
def test_page_model_page_types(self) -> None:
"""Test different page types."""
page_types = [
PageType.FRONT_COVER,
PageType.INNER_COVER,
PageType.ROUNDUP,
PageType.STORY,
PageType.ADVERTISEMENT,
PageType.EDITORIAL,
PageType.LETTERS,
PageType.PREVIEW,
PageType.BACK_COVER,
PageType.OTHER,
PageType.DELETED
]
for page_type in page_types:
model = PageModel(type=page_type)
assert model.type == page_type
def test_page_model_boolean_properties(self) -> None:
"""Test boolean properties in PageModel."""
model = PageModel()
# Test double property
model.double = True
assert model.double
model.double = False
assert not model.double
def test_page_model_numeric_properties(self) -> None:
"""Test numeric properties in PageModel."""
model = PageModel(
image_size=2048000,
image_width=1920,
image_height=1080
)
assert model.image_size == 2048000
assert model.image_width == 1920
assert model.image_height == 1080
# Test that they're integers
assert isinstance(model.image_size, int)
assert isinstance(model.image_width, int)
assert isinstance(model.image_height, int)
+143
View File
@@ -0,0 +1,143 @@
from pathlib import Path
import pytest
from cbz.page import PageInfo
from cbz.constants import PageType
class TestPageInfo:
"""Test cases for PageInfo class."""
def test_load_from_file(self, sample_image_path: Path) -> None:
"""Test loading PageInfo from image file."""
page = PageInfo.load(path=sample_image_path)
assert page is not None
assert isinstance(page.content, bytes)
assert len(page.content) > 0
assert page.name == sample_image_path.name
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
assert page.suffix is not None
def test_load_with_page_type(self, sample_image_path: Path) -> None:
"""Test loading PageInfo with specific page type."""
page = PageInfo.load(path=sample_image_path, type=PageType.FRONT_COVER)
assert page.type == PageType.FRONT_COVER
def test_load_with_custom_name(self, sample_image_path: Path) -> None:
"""Test loading PageInfo with custom name."""
custom_name = 'custom_page.jpg'
page = PageInfo.load(path=sample_image_path, name=custom_name)
assert page.name == custom_name
def test_page_content_property(self, sample_image_path: Path) -> None:
"""Test page content property getter and setter."""
page = PageInfo.load(path=sample_image_path)
original_content = page.content
# Test getter
assert page.content == original_content
assert isinstance(page.content, bytes)
# Test that content is properly set and metadata extracted
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
def test_image_metadata_extraction(self, sample_image_path: Path) -> None:
"""Test that image metadata is correctly extracted."""
page = PageInfo.load(path=sample_image_path)
# Check that all image metadata properties are set
assert hasattr(page, 'image_width') and page.image_width > 0
assert hasattr(page, 'image_height') and page.image_height > 0
assert hasattr(page, 'image_size') and page.image_size > 0
assert hasattr(page, 'suffix') and page.suffix is not None
# Verify dimensions make sense for an image
assert isinstance(page.image_width, int)
assert isinstance(page.image_height, int)
assert isinstance(page.image_size, int)
def test_multiple_image_formats(self, images_dir: Path) -> None:
"""Test loading different image formats."""
image_files = list(images_dir.glob('*.jpg'))
if not image_files:
pytest.skip('No image files found in example directory')
for image_path in image_files[:3]: # Test first 3 images
page = PageInfo.load(path=image_path)
assert page is not None
assert isinstance(page.content, bytes)
assert len(page.content) > 0
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
def test_page_type_assignment(self, sample_image_path: Path) -> None:
"""Test different page type assignments."""
page_types = [
PageType.FRONT_COVER,
PageType.INNER_COVER,
PageType.ROUNDUP,
PageType.STORY,
PageType.ADVERTISEMENT,
PageType.EDITORIAL,
PageType.LETTERS,
PageType.PREVIEW,
PageType.BACK_COVER,
PageType.OTHER,
PageType.DELETED
]
for page_type in page_types:
page = PageInfo.load(path=sample_image_path, type=page_type)
assert page.type == page_type
def test_page_creation_from_bytes(self, sample_image_path: Path) -> None:
"""Test creating PageInfo directly from bytes."""
# Read image file as bytes
with open(sample_image_path, 'rb') as f:
image_bytes = f.read()
# Create page from bytes
page = PageInfo(content=image_bytes, name='test_page.jpg')
assert page.content == image_bytes
assert page.name == 'test_page.jpg'
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
def test_repr_string(self, sample_image_path: Path) -> None:
"""Test string representation of PageInfo."""
page = PageInfo.load(path=sample_image_path, type=PageType.STORY)
repr_str = repr(page)
assert 'PageInfo' in repr_str
assert isinstance(repr_str, str)
def test_page_bookmark_property(self, sample_image_path: Path) -> None:
"""Test page bookmark property."""
# Test with bookmark
page = PageInfo.load(path=sample_image_path, bookmark='Chapter 1')
assert page.bookmark == 'Chapter 1'
# Test without bookmark
page_no_bookmark = PageInfo.load(path=sample_image_path)
assert hasattr(page_no_bookmark, 'bookmark')
def test_page_double_page_property(self, sample_image_path: Path) -> None:
"""Test page double_page property."""
page = PageInfo.load(path=sample_image_path, double=True)
assert page.double
page_no_double = PageInfo.load(path=sample_image_path)
assert hasattr(page_no_double, 'double')