sniptide 0.1.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.
sniptide-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dino Kupinic
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.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: sniptide
3
+ Version: 0.1.0
4
+ Summary: Official Sniptide API client
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://sniptide.com
7
+ Project-URL: Repository, https://github.com/Dino-Kupinic/sniptide
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # Sniptide Python SDK
14
+
15
+ Install the published package:
16
+
17
+ ```sh
18
+ python -m pip install sniptide
19
+ ```
20
+
21
+ ```python
22
+ from sniptide import SniptideClient, SniptideError
23
+ client = SniptideClient("snpt_...")
24
+ page = client.list_pastes(limit=20)
25
+ if page["nextCursor"]:
26
+ next_page = client.list_pastes(cursor=page["nextCursor"])
27
+ created = client.create_paste({"title": "Example", "files": [{"name": "hello.py", "content": "print('hello')"}]})
28
+ ```
29
+
30
+ Methods `me`, `usage`, `read_paste`, `create_paste`, `update_paste`, `update_settings`, `trash_paste`, `list_collections`, `create_collection`, and `collection_pastes` return API JSON dictionaries. Create methods accept an optional `idempotency_key`; current main ignores these keys; support requires a server version with idempotency enabled. `SniptideError` exposes `status`, `code`, and `retry_after`. Requests are not automatically retried. Set `base_url` for a self-hosted instance; HTTPS is required except on loopback. Redirects are rejected.
31
+
32
+ Environment group reads and writes return variable values, which can contain secrets. Handle responses accordingly. Listing groups returns counts, not values.
33
+
34
+ The response limit defaults to 64 MiB. Set `max_response_bytes` to a positive integer for a different limit.
35
+
36
+ ## License
37
+
38
+ This package is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,26 @@
1
+ # Sniptide Python SDK
2
+
3
+ Install the published package:
4
+
5
+ ```sh
6
+ python -m pip install sniptide
7
+ ```
8
+
9
+ ```python
10
+ from sniptide import SniptideClient, SniptideError
11
+ client = SniptideClient("snpt_...")
12
+ page = client.list_pastes(limit=20)
13
+ if page["nextCursor"]:
14
+ next_page = client.list_pastes(cursor=page["nextCursor"])
15
+ created = client.create_paste({"title": "Example", "files": [{"name": "hello.py", "content": "print('hello')"}]})
16
+ ```
17
+
18
+ Methods `me`, `usage`, `read_paste`, `create_paste`, `update_paste`, `update_settings`, `trash_paste`, `list_collections`, `create_collection`, and `collection_pastes` return API JSON dictionaries. Create methods accept an optional `idempotency_key`; current main ignores these keys; support requires a server version with idempotency enabled. `SniptideError` exposes `status`, `code`, and `retry_after`. Requests are not automatically retried. Set `base_url` for a self-hosted instance; HTTPS is required except on loopback. Redirects are rejected.
19
+
20
+ Environment group reads and writes return variable values, which can contain secrets. Handle responses accordingly. Listing groups returns counts, not values.
21
+
22
+ The response limit defaults to 64 MiB. Set `max_response_bytes` to a positive integer for a different limit.
23
+
24
+ ## License
25
+
26
+ This package is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sniptide"
7
+ version = "0.1.0"
8
+ description = "Official Sniptide API client"
9
+ requires-python = ">=3.9"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ readme = "README.md"
13
+
14
+ [project.urls]
15
+ Homepage = "https://sniptide.com"
16
+ Repository = "https://github.com/Dino-Kupinic/sniptide"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,90 @@
1
+ """A small client for the Sniptide token API."""
2
+ import json
3
+ from http.client import IncompleteRead
4
+ from urllib.error import HTTPError, URLError
5
+ from urllib.parse import quote, urlencode, urlsplit, urlunsplit
6
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
7
+
8
+ _MAX_RESPONSE = 64 * 1024 * 1024
9
+
10
+ class SniptideError(Exception):
11
+ def __init__(self, message, status=None, code=None, retry_after=None):
12
+ super().__init__(message)
13
+ self.status, self.code, self.retry_after = status, code, retry_after
14
+
15
+ class _NoRedirect(HTTPRedirectHandler):
16
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
17
+ return None
18
+
19
+ def _segment(value):
20
+ if not value or value in (".", ".."):
21
+ raise ValueError("Invalid identifier")
22
+ return quote(value, safe="")
23
+
24
+ class SniptideClient:
25
+ def __init__(self, token, base_url="https://app.sniptide.com", timeout=30, max_response_bytes=_MAX_RESPONSE):
26
+ url = urlsplit(base_url)
27
+ if (url.scheme != "https" and not (url.scheme == "http" and url.hostname in ("localhost", "127.0.0.1", "::1"))) or not url.hostname or url.username or url.password or url.query or url.fragment:
28
+ raise ValueError("Base URL must use HTTPS (or HTTP on loopback) and have no credentials, query or fragment")
29
+ if not token or "\r" in token or "\n" in token:
30
+ raise ValueError("A valid token is required")
31
+ if isinstance(max_response_bytes, bool) or not isinstance(max_response_bytes, int) or max_response_bytes <= 0:
32
+ raise ValueError("max_response_bytes must be a positive integer")
33
+ self._max_response_bytes = max_response_bytes
34
+ self._base_url = urlunsplit((url.scheme, url.netloc, url.path.rstrip("/"), "", ""))
35
+ self._token, self._timeout = token, timeout
36
+ self._opener = build_opener(_NoRedirect())
37
+
38
+ def _request(self, path, method="GET", body=None, idempotency_key=None):
39
+ headers = {"Authorization": "Bearer " + self._token, "Accept": "application/json"}
40
+ data = None
41
+ if body is not None:
42
+ data = json.dumps(body).encode("utf-8")
43
+ headers["Content-Type"] = "application/json"
44
+ if idempotency_key is not None:
45
+ headers["Idempotency-Key"] = idempotency_key
46
+ request = Request(self._base_url + "/api/v1/" + path, data=data, headers=headers, method=method)
47
+ try:
48
+ response = self._opener.open(request, timeout=self._timeout)
49
+ except HTTPError as error:
50
+ response = error
51
+ except (URLError, OSError) as error:
52
+ raise SniptideError("Cannot reach Sniptide", code="network_error") from error
53
+ with response:
54
+ status = response.status
55
+ retry_after = response.headers.get("Retry-After")
56
+ if 300 <= status < 400:
57
+ raise SniptideError("Sniptide redirected the request", status, "redirect", retry_after)
58
+ try:
59
+ raw = response.read(self._max_response_bytes + 1)
60
+ if len(raw) > self._max_response_bytes:
61
+ raise ValueError("Response too large")
62
+ value = json.loads(raw)
63
+ if not isinstance(value, dict):
64
+ raise ValueError("Expected an object")
65
+ except (OSError, IncompleteRead) as error:
66
+ raise SniptideError("Cannot reach Sniptide", code="network_error") from error
67
+ except (ValueError, UnicodeError) as error:
68
+ raise SniptideError("Sniptide returned an invalid or oversized response", status, "invalid_response", retry_after) from error
69
+ if status >= 400:
70
+ raise SniptideError(value.get("message", f"HTTP {status}"), status, value.get("code"), retry_after)
71
+ return value
72
+
73
+ def me(self): return self._request("me")
74
+ def usage(self): return self._request("usage")
75
+ def list_pastes(self, **options):
76
+ query = urlencode({key: value for key, value in options.items() if value is not None}, doseq=True)
77
+ return self._request("pastes" + ("?" + query if query else ""))
78
+ def read_paste(self, identifier): return self._request("pastes/" + _segment(identifier))
79
+ def create_paste(self, capture, idempotency_key=None): return self._request("pastes", "POST", capture, idempotency_key)
80
+ def update_paste(self, identifier, capture): return self._request("pastes/" + _segment(identifier), "PUT", capture)
81
+ def update_settings(self, identifier, settings): return self._request("pastes/" + _segment(identifier), "PATCH", settings)
82
+ def trash_paste(self, identifier): return self._request("pastes/" + _segment(identifier), "DELETE")
83
+ def list_env_groups(self): return self._request("env")
84
+ def read_env_group(self, group): return self._request("env/" + _segment(group))
85
+ def write_env_group(self, group, variables): return self._request("env/" + _segment(group), "PUT", {"variables": variables})
86
+ def delete_env_group(self, group): return self._request("env/" + _segment(group), "DELETE")
87
+ def list_collections(self): return self._request("collections")
88
+ def create_collection(self, name, idempotency_key=None): return self._request("collections", "POST", {"name": name}, idempotency_key)
89
+ def collection_pastes(self, slug, cursor=None):
90
+ return self._request("collections/" + _segment(slug) + "/pastes" + ("?" + urlencode({"cursor": cursor}) if cursor is not None else ""))
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: sniptide
3
+ Version: 0.1.0
4
+ Summary: Official Sniptide API client
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://sniptide.com
7
+ Project-URL: Repository, https://github.com/Dino-Kupinic/sniptide
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # Sniptide Python SDK
14
+
15
+ Install the published package:
16
+
17
+ ```sh
18
+ python -m pip install sniptide
19
+ ```
20
+
21
+ ```python
22
+ from sniptide import SniptideClient, SniptideError
23
+ client = SniptideClient("snpt_...")
24
+ page = client.list_pastes(limit=20)
25
+ if page["nextCursor"]:
26
+ next_page = client.list_pastes(cursor=page["nextCursor"])
27
+ created = client.create_paste({"title": "Example", "files": [{"name": "hello.py", "content": "print('hello')"}]})
28
+ ```
29
+
30
+ Methods `me`, `usage`, `read_paste`, `create_paste`, `update_paste`, `update_settings`, `trash_paste`, `list_collections`, `create_collection`, and `collection_pastes` return API JSON dictionaries. Create methods accept an optional `idempotency_key`; current main ignores these keys; support requires a server version with idempotency enabled. `SniptideError` exposes `status`, `code`, and `retry_after`. Requests are not automatically retried. Set `base_url` for a self-hosted instance; HTTPS is required except on loopback. Redirects are rejected.
31
+
32
+ Environment group reads and writes return variable values, which can contain secrets. Handle responses accordingly. Listing groups returns counts, not values.
33
+
34
+ The response limit defaults to 64 MiB. Set `max_response_bytes` to a positive integer for a different limit.
35
+
36
+ ## License
37
+
38
+ This package is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ sniptide/__init__.py
5
+ sniptide.egg-info/PKG-INFO
6
+ sniptide.egg-info/SOURCES.txt
7
+ sniptide.egg-info/dependency_links.txt
8
+ sniptide.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ sniptide
@@ -0,0 +1,61 @@
1
+ import json
2
+ import unittest
3
+ from sniptide import SniptideClient, SniptideError
4
+
5
+ class ContractTest(unittest.TestCase):
6
+ def test_all_methods(self):
7
+ from unittest.mock import MagicMock
8
+ client = SniptideClient("token")
9
+ response = MagicMock()
10
+ response.__enter__.return_value = response
11
+ response.status = 200
12
+ response.headers = {}
13
+ client._opener = MagicMock()
14
+ client._opener.open.return_value = response
15
+ capture = {"title": "a", "files": [{"name": "a", "content": "b"}]}
16
+ variables = [{"name": "SECRET", "value": "value"}]
17
+ cases = [
18
+ (lambda: client.me(), "GET", "me", None, {"user": {"id": "1"}, "scopes": []}, None),
19
+ (lambda: client.usage(), "GET", "usage", None, {"storageBytes": 0, "apiRequests": []}, None),
20
+ (lambda: client.list_pastes(limit=1), "GET", "pastes?limit=1", None, {"pastes": [], "nextCursor": None}, None),
21
+ (lambda: client.read_paste("a/b"), "GET", "pastes/a%2Fb", None, {"paste": {"id": "1"}}, None),
22
+ (lambda: client.create_paste(capture, "key"), "POST", "pastes", capture, {"paste": {"id": "1"}}, "key"),
23
+ (lambda: client.update_paste("a", capture), "PUT", "pastes/a", capture, {"paste": {"id": "1"}}, None),
24
+ (lambda: client.update_settings("a", {"visibility": "private"}), "PATCH", "pastes/a", {"visibility": "private"}, {"paste": {}, "settings": {}}, None),
25
+ (lambda: client.trash_paste("a"), "DELETE", "pastes/a", None, {"trashed": {"id": "1", "slug": "a"}}, None),
26
+ (lambda: client.list_collections(), "GET", "collections", None, {"collections": []}, None),
27
+ (lambda: client.create_collection("a", "key"), "POST", "collections", {"name": "a"}, {"collection": {"slug": "a", "name": "a", "pasteCount": 0}}, "key"),
28
+ (lambda: client.collection_pastes("a", "next +"), "GET", "collections/a/pastes?cursor=next+%2B", None, {"pastes": [], "nextCursor": None}, None),
29
+ (lambda: client.list_env_groups(), "GET", "env", None, {"groups": [{"slug": "a", "variables": 1, "createdAt": "now", "updatedAt": "now"}]}, None),
30
+ (lambda: client.read_env_group("a"), "GET", "env/a", None, {"group": {"slug": "a", "updatedAt": "now", "variables": variables}}, None),
31
+ (lambda: client.write_env_group("a", variables), "PUT", "env/a", {"variables": variables}, {"group": {"slug": "a", "updatedAt": "now", "variables": variables}}, None),
32
+ (lambda: client.delete_env_group("a"), "DELETE", "env/a", None, {"ok": True}, None),
33
+ ]
34
+ for call, method, path, body, envelope, key in cases:
35
+ with self.subTest(path=path, method=method):
36
+ response.read.return_value = json.dumps(envelope).encode()
37
+ self.assertEqual(call(), envelope)
38
+ req = client._opener.open.call_args.args[0]
39
+ self.assertEqual(req.get_header("Authorization"), "Bearer token")
40
+ self.assertEqual((req.full_url, req.method, json.loads(req.data) if req.data else None, req.get_header("Idempotency-key")), ("https://app.sniptide.com/api/v1/" + path, method, body, key))
41
+
42
+ def test_read_errors_and_limits(self):
43
+ from http.client import IncompleteRead
44
+ from unittest.mock import MagicMock
45
+ client = SniptideClient("token", max_response_bytes=5)
46
+ response = MagicMock()
47
+ response.__enter__.return_value = response
48
+ response.status = 200
49
+ response.headers = {}
50
+ client._opener = MagicMock()
51
+ client._opener.open.return_value = response
52
+ for error in (TimeoutError(), OSError(), IncompleteRead(b"a")):
53
+ response.read.side_effect = error
54
+ with self.assertRaises(SniptideError) as caught: client.me()
55
+ self.assertEqual(caught.exception.code, "network_error")
56
+ response.read.side_effect = None
57
+ response.read.return_value = b'{"a":1}'
58
+ with self.assertRaises(SniptideError) as caught: client.me()
59
+ self.assertEqual(caught.exception.code, "invalid_response")
60
+ for value in (0, -1, True, 1.5):
61
+ with self.assertRaises(ValueError): SniptideClient("token", max_response_bytes=value)