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 +61 -0
- jerkup-2.0.0/README.md +41 -0
- jerkup-2.0.0/jerkup/__init__.py +0 -0
- jerkup-2.0.0/jerkup/__main__.py +223 -0
- jerkup-2.0.0/jerkup/configuration.py +35 -0
- jerkup-2.0.0/jerkup/formatter.py +26 -0
- jerkup-2.0.0/jerkup/iso8601.py +39 -0
- jerkup-2.0.0/jerkup/messages.py +31 -0
- jerkup-2.0.0/jerkup/py.typed +0 -0
- jerkup-2.0.0/jerkup/session.py +25 -0
- jerkup-2.0.0/jerkup/uploader.py +90 -0
- jerkup-2.0.0/pyproject.toml +53 -0
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"
|