six2one 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- six2one/__init__.py +3 -0
- six2one/api.py +132 -0
- six2one/auth.py +131 -0
- six2one/cli.py +744 -0
- six2one/errors.py +21 -0
- six2one/fetcher.py +826 -0
- six2one/manifest.py +441 -0
- six2one/models.py +156 -0
- six2one/network.py +89 -0
- six2one/prune.py +207 -0
- six2one/query.py +156 -0
- six2one/show.py +397 -0
- six2one-0.1.0.dist-info/METADATA +317 -0
- six2one-0.1.0.dist-info/RECORD +16 -0
- six2one-0.1.0.dist-info/WHEEL +4 -0
- six2one-0.1.0.dist-info/entry_points.txt +4 -0
six2one/__init__.py
ADDED
six2one/api.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Small async client for the e621/e926 endpoints used by fetch."""
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import Any, Final
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from .auth import load_login, request_headers
|
|
10
|
+
from .models import Site
|
|
11
|
+
from .network import RequestAdapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_POSTS_PER_REQUEST: Final = 320
|
|
15
|
+
MAX_TAGS_PER_REQUEST: Final = 320
|
|
16
|
+
DOWNLOAD_CHUNK_SIZE_BYTES: Final = 64 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class E621API:
|
|
20
|
+
"""Async API client for e621-compatible GET operations."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, site: Site = Site.E621) -> None:
|
|
23
|
+
self.site = site
|
|
24
|
+
self.base_url = site.base_url
|
|
25
|
+
self._adapter: RequestAdapter | None = None
|
|
26
|
+
|
|
27
|
+
async def __aenter__(self) -> "E621API":
|
|
28
|
+
self._adapter = RequestAdapter(headers=request_headers(load_login()))
|
|
29
|
+
await self._adapter.__aenter__()
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
33
|
+
if self._adapter is not None:
|
|
34
|
+
await self._adapter.__aexit__(exc_type, exc_val, exc_tb)
|
|
35
|
+
self._adapter = None
|
|
36
|
+
|
|
37
|
+
async def get_posts(
|
|
38
|
+
self,
|
|
39
|
+
tags: str,
|
|
40
|
+
limit: int,
|
|
41
|
+
page: str | None = None,
|
|
42
|
+
) -> list[dict[str, Any]]:
|
|
43
|
+
"""Search posts.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If limit is outside the e621 per-request bounds.
|
|
47
|
+
RuntimeError: If called outside the async context manager.
|
|
48
|
+
aiohttp.ClientResponseError: If the site returns an error response.
|
|
49
|
+
"""
|
|
50
|
+
_validate_limit(limit, MAX_POSTS_PER_REQUEST, "post")
|
|
51
|
+
params: dict[str, str] = {
|
|
52
|
+
"tags": tags,
|
|
53
|
+
"limit": str(limit),
|
|
54
|
+
}
|
|
55
|
+
if page is not None:
|
|
56
|
+
params["page"] = page
|
|
57
|
+
data = await self._get("/posts.json", params)
|
|
58
|
+
if "posts" not in data:
|
|
59
|
+
raise ValueError("Post search response did not include 'posts'")
|
|
60
|
+
posts = data["posts"]
|
|
61
|
+
if not isinstance(posts, list):
|
|
62
|
+
raise ValueError("Post search response 'posts' value was not a list")
|
|
63
|
+
return posts
|
|
64
|
+
|
|
65
|
+
async def get_tags(self, name_matches: str, limit: int = 1) -> list[dict[str, Any]]:
|
|
66
|
+
"""List tags matching a name expression.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If limit is outside the e621 per-request bounds.
|
|
70
|
+
RuntimeError: If called outside the async context manager.
|
|
71
|
+
aiohttp.ClientResponseError: If the site returns an error response.
|
|
72
|
+
"""
|
|
73
|
+
_validate_limit(limit, MAX_TAGS_PER_REQUEST, "tag")
|
|
74
|
+
params = {
|
|
75
|
+
"search[name_matches]": name_matches,
|
|
76
|
+
"limit": str(limit),
|
|
77
|
+
}
|
|
78
|
+
data = await self._get("/tags.json", params)
|
|
79
|
+
if not isinstance(data, list):
|
|
80
|
+
raise ValueError("Tag listing response was not a list")
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
async def download_url(self, url: str) -> bytes:
|
|
84
|
+
"""Download a URL with a byte progress bar and return its response body.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
RuntimeError: If called outside the async context manager.
|
|
88
|
+
aiohttp.ClientResponseError: If the site returns an error response.
|
|
89
|
+
"""
|
|
90
|
+
adapter = self._require_adapter()
|
|
91
|
+
request_context = await adapter.request("GET", url)
|
|
92
|
+
async with request_context as response:
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
chunks: list[bytes] = []
|
|
95
|
+
with tqdm(
|
|
96
|
+
total=response.content_length,
|
|
97
|
+
unit="B",
|
|
98
|
+
unit_scale=True,
|
|
99
|
+
unit_divisor=1024,
|
|
100
|
+
desc=_download_description(url),
|
|
101
|
+
) as progress_bar:
|
|
102
|
+
async for chunk in response.content.iter_chunked(DOWNLOAD_CHUNK_SIZE_BYTES):
|
|
103
|
+
chunks.append(chunk)
|
|
104
|
+
progress_bar.update(len(chunk))
|
|
105
|
+
return b"".join(chunks)
|
|
106
|
+
|
|
107
|
+
async def _get(self, endpoint: str, params: dict[str, str]) -> Any:
|
|
108
|
+
adapter = self._require_adapter()
|
|
109
|
+
url = f"{self.base_url}{endpoint}"
|
|
110
|
+
request_context = await adapter.request("GET", url, params=params)
|
|
111
|
+
async with request_context as response:
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
return await response.json()
|
|
114
|
+
|
|
115
|
+
def _require_adapter(self) -> RequestAdapter:
|
|
116
|
+
if self._adapter is None:
|
|
117
|
+
raise RuntimeError("E621API must be used as an async context manager")
|
|
118
|
+
return self._adapter
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_limit(limit: int, maximum: int, label: str) -> None:
|
|
122
|
+
if limit < 0:
|
|
123
|
+
raise ValueError(f"{label} request limit must be at least zero")
|
|
124
|
+
if limit > maximum:
|
|
125
|
+
raise ValueError(f"{label} request limit cannot exceed {maximum}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _download_description(url: str) -> str:
|
|
129
|
+
path_name = PurePosixPath(urlparse(url).path).name
|
|
130
|
+
if path_name:
|
|
131
|
+
return path_name
|
|
132
|
+
return "download"
|
six2one/auth.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Project-local e621 login storage and request headers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from .errors import UsageError
|
|
11
|
+
from .models import TOOL_NAME, TOOL_VERSION
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LOGIN_FILENAME: Final = ".six2one-login.json"
|
|
15
|
+
PROJECT_MARKER_FILENAME: Final = "pyproject.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LoginCredentials:
|
|
20
|
+
"""Validated login credentials for e621-compatible Basic auth."""
|
|
21
|
+
|
|
22
|
+
username: str
|
|
23
|
+
api_key: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_project_root(start_path: Path | None = None) -> Path:
|
|
27
|
+
"""Find the nearest project root from a start path.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
UsageError: If no project marker is found.
|
|
31
|
+
"""
|
|
32
|
+
current_path = Path.cwd() if start_path is None else start_path
|
|
33
|
+
resolved_path = current_path.resolve()
|
|
34
|
+
search_path = resolved_path if resolved_path.is_dir() else resolved_path.parent
|
|
35
|
+
for candidate in (search_path, *search_path.parents):
|
|
36
|
+
if (candidate / PROJECT_MARKER_FILENAME).is_file():
|
|
37
|
+
return candidate
|
|
38
|
+
raise UsageError(
|
|
39
|
+
f"Could not find project root from {resolved_path}; missing {PROJECT_MARKER_FILENAME}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def login_path(project_root: Path | None = None) -> Path:
|
|
44
|
+
"""Return the login file path for the project root."""
|
|
45
|
+
root = find_project_root() if project_root is None else project_root
|
|
46
|
+
return root / LOGIN_FILENAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_login(username: str, api_key: str, project_root: Path | None = None) -> Path:
|
|
50
|
+
"""Save login credentials to the project root.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
UsageError: If username or api key is empty.
|
|
54
|
+
"""
|
|
55
|
+
credentials = _credentials_from_values(username, api_key)
|
|
56
|
+
path = login_path(project_root)
|
|
57
|
+
data = {
|
|
58
|
+
"username": credentials.username,
|
|
59
|
+
"api_key": credentials.api_key,
|
|
60
|
+
}
|
|
61
|
+
with path.open("w", encoding="utf-8") as file:
|
|
62
|
+
json.dump(data, file, indent=2, sort_keys=True)
|
|
63
|
+
file.write("\n")
|
|
64
|
+
os.chmod(path, 0o600)
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def delete_login(project_root: Path | None = None) -> Path:
|
|
69
|
+
"""Delete the project login file.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
UsageError: If the login file does not exist.
|
|
73
|
+
"""
|
|
74
|
+
path = login_path(project_root)
|
|
75
|
+
if not path.exists():
|
|
76
|
+
raise UsageError(f"No login file exists at {path}")
|
|
77
|
+
path.unlink()
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_login(project_root: Path | None = None) -> LoginCredentials | None:
|
|
82
|
+
"""Load project login credentials if present.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
UsageError: If the login file is malformed.
|
|
86
|
+
"""
|
|
87
|
+
path = login_path(project_root)
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return None
|
|
90
|
+
with path.open("r", encoding="utf-8") as file:
|
|
91
|
+
try:
|
|
92
|
+
data = json.load(file)
|
|
93
|
+
except json.JSONDecodeError as error:
|
|
94
|
+
raise UsageError(f"Login file is not valid JSON: {path}") from error
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise UsageError(f"Login file root must be a JSON object: {path}")
|
|
97
|
+
if "username" not in data:
|
|
98
|
+
raise UsageError(f"Login file is missing required key: username")
|
|
99
|
+
if "api_key" not in data:
|
|
100
|
+
raise UsageError(f"Login file is missing required key: api_key")
|
|
101
|
+
return _credentials_from_values(data["username"], data["api_key"])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def request_headers(credentials: LoginCredentials | None) -> dict[str, str]:
|
|
105
|
+
"""Build HTTP headers for API requests."""
|
|
106
|
+
if credentials is None:
|
|
107
|
+
return {
|
|
108
|
+
"User-Agent": f"{TOOL_NAME}/{TOOL_VERSION}",
|
|
109
|
+
}
|
|
110
|
+
token = base64.b64encode(f"{credentials.username}:{credentials.api_key}".encode("utf-8"))
|
|
111
|
+
return {
|
|
112
|
+
"Authorization": f"Basic {token.decode('ascii')}",
|
|
113
|
+
"User-Agent": f"{TOOL_NAME}/{TOOL_VERSION} (by {credentials.username} on e621)",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _credentials_from_values(username: object, api_key: object) -> LoginCredentials:
|
|
118
|
+
if not isinstance(username, str):
|
|
119
|
+
raise UsageError("username must be a string")
|
|
120
|
+
if not isinstance(api_key, str):
|
|
121
|
+
raise UsageError("api_key must be a string")
|
|
122
|
+
normalized_username = username.strip()
|
|
123
|
+
normalized_api_key = api_key.strip()
|
|
124
|
+
if not normalized_username:
|
|
125
|
+
raise UsageError("username cannot be empty")
|
|
126
|
+
if not normalized_api_key:
|
|
127
|
+
raise UsageError("api_key cannot be empty")
|
|
128
|
+
return LoginCredentials(
|
|
129
|
+
username=normalized_username,
|
|
130
|
+
api_key=normalized_api_key,
|
|
131
|
+
)
|