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 ADDED
@@ -0,0 +1,3 @@
1
+ """Manifest-backed e621/e926 dataset fetching."""
2
+
3
+ __version__ = "0.1.0"
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
+ )