saveapi 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.
saveapi/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Python client for saveapi.org — one REST API to download media from
2
+ Instagram, TikTok, YouTube, Facebook, X, Pinterest, Likee, Snapchat,
3
+ Threads, VK, Rutube, 9GAG and Twitch.
4
+
5
+ Docs: https://saveapi.org/en/docs/
6
+ """
7
+
8
+ from ._client import AsyncSaveAPI, SaveAPI, __version__
9
+ from .errors import RETRYABLE_CODES, SaveAPIError
10
+
11
+ __all__ = ["SaveAPI", "AsyncSaveAPI", "SaveAPIError", "RETRYABLE_CODES", "__version__"]
saveapi/_client.py ADDED
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+
10
+ from .errors import SaveAPIError
11
+ from .types import Detection, DownloadResult, YouTubeInfo, YouTubeLink, YouTubeQuality
12
+
13
+ DEFAULT_BASE_URL = "https://api.saveapi.org"
14
+ #: Social links resolve in 1–10 s; a slow source can take up to ~30 s on our side.
15
+ DEFAULT_TIMEOUT = 45.0
16
+ #: Longest ``Retry-After`` the client is willing to sleep through on 429.
17
+ MAX_RATE_LIMIT_WAIT = 60.0
18
+
19
+ try:
20
+ from importlib.metadata import version as _version
21
+
22
+ __version__ = _version("saveapi")
23
+ except Exception: # pragma: no cover - running from a source tree
24
+ __version__ = "0.0.0"
25
+
26
+
27
+ def _key(api_key: Optional[str]) -> str:
28
+ key = api_key or os.environ.get("SAVEAPI_KEY")
29
+ if not key:
30
+ raise ValueError(
31
+ "No API key. Pass api_key=... or set SAVEAPI_KEY. "
32
+ "Get one free at https://saveapi.org/en/signup/"
33
+ )
34
+ return key
35
+
36
+
37
+ def _clean(params: dict[str, Any]) -> dict[str, Any]:
38
+ out = {}
39
+ for name, value in params.items():
40
+ if value is None:
41
+ continue
42
+ out[name] = ("true" if value else "false") if isinstance(value, bool) else value
43
+ return out
44
+
45
+
46
+ def _error(response: httpx.Response) -> SaveAPIError:
47
+ code, message = "HTTP_ERROR", response.reason_phrase or "Request failed"
48
+ try:
49
+ body = response.json()
50
+ error = body.get("error") or {}
51
+ code = error.get("code") or code
52
+ message = error.get("message") or message
53
+ except ValueError:
54
+ pass
55
+ retry_after = response.headers.get("Retry-After")
56
+ return SaveAPIError(
57
+ code, message, response.status_code,
58
+ float(retry_after) if retry_after and retry_after.replace(".", "", 1).isdigit() else None,
59
+ )
60
+
61
+
62
+ def _delay(error: SaveAPIError, attempt: int) -> Optional[float]:
63
+ """Seconds to wait before retrying, or ``None`` to give up."""
64
+ if error.code == "RATE_LIMITED":
65
+ wait = error.retry_after if error.retry_after is not None else 5.0
66
+ return wait if wait <= MAX_RATE_LIMIT_WAIT else None
67
+ if error.retryable:
68
+ return min(1.0 * (2 ** attempt), 8.0)
69
+ return None
70
+
71
+
72
+ class _Base:
73
+ def __init__(self, api_key: Optional[str], base_url: str, max_retries: int):
74
+ self._headers = {
75
+ "Authorization": f"Bearer {_key(api_key)}",
76
+ "User-Agent": f"saveapi-python/{__version__}",
77
+ "Accept": "application/json",
78
+ }
79
+ self._base_url = base_url.rstrip("/")
80
+ self.max_retries = max_retries
81
+
82
+
83
+ class SaveAPI(_Base):
84
+ """Synchronous client for https://api.saveapi.org.
85
+
86
+ >>> from saveapi import SaveAPI
87
+ >>> api = SaveAPI() # reads SAVEAPI_KEY
88
+ >>> post = api.download("https://www.tiktok.com/@zachking/video/6768504823336815877")
89
+ >>> post["medias"][0]["url"]
90
+
91
+ Args:
92
+ api_key: Your ``sk_live_...`` key. Defaults to ``$SAVEAPI_KEY``.
93
+ max_retries: How many times to retry ``UPSTREAM_TIMEOUT`` /
94
+ ``UPSTREAM_ERROR`` / ``RATE_LIMITED``. Final errors (private,
95
+ deleted, unsupported) are never retried.
96
+ timeout: Per-request timeout in seconds.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ api_key: Optional[str] = None,
102
+ *,
103
+ base_url: str = DEFAULT_BASE_URL,
104
+ max_retries: int = 1,
105
+ timeout: float = DEFAULT_TIMEOUT,
106
+ http_client: Optional[httpx.Client] = None,
107
+ ):
108
+ super().__init__(api_key, base_url, max_retries)
109
+ self._http = http_client or httpx.Client(timeout=timeout)
110
+
111
+ def close(self) -> None:
112
+ self._http.close()
113
+
114
+ def __enter__(self) -> "SaveAPI":
115
+ return self
116
+
117
+ def __exit__(self, *exc: object) -> None:
118
+ self.close()
119
+
120
+ def _get(self, path: str, **params: Any) -> Any:
121
+ attempt = 0
122
+ while True:
123
+ response = self._http.get(self._base_url + path, params=_clean(params), headers=self._headers)
124
+ if response.is_success:
125
+ return response.json()
126
+ error = _error(response)
127
+ wait = _delay(error, attempt) if attempt < self.max_retries else None
128
+ if wait is None:
129
+ raise error
130
+ time.sleep(wait)
131
+ attempt += 1
132
+
133
+ # ── Media ──────────────────────────────────────────────────────────────
134
+
135
+ def download(self, url: str, *, full_meta: bool = False, limit_mb: Optional[float] = None) -> DownloadResult:
136
+ """Resolve any supported post link (platform detected from the URL).
137
+
138
+ ``full_meta=True`` adds likes, location and music on Instagram (~+0.3 s,
139
+ same price). ``limit_mb`` skips renditions above that size where the
140
+ platform reports sizes.
141
+ """
142
+ return self._get("/v1/download", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
143
+
144
+ def platform(self, platform: str, url: str, *, full_meta: bool = False,
145
+ limit_mb: Optional[float] = None) -> DownloadResult:
146
+ """Resolve through one platform's endpoint, e.g. ``platform("tiktok", url)``."""
147
+ return self._get(f"/v1/{platform}", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
148
+
149
+ def youtube_info(self, url: str) -> YouTubeInfo:
150
+ """Title, duration and the qualities a YouTube video offers (2 credits)."""
151
+ return self._get("/v1/youtube/info", url=url)
152
+
153
+ def youtube_create(self, url: str, quality: YouTubeQuality = "720p") -> YouTubeLink:
154
+ """A short-lived download link for one quality (10 credits).
155
+
156
+ Video qualities are H.264 MP4 144p–1080p; ``mp3`` and ``m4a`` are audio.
157
+ """
158
+ return self._get("/v1/youtube/create", url=url, quality=quality)
159
+
160
+ def instagram_profile(self, username: Optional[str] = None, *, url: Optional[str] = None) -> dict:
161
+ """Public profile by username (``natgeo`` or ``@natgeo``) or profile link (3 credits)."""
162
+ if not username and not url:
163
+ raise ValueError("Pass a username or a profile url")
164
+ return self._get("/v1/instagram/profile", username=username, url=url)
165
+
166
+ # ── Free calls ─────────────────────────────────────────────────────────
167
+
168
+ def detect(self, url: str) -> Detection:
169
+ """Which platform a link belongs to. Free."""
170
+ return self._get("/v1/detect", url=url)
171
+
172
+ def platforms(self) -> dict:
173
+ """Supported platforms and their endpoints. Free."""
174
+ return self._get("/v1/platforms")
175
+
176
+ def me(self) -> dict:
177
+ """Key, plan and credit balance. Free."""
178
+ return self._get("/v1/me")
179
+
180
+ def usage(self, days: int = 30) -> dict:
181
+ """Daily usage for the last ``days`` (1–180). Free."""
182
+ return self._get("/v1/usage", days=days)
183
+
184
+
185
+ class AsyncSaveAPI(_Base):
186
+ """``asyncio`` twin of :class:`SaveAPI` — same methods, awaitable.
187
+
188
+ >>> async with AsyncSaveAPI() as api:
189
+ ... post = await api.download(url)
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ api_key: Optional[str] = None,
195
+ *,
196
+ base_url: str = DEFAULT_BASE_URL,
197
+ max_retries: int = 1,
198
+ timeout: float = DEFAULT_TIMEOUT,
199
+ http_client: Optional[httpx.AsyncClient] = None,
200
+ ):
201
+ super().__init__(api_key, base_url, max_retries)
202
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
203
+
204
+ async def aclose(self) -> None:
205
+ await self._http.aclose()
206
+
207
+ async def __aenter__(self) -> "AsyncSaveAPI":
208
+ return self
209
+
210
+ async def __aexit__(self, *exc: object) -> None:
211
+ await self.aclose()
212
+
213
+ async def _get(self, path: str, **params: Any) -> Any:
214
+ attempt = 0
215
+ while True:
216
+ response = await self._http.get(self._base_url + path, params=_clean(params), headers=self._headers)
217
+ if response.is_success:
218
+ return response.json()
219
+ error = _error(response)
220
+ wait = _delay(error, attempt) if attempt < self.max_retries else None
221
+ if wait is None:
222
+ raise error
223
+ await asyncio.sleep(wait)
224
+ attempt += 1
225
+
226
+ async def download(self, url: str, *, full_meta: bool = False,
227
+ limit_mb: Optional[float] = None) -> DownloadResult:
228
+ return await self._get("/v1/download", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
229
+
230
+ async def platform(self, platform: str, url: str, *, full_meta: bool = False,
231
+ limit_mb: Optional[float] = None) -> DownloadResult:
232
+ return await self._get(f"/v1/{platform}", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
233
+
234
+ async def youtube_info(self, url: str) -> YouTubeInfo:
235
+ return await self._get("/v1/youtube/info", url=url)
236
+
237
+ async def youtube_create(self, url: str, quality: YouTubeQuality = "720p") -> YouTubeLink:
238
+ return await self._get("/v1/youtube/create", url=url, quality=quality)
239
+
240
+ async def instagram_profile(self, username: Optional[str] = None, *, url: Optional[str] = None) -> dict:
241
+ if not username and not url:
242
+ raise ValueError("Pass a username or a profile url")
243
+ return await self._get("/v1/instagram/profile", username=username, url=url)
244
+
245
+ async def detect(self, url: str) -> Detection:
246
+ return await self._get("/v1/detect", url=url)
247
+
248
+ async def platforms(self) -> dict:
249
+ return await self._get("/v1/platforms")
250
+
251
+ async def me(self) -> dict:
252
+ return await self._get("/v1/me")
253
+
254
+ async def usage(self, days: int = 30) -> dict:
255
+ return await self._get("/v1/usage", days=days)
saveapi/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Errors raised by the client.
2
+
3
+ Every failed call raises :class:`SaveAPIError`. Match on ``error.code`` —
4
+ the codes are stable, the messages are not.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ #: Codes a retry can fix. Everything else is a final answer: retrying a
10
+ #: private or deleted post does not change it, and where the error is billed
11
+ #: (0.1 credit for a link the source refuses) it costs another credit.
12
+ RETRYABLE_CODES = frozenset({"UPSTREAM_TIMEOUT", "UPSTREAM_ERROR", "RATE_LIMITED"})
13
+
14
+
15
+ class SaveAPIError(Exception):
16
+ """A non-2xx answer from api.saveapi.org.
17
+
18
+ Attributes:
19
+ code: Stable error code, e.g. ``PRIVATE_CONTENT`` or ``QUOTA_EXCEEDED``.
20
+ Full list: https://saveapi.org/en/docs/#errors
21
+ message: Human-readable explanation (may change between releases).
22
+ status: HTTP status code.
23
+ retry_after: Seconds to wait, set on ``RATE_LIMITED``.
24
+ """
25
+
26
+ def __init__(self, code: str, message: str, status: int, retry_after: float | None = None):
27
+ super().__init__(f"{code}: {message}")
28
+ self.code = code
29
+ self.message = message
30
+ self.status = status
31
+ self.retry_after = retry_after
32
+
33
+ @property
34
+ def retryable(self) -> bool:
35
+ """True when sending the same request again can succeed."""
36
+ return self.code in RETRYABLE_CODES
saveapi/py.typed ADDED
File without changes
saveapi/types.py ADDED
@@ -0,0 +1,133 @@
1
+ """Response shapes (``TypedDict``) — the client returns plain dicts.
2
+
3
+ Field names match https://saveapi.org/en/docs/. Fields the source does not
4
+ provide are ``None`` rather than missing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal, Optional, TypedDict
10
+
11
+ from typing_extensions import NotRequired
12
+
13
+
14
+ class Credits(TypedDict):
15
+ spent: float
16
+ remaining: Optional[float]
17
+
18
+
19
+ class Media(TypedDict):
20
+ type: Literal["video", "image", "audio"]
21
+ url: str
22
+ ext: Optional[str]
23
+ quality: Optional[str]
24
+ width: Optional[int]
25
+ height: Optional[int]
26
+ size_mb: Optional[float]
27
+ watermark: Optional[bool]
28
+
29
+
30
+ class Format(TypedDict):
31
+ """A ready rendition the source itself offers (TikTok, VK, Likee)."""
32
+
33
+ format: str
34
+ label: str
35
+ type: Literal["video", "audio"]
36
+ size_mb: Optional[float]
37
+ exact: bool
38
+ url: str
39
+ watermark: NotRequired[Optional[bool]]
40
+
41
+
42
+ class Meta(TypedDict, total=False):
43
+ title: Optional[str]
44
+ text: Optional[str]
45
+ author: Optional[str]
46
+ author_url: Optional[str]
47
+ thumbnail: Optional[str]
48
+ duration: Optional[float]
49
+ views: Optional[int]
50
+ published_at: Optional[int]
51
+ username: Optional[str]
52
+ author_avatar: Optional[str]
53
+ author_verified: Optional[bool]
54
+ likes: Optional[int]
55
+ comments: Optional[int]
56
+ shares: Optional[int]
57
+ saves: Optional[int]
58
+ location: Optional[str]
59
+ region: Optional[str]
60
+ music: Optional[dict]
61
+
62
+
63
+ class DownloadResult(TypedDict):
64
+ """``/v1/download`` and ``/v1/{platform}``."""
65
+
66
+ success: Literal[True]
67
+ platform: str
68
+ type: str
69
+ source_url: str
70
+ video_id: Optional[str]
71
+ medias: list[Media]
72
+ music: Optional[Media]
73
+ meta: Meta
74
+ formats: NotRequired[list[Format]]
75
+ recommended_format: NotRequired[Optional[str]]
76
+ credits: Credits
77
+
78
+
79
+ class YouTubeFormat(TypedDict):
80
+ quality: str
81
+ file_size: Optional[int]
82
+ file_size_str: Optional[str]
83
+ exact: bool
84
+
85
+
86
+ class YouTubeInfo(TypedDict):
87
+ success: Literal[True]
88
+ platform: Literal["youtube"]
89
+ source_url: str
90
+ video_id: str
91
+ title: str
92
+ author: Optional[str]
93
+ duration_seconds: Optional[int]
94
+ duration_str: Optional[str]
95
+ thumbnail: Optional[str]
96
+ is_short: bool
97
+ formats: list[YouTubeFormat]
98
+ audio_formats: list[YouTubeFormat]
99
+ credits: Credits
100
+
101
+
102
+ class YouTubeLink(TypedDict):
103
+ success: Literal[True]
104
+ platform: Literal["youtube"]
105
+ source_url: str
106
+ video_id: str
107
+ url: str
108
+ filename: str
109
+ quality: str
110
+ mode: Literal["video", "audio"]
111
+ ext: str
112
+ file_size: Optional[int]
113
+ file_size_str: Optional[str]
114
+ exact: bool
115
+ seekable: bool
116
+ expires: int
117
+ title: str
118
+ duration_seconds: Optional[int]
119
+ duration_str: Optional[str]
120
+ thumbnail: Optional[str]
121
+ credits: Credits
122
+
123
+
124
+ class Detection(TypedDict):
125
+ success: Literal[True]
126
+ supported: bool
127
+ platform: Optional[str]
128
+ name: NotRequired[Optional[str]]
129
+ host: NotRequired[Optional[str]]
130
+ formats: NotRequired[bool]
131
+
132
+
133
+ YouTubeQuality = Literal["144p", "240p", "360p", "480p", "720p", "1080p", "mp3", "m4a"]
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: saveapi
3
+ Version: 0.1.0
4
+ Summary: Official Python client for saveapi.org — download media from Instagram, TikTok, YouTube, Facebook, X, Pinterest and 7 more platforms with one API.
5
+ Project-URL: Homepage, https://saveapi.org/
6
+ Project-URL: Documentation, https://saveapi.org/en/docs/
7
+ Project-URL: Pricing, https://saveapi.org/en/pricing/
8
+ Project-URL: Source, https://github.com/akhroruz/saveapi-sdk
9
+ Project-URL: Changelog, https://saveapi.org/en/changelog/
10
+ Author-email: "saveapi.org" <hello@saveapi.org>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: instagram api,instagram downloader api,reels downloader,saveapi,social media downloader,tiktok api,tiktok downloader api,video downloader,youtube downloader api
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Multimedia :: Video
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.24
23
+ Requires-Dist: typing-extensions>=4.5
24
+ Description-Content-Type: text/markdown
25
+
26
+ # saveapi — Python client for saveapi.org
27
+
28
+ Download videos, photos and audio from **Instagram, TikTok, YouTube, Facebook, X (Twitter), Pinterest, Likee, Snapchat, Threads, VK, Rutube, 9GAG and Twitch** with one API call. Send a public post link, get back direct media URLs and metadata in one JSON shape.
29
+
30
+ - Docs: <https://saveapi.org/en/docs/>
31
+ - Free key (1,000 credits, no card): <https://saveapi.org/en/signup/>
32
+ - Pricing: <https://saveapi.org/en/pricing/>
33
+
34
+ ```bash
35
+ pip install saveapi
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ from saveapi import SaveAPI
42
+
43
+ api = SaveAPI("sk_live_...") # or set SAVEAPI_KEY and call SaveAPI()
44
+
45
+ post = api.download("https://www.tiktok.com/@zachking/video/6768504823336815877")
46
+ print(post["platform"], post["meta"]["title"])
47
+ for media in post["medias"]:
48
+ print(media["type"], media["ext"], media["url"])
49
+ ```
50
+
51
+ `download()` detects the platform from the link. To call one platform's endpoint directly use `api.platform("instagram", url)`.
52
+
53
+ ## Async
54
+
55
+ ```python
56
+ import asyncio
57
+ from saveapi import AsyncSaveAPI
58
+
59
+ async def main():
60
+ async with AsyncSaveAPI() as api:
61
+ post = await api.download("https://www.instagram.com/reel/DdwXSSkMvJz/")
62
+ print(post["medias"][0]["url"])
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ ## YouTube
68
+
69
+ YouTube is two steps: read the available qualities, then create a link for one of them.
70
+
71
+ ```python
72
+ info = api.youtube_info("https://youtu.be/dQw4w9WgXcQ") # 2 credits
73
+ print([f["quality"] for f in info["formats"]])
74
+
75
+ link = api.youtube_create("https://youtu.be/dQw4w9WgXcQ", "mp3") # 10 credits
76
+ print(link["url"], link["filename"])
77
+ ```
78
+
79
+ Video qualities are H.264 MP4 `144p`–`1080p`; `mp3` and `m4a` are audio. The link is short-lived — download it right away.
80
+
81
+ ## Errors
82
+
83
+ Every failed call raises `SaveAPIError`. Match on `error.code`, not on the message:
84
+
85
+ ```python
86
+ from saveapi import SaveAPI, SaveAPIError
87
+
88
+ try:
89
+ post = api.download(url)
90
+ except SaveAPIError as error:
91
+ if error.code in ("PRIVATE_CONTENT", "MEDIA_NOT_FOUND"):
92
+ print("The post is private or gone") # final answer, costs 0.1 credit
93
+ elif error.code == "QUOTA_EXCEEDED":
94
+ print("Top up at https://saveapi.org/en/dashboard/billing/")
95
+ else:
96
+ raise
97
+ ```
98
+
99
+ The client retries only what a retry can fix: `UPSTREAM_TIMEOUT` and `UPSTREAM_ERROR` (once by default, `max_retries=`), and `RATE_LIMITED` after the `Retry-After` delay. Private, deleted and unsupported links are never retried. Full list of codes: <https://saveapi.org/en/docs/#errors>.
100
+
101
+ ## All methods
102
+
103
+ | Method | Endpoint | Cost |
104
+ |---|---|---|
105
+ | `download(url, full_meta=False, limit_mb=None)` | `/v1/download` | 1.5 credits |
106
+ | `platform(name, url, ...)` | `/v1/{platform}` | 1.5 credits |
107
+ | `youtube_info(url)` | `/v1/youtube/info` | 2 credits |
108
+ | `youtube_create(url, quality="720p")` | `/v1/youtube/create` | 10 credits |
109
+ | `instagram_profile(username=None, url=None)` | `/v1/instagram/profile` | 3 credits |
110
+ | `detect(url)` | `/v1/detect` | free |
111
+ | `platforms()` | `/v1/platforms` | free |
112
+ | `me()` | `/v1/me` | free |
113
+ | `usage(days=30)` | `/v1/usage` | free |
114
+
115
+ Instagram stories cost 5 credits, Rutube 3, and a link the source refuses 0.1. Current prices: <https://saveapi.org/en/pricing/>.
116
+
117
+ ## Guides
118
+
119
+ - [Telegram downloader bot](https://saveapi.org/en/guides/telegram-bot/)
120
+ - [Download social media videos in Python](https://saveapi.org/en/guides/python/)
121
+ - [TikTok without watermark](https://saveapi.org/en/guides/no-watermark/)
122
+ - [YouTube to MP3 API](https://saveapi.org/en/guides/youtube-mp3-api/)
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,9 @@
1
+ saveapi/__init__.py,sha256=-kD6hnrZA_RbpHGOA11CgLMwzYiX2_W6_8gsztDUYb4,416
2
+ saveapi/_client.py,sha256=yGmN6Zhyf1Vw3u5dvUYCzRs97zSvkhqUUK0XaVpFG8c,9789
3
+ saveapi/errors.py,sha256=rSz5w5cZ_rVATd3OEWRYubTWWA9BVbpJhMQcrV4gplY,1342
4
+ saveapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ saveapi/types.py,sha256=2_e2BTsPmlAB_7TxsFYm1KXvLu8GCY3D7AbpGNqFEso,3105
6
+ saveapi-0.1.0.dist-info/METADATA,sha256=-xt1pfPpMU9MeXccHJSXrlEQgq4FA2iTFx2eGC5Qe3g,4729
7
+ saveapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ saveapi-0.1.0.dist-info/licenses/LICENSE,sha256=0zEE0_GdMg3sMgU9CMCgbg02qEutYVEGCxeJOBvrNgA,1068
9
+ saveapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 saveapi.org
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.