17 Commits
0.1.1 ... main

Author SHA1 Message Date
c781b57bf8 Merge pull request 'Configure Renovate' (#7) from renovate/configure into main
Reviewed-on: #7
2025-11-27 17:29:32 +00:00
7f2828b552 Add renovate.json 2025-11-27 17:28:22 +00:00
Gitea CI
31fe85995d Bump version: 0.1.2 → 0.1.3 2025-11-01 20:38:39 +00:00
e607f33200 Merge pull request 'add __all__' (#6) from dev into main
Reviewed-on: #6
2025-11-01 20:33:00 +00:00
b4108e4b36 add __all__ 2025-11-01 21:32:28 +01:00
Gitea CI
1776d17c52 Bump version: 0.1.1 → 0.1.2 2025-06-03 18:32:53 +00:00
afec45a002 Merge pull request 'Feature: add delete function to delete entries in the database' (#5) from dev into main
Reviewed-on: #5
2025-06-03 19:30:08 +01:00
4e0a19c973 update workflow 2025-06-03 20:28:33 +02:00
9269736f23 add delete function 2025-06-03 20:25:51 +02:00
Gitea CI
6ee88534a1 Bump version: 0.1.0 → 0.1.1 2025-05-29 09:24:01 +00:00
922b0f7cfe Merge pull request 'Update workflows, improve repo' (#4) from dev into main
Reviewed-on: #4
2025-05-29 10:23:41 +01:00
ac6750ab69 Remove release notes input and streamline changelog generation in build workflow 2025-05-29 11:21:15 +02:00
39e9ce2f8f add changelog template 2025-05-29 11:19:40 +02:00
63a14a159c Enable version tagging and allow commits in bumpversion configuration 2025-05-29 11:14:42 +02:00
9ea5739bad move bumpmyversion to pyproject 2025-05-29 11:12:08 +02:00
f963e343d2 Merge pull request 'Implement SQLAlchemy integration and create database schemas for local and remote modes; refactor KomCache class methods' (#3) from dev into main
Reviewed-on: #3
2025-05-29 08:49:08 +01:00
2c9505b23a Implement SQLAlchemy integration and create database schemas for local and remote modes; refactor KomCache class methods 2025-05-29 09:47:07 +02:00
9 changed files with 355 additions and 144 deletions

View File

@@ -1,21 +0,0 @@
[tool.bumpversion]
current_version = "0.1.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = false
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
allow_dirty = false
commit = false
message = "Bump version: {current_version} → {new_version}"
moveable_tags = []
commit_args = ""
setup_hooks = []
pre_commit_hooks = []
post_commit_hooks = []

View File

@@ -0,0 +1,46 @@
{
"categories": [
{
"title": "## 🚀 Features",
"labels": ["add","Add", "Kind/Feature"]
},
{
"title": "## 🐛 Fixes",
"labels": ["fix","Fix", "Kind/Bug"]
},
{
"title": "## 🧪 Upgrade",
"labels": ["upgrade","Upgrade","Clean"]
}
,
{
"title": "## 📝 Documentation",
"labels": ["docs","Docs", "Kind/Documentation"]
},
{
"title": "## 🧹 Chore",
"labels": ["chore","Chore", "Kind/Chore"]
},
{
"title": "## 🛠️ Maintenance",
"labels": ["maintenance","Maintenance", "Kind/Maintenance"]
},
{
"title": "## 🗑️ Deprecation",
"labels": ["deprecation","Deprecation", "Kind/Deprecation"]
}
],
"label_extractor": [
{
"pattern": "(\\w+) (.+)",
"target": "$1",
"on_property": "title"
}
],
"sort": "ASC",
"template": "${{CHANGELOG}}",
"pr_template": "- ${{TITLE}}\n - PR: #${{NUMBER}}",
"empty_template": "- no changes",
"max_pull_requests": 1000,
"max_back_track_time_days": 1000
}

View File

@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
release_notes:
description: Release notes (use \n for newlines)
type: string
required: false
github_release:
description: 'Create Gitea Release'
default: true
@@ -25,10 +21,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@master
with:
fetch-depth: 0
fetch-tags: true
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install
with:
python-version-file: "pyproject.toml"
- name: Set Git identity
run: |
git config user.name "Gitea CI"
@@ -48,13 +49,15 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
- name: Create release notes
run: |
mkdir release_notes
echo -e "${{ inputs.release_notes }}" >> release_notes/release_notes.md
echo "Release notes:"
cat release_notes/release_notes.md
echo ""
- name: Build Changelog
id: build_changelog
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
with:
platform: "gitea"
baseURL: "http://gitea:3000"
configuration: ".gitea/changelog-config.json"
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Build package
run: uv build
- name: Publish package
@@ -70,7 +73,7 @@ jobs:
with:
tag_name: ${{ env.VERSION }}
release_name: Release ${{ env.VERSION }}
body_path: release_notes/release_notes.md
body: ${{steps.build_changelog.outputs.changelog}}
draft: false
prerelease: false
make_latest: true

View File

@@ -1,6 +1,6 @@
[project]
name = "komcache"
version = "0.1.0"
version = "0.1.3"
description = "Add your description here"
readme = "README.md"
authors = [
@@ -9,6 +9,8 @@ authors = [
requires-python = ">=3.13"
dependencies = [
"komconfig",
"pymysql>=1.1.1",
"sqlalchemy[asyncio]>=2.0.41",
]
[build-system]
@@ -17,3 +19,25 @@ build-backend = "hatchling.build"
[tool.uv.sources]
komconfig = { workspace = true }
[tool.bumpversion]
current_version = "0.1.3"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = true
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
allow_dirty = true
commit = true
message = "Bump version: {current_version} → {new_version}"
moveable_tags = []
commit_args = ""
setup_hooks = []
pre_commit_hooks = []
post_commit_hooks = []

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -1 +1,2 @@
__all__ = ["KomCache"]
from .cache import KomCache

View File

@@ -1,138 +1,240 @@
import sqlite3
from komconfig import KomConfig, CONFIG_PATH
from pathlib import Path
import os
import time
from typing import Any, Tuple, Union
import loguru
from komconfig import KomConfig
from sqlalchemy import Column, Date, Integer, String, create_engine, text
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import declarative_base, sessionmaker
from komcache.schemas.mariadb import CREATE_MARIADB_TABLES
from komcache.schemas.sqlite import CREATE_SQLITE_TABLES
log = loguru.logger
log.remove()
log.add("logs/cache.log", level="INFO", rotation="15MB", retention="1 week")
log.add("logs/cli.log", rotation="15MB", retention="1 week") # type:ignore
config = KomConfig()
Base = declarative_base()
class KomGrabber(Base):
__tablename__ = "komgrabber"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
series_id = Column(String, nullable=False)
status = Column(String, nullable=False)
created_at = Column(Date, nullable=False)
updated_at = Column(Date, nullable=False)
last_checked = Column(Date, nullable=False)
complete = Column(Integer, nullable=False)
def protect(url: str) -> str:
return "mysql+pymysql://user:pass@host:3306/dbname"
class KomCache:
def __init__(self, db_path: str = CONFIG_PATH):
self.db_path = Path(db_path, "komcache.db")
if "~" in str(self.db_path):
self.db_path = os.path.expanduser(str(self.db_path))
self.conn = sqlite3.connect(self.db_path)
self.cursor = self.conn.cursor()
def __init__(self, db_path: str = ""): # Default to empty string if not provided
self.db_path = db_path or config.cache.path
log.debug(f"Cache path: {self.db_path}")
if config.cache.mode == "local":
self.db_path = db_path or config.cache.path
log.debug(f"Cache path: {self.db_path}")
self.engine = create_engine(f"sqlite:///{self.db_path}", pool_pre_ping=True)
elif config.cache.mode == "remote":
db_url = (
config.cache.url
) # e.g., "mysql+pymysql://user:pass@host:3306/dbname"
def __enter__(self):
return self
log.debug(f"Using remote DB URL: {protect(db_url)}")
self.engine = create_engine(
db_url,
pool_pre_ping=True,
pool_recycle=1800,
pool_size=5,
max_overflow=10,
pool_timeout=30,
connect_args={"connect_timeout": 10},
)
def __exit__(self, exc_type, exc_val, exc_tb):
self.conn.close()
pass
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
# if tables do not exist, create them
if config.cache.mode == "local":
if not self.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='komgrabber'"
):
self.create_table()
elif config.cache.mode == "remote":
if not self.query("SHOW TABLES LIKE 'komgrabber'"):
self.create_table()
def create_table(self, table_query: str) -> None:
"""
Create a table in the database.
def _run(self, fn_desc: str, callable_, retries: int = 2, *args, **kwargs):
attempt = 0
while True:
try:
return callable_(*args, **kwargs)
except OperationalError as e:
# MySQL server has gone away (2006) / Lost connection (2013)
if attempt < retries and any(
code in str(e.orig) for code in ("2006", "2013")
):
attempt += 1
wait = 1 * attempt
log.warning(
f"{fn_desc} failed due to connection loss (attempt {attempt}/{retries}). Retrying in {wait}s."
)
time.sleep(wait)
continue
log.error(f"{fn_desc} failed with OperationalError: {e}")
return None
except SQLAlchemyError as e:
log.error(f"{fn_desc} failed with SQLAlchemyError: {e}")
return None
Parameters
----------
table_query : str
the SQL query to create the table.
"""
self.cursor.execute(table_query)
self.conn.commit()
def create_table(self):
"""Ensure all tables are created in the database."""
if config.cache.mode == "local":
log.debug("Creating SQLite tables")
with self.engine.begin() as connection:
log.debug(f"DB engine URL: {self.engine.url}")
for table_sql in CREATE_SQLITE_TABLES:
connection.execute(text(table_sql))
elif config.cache.mode == "remote":
log.debug("Creating MariaDB tables")
with self.engine.begin() as connection:
log.debug(f"DB engine URL: {self.engine.url}")
for table_sql in CREATE_MARIADB_TABLES:
log.debug(f"Executing table creation SQL: {table_sql}")
try:
connection.execute(text(table_sql))
except Exception as e:
log.exception(
f"Failed to execute table creation SQL:\n{table_sql}"
)
log.exception(f"Error: {e}")
# create the KomGrabber table using SQLAlchemy ORM
Base.metadata.create_all(self.engine)
def delete_table(self, table_name: str) -> bool:
try:
self.cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
self.conn.commit()
with self.engine.connect() as connection:
connection.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
return True
except sqlite3.Error as e:
print(f"Error deleting table {table_name}: {e}")
except SQLAlchemyError as e:
log.error(f"Error deleting table {table_name}: {e}")
return False
def query_table(self, table: str, query: str) -> list:
try:
self.cursor.execute(f"SELECT {query} FROM {table}")
return self.cursor.fetchall()
except sqlite3.Error as e:
print(f"Error querying table {table}: {e}")
return []
def query(self, query, args=None):
def query(self, query: str, args: dict[str, Any] = None):
"""Run an arbitrary SQL statement.
For SELECT (or other rowreturning) statements: returns list of rows.
For nonSELECT: executes and commits, returns [].
"""
if args is None:
args = []
try:
self.cursor.execute(query, args)
data = self.cursor.fetchall()
if data:
return data
else:
args = {}
def _do():
session = self.Session()
try:
result = session.execute(text(query), args)
# SQLAlchemy 1.4/2.0: result.returns_rows tells us if rows are present
if getattr(result, "returns_rows", False):
rows = result.fetchall()
return rows
# Non-row statements: commit if they mutate
first = query.lstrip().split(None, 1)[0].upper()
if first in {
"INSERT",
"UPDATE",
"DELETE",
"REPLACE",
"ALTER",
"CREATE",
"DROP",
"TRUNCATE",
}:
session.commit()
return []
finally:
session.close()
result = self._run("query", _do)
return result or []
def insert(self, query: str, args: dict[str, Any]) -> bool:
# (Optionally you can now just call self.query(query, args))
def _do():
session = self.Session()
try:
session.execute(text(query), args)
session.commit()
return True
finally:
session.close()
return bool(self._run("insert", _do))
def update(self, query: str, args: dict[str, Any]) -> bool:
def _do():
session = self.Session()
try:
session.execute(text(query), args)
session.commit()
return True
finally:
session.close()
return bool(self._run("update", _do))
def delete(self, query: str, args: dict[str, Any]) -> bool:
def _do():
session = self.Session()
try:
session.execute(text(query), args)
session.commit()
return True
finally:
session.close()
return bool(self._run("delete", _do))
def fetch_one(self, query: str, args: dict[str, Any] = None) -> Union[Tuple, None]:
if args is None:
args = {}
def _do():
session = self.Session()
try:
result = session.execute(text(query), args)
if getattr(result, "returns_rows", False):
return result.fetchone()
return None
except sqlite3.Error as e:
print(f"Error executing query: {e}")
return []
finally:
session.close()
def insert(self, query: str, args=None):
return self._run("fetch_one", _do)
def fetch_all(self, query: str, args: dict[str, Any] | None = None):
if args is None:
args = []
try:
self.cursor.execute(query, args)
self.conn.commit()
return True
except sqlite3.Error as e:
print(f"Error inserting data: {e}")
return False
args = {}
def update(self, query, args=None):
if args is None:
args = []
try:
self.cursor.execute(query, args)
log.debug("Query: {}, Args: {}".format(query, args))
self.conn.commit()
return True
except sqlite3.Error as e:
print(f"Error updating data: {e}")
return False
def _do():
session = self.Session()
try:
result = session.execute(text(query), args)
if getattr(result, "returns_rows", False):
return result.fetchall()
return []
finally:
session.close()
def get_last_update_date(self, series_name: str) -> str:
"""
Get the last update date for a series.
Parameters
----------
series_name : str
The name of the series.
Returns
-------
str
The last update date.
"""
query = "SELECT last_update_date FROM komgrabber WHERE series_name = ?"
result = self.query(query, (series_name,))
if result:
return result[0][0]
return ""
def fetch_one(self, query: str, args=None):
if args is None:
args = []
try:
self.cursor.execute(query, args)
return self.cursor.fetchone()
except sqlite3.Error as e:
print(f"Error fetching one: {e}")
return None
def fetch_all(self, query: str, args=None):
if args is None:
args = []
try:
self.cursor.execute(query, args)
return self.cursor.fetchall()
except sqlite3.Error as e:
print(f"Error fetching one: {e}")
return None
result = self._run("fetch_all", _do)
return result or []
if __name__ == "__main__":
# Example usage
with KomCache() as cache:
cache.delete_table("komgrabber")
cache = KomCache()
cache.create_table()

View File

@@ -0,0 +1,33 @@
CREATE_MARIADB_TABLES = [
"""
CREATE TABLE IF NOT EXISTS manga_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
manga_id INT,
grabbed TINYINT(1) DEFAULT 0,
image TEXT NOT NULL,
title TEXT NOT NULL
);""",
"""CREATE TABLE IF NOT EXISTS komgrabber (
id INT AUTO_INCREMENT PRIMARY KEY,
name TEXT NOT NULL,
series_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_checked TIMESTAMP DEFAULT NULL,
completed TINYINT(1) DEFAULT 0
);""",
"""CREATE TABLE IF NOT EXISTS komtagger (
id INT AUTO_INCREMENT PRIMARY KEY,
series_id TEXT NOT NULL,
title TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_checked DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_series_id (series_id(255)),
status TEXT NOT NULL,
tag_status TEXT NOT NULL DEFAULT 'untagged',
anilist_id INT DEFAULT NULL
);
""",
]

View File

@@ -0,0 +1,20 @@
CREATE_SQLITE_TABLES = ["""
CREATE TABLE IF NOT EXISTS manga_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
manga_id INTEGER,
grabbed BOOLEAN DEFAULT 0);""","""CREATE TABLE IF NOT EXISTS komgrabber (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
series_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_checked TIMESTAMP DEFAULT 0,
completed BOOLEAN DEFAULT 0);""","""CREATE TABLE IF NOT EXISTS komtagger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
series_id TEXT NOT NULL,
title TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_checked TIMESTAMP DEFAULT 0,
status TEXT NOT NULL);
"""]