mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 21:58:11 +00:00
fix(download): send every file of a multi-file download to the browser (#1489)
Download to Browser only ever sent the first file of a multi-file release (audiobook chapters, packs): the folder output returned final_paths[0] and /api/localdownload served that one path. The folder output now keeps every final path on the in-memory task (download_paths), and /api/localdownload streams them as one uncompressed ZIP named after the book when there is more than one. The archive is never staged on disk or held in memory, and ZIP64 covers large audiobooks. History still stores only the first path, so a re-download from history after a dismiss or restart serves a single file, as before. Also brings every linter back to green on all files: - pre-commit: bump the ruff hook to v0.17.1 to match uv.lock; ruff-format the Python examples in docs/dev and skills - ruff: fix the remaining issues in skills/shelfmark/download_books.py; format base_handler.py - basedpyright: Optional and attrs errors in databazeknih.py - knip: drop 31 unused exports, reuse ColumnColorHint in colorMaps - oxfmt 0.72: styles.css - direct download: fix the "Welib, mirrors" typo and the test that still expected the pre-OceanofPDF message Fixes #1467
This commit is contained in:
1 parent
3025f120eb
commit
10710abd4e
26 files changed
+664
-344
No files matched your search
+36
-29
@@ -23,12 +23,13 @@ from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
)
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="my_plugin", # Unique identifier
|
||||
display_name="My Plugin", # Shown in sidebar
|
||||
icon="wrench", # Icon name
|
||||
order=100, # Sort order (lower = higher in list)
|
||||
group="metadata_providers" # Optional: group in sidebar
|
||||
name="my_plugin", # Unique identifier
|
||||
display_name="My Plugin", # Shown in sidebar
|
||||
icon="wrench", # Icon name
|
||||
order=100, # Sort order (lower = higher in list)
|
||||
group="metadata_providers", # Optional: group in sidebar
|
||||
)
|
||||
def my_plugin_settings():
|
||||
return [
|
||||
@@ -46,6 +47,7 @@ def my_plugin_settings():
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _test_connection():
|
||||
# Perform connection test
|
||||
return {"success": True, "message": "Connected successfully!"}
|
||||
@@ -59,16 +61,16 @@ Single-line text input for strings.
|
||||
|
||||
```python
|
||||
TextField(
|
||||
key="MY_SETTING", # Config key
|
||||
label="Setting Name", # Display label
|
||||
description="Help text", # Optional description below field
|
||||
default="", # Default value
|
||||
key="MY_SETTING", # Config key
|
||||
label="Setting Name", # Display label
|
||||
description="Help text", # Optional description below field
|
||||
default="", # Default value
|
||||
placeholder="Enter value", # Placeholder text
|
||||
max_length=100, # Optional max characters
|
||||
required=False, # Is this field required?
|
||||
requires_restart=False, # Does changing this need a restart?
|
||||
show_when=None, # Conditional visibility (see below)
|
||||
disabled_when=None, # Conditional disable (see below)
|
||||
max_length=100, # Optional max characters
|
||||
required=False, # Is this field required?
|
||||
requires_restart=False, # Does changing this need a restart?
|
||||
show_when=None, # Conditional visibility (see below)
|
||||
disabled_when=None, # Conditional disable (see below)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -98,7 +100,7 @@ NumberField(
|
||||
default=30,
|
||||
min_value=5,
|
||||
max_value=300,
|
||||
step=1, # Increment step
|
||||
step=1, # Increment step
|
||||
required=False,
|
||||
)
|
||||
```
|
||||
@@ -160,13 +162,14 @@ Button that executes a callback function. Does not store a value.
|
||||
|
||||
```python
|
||||
ActionButton(
|
||||
key="test_connection", # Unique key for the action
|
||||
label="Test Connection", # Button text
|
||||
key="test_connection", # Unique key for the action
|
||||
label="Test Connection", # Button text
|
||||
description="Test the API connection",
|
||||
style="primary", # "default", "primary", or "danger"
|
||||
callback=my_callback_fn, # Function to execute
|
||||
style="primary", # "default", "primary", or "danger"
|
||||
callback=my_callback_fn, # Function to execute
|
||||
)
|
||||
|
||||
|
||||
def my_callback_fn():
|
||||
"""Callback must return dict with 'success' and 'message' keys."""
|
||||
try:
|
||||
@@ -182,11 +185,11 @@ Display-only section heading with optional link. Does not store a value.
|
||||
|
||||
```python
|
||||
HeadingField(
|
||||
key="section_heading", # Unique key
|
||||
title="Configuration", # Heading text
|
||||
key="section_heading", # Unique key
|
||||
title="Configuration", # Heading text
|
||||
description="Configure the plugin settings below",
|
||||
link_url="https://example.com/docs", # Optional link
|
||||
link_text="View Documentation", # Link text
|
||||
link_text="View Documentation", # Link text
|
||||
)
|
||||
```
|
||||
|
||||
@@ -204,8 +207,14 @@ CustomComponentField(
|
||||
label="Request Policy Rules",
|
||||
description="Custom editor for policy defaults and matrix rules.",
|
||||
value_fields=[
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"
|
||||
),
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
label="Default Audiobook Mode",
|
||||
default="download",
|
||||
),
|
||||
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
|
||||
],
|
||||
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
|
||||
@@ -263,7 +272,7 @@ NumberField(
|
||||
disabled_when={
|
||||
"field": "FEATURE_ENABLED",
|
||||
"value": False,
|
||||
"reason": "Enable the feature first"
|
||||
"reason": "Enable the feature first",
|
||||
},
|
||||
)
|
||||
```
|
||||
@@ -285,6 +294,7 @@ register_group(
|
||||
order=50,
|
||||
)
|
||||
|
||||
|
||||
# Then register settings to the group
|
||||
@register_settings(
|
||||
name="plugin_a",
|
||||
@@ -426,10 +436,7 @@ class MyProvider(MetadataProvider):
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
|
||||
self.base_url = config.get(
|
||||
"MY_PROVIDER_BASE_URL",
|
||||
"https://api.myprovider.com/v1"
|
||||
)
|
||||
self.base_url = config.get("MY_PROVIDER_BASE_URL", "https://api.myprovider.com/v1")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
@@ -66,10 +66,11 @@ This separation allows:
|
||||
A source has both a `ReleaseSource` (for searching) and a `DownloadHandler` (for downloading). They share the same `name` identifier:
|
||||
|
||||
```python
|
||||
@register_source("my_source") # Search registration
|
||||
@register_source("my_source") # Search registration
|
||||
class MySource(ReleaseSource): ...
|
||||
|
||||
@register_handler("my_source") # Download registration
|
||||
|
||||
@register_handler("my_source") # Download registration
|
||||
class MyHandler(DownloadHandler): ...
|
||||
```
|
||||
|
||||
@@ -142,10 +143,11 @@ from typing import List
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import Release, ReleaseColumnConfig
|
||||
|
||||
|
||||
class ReleaseSource(ABC):
|
||||
"""Interface for searching a release source."""
|
||||
|
||||
name: str # Internal identifier: "direct_download", "prowlarr"
|
||||
name: str # Internal identifier: "direct_download", "prowlarr"
|
||||
display_name: str # User-facing name: "Direct Download", "Prowlarr"
|
||||
|
||||
@abstractmethod
|
||||
@@ -191,14 +193,16 @@ def search(self, book: BookMetadata) -> List[Release]:
|
||||
if book.isbn_13:
|
||||
api_results = self.api.search_isbn(book.isbn_13)
|
||||
for r in api_results:
|
||||
results.append(Release(
|
||||
source="my_source",
|
||||
source_id=r.id,
|
||||
title=r.title,
|
||||
format=r.format,
|
||||
size=r.size,
|
||||
# ... more fields
|
||||
))
|
||||
results.append(
|
||||
Release(
|
||||
source="my_source",
|
||||
source_id=r.id,
|
||||
title=r.title,
|
||||
format=r.format,
|
||||
size=r.size,
|
||||
# ... more fields
|
||||
)
|
||||
)
|
||||
|
||||
# Strategy 2: Fallback to title + author search
|
||||
if not results:
|
||||
@@ -239,6 +243,7 @@ from typing import Callable, Optional
|
||||
from threading import Event
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
|
||||
class DownloadHandler(ABC):
|
||||
"""Interface for executing downloads from a source."""
|
||||
|
||||
@@ -286,6 +291,7 @@ downloading it; the default returns `None`, which the UI reports as "can't inspe
|
||||
```python
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
|
||||
def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
|
||||
"""Return the release's files (release-relative paths + sizes), or None."""
|
||||
torrent_bytes = ... # e.g. fetch the .torrent, or scrape the indexer's detail page
|
||||
@@ -380,20 +386,20 @@ Standardized search result returned by all sources:
|
||||
```python
|
||||
@dataclass
|
||||
class Release:
|
||||
source: str # "direct_download", "prowlarr", "irc"
|
||||
source_id: str # Unique ID within that source
|
||||
title: str # Display title
|
||||
source: str # "direct_download", "prowlarr", "irc"
|
||||
source_id: str # Unique ID within that source
|
||||
title: str # Display title
|
||||
|
||||
format: Optional[str] = None # File format: "epub", "mobi", "pdf"
|
||||
size: Optional[str] = None # Human-readable: "5.2 MB"
|
||||
size_bytes: Optional[int] = None # For sorting
|
||||
format: Optional[str] = None # File format: "epub", "mobi", "pdf"
|
||||
size: Optional[str] = None # Human-readable: "5.2 MB"
|
||||
size_bytes: Optional[int] = None # For sorting
|
||||
|
||||
download_url: Optional[str] = None # Direct download URL if available
|
||||
info_url: Optional[str] = None # Link to tracker/info page
|
||||
info_url: Optional[str] = None # Link to tracker/info page
|
||||
|
||||
protocol: Optional[str] = None # "http", "torrent", "usenet"
|
||||
indexer: Optional[str] = None # Display name: "Direct Download", "My Indexer"
|
||||
seeders: Optional[int] = None # For torrents
|
||||
protocol: Optional[str] = None # "http", "torrent", "usenet"
|
||||
indexer: Optional[str] = None # Display name: "Direct Download", "My Indexer"
|
||||
seeders: Optional[int] = None # For torrents
|
||||
|
||||
extra: Dict = field(default_factory=dict) # Source-specific metadata
|
||||
```
|
||||
@@ -415,7 +421,7 @@ Release(
|
||||
"preview": "https://example.com/cover.jpg",
|
||||
"quality": "high",
|
||||
"publisher": "Publisher Name",
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -428,15 +434,15 @@ Source-agnostic task in the download queue:
|
||||
```python
|
||||
@dataclass
|
||||
class DownloadTask:
|
||||
task_id: str # Unique ID (e.g., AA MD5, Prowlarr GUID)
|
||||
source: str # Handler name: "direct_download"
|
||||
title: str # Display title
|
||||
task_id: str # Unique ID (e.g., AA MD5, Prowlarr GUID)
|
||||
source: str # Handler name: "direct_download"
|
||||
title: str # Display title
|
||||
|
||||
# Display info (from Release.extra or Release fields)
|
||||
author: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
preview: Optional[str] = None # Cover image URL
|
||||
preview: Optional[str] = None # Cover image URL
|
||||
|
||||
# Runtime state (managed by orchestrator)
|
||||
priority: int = 0
|
||||
@@ -454,8 +460,8 @@ Book information passed to `search()`:
|
||||
```python
|
||||
@dataclass
|
||||
class BookMetadata:
|
||||
provider: str # Metadata provider: "hardcover", "openlibrary"
|
||||
provider_id: str # ID in provider's system
|
||||
provider: str # Metadata provider: "hardcover", "openlibrary"
|
||||
provider_id: str # ID in provider's system
|
||||
title: str
|
||||
|
||||
provider_display_name: Optional[str] = None
|
||||
@@ -466,7 +472,7 @@ class BookMetadata:
|
||||
description: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
publish_year: Optional[int] = None
|
||||
language: Optional[str] = None # ISO 639-1 code: "en", "de", "fr"
|
||||
language: Optional[str] = None # ISO 639-1 code: "en", "de", "fr"
|
||||
genres: List[str] = field(default_factory=list)
|
||||
source_url: Optional[str] = None
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
@@ -481,13 +487,13 @@ class BookMetadata:
|
||||
```python
|
||||
from shelfmark.release_sources import register_source, register_handler
|
||||
|
||||
|
||||
@register_source("my_source")
|
||||
class MySource(ReleaseSource):
|
||||
...
|
||||
class MySource(ReleaseSource): ...
|
||||
|
||||
|
||||
@register_handler("my_source")
|
||||
class MyHandler(DownloadHandler):
|
||||
...
|
||||
class MyHandler(DownloadHandler): ...
|
||||
```
|
||||
|
||||
### Registry Functions
|
||||
@@ -544,12 +550,13 @@ from shelfmark.core.settings_registry import (
|
||||
is_value_from_env,
|
||||
)
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="my_source", # Unique identifier
|
||||
display_name="My Source", # Shown in settings UI
|
||||
icon="globe", # Icon name (optional)
|
||||
order=60, # Sort order in sidebar
|
||||
group="sources", # Grouping in sidebar
|
||||
name="my_source", # Unique identifier
|
||||
display_name="My Source", # Shown in settings UI
|
||||
icon="globe", # Icon name (optional)
|
||||
order=60, # Sort order in sidebar
|
||||
group="sources", # Grouping in sidebar
|
||||
)
|
||||
def my_source_settings():
|
||||
"""Register settings for My Source plugin."""
|
||||
@@ -559,7 +566,6 @@ def my_source_settings():
|
||||
title="My Source Configuration",
|
||||
description="Configure connection to My Source API",
|
||||
),
|
||||
|
||||
PasswordField(
|
||||
key="MY_SOURCE_API_KEY",
|
||||
label="API Key",
|
||||
@@ -567,14 +573,12 @@ def my_source_settings():
|
||||
required=True,
|
||||
env_supported=True, # Can be set via MY_SOURCE_API_KEY env var
|
||||
),
|
||||
|
||||
TextField(
|
||||
key="MY_SOURCE_BASE_URL",
|
||||
label="Base URL",
|
||||
placeholder="https://api.mysource.com",
|
||||
env_supported=True,
|
||||
),
|
||||
|
||||
NumberField(
|
||||
key="MY_SOURCE_TIMEOUT",
|
||||
label="Request Timeout",
|
||||
@@ -583,14 +587,12 @@ def my_source_settings():
|
||||
min_value=5,
|
||||
max_value=120,
|
||||
),
|
||||
|
||||
CheckboxField(
|
||||
key="MY_SOURCE_ENABLED",
|
||||
label="Enable My Source",
|
||||
description="Include in release searches",
|
||||
default=True,
|
||||
),
|
||||
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
@@ -599,22 +601,19 @@ def my_source_settings():
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_my_source_connection():
|
||||
"""Test connection callback - returns result dict."""
|
||||
try:
|
||||
# Get current API key
|
||||
api_key = get_setting_value(
|
||||
PasswordField(key="MY_SOURCE_API_KEY", label=""),
|
||||
"my_source"
|
||||
)
|
||||
api_key = get_setting_value(PasswordField(key="MY_SOURCE_API_KEY", label=""), "my_source")
|
||||
|
||||
if not api_key:
|
||||
return {"success": False, "message": "API key not configured"}
|
||||
|
||||
# Test the connection
|
||||
response = requests.get(
|
||||
"https://api.mysource.com/ping",
|
||||
headers={"Authorization": f"Bearer {api_key}"}
|
||||
"https://api.mysource.com/ping", headers={"Authorization": f"Bearer {api_key}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
@@ -648,6 +647,7 @@ from shelfmark.core.settings_registry import (
|
||||
load_config_file,
|
||||
)
|
||||
|
||||
|
||||
# In your source/handler class
|
||||
def __init__(self):
|
||||
# Load from config file
|
||||
@@ -655,11 +655,9 @@ def __init__(self):
|
||||
self.api_key = config.get("MY_SOURCE_API_KEY")
|
||||
self.base_url = config.get("MY_SOURCE_BASE_URL", "https://api.mysource.com")
|
||||
|
||||
|
||||
# Or use get_setting_value for ENV var priority
|
||||
api_key = get_setting_value(
|
||||
PasswordField(key="MY_SOURCE_API_KEY", label=""),
|
||||
"my_source"
|
||||
)
|
||||
api_key = get_setting_value(PasswordField(key="MY_SOURCE_API_KEY", label=""), "my_source")
|
||||
```
|
||||
|
||||
### Settings Priority
|
||||
@@ -689,6 +687,7 @@ from shelfmark.release_sources import (
|
||||
LeadingCellType,
|
||||
)
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_column_config(cls) -> ReleaseColumnConfig:
|
||||
return ReleaseColumnConfig(
|
||||
@@ -759,7 +758,7 @@ For `BADGE` render type, specify colors:
|
||||
|
||||
```python
|
||||
# Use frontend color map (defined in colorMaps.ts)
|
||||
ColumnColorHint(type="map", value="format") # Maps epub→green, pdf→red, etc.
|
||||
ColumnColorHint(type="map", value="format") # Maps epub→green, pdf→red, etc.
|
||||
ColumnColorHint(type="map", value="language") # Maps en→blue, de→yellow, etc.
|
||||
|
||||
# Use static Tailwind class
|
||||
@@ -963,6 +962,7 @@ logger = setup_logger(__name__)
|
||||
# Settings Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="booknet",
|
||||
display_name="BookNet",
|
||||
@@ -980,7 +980,6 @@ def booknet_settings():
|
||||
link_url="https://booknet.example.com/api-docs",
|
||||
link_text="API Documentation",
|
||||
),
|
||||
|
||||
PasswordField(
|
||||
key="BOOKNET_API_KEY",
|
||||
label="API Key",
|
||||
@@ -988,14 +987,12 @@ def booknet_settings():
|
||||
required=True,
|
||||
env_supported=True,
|
||||
),
|
||||
|
||||
TextField(
|
||||
key="BOOKNET_BASE_URL",
|
||||
label="Base URL",
|
||||
placeholder="https://api.booknet.example.com",
|
||||
env_supported=True,
|
||||
),
|
||||
|
||||
NumberField(
|
||||
key="BOOKNET_TIMEOUT",
|
||||
label="Request Timeout",
|
||||
@@ -1004,14 +1001,12 @@ def booknet_settings():
|
||||
min_value=5,
|
||||
max_value=120,
|
||||
),
|
||||
|
||||
CheckboxField(
|
||||
key="BOOKNET_PREFER_EPUB",
|
||||
label="Prefer EPUB format",
|
||||
description="Sort EPUB results first when available",
|
||||
default=True,
|
||||
),
|
||||
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
@@ -1041,7 +1036,7 @@ def _test_booknet_connection() -> Dict:
|
||||
data = response.json()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connected! {data.get('books_available', 0):,} books available"
|
||||
"message": f"Connected! {data.get('books_available', 0):,} books available",
|
||||
}
|
||||
elif response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid API key"}
|
||||
@@ -1058,6 +1053,7 @@ def _test_booknet_connection() -> Dict:
|
||||
# Release Source Implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@register_source("booknet")
|
||||
class BookNetSource(ReleaseSource):
|
||||
"""Search BookNet for book releases."""
|
||||
@@ -1090,7 +1086,7 @@ class BookNetSource(ReleaseSource):
|
||||
|
||||
# Sort by preference
|
||||
if self.prefer_epub:
|
||||
releases.sort(key=lambda r: (0 if r.format == "epub" else 1))
|
||||
releases.sort(key=lambda r: 0 if r.format == "epub" else 1)
|
||||
|
||||
return releases
|
||||
|
||||
@@ -1164,7 +1160,9 @@ class BookNetSource(ReleaseSource):
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="70px",
|
||||
color_hint=ColumnColorHint(type="static", value="bg-purple-500/20 text-purple-400"),
|
||||
color_hint=ColumnColorHint(
|
||||
type="static", value="bg-purple-500/20 text-purple-400"
|
||||
),
|
||||
),
|
||||
ColumnSchema(
|
||||
key="extra.language",
|
||||
@@ -1202,6 +1200,7 @@ class BookNetSource(ReleaseSource):
|
||||
# Download Handler Implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@register_handler("booknet")
|
||||
class BookNetHandler(DownloadHandler):
|
||||
"""Handle downloads from BookNet."""
|
||||
@@ -1264,8 +1263,7 @@ class BookNetHandler(DownloadHandler):
|
||||
|
||||
# Phase 3: Move to final location
|
||||
safe_title = "".join(
|
||||
c if c.isalnum() or c in " .-_" else "_"
|
||||
for c in task.title[:100]
|
||||
c if c.isalnum() or c in " .-_" else "_" for c in task.title[:100]
|
||||
).strip()
|
||||
|
||||
final_filename = f"{safe_title}.{task.format or 'epub'}"
|
||||
@@ -1274,7 +1272,9 @@ class BookNetHandler(DownloadHandler):
|
||||
# Avoid overwriting existing files
|
||||
counter = 1
|
||||
while final_path.exists():
|
||||
final_path = Path(INGEST_DIR) / f"{safe_title} ({counter}).{task.format or 'epub'}"
|
||||
final_path = (
|
||||
Path(INGEST_DIR) / f"{safe_title} ({counter}).{task.format or 'epub'}"
|
||||
)
|
||||
counter += 1
|
||||
|
||||
shutil.move(str(temp_path), str(final_path))
|
||||
@@ -1363,6 +1363,7 @@ def is_available(self) -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def search(self, book: BookMetadata) -> List[Release]:
|
||||
"""Return empty list on errors, don't raise."""
|
||||
try:
|
||||
@@ -1446,10 +1447,10 @@ from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
logger.debug("Detailed debug info") # Development only
|
||||
logger.info("Normal operation info") # General progress
|
||||
logger.warning("Recoverable issues") # Fallback used, retry happening
|
||||
logger.error("Failures") # Unrecoverable errors
|
||||
logger.debug("Detailed debug info") # Development only
|
||||
logger.info("Normal operation info") # General progress
|
||||
logger.warning("Recoverable issues") # Fallback used, retry happening
|
||||
logger.error("Failures") # Unrecoverable errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reference in new issue
Block a user