jerkup 2.0.0__tar.gz

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.
jerkup-2.0.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: jerkup
3
+ Version: 2.0.0
4
+ Summary: Hamster Uploader
5
+ License: MIT
6
+ Author: deadmaster
7
+ Author-email: deadmaster@noreply.codeberg.org
8
+ Requires-Python: >=3.13,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Topic :: Internet :: WWW/HTTP
13
+ Requires-Dist: msgspec (>=0.19.0,<0.20.0)
14
+ Requires-Dist: platformdirs (>=4.3.7,<5.0.0)
15
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
16
+ Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
17
+ Project-URL: Repository, https://codeberg.org/deadmaster/jerkup
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Hamster Uploader
21
+
22
+ ## Installation
23
+
24
+ Requires Python 3.13.
25
+
26
+ Install using `pip install --user jerkup`.
27
+
28
+ Make sure `PATH` is properly set.
29
+
30
+ Generate an [API key](https://hamster.is/settings/api) and provide it using the `--api-key` option on first invocation.
31
+
32
+ ## Usage
33
+
34
+ Upload an image and get the URL:
35
+
36
+ ```
37
+ jerkup image.jpg
38
+ ```
39
+
40
+ Upload multiple images:
41
+
42
+ ```
43
+ jerkup image1.jpg image2.jpg
44
+ ```
45
+
46
+ Output to file:
47
+
48
+ ```
49
+ jerkup image.jpg --output file.txt
50
+ ```
51
+
52
+ Generate BBCode linked thumbnails:
53
+
54
+ ```
55
+ jerkup image.jpg --format bbcode-thumbnail-linked
56
+ ```
57
+
58
+ ## Reference
59
+
60
+ Use `jerkup --help` to see the full documentation on options and output formats.
61
+
jerkup-2.0.0/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Hamster Uploader
2
+
3
+ ## Installation
4
+
5
+ Requires Python 3.13.
6
+
7
+ Install using `pip install --user jerkup`.
8
+
9
+ Make sure `PATH` is properly set.
10
+
11
+ Generate an [API key](https://hamster.is/settings/api) and provide it using the `--api-key` option on first invocation.
12
+
13
+ ## Usage
14
+
15
+ Upload an image and get the URL:
16
+
17
+ ```
18
+ jerkup image.jpg
19
+ ```
20
+
21
+ Upload multiple images:
22
+
23
+ ```
24
+ jerkup image1.jpg image2.jpg
25
+ ```
26
+
27
+ Output to file:
28
+
29
+ ```
30
+ jerkup image.jpg --output file.txt
31
+ ```
32
+
33
+ Generate BBCode linked thumbnails:
34
+
35
+ ```
36
+ jerkup image.jpg --format bbcode-thumbnail-linked
37
+ ```
38
+
39
+ ## Reference
40
+
41
+ Use `jerkup --help` to see the full documentation on options and output formats.
File without changes
@@ -0,0 +1,223 @@
1
+ import argparse
2
+ import contextlib
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ import traceback
8
+ import typing
9
+
10
+ import requests
11
+
12
+ from .configuration import has_api_key, load_api_key, save_api_key
13
+ from .formatter import (
14
+ FORMAT_BBCODE_FULL,
15
+ FORMAT_BBCODE_FULL_LINKED,
16
+ FORMAT_BBCODE_MEDIUM,
17
+ FORMAT_BBCODE_MEDIUM_LINKED,
18
+ FORMAT_BBCODE_THUMBNAIL,
19
+ FORMAT_BBCODE_THUMBNAIL_LINKED,
20
+ FORMAT_DIRECT_LINK,
21
+ apply_format,
22
+ extract_format,
23
+ )
24
+ from .session import create_session
25
+ from .uploader import DEFAULT_SETTINGS, Result, Settings, upload
26
+
27
+ FORMAT_MAP: dict[str, str] = {
28
+ 'direct-link': FORMAT_DIRECT_LINK,
29
+ 'bbcode-full': FORMAT_BBCODE_FULL,
30
+ 'bbcode-full-linked': FORMAT_BBCODE_FULL_LINKED,
31
+ 'bbcode-medium': FORMAT_BBCODE_MEDIUM,
32
+ 'bbcode-medium-linked': FORMAT_BBCODE_MEDIUM_LINKED,
33
+ 'bbcode-thumbnail': FORMAT_BBCODE_THUMBNAIL,
34
+ 'bbcode-thumbnail-linked': FORMAT_BBCODE_THUMBNAIL_LINKED,
35
+ }
36
+
37
+
38
+ def parse_tags(tags: str) -> list[str]:
39
+ if not tags:
40
+ return []
41
+ return list(map(str.strip, tags.split(',')))
42
+
43
+
44
+ def create_parser(require_api_key: bool) -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser()
46
+ parser.description = 'hamster uploader'
47
+
48
+ parser.add_argument('image', type=str, nargs='+', help='path to image file')
49
+
50
+ parser.add_argument('--api-key', type=str, required=require_api_key, help='hamster api key')
51
+
52
+ parser.add_argument('--title', type=str, help='image title')
53
+ parser.add_argument('--description', type=str, help='image description')
54
+ parser.add_argument('--tags', type=parse_tags, help='comma-separated image tags')
55
+ parser.add_argument('--album', type=str, help='add to album with id')
56
+ parser.add_argument('--category', type=int, help='assign category id')
57
+ parser.add_argument('--width', type=int, help='target resize width')
58
+ parser.add_argument('--expiration', type=int, help='expiration, in seconds')
59
+ parser.add_argument('--nsfw', action='store_true', help='flag as nsfw')
60
+ parser.add_argument(
61
+ '--use-file-date',
62
+ action='store_true',
63
+ help='use exif date instead of upload date (admin only)',
64
+ )
65
+
66
+ parser.add_argument('--retry-count', type=int, default=3, help='number of retries on failure')
67
+ parser.add_argument('--retry-delay', type=float, default=2.0, help='delay between retries')
68
+
69
+ parser.add_argument('--output', '-o', type=str, help='output file')
70
+ parser.add_argument(
71
+ '--format',
72
+ '-f',
73
+ type=str,
74
+ default='direct-link',
75
+ choices=('json',) + tuple(FORMAT_MAP.keys()),
76
+ help='output format',
77
+ )
78
+
79
+ sub = parser.add_mutually_exclusive_group()
80
+ sub.add_argument(
81
+ '--multiline', dest='multiline', default=None, action='store_true', help='multiline output'
82
+ )
83
+ sub.add_argument('--single', dest='multiline', action='store_false', help='single line output')
84
+
85
+ return parser
86
+
87
+
88
+ def safe_upload(
89
+ session: requests.Session,
90
+ path: typing.Union[str, os.PathLike[str]],
91
+ api_key: str,
92
+ retry: tuple[int, float],
93
+ output: typing.TextIO,
94
+ settings: Settings = DEFAULT_SETTINGS,
95
+ ) -> typing.Optional[Result]:
96
+ for index in range(retry[0]):
97
+ if index:
98
+ time.sleep(retry[1])
99
+ output.write('{} ({}/{})\n'.format(os.path.basename(path), index + 1, retry[0]))
100
+ else:
101
+ output.write('{}\n'.format(os.path.basename(path)))
102
+
103
+ try:
104
+ return upload(session, path, api_key, settings)
105
+ except Exception:
106
+ traceback.print_exc(file=output)
107
+ output.write('\n')
108
+
109
+ return None
110
+
111
+
112
+ def dump(
113
+ results: list[Result],
114
+ output: typing.TextIO,
115
+ format: typing.Optional[str],
116
+ multiline: typing.Optional[bool],
117
+ ) -> None:
118
+ if multiline is None:
119
+ multiline = output.isatty() or format == FORMAT_DIRECT_LINK
120
+
121
+ if format is None:
122
+ data = list(map(extract_format, results))
123
+ indent = 2 if multiline else None
124
+
125
+ json.dump(data, fp=output, indent=indent)
126
+ output.write('\n')
127
+
128
+ return
129
+
130
+ count = 0
131
+ for result in results:
132
+ value = apply_format(result, format)
133
+ if value is None:
134
+ continue
135
+
136
+ if count and not multiline:
137
+ output.write(' ')
138
+
139
+ output.write(value)
140
+ if multiline:
141
+ output.write('\n')
142
+
143
+ count += 1
144
+
145
+ if count and not multiline:
146
+ output.write('\n')
147
+
148
+
149
+ def execute(
150
+ images: typing.Iterable[typing.Union[str, os.PathLike[str]]],
151
+ api_key: str,
152
+ settings: Settings,
153
+ retry: tuple[int, float],
154
+ output_data: typing.TextIO,
155
+ output_text: typing.TextIO,
156
+ format: typing.Optional[str],
157
+ multiline: typing.Optional[bool],
158
+ ) -> None:
159
+ results: list[Result] = []
160
+
161
+ with create_session() as session:
162
+ for image in images:
163
+ result = safe_upload(session, image, api_key, retry, output_text, settings)
164
+
165
+ if result is not None:
166
+ results.append(result)
167
+
168
+ dump(results, output_data, format, multiline)
169
+
170
+
171
+ def open_output(path: typing.Optional[str]) -> contextlib.AbstractContextManager[typing.TextIO]:
172
+ if path and path != '-':
173
+ return open(path, 'w', encoding='utf-8')
174
+ else:
175
+ return contextlib.nullcontext(sys.stdout)
176
+
177
+
178
+ def main() -> None:
179
+ parser = create_parser(not has_api_key())
180
+ options = parser.parse_args()
181
+
182
+ images: list[str] = options.image
183
+ api_key: typing.Optional[str] = options.api_key
184
+ title: typing.Optional[str] = options.title
185
+ description: typing.Optional[str] = options.description
186
+ tags: typing.Optional[list[str]] = options.tags
187
+ album_id: typing.Optional[str] = options.album
188
+ category_id: typing.Optional[int] = options.category
189
+ width: typing.Optional[int] = options.width
190
+ expiration: typing.Optional[int] = options.expiration
191
+ nsfw: bool = options.nsfw
192
+ use_file_date: bool = options.use_file_date
193
+ retry_count: int = options.retry_count
194
+ retry_delay: float = options.retry_delay
195
+ output: typing.Optional[str] = options.output
196
+ format: str = options.format
197
+ multiline: typing.Optional[bool] = options.multiline
198
+
199
+ if api_key is None:
200
+ api_key = load_api_key()
201
+ else:
202
+ save_api_key(api_key)
203
+
204
+ settings = Settings(
205
+ title=title,
206
+ description=description,
207
+ tags=tags,
208
+ album_id=album_id,
209
+ category_id=category_id,
210
+ width=width,
211
+ expiration=expiration,
212
+ nsfw=nsfw,
213
+ use_file_date=use_file_date,
214
+ )
215
+
216
+ retry = (retry_count, retry_delay)
217
+
218
+ with open_output(output) as fp:
219
+ execute(images, api_key, settings, retry, fp, sys.stderr, FORMAT_MAP.get(format), multiline)
220
+
221
+
222
+ if __name__ == '__main__':
223
+ main()
@@ -0,0 +1,35 @@
1
+ import functools
2
+ import os
3
+ import sys
4
+
5
+ import platformdirs
6
+
7
+ APPLICATION = 'JerkUp' if sys.platform in ('win32', 'darwin') else 'jerkup'
8
+ DIRECTORY = platformdirs.user_data_path(APPLICATION, False)
9
+
10
+ APIKEY_NAME = 'apikey.txt'
11
+ APIKEY_ENV = 'JERKUP_API_KEY'
12
+ APIKEY_PATH = DIRECTORY / APIKEY_NAME
13
+ APIKEY_ENCODING = 'utf-8'
14
+
15
+
16
+ def has_api_key() -> bool:
17
+ return APIKEY_ENV in os.environ or APIKEY_PATH.is_file()
18
+
19
+
20
+ def load_api_key() -> str:
21
+ value = os.environ.get(APIKEY_ENV)
22
+ if value is not None:
23
+ return value
24
+
25
+ with APIKEY_PATH.open('r', encoding=APIKEY_ENCODING) as fp:
26
+ value = fp.readline().rstrip('\n')
27
+ return value
28
+
29
+
30
+ def save_api_key(api_key: str) -> None:
31
+ DIRECTORY.mkdir(parents=True, exist_ok=True)
32
+
33
+ opener = functools.partial(os.open, mode=0o660)
34
+ with open(APIKEY_PATH, mode='w', encoding=APIKEY_ENCODING, opener=opener) as fp:
35
+ fp.write(api_key)
@@ -0,0 +1,26 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+ from .uploader import Result
5
+
6
+ FORMAT_DIRECT_LINK = '{image}'
7
+ FORMAT_BBCODE_FULL = '[img]{image}[/img]'
8
+ FORMAT_BBCODE_FULL_LINKED = '[url={viewer}][img]{image}[/img][/url]'
9
+ FORMAT_BBCODE_MEDIUM = '[img]{medium}[/img]'
10
+ FORMAT_BBCODE_MEDIUM_LINKED = '[url={viewer}][img]{medium}[/img][/url]'
11
+ FORMAT_BBCODE_THUMBNAIL = '[img]{thumbnail}[/img]'
12
+ FORMAT_BBCODE_THUMBNAIL_LINKED = '[url={viewer}][img]{thumbnail}[/img][/url]'
13
+
14
+
15
+ def extract_format(result: Result) -> dict[str, object]:
16
+ data = dataclasses.asdict(result)
17
+ if result.medium is None:
18
+ data.pop('medium')
19
+ return data
20
+
21
+
22
+ def apply_format(result: Result, format: str) -> typing.Optional[str]:
23
+ try:
24
+ return format.format(**extract_format(result))
25
+ except KeyError:
26
+ return None
@@ -0,0 +1,39 @@
1
+ import datetime
2
+ import typing
3
+
4
+
5
+ # https://en.wikipedia.org/wiki/ISO_8601#Durations
6
+ def format_duration(duration: typing.Union[datetime.timedelta, int]) -> str:
7
+ if isinstance(duration, datetime.timedelta):
8
+ duration = duration.days * 86400 + duration.seconds
9
+
10
+ parts: list[typing.Union[str, int]] = []
11
+ seconds = duration
12
+
13
+ minutes, seconds = divmod(seconds, 60)
14
+ hours, minutes = divmod(minutes, 60)
15
+ days, hours = divmod(hours, 24)
16
+
17
+ if seconds or not (minutes or hours or days):
18
+ parts.append('S')
19
+ parts.append(seconds)
20
+
21
+ if minutes:
22
+ parts.append('M')
23
+ parts.append(minutes)
24
+
25
+ if hours:
26
+ parts.append('H')
27
+ parts.append(hours)
28
+
29
+ if parts:
30
+ parts.append('T')
31
+
32
+ if days:
33
+ parts.append('D')
34
+ parts.append(days)
35
+
36
+ parts.append('P')
37
+ parts.reverse()
38
+
39
+ return ''.join(map(str, parts))
@@ -0,0 +1,31 @@
1
+ import typing
2
+
3
+ import msgspec
4
+
5
+ TStr = typing.TypeVar('TStr', str, typing.Optional[str])
6
+
7
+
8
+ class Status(msgspec.Struct):
9
+ message: str
10
+ code: typing.Union[int, str]
11
+
12
+
13
+ class Image(typing.Generic[TStr], msgspec.Struct):
14
+ url: TStr
15
+
16
+
17
+ class Body(msgspec.Struct):
18
+ url_viewer: str
19
+ image: Image[str]
20
+ thumb: Image[str]
21
+ medium: Image[typing.Optional[str]]
22
+
23
+
24
+ class Response(msgspec.Struct):
25
+ status_code: int
26
+ success: Status
27
+ image: Body
28
+ status_txt: str
29
+
30
+
31
+ DECODER = msgspec.json.Decoder(Response)
File without changes
@@ -0,0 +1,25 @@
1
+ import functools
2
+ import importlib.metadata
3
+ import platform
4
+
5
+ import requests
6
+
7
+
8
+ @functools.cache
9
+ def get_user_agent() -> str:
10
+ parts: tuple[str, ...] = (
11
+ '{}/{}'.format(__package__, importlib.metadata.version(__package__)),
12
+ 'Python {}'.format(platform.python_version()),
13
+ '{} {}'.format(platform.system(), platform.release()),
14
+ platform.machine(),
15
+ '+https://codeberg.org/deadmaster/jerkup',
16
+ )
17
+
18
+ return '{} ({})'.format(parts[0], '; '.join(parts[1:]))
19
+
20
+
21
+ def create_session() -> requests.Session:
22
+ session = requests.Session()
23
+ session.headers['User-Agent'] = get_user_agent()
24
+ session.stream = True
25
+ return session
@@ -0,0 +1,90 @@
1
+ import dataclasses
2
+ import datetime
3
+ import mimetypes
4
+ import os
5
+ import typing
6
+
7
+ import requests
8
+ import requests_toolbelt # type: ignore[import-untyped]
9
+
10
+ from .iso8601 import format_duration
11
+ from .messages import DECODER
12
+
13
+ ENDPOINT = 'https://hamster.is/api/1/upload'
14
+ TIMEOUT = (10.0, 30.0)
15
+
16
+
17
+ # https://v4-docs.chevereto.com/developer/api/api-v1.html
18
+ @dataclasses.dataclass(eq=False, frozen=True, kw_only=True)
19
+ class Settings:
20
+ title: typing.Optional[str] = None
21
+ description: typing.Optional[str] = None
22
+ tags: typing.Optional[list[str]] = None
23
+ album_id: typing.Optional[str] = None
24
+ category_id: typing.Optional[int] = None
25
+ width: typing.Optional[int] = None
26
+ expiration: typing.Union[datetime.timedelta, int, None] = None
27
+ nsfw: typing.Optional[bool] = None
28
+ use_file_date: typing.Optional[bool] = None
29
+
30
+
31
+ DEFAULT_SETTINGS = Settings()
32
+
33
+
34
+ @dataclasses.dataclass(eq=False, frozen=True, kw_only=True)
35
+ class Result:
36
+ viewer: str
37
+ image: str
38
+ medium: typing.Optional[str] = None
39
+ thumbnail: str
40
+
41
+
42
+ def upload(
43
+ session: requests.Session,
44
+ path: typing.Union[str, os.PathLike[str]],
45
+ api_key: str,
46
+ settings: Settings = DEFAULT_SETTINGS,
47
+ ) -> Result:
48
+ mimetypes.init()
49
+ mime_type, _ = mimetypes.guess_type(path)
50
+ if mime_type is None:
51
+ raise ValueError('unknown file type')
52
+
53
+ with open(path, 'rb') as fp:
54
+ fields: dict[str, typing.Union[str, tuple[str, typing.BinaryIO, str]]] = {}
55
+ fields['source'] = (os.path.basename(path), fp, mime_type)
56
+ if settings.title is not None:
57
+ fields['title'] = settings.title
58
+ if settings.description is not None:
59
+ fields['description'] = settings.description
60
+ if settings.tags is not None:
61
+ fields['tags'] = ','.join(settings.tags)
62
+ if settings.album_id is not None:
63
+ fields['album_id'] = settings.album_id
64
+ if settings.category_id is not None:
65
+ fields['category_id'] = str(settings.category_id)
66
+ if settings.width is not None:
67
+ fields['width'] = str(settings.width)
68
+ if settings.expiration is not None:
69
+ fields['expiration'] = format_duration(settings.expiration)
70
+ if settings.nsfw is not None:
71
+ fields['nsfw'] = str(int(settings.nsfw))
72
+ fields['format'] = 'json'
73
+ if settings.use_file_date is not None:
74
+ fields['use_file_date'] = str(int(settings.use_file_date))
75
+
76
+ encoder = requests_toolbelt.MultipartEncoder(fields)
77
+ headers = {'X-API-Key': api_key, 'Content-Type': encoder.content_type}
78
+
79
+ with session.post(
80
+ ENDPOINT, data=encoder, headers=headers, timeout=TIMEOUT, allow_redirects=False
81
+ ) as response:
82
+ response.raise_for_status()
83
+ data = DECODER.decode(response.content)
84
+
85
+ return Result(
86
+ viewer=data.image.url_viewer,
87
+ image=data.image.image.url,
88
+ medium=data.image.medium.url,
89
+ thumbnail=data.image.thumb.url,
90
+ )
@@ -0,0 +1,53 @@
1
+ [tool.poetry]
2
+ name = "jerkup"
3
+ version = "2.0.0"
4
+ description = "Hamster Uploader"
5
+ license = "MIT"
6
+ authors = ["deadmaster <deadmaster@noreply.codeberg.org>"]
7
+ readme = "README.md"
8
+ repository = "https://codeberg.org/deadmaster/jerkup"
9
+ classifiers = ["Topic :: Internet :: WWW/HTTP"]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.13"
13
+ requests = "^2.32.3"
14
+ requests-toolbelt = "^1.0.0"
15
+ msgspec = "^0.19.0"
16
+ platformdirs = "^4.3.7"
17
+
18
+ [tool.poetry.group.dev.dependencies]
19
+ mypy = "^1.15.0"
20
+ ruff = "^0.11.8"
21
+ types-requests = "^2.32.0"
22
+
23
+ [tool.poetry.scripts]
24
+ jerkup = "jerkup.__main__:main"
25
+
26
+ [tool.ruff]
27
+ include = ["jerkup/**/*.py"]
28
+ target-version = "py313"
29
+ line-length = 100
30
+ indent-width = 4
31
+
32
+ [tool.ruff.lint]
33
+ select = ["F", "I", "UP", "G"]
34
+ ignore = ["UP007", "UP015", "UP032", "UP038"]
35
+
36
+ [tool.ruff.lint.isort]
37
+ split-on-trailing-comma = false
38
+
39
+ [tool.ruff.format]
40
+ quote-style = "single"
41
+ indent-style = "tab"
42
+ skip-magic-trailing-comma = true
43
+
44
+ [tool.mypy]
45
+ python_version = "3.13"
46
+ show_error_codes = true
47
+ show_absolute_path = true
48
+ files = ["jerkup"]
49
+ strict = true
50
+
51
+ [build-system]
52
+ requires = ["poetry-core"]
53
+ build-backend = "poetry.core.masonry.api"