vbase-api 0.1.2__tar.gz → 0.1.4__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.
- vbase_api-0.1.4/CHANGELOG.md +17 -0
- vbase_api-0.1.4/MANIFEST.in +2 -0
- {vbase_api-0.1.2/src/vbase_api.egg-info → vbase_api-0.1.4}/PKG-INFO +9 -4
- {vbase_api-0.1.2 → vbase_api-0.1.4}/README.md +3 -1
- {vbase_api-0.1.2 → vbase_api-0.1.4}/pyproject.toml +38 -38
- vbase_api-0.1.4/requirements.in +1 -0
- vbase_api-0.1.4/src/vbase_api/_version.py +1 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api/vbase_api_client.py +59 -41
- {vbase_api-0.1.2 → vbase_api-0.1.4/src/vbase_api.egg-info}/PKG-INFO +9 -4
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api.egg-info/SOURCES.txt +6 -1
- vbase_api-0.1.4/src/vbase_api.egg-info/requires.txt +1 -0
- vbase_api-0.1.4/tests/test_api_client.py +107 -0
- vbase_api-0.1.4/tests/test_file_integrity_e2e.py +400 -0
- vbase_api-0.1.2/src/vbase_api/_version.py +0 -1
- vbase_api-0.1.2/src/vbase_api.egg-info/requires.txt +0 -1
- {vbase_api-0.1.2 → vbase_api-0.1.4}/LICENSE.txt +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/NOTICE.txt +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/setup.cfg +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api/__init__.py +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api/vbase_api_models.py +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api.egg-info/dependency_links.txt +0 -0
- {vbase_api-0.1.2 → vbase_api-0.1.4}/src/vbase_api.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are documented here, following
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
Earlier releases were not tracked in this file; see the
|
|
8
|
+
[commit history](https://github.com/validityBase/vbase-api-py/commits/main/)
|
|
9
|
+
for prior changes.
|
|
10
|
+
|
|
11
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## [0.1.4] - 2026-10-26
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Compatibility test matrix for Python 3.8, 3.11, and 3.12.
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vbase-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: vBase REST API Python Client
|
|
5
5
|
Author-email: vBase <tech@vbase.com>
|
|
6
|
-
License
|
|
6
|
+
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/validityBase/vbase-api-py
|
|
8
8
|
Project-URL: Documentation, https://docs.vbase.com/
|
|
9
9
|
Keywords: vbase,stamping,web3,blockchain,validation
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
14
|
Classifier: Operating System :: OS Independent
|
|
12
15
|
Requires-Python: >=3.8
|
|
13
16
|
Description-Content-Type: text/markdown
|
|
14
17
|
License-File: LICENSE.txt
|
|
15
18
|
License-File: NOTICE.txt
|
|
16
|
-
Requires-Dist: requests
|
|
19
|
+
Requires-Dist: requests<3,>=2.7.0
|
|
17
20
|
Dynamic: license-file
|
|
18
21
|
|
|
19
|
-
# vbase-py
|
|
22
|
+
# vbase-api-py
|
|
20
23
|
|
|
21
24
|
vBase REST API Python Client
|
|
22
25
|
|
|
26
|
+
Requires Python 3.11 or newer.
|
|
27
|
+
|
|
23
28
|
See [documentation](https://docs.vbase.com/) and the [Swagger UI](https://app.vbase.com/swagger/) for more details.
|
|
24
29
|
|
|
25
30
|
---
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools >=
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "vbase-api" # This is the name on PyPI
|
|
7
|
-
dynamic = ["version"]
|
|
8
|
-
description = "vBase REST API Python Client"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.8"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"Programming Language :: Python :: 3",
|
|
22
|
-
"Operating System :: OS Independent",
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
[project.urls]
|
|
26
|
-
Homepage = "https://github.com/validityBase/vbase-api-py"
|
|
27
|
-
Documentation = "https://docs.vbase.com/"
|
|
28
|
-
|
|
29
|
-
# This tells setuptools where to find the code
|
|
30
|
-
[tool.setuptools]
|
|
31
|
-
package-dir = {"" = "src"}
|
|
32
|
-
|
|
33
|
-
[tool.setuptools.packages.find]
|
|
34
|
-
where = ["src"]
|
|
35
|
-
|
|
36
|
-
[tool.setuptools.dynamic]
|
|
37
|
-
version = {attr = "vbase_api._version.__version__"}
|
|
38
|
-
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 62.6"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vbase-api" # This is the name on PyPI
|
|
7
|
+
dynamic = ["version", "dependencies"]
|
|
8
|
+
description = "vBase REST API Python Client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "vBase", email = "tech@vbase.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
keywords = ["vbase", "stamping", "web3", "blockchain", "validation"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/validityBase/vbase-api-py"
|
|
27
|
+
Documentation = "https://docs.vbase.com/"
|
|
28
|
+
|
|
29
|
+
# This tells setuptools where to find the code
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.dynamic]
|
|
37
|
+
version = {attr = "vbase_api._version.__version__"}
|
|
38
|
+
dependencies = {file = ["requirements.in"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.7.0,<3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.4"
|
|
@@ -49,9 +49,11 @@ class VBaseAPIClient:
|
|
|
49
49
|
timeout: Request timeout in seconds (default: 30)
|
|
50
50
|
|
|
51
51
|
Example:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
.. code-block:: python
|
|
53
|
+
|
|
54
|
+
client = VBaseAPIClient(api_key="your-bearer-token")
|
|
55
|
+
collections = client.list_collections()
|
|
56
|
+
stamp = client.create_stamp(data={"hello": "world"})
|
|
55
57
|
"""
|
|
56
58
|
|
|
57
59
|
DEFAULT_BASE_URL = "https://app.vbase.com"
|
|
@@ -146,9 +148,11 @@ class VBaseAPIClient:
|
|
|
146
148
|
VBaseAPIError: If the request fails
|
|
147
149
|
|
|
148
150
|
Example:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
.. code-block:: python
|
|
152
|
+
|
|
153
|
+
collections = client.get_collections(is_pinned=True)
|
|
154
|
+
for collection in collections:
|
|
155
|
+
print(f"{collection.name}: {collection.cid}")
|
|
152
156
|
"""
|
|
153
157
|
params = {}
|
|
154
158
|
if user_address is not None:
|
|
@@ -187,13 +191,15 @@ class VBaseAPIClient:
|
|
|
187
191
|
VBaseAPIError: If the request fails or collection already exists
|
|
188
192
|
|
|
189
193
|
Example:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
.. code-block:: python
|
|
195
|
+
|
|
196
|
+
collection = client.create_collection(
|
|
197
|
+
name="My Collection",
|
|
198
|
+
cid="0x1234567890abcdef...",
|
|
199
|
+
description="A sample collection",
|
|
200
|
+
is_pinned=True
|
|
201
|
+
)
|
|
202
|
+
print(f"Created: {collection.name}")
|
|
197
203
|
"""
|
|
198
204
|
data = {
|
|
199
205
|
'name': name,
|
|
@@ -252,17 +258,19 @@ class VBaseAPIClient:
|
|
|
252
258
|
ValueError: If invalid parameters are provided
|
|
253
259
|
|
|
254
260
|
Example:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
261
|
+
.. code-block:: python
|
|
262
|
+
|
|
263
|
+
# Stamp inline data
|
|
264
|
+
stamp = client.create_stamp(data={"hello": "world"})
|
|
265
|
+
print(f"Object CID: {stamp.commitment_receipt.object_cid}")
|
|
266
|
+
|
|
267
|
+
# Stamp a file
|
|
268
|
+
stamp = client.create_stamp(file="document.pdf", collection_name="Documents")
|
|
269
|
+
if stamp.file_object:
|
|
270
|
+
print(f"File: {stamp.file_object.file_name}")
|
|
271
|
+
|
|
272
|
+
# Stamp an existing CID
|
|
273
|
+
stamp = client.create_stamp(data_cid="Qm...")
|
|
266
274
|
"""
|
|
267
275
|
if not any([file, data, data_cid]):
|
|
268
276
|
raise ValueError("At least one of 'file', 'data', or 'data_cid' must be provided")
|
|
@@ -337,11 +345,13 @@ class VBaseAPIClient:
|
|
|
337
345
|
VBaseAPIError: If validation fails or file not found in blockchain
|
|
338
346
|
|
|
339
347
|
Example:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
348
|
+
.. code-block:: python
|
|
349
|
+
|
|
350
|
+
result = client.upload_stamped_file(
|
|
351
|
+
collection_name="My Collection",
|
|
352
|
+
file="stamped_document.pdf"
|
|
353
|
+
)
|
|
354
|
+
print(f"Uploaded: {result.file_object.file_name}")
|
|
345
355
|
"""
|
|
346
356
|
form_data = {
|
|
347
357
|
'collection_name': collection_name
|
|
@@ -386,12 +396,14 @@ class VBaseAPIClient:
|
|
|
386
396
|
VBaseAPIError: If the request fails
|
|
387
397
|
|
|
388
398
|
Example:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
399
|
+
.. code-block:: python
|
|
400
|
+
|
|
401
|
+
result = client.verify_stamps(
|
|
402
|
+
cids=["0xbd...1", "0xcd...2"],
|
|
403
|
+
filter_by_user=True
|
|
404
|
+
)
|
|
405
|
+
for stamp in result.stamp_list:
|
|
406
|
+
print(f"Found stamp at {stamp.timestamp}")
|
|
395
407
|
"""
|
|
396
408
|
data = {
|
|
397
409
|
'cids': cids,
|
|
@@ -421,8 +433,10 @@ class VBaseAPIClient:
|
|
|
421
433
|
VBaseAPIError: If the request fails
|
|
422
434
|
|
|
423
435
|
Example:
|
|
424
|
-
|
|
425
|
-
|
|
436
|
+
.. code-block:: python
|
|
437
|
+
|
|
438
|
+
user = client.get_current_user()
|
|
439
|
+
print(f"User email: {user.email}")
|
|
426
440
|
"""
|
|
427
441
|
response = self.session.get(
|
|
428
442
|
self._get_url('users/me'),
|
|
@@ -445,8 +459,10 @@ class VBaseAPIClient:
|
|
|
445
459
|
VBaseAPIError: If the request fails or user not found
|
|
446
460
|
|
|
447
461
|
Example:
|
|
448
|
-
|
|
449
|
-
|
|
462
|
+
.. code-block:: python
|
|
463
|
+
|
|
464
|
+
user = client.get_user("0x...")
|
|
465
|
+
print(f"User name: {user.name}")
|
|
450
466
|
"""
|
|
451
467
|
response = self.session.get(
|
|
452
468
|
self._get_url(f'users/{user_address}'),
|
|
@@ -486,7 +502,9 @@ def create_client(
|
|
|
486
502
|
Configured VBaseAPIClient instance
|
|
487
503
|
|
|
488
504
|
Example:
|
|
489
|
-
|
|
490
|
-
|
|
505
|
+
.. code-block:: python
|
|
506
|
+
|
|
507
|
+
client = create_client(api_key="your-bearer-token")
|
|
508
|
+
collections = client.get_collections()
|
|
491
509
|
"""
|
|
492
510
|
return VBaseAPIClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vbase-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: vBase REST API Python Client
|
|
5
5
|
Author-email: vBase <tech@vbase.com>
|
|
6
|
-
License
|
|
6
|
+
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/validityBase/vbase-api-py
|
|
8
8
|
Project-URL: Documentation, https://docs.vbase.com/
|
|
9
9
|
Keywords: vbase,stamping,web3,blockchain,validation
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
14
|
Classifier: Operating System :: OS Independent
|
|
12
15
|
Requires-Python: >=3.8
|
|
13
16
|
Description-Content-Type: text/markdown
|
|
14
17
|
License-File: LICENSE.txt
|
|
15
18
|
License-File: NOTICE.txt
|
|
16
|
-
Requires-Dist: requests
|
|
19
|
+
Requires-Dist: requests<3,>=2.7.0
|
|
17
20
|
Dynamic: license-file
|
|
18
21
|
|
|
19
|
-
# vbase-py
|
|
22
|
+
# vbase-api-py
|
|
20
23
|
|
|
21
24
|
vBase REST API Python Client
|
|
22
25
|
|
|
26
|
+
Requires Python 3.11 or newer.
|
|
27
|
+
|
|
23
28
|
See [documentation](https://docs.vbase.com/) and the [Swagger UI](https://app.vbase.com/swagger/) for more details.
|
|
24
29
|
|
|
25
30
|
---
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
CHANGELOG.md
|
|
1
2
|
LICENSE.txt
|
|
3
|
+
MANIFEST.in
|
|
2
4
|
NOTICE.txt
|
|
3
5
|
README.md
|
|
4
6
|
pyproject.toml
|
|
7
|
+
requirements.in
|
|
5
8
|
src/vbase_api/__init__.py
|
|
6
9
|
src/vbase_api/_version.py
|
|
7
10
|
src/vbase_api/vbase_api_client.py
|
|
@@ -10,4 +13,6 @@ src/vbase_api.egg-info/PKG-INFO
|
|
|
10
13
|
src/vbase_api.egg-info/SOURCES.txt
|
|
11
14
|
src/vbase_api.egg-info/dependency_links.txt
|
|
12
15
|
src/vbase_api.egg-info/requires.txt
|
|
13
|
-
src/vbase_api.egg-info/top_level.txt
|
|
16
|
+
src/vbase_api.egg-info/top_level.txt
|
|
17
|
+
tests/test_api_client.py
|
|
18
|
+
tests/test_file_integrity_e2e.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests<3,>=2.7.0
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Client request/response contracts that run without live services."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
import vbase_api
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIClientTests(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.client = vbase_api.VBaseAPIClient(
|
|
17
|
+
api_key="test-only-key", base_url="https://example.invalid", timeout=7
|
|
18
|
+
)
|
|
19
|
+
self.addCleanup(self.client.close)
|
|
20
|
+
self.receipt = {
|
|
21
|
+
"transaction_hash": "0xtransaction",
|
|
22
|
+
"user_address": "0xuser",
|
|
23
|
+
"set_cid": "0xset",
|
|
24
|
+
"object_cid": "0xobject",
|
|
25
|
+
"timestamp": "2026-10-26T00:00:00Z",
|
|
26
|
+
"chain_id": 1,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def response(status, payload):
|
|
31
|
+
response = requests.Response()
|
|
32
|
+
response.status_code = status
|
|
33
|
+
response._content = json.dumps(payload).encode("utf-8")
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
def test_collection_request_and_response(self):
|
|
37
|
+
payload = {
|
|
38
|
+
"id": 1,
|
|
39
|
+
"name": "Example",
|
|
40
|
+
"cid": "0xset",
|
|
41
|
+
"is_pinned": True,
|
|
42
|
+
"is_public": False,
|
|
43
|
+
"created_at": "2026-10-26T00:00:00Z",
|
|
44
|
+
"description": "Example collection",
|
|
45
|
+
}
|
|
46
|
+
with patch.object(
|
|
47
|
+
self.client.session, "post", return_value=self.response(201, payload)
|
|
48
|
+
) as post:
|
|
49
|
+
collection = self.client.create_collection("Example", "Example collection")
|
|
50
|
+
self.assertEqual(collection.cid, "0xset")
|
|
51
|
+
self.assertEqual(collection.name, "Example")
|
|
52
|
+
post.assert_called_once_with(
|
|
53
|
+
"https://example.invalid/api/v1/collections",
|
|
54
|
+
json={
|
|
55
|
+
"name": "Example",
|
|
56
|
+
"cid": None,
|
|
57
|
+
"description": "Example collection",
|
|
58
|
+
"is_pinned": True,
|
|
59
|
+
},
|
|
60
|
+
timeout=7,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def test_stamp_response_types(self):
|
|
64
|
+
for status, expected in [
|
|
65
|
+
(200, vbase_api.IdempotentStampResponse),
|
|
66
|
+
(201, vbase_api.StampCreatedResponse),
|
|
67
|
+
]:
|
|
68
|
+
with self.subTest(status=status):
|
|
69
|
+
with patch.object(
|
|
70
|
+
self.client.session,
|
|
71
|
+
"post",
|
|
72
|
+
return_value=self.response(
|
|
73
|
+
status, {"commitment_receipt": self.receipt}
|
|
74
|
+
),
|
|
75
|
+
):
|
|
76
|
+
stamp = self.client.create_stamp(data_cid="0xobject")
|
|
77
|
+
self.assertIsInstance(stamp, expected)
|
|
78
|
+
self.assertEqual(stamp.commitment_receipt.object_cid, "0xobject")
|
|
79
|
+
|
|
80
|
+
def test_upload_closes_file_after_request(self):
|
|
81
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
82
|
+
path = Path(directory) / "example.bin"
|
|
83
|
+
path.write_bytes(b"example")
|
|
84
|
+
with patch.object(
|
|
85
|
+
self.client.session,
|
|
86
|
+
"post",
|
|
87
|
+
return_value=self.response(201, {"commitment_receipt": self.receipt}),
|
|
88
|
+
) as post:
|
|
89
|
+
result = self.client.upload_stamped_file("Example", path)
|
|
90
|
+
uploaded = post.call_args.kwargs["files"]["file"][1]
|
|
91
|
+
self.assertTrue(uploaded.closed)
|
|
92
|
+
self.assertEqual(result.commitment_receipt.object_cid, "0xobject")
|
|
93
|
+
|
|
94
|
+
def test_api_error_preserves_status_and_message(self):
|
|
95
|
+
with patch.object(
|
|
96
|
+
self.client.session,
|
|
97
|
+
"get",
|
|
98
|
+
return_value=self.response(403, {"error": "Access denied"}),
|
|
99
|
+
):
|
|
100
|
+
with self.assertRaises(vbase_api.VBaseAPIError) as raised:
|
|
101
|
+
self.client.get_collections()
|
|
102
|
+
self.assertEqual(raised.exception.status_code, 403)
|
|
103
|
+
self.assertEqual(str(raised.exception), "[403] Access denied")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Live file-integrity E2E coverage for the vBase API Python client.
|
|
2
|
+
|
|
3
|
+
These tests call the public ``VBaseAPIClient`` stamping methods and then read
|
|
4
|
+
the stored object from S3 to verify the final bytes. They are skipped unless
|
|
5
|
+
live E2E credentials are supplied through GitHub Actions secrets or equivalent
|
|
6
|
+
environment variables.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib.metadata as metadata
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import unittest
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, Optional
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import boto3
|
|
24
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
25
|
+
except ImportError as exc: # pragma: no cover
|
|
26
|
+
raise unittest.SkipTest(
|
|
27
|
+
"Install requirements/e2e.txt to run live file-integrity E2E tests."
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
import vbase_api
|
|
31
|
+
from vbase_api import VBaseAPIClient, VBaseAPIError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sha3_256_cid(payload: bytes) -> str:
|
|
35
|
+
return "0x" + hashlib.sha3_256(payload).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _first_env(*names: str) -> Optional[str]:
|
|
39
|
+
for name in names:
|
|
40
|
+
value = os.environ.get(name)
|
|
41
|
+
if value:
|
|
42
|
+
return value
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _int_env(name: str, default: int) -> int:
|
|
47
|
+
value = _first_env(name)
|
|
48
|
+
if value is None:
|
|
49
|
+
return default
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return int(value)
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{name} must be an integer number of seconds; got {value!r}."
|
|
56
|
+
) from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _log_and_assert_package_source() -> None:
|
|
60
|
+
install_source = _first_env("VBASE_API_PACKAGE_SOURCE")
|
|
61
|
+
if not install_source:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
package_path = Path(vbase_api.__file__).resolve()
|
|
65
|
+
package_version = getattr(vbase_api, "__version__", "unknown")
|
|
66
|
+
try:
|
|
67
|
+
distribution_version = metadata.version("vbase-api")
|
|
68
|
+
except metadata.PackageNotFoundError:
|
|
69
|
+
distribution_version = "not installed"
|
|
70
|
+
print(
|
|
71
|
+
"vbase-api "
|
|
72
|
+
f"package_version={package_version} "
|
|
73
|
+
f"distribution_version={distribution_version} "
|
|
74
|
+
f"imported from {package_path}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
repo_root_raw = _first_env("GITHUB_WORKSPACE")
|
|
78
|
+
if not repo_root_raw:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
repo_root = Path(repo_root_raw).resolve()
|
|
82
|
+
try:
|
|
83
|
+
package_path.relative_to(repo_root)
|
|
84
|
+
package_in_repo = True
|
|
85
|
+
except ValueError:
|
|
86
|
+
package_in_repo = False
|
|
87
|
+
|
|
88
|
+
if install_source == "source":
|
|
89
|
+
if not package_in_repo:
|
|
90
|
+
raise AssertionError(
|
|
91
|
+
"Source-install E2E run imported vbase_api outside the checkout: "
|
|
92
|
+
f"{package_path}"
|
|
93
|
+
)
|
|
94
|
+
elif install_source == "pypi" and package_in_repo:
|
|
95
|
+
raise AssertionError(
|
|
96
|
+
"PyPI-install E2E run imported vbase_api from the checkout instead of "
|
|
97
|
+
f"site-packages: {package_path}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class LiveE2EConfig:
|
|
103
|
+
"""Configuration needed for live app/API file-integrity tests."""
|
|
104
|
+
|
|
105
|
+
api_key: str
|
|
106
|
+
base_url: str
|
|
107
|
+
s3_bucket: str
|
|
108
|
+
aws_access_key_id: str
|
|
109
|
+
aws_secret_access_key: str
|
|
110
|
+
aws_session_token: Optional[str]
|
|
111
|
+
aws_region: str
|
|
112
|
+
timeout: int
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_env(cls) -> "LiveE2EConfig":
|
|
116
|
+
api_key = _first_env("VBASE_API_KEY")
|
|
117
|
+
if not api_key:
|
|
118
|
+
raise unittest.SkipTest(
|
|
119
|
+
"Set VBASE_API_KEY to run live file-integrity E2E tests."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
s3_bucket = _first_env("S3_VALIDATION_BUCKET")
|
|
123
|
+
if not s3_bucket:
|
|
124
|
+
raise unittest.SkipTest(
|
|
125
|
+
"Set S3_VALIDATION_BUCKET to run live file-integrity E2E tests."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
aws_access_key_id = _first_env("AWS_ACCESS_KEY_ID")
|
|
129
|
+
aws_secret_access_key = _first_env("AWS_SECRET_ACCESS_KEY")
|
|
130
|
+
if not aws_access_key_id or not aws_secret_access_key:
|
|
131
|
+
raise unittest.SkipTest(
|
|
132
|
+
"Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY "
|
|
133
|
+
"to download stored files from S3."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return cls(
|
|
137
|
+
api_key=api_key,
|
|
138
|
+
base_url=_first_env("BASE_URL") or "https://staging.app.vbase.com",
|
|
139
|
+
s3_bucket=s3_bucket,
|
|
140
|
+
aws_access_key_id=aws_access_key_id,
|
|
141
|
+
aws_secret_access_key=aws_secret_access_key,
|
|
142
|
+
aws_session_token=_first_env("AWS_SESSION_TOKEN"),
|
|
143
|
+
aws_region=_first_env("AWS_REGION", "AWS_DEFAULT_REGION") or "us-east-1",
|
|
144
|
+
timeout=_int_env("FILE_INTEGRITY_E2E_TIMEOUT", 60),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class FileCase:
|
|
150
|
+
"""Source bytes for one integrity scenario."""
|
|
151
|
+
|
|
152
|
+
label: str
|
|
153
|
+
file_name: str
|
|
154
|
+
content: bytes
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def object_cid(self) -> str:
|
|
158
|
+
return _sha3_256_cid(self.content)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class VBaseAPIClientFileIntegrityE2ETests(unittest.TestCase):
|
|
162
|
+
"""Verify vbase-api-py stamped-file storage preserves exact bytes."""
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def setUpClass(cls) -> None:
|
|
166
|
+
_log_and_assert_package_source()
|
|
167
|
+
cls.config = LiveE2EConfig.from_env()
|
|
168
|
+
cls.client = VBaseAPIClient(
|
|
169
|
+
api_key=cls.config.api_key,
|
|
170
|
+
base_url=cls.config.base_url.rstrip("/"),
|
|
171
|
+
timeout=cls.config.timeout,
|
|
172
|
+
)
|
|
173
|
+
cls.s3_client = boto3.session.Session(
|
|
174
|
+
aws_access_key_id=cls.config.aws_access_key_id,
|
|
175
|
+
aws_secret_access_key=cls.config.aws_secret_access_key,
|
|
176
|
+
aws_session_token=cls.config.aws_session_token,
|
|
177
|
+
region_name=cls.config.aws_region,
|
|
178
|
+
).client("s3")
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def tearDownClass(cls) -> None:
|
|
182
|
+
client = getattr(cls, "client", None)
|
|
183
|
+
if client is not None:
|
|
184
|
+
client.close()
|
|
185
|
+
|
|
186
|
+
s3_client = getattr(cls, "s3_client", None)
|
|
187
|
+
if s3_client is not None:
|
|
188
|
+
s3_client.close()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _file_cases(prefix: str) -> Iterable[FileCase]:
|
|
192
|
+
suffix = uuid.uuid4().hex
|
|
193
|
+
yield FileCase(
|
|
194
|
+
label="text",
|
|
195
|
+
file_name=f"{prefix}-{suffix}.txt",
|
|
196
|
+
content=f"vbase-api-py {prefix} text payload {suffix}\n".encode("utf-8"),
|
|
197
|
+
)
|
|
198
|
+
yield FileCase(
|
|
199
|
+
label="binary",
|
|
200
|
+
file_name=f"{prefix}-{suffix}.bin",
|
|
201
|
+
content=(
|
|
202
|
+
bytes([0, 1, 2, 3, 4, 127, 128, 254, 255])
|
|
203
|
+
+ f" vbase-api-py {prefix} binary payload {suffix} ".encode("utf-8")
|
|
204
|
+
+ bytes([255, 0, 64, 32, 16, 8])
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _create_collection(self, name_prefix: str):
|
|
209
|
+
collection_name = f"{name_prefix}-{uuid.uuid4().hex[:12]}"
|
|
210
|
+
deadline = time.monotonic() + _int_env(
|
|
211
|
+
"FILE_INTEGRITY_E2E_API_WAIT_SECONDS", 60
|
|
212
|
+
)
|
|
213
|
+
last_error = None
|
|
214
|
+
|
|
215
|
+
while True:
|
|
216
|
+
try:
|
|
217
|
+
return self.client.create_collection(
|
|
218
|
+
name=collection_name,
|
|
219
|
+
description="vbase-api-py file-integrity E2E collection",
|
|
220
|
+
is_pinned=False,
|
|
221
|
+
)
|
|
222
|
+
except VBaseAPIError as exc:
|
|
223
|
+
if not exc.status_code or exc.status_code < 500:
|
|
224
|
+
raise
|
|
225
|
+
last_error = exc
|
|
226
|
+
|
|
227
|
+
if time.monotonic() >= deadline:
|
|
228
|
+
raise AssertionError(
|
|
229
|
+
f"Timed out creating collection {collection_name}; "
|
|
230
|
+
f"last error: {last_error}"
|
|
231
|
+
) from last_error
|
|
232
|
+
|
|
233
|
+
time.sleep(5)
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _write_temp_file(directory: Path, file_case: FileCase) -> Path:
|
|
237
|
+
path = directory / file_case.file_name
|
|
238
|
+
path.write_bytes(file_case.content)
|
|
239
|
+
return path
|
|
240
|
+
|
|
241
|
+
def _download_stored_file(self, file_path: str) -> bytes:
|
|
242
|
+
deadline = time.monotonic() + _int_env(
|
|
243
|
+
"FILE_INTEGRITY_E2E_STORAGE_WAIT_SECONDS", 120
|
|
244
|
+
)
|
|
245
|
+
last_error = None
|
|
246
|
+
|
|
247
|
+
while time.monotonic() < deadline:
|
|
248
|
+
try:
|
|
249
|
+
response = self.s3_client.get_object(
|
|
250
|
+
Bucket=self.config.s3_bucket,
|
|
251
|
+
Key=file_path,
|
|
252
|
+
)
|
|
253
|
+
body = response["Body"]
|
|
254
|
+
try:
|
|
255
|
+
payload = body.read()
|
|
256
|
+
finally:
|
|
257
|
+
body.close()
|
|
258
|
+
|
|
259
|
+
content_length = response.get("ContentLength")
|
|
260
|
+
if content_length is not None and content_length != len(payload):
|
|
261
|
+
raise AssertionError(
|
|
262
|
+
f"S3 ContentLength mismatch for {file_path}: "
|
|
263
|
+
f"metadata={content_length}, bytes={len(payload)}"
|
|
264
|
+
)
|
|
265
|
+
return payload
|
|
266
|
+
except (BotoCoreError, ClientError, KeyError) as exc:
|
|
267
|
+
last_error = str(exc)
|
|
268
|
+
|
|
269
|
+
time.sleep(5)
|
|
270
|
+
|
|
271
|
+
raise AssertionError(
|
|
272
|
+
f"Stored file was not downloadable before timeout: {file_path}; "
|
|
273
|
+
f"last error: {last_error}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _assert_file_object_path(self, file_path: str, collection_name: str) -> None:
|
|
277
|
+
self.assertIn(
|
|
278
|
+
f"/collections/{collection_name}/stamped/",
|
|
279
|
+
file_path,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _assert_download_integrity(self, file_path: str, file_case: FileCase) -> None:
|
|
283
|
+
downloaded = self._download_stored_file(file_path)
|
|
284
|
+
|
|
285
|
+
self.assertEqual(
|
|
286
|
+
len(downloaded),
|
|
287
|
+
len(file_case.content),
|
|
288
|
+
f"{file_case.label} downloaded byte size",
|
|
289
|
+
)
|
|
290
|
+
self.assertEqual(
|
|
291
|
+
_sha3_256_cid(downloaded),
|
|
292
|
+
file_case.object_cid,
|
|
293
|
+
f"{file_case.label} downloaded SHA3-256",
|
|
294
|
+
)
|
|
295
|
+
self.assertEqual(
|
|
296
|
+
downloaded,
|
|
297
|
+
file_case.content,
|
|
298
|
+
f"{file_case.label} downloaded bytes",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _wait_for_indexed_stamp(self, object_cid: str, collection_cid: str) -> None:
|
|
302
|
+
deadline = time.monotonic() + _int_env(
|
|
303
|
+
"FILE_INTEGRITY_E2E_INDEX_WAIT_SECONDS", 120
|
|
304
|
+
)
|
|
305
|
+
while time.monotonic() < deadline:
|
|
306
|
+
result = self.client.verify_stamps([object_cid], filter_by_user=True)
|
|
307
|
+
for receipt in result.stamp_list:
|
|
308
|
+
if (
|
|
309
|
+
receipt.object_cid.lower() == object_cid.lower()
|
|
310
|
+
and receipt.set_cid.lower() == collection_cid.lower()
|
|
311
|
+
):
|
|
312
|
+
return
|
|
313
|
+
time.sleep(5)
|
|
314
|
+
|
|
315
|
+
raise AssertionError(
|
|
316
|
+
f"Timed out waiting for indexed stamp: object_cid={object_cid}, "
|
|
317
|
+
f"collection_cid={collection_cid}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def test_create_stamp_with_storage_preserves_downloaded_file_bytes(self):
|
|
321
|
+
"""UC-A: create_stamp stores text and binary files without byte drift."""
|
|
322
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
323
|
+
temp_dir = Path(tmpdir)
|
|
324
|
+
for file_case in self._file_cases("create-stamp"):
|
|
325
|
+
with self.subTest(format=file_case.label):
|
|
326
|
+
collection = self._create_collection(
|
|
327
|
+
f"vapi-create-{file_case.label}"
|
|
328
|
+
)
|
|
329
|
+
source_path = self._write_temp_file(temp_dir, file_case)
|
|
330
|
+
|
|
331
|
+
response = self.client.create_stamp(
|
|
332
|
+
file=source_path,
|
|
333
|
+
collection_name=collection.name,
|
|
334
|
+
store_stamped_file=True,
|
|
335
|
+
idempotent=False,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
self.assertEqual(
|
|
339
|
+
response.commitment_receipt.object_cid.lower(),
|
|
340
|
+
file_case.object_cid.lower(),
|
|
341
|
+
)
|
|
342
|
+
self.assertEqual(
|
|
343
|
+
response.commitment_receipt.set_cid.lower(),
|
|
344
|
+
collection.cid.lower(),
|
|
345
|
+
)
|
|
346
|
+
self.assertIsNotNone(response.file_object)
|
|
347
|
+
|
|
348
|
+
file_path = response.file_object.file_path
|
|
349
|
+
self._assert_file_object_path(file_path, collection.name)
|
|
350
|
+
self._assert_download_integrity(file_path, file_case)
|
|
351
|
+
|
|
352
|
+
def test_upload_stamped_file_preserves_downloaded_file_bytes(self):
|
|
353
|
+
"""UC-B: upload_stamped_file stores text and binary files without byte drift."""
|
|
354
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
355
|
+
temp_dir = Path(tmpdir)
|
|
356
|
+
for file_case in self._file_cases("upload-stamped-file"):
|
|
357
|
+
with self.subTest(format=file_case.label):
|
|
358
|
+
collection = self._create_collection(
|
|
359
|
+
f"vapi-upload-{file_case.label}"
|
|
360
|
+
)
|
|
361
|
+
source_path = self._write_temp_file(temp_dir, file_case)
|
|
362
|
+
|
|
363
|
+
stamp = self.client.create_stamp(
|
|
364
|
+
data_cid=file_case.object_cid,
|
|
365
|
+
collection_cid=collection.cid,
|
|
366
|
+
store_stamped_file=False,
|
|
367
|
+
idempotent=False,
|
|
368
|
+
)
|
|
369
|
+
self.assertEqual(
|
|
370
|
+
stamp.commitment_receipt.object_cid.lower(),
|
|
371
|
+
file_case.object_cid.lower(),
|
|
372
|
+
)
|
|
373
|
+
self.assertEqual(
|
|
374
|
+
stamp.commitment_receipt.set_cid.lower(),
|
|
375
|
+
collection.cid.lower(),
|
|
376
|
+
)
|
|
377
|
+
self._wait_for_indexed_stamp(file_case.object_cid, collection.cid)
|
|
378
|
+
|
|
379
|
+
upload = self.client.upload_stamped_file(
|
|
380
|
+
collection_name=collection.name,
|
|
381
|
+
file=source_path,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
self.assertEqual(
|
|
385
|
+
upload.commitment_receipt.object_cid.lower(),
|
|
386
|
+
file_case.object_cid.lower(),
|
|
387
|
+
)
|
|
388
|
+
self.assertEqual(
|
|
389
|
+
upload.commitment_receipt.set_cid.lower(),
|
|
390
|
+
collection.cid.lower(),
|
|
391
|
+
)
|
|
392
|
+
self.assertIsNotNone(upload.file_object)
|
|
393
|
+
|
|
394
|
+
file_path = upload.file_object.file_path
|
|
395
|
+
self._assert_file_object_path(file_path, collection.name)
|
|
396
|
+
self._assert_download_integrity(file_path, file_case)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
requests>=2.7.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|