obsideo-cli 0.6.0__tar.gz → 0.6.1.post1__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.
Files changed (27) hide show
  1. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/PKG-INFO +3 -2
  2. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/__init__.py +1 -1
  3. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/__main__.py +4 -4
  4. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/manifest.py +59 -59
  5. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/PKG-INFO +3 -2
  6. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/SOURCES.txt +2 -1
  7. obsideo_cli-0.6.1.post1/obsideo_cli.egg-info/requires.txt +4 -0
  8. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/__init__.py +4 -4
  9. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/identity.py +36 -36
  10. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/names.py +65 -65
  11. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/storage.py +46 -0
  12. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/pyproject.toml +6 -2
  13. obsideo_cli-0.6.1.post1/tests/test_send_buffer.py +46 -0
  14. obsideo_cli-0.6.0/obsideo_cli.egg-info/requires.txt +0 -3
  15. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/LICENSE +0 -0
  16. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/README.md +0 -0
  17. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/cli.py +0 -0
  18. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/sync.py +0 -0
  19. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  20. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/entry_points.txt +0 -0
  21. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/top_level.txt +0 -0
  22. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/config.py +0 -0
  23. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/crypto.py +0 -0
  24. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/login.py +0 -0
  25. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/setup.cfg +0 -0
  26. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/tests/test_cli.py +0 -0
  27. {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1.post1
4
4
  Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -10,7 +10,8 @@ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encrypt
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: boto3>=1.28
13
+ Requires-Dist: boto3>=1.42.43
14
+ Requires-Dist: botocore>=1.42.43
14
15
  Requires-Dist: cryptography>=41.0
15
16
  Requires-Dist: certifi
16
17
  Dynamic: license-file
@@ -1 +1 @@
1
- """The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
1
+ """The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
@@ -1,4 +1,4 @@
1
- from obsideo.cli import main
2
-
3
- if __name__ == "__main__":
4
- main()
1
+ from obsideo.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -1,59 +1,59 @@
1
- """Local manifest for tracking synced files (lifted from Cloud_Terminal,
2
- re-homed under ~/.obsideo)."""
3
-
4
- import hashlib
5
- import json
6
- from datetime import datetime, timezone
7
-
8
- from obsideo_core import config
9
-
10
- _MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
11
-
12
-
13
- def _load() -> dict:
14
- if not _MANIFEST_FILE.exists():
15
- return {"files": {}}
16
- try:
17
- data = json.loads(_MANIFEST_FILE.read_text())
18
- if not isinstance(data, dict):
19
- return {"files": {}}
20
- data.setdefault("files", {})
21
- return data
22
- except Exception:
23
- return {"files": {}}
24
-
25
-
26
- def _save(m: dict) -> None:
27
- _MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
28
- _MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
29
-
30
-
31
- def upsert(name: str, remote_key: str, local_hash: str | None = None,
32
- size: int | None = None, encrypted: bool = True) -> None:
33
- m = _load()
34
- m["files"][name] = {
35
- "remote_key": remote_key,
36
- "local_hash": local_hash,
37
- "size": size,
38
- "encrypted": encrypted,
39
- "last_synced": datetime.now(timezone.utc).isoformat(),
40
- }
41
- _save(m)
42
-
43
-
44
- def remove(name: str) -> None:
45
- m = _load()
46
- m["files"].pop(name, None)
47
- _save(m)
48
-
49
-
50
- def get_all() -> dict:
51
- return _load()["files"]
52
-
53
-
54
- def file_sha256(filepath) -> str:
55
- h = hashlib.sha256()
56
- with open(filepath, "rb") as f:
57
- for chunk in iter(lambda: f.read(8192), b""):
58
- h.update(chunk)
59
- return f"sha256:{h.hexdigest()}"
1
+ """Local manifest for tracking synced files (lifted from Cloud_Terminal,
2
+ re-homed under ~/.obsideo)."""
3
+
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime, timezone
7
+
8
+ from obsideo_core import config
9
+
10
+ _MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
11
+
12
+
13
+ def _load() -> dict:
14
+ if not _MANIFEST_FILE.exists():
15
+ return {"files": {}}
16
+ try:
17
+ data = json.loads(_MANIFEST_FILE.read_text())
18
+ if not isinstance(data, dict):
19
+ return {"files": {}}
20
+ data.setdefault("files", {})
21
+ return data
22
+ except Exception:
23
+ return {"files": {}}
24
+
25
+
26
+ def _save(m: dict) -> None:
27
+ _MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
28
+ _MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
29
+
30
+
31
+ def upsert(name: str, remote_key: str, local_hash: str | None = None,
32
+ size: int | None = None, encrypted: bool = True) -> None:
33
+ m = _load()
34
+ m["files"][name] = {
35
+ "remote_key": remote_key,
36
+ "local_hash": local_hash,
37
+ "size": size,
38
+ "encrypted": encrypted,
39
+ "last_synced": datetime.now(timezone.utc).isoformat(),
40
+ }
41
+ _save(m)
42
+
43
+
44
+ def remove(name: str) -> None:
45
+ m = _load()
46
+ m["files"].pop(name, None)
47
+ _save(m)
48
+
49
+
50
+ def get_all() -> dict:
51
+ return _load()["files"]
52
+
53
+
54
+ def file_sha256(filepath) -> str:
55
+ h = hashlib.sha256()
56
+ with open(filepath, "rb") as f:
57
+ for chunk in iter(lambda: f.read(8192), b""):
58
+ h.update(chunk)
59
+ return f"sha256:{h.hexdigest()}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1.post1
4
4
  Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -10,7 +10,8 @@ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encrypt
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: boto3>=1.28
13
+ Requires-Dist: boto3>=1.42.43
14
+ Requires-Dist: botocore>=1.42.43
14
15
  Requires-Dist: cryptography>=41.0
15
16
  Requires-Dist: certifi
16
17
  Dynamic: license-file
@@ -20,4 +20,5 @@ obsideo_core/login.py
20
20
  obsideo_core/names.py
21
21
  obsideo_core/storage.py
22
22
  tests/test_cli.py
23
- tests/test_core.py
23
+ tests/test_core.py
24
+ tests/test_send_buffer.py
@@ -0,0 +1,4 @@
1
+ boto3>=1.42.43
2
+ botocore>=1.42.43
3
+ cryptography>=41.0
4
+ certifi
@@ -1,4 +1,4 @@
1
- """Obsideo core — the shared client layer for the Obsideo CLI and the mlvault
2
- extension: storage seam, signing identity, account-level crypto, email-OTP login,
3
- and config. Client encrypts; Obsideo sees ciphertext only (Principle 1).
4
- """
1
+ """Obsideo core — the shared client layer for the Obsideo CLI and the mlvault
2
+ extension: storage seam, signing identity, account-level crypto, email-OTP login,
3
+ and config. Client encrypts; Obsideo sees ciphertext only (Principle 1).
4
+ """
@@ -1,36 +1,36 @@
1
- """Client-held signing identity (Ed25519).
2
-
3
- Obsideo external accounts require a customer Ed25519 signing public key — it
4
- authorizes destructive operations on your data (Principle 2: the network can't
5
- delete your data without your signature). The PRIVATE half is generated here and
6
- never leaves your machine; only the public key (obk_sig_…) is sent at signup.
7
- """
8
-
9
- import base64
10
-
11
- from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
- from cryptography.hazmat.primitives.serialization import (
13
- Encoding, NoEncryption, PrivateFormat, PublicFormat,
14
- )
15
-
16
- from obsideo_core import config
17
-
18
- SIGNING_KEY_FILE = config.CONFIG_DIR / "signing.key"
19
-
20
-
21
- def _encode_pub(raw: bytes) -> str:
22
- return "obk_sig_" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
23
-
24
-
25
- def get_or_create_signing_pubkey() -> str:
26
- """Return the Ed25519 signing public key as 'obk_sig_<43 chars>',
27
- generating + persisting the private key locally (0600) on first use."""
28
- if SIGNING_KEY_FILE.exists():
29
- raw = bytes.fromhex(SIGNING_KEY_FILE.read_text().strip())
30
- priv = Ed25519PrivateKey.from_private_bytes(raw)
31
- else:
32
- priv = Ed25519PrivateKey.generate()
33
- raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
34
- config.write_secret_file(SIGNING_KEY_FILE, raw.hex())
35
- pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
36
- return _encode_pub(pub)
1
+ """Client-held signing identity (Ed25519).
2
+
3
+ Obsideo external accounts require a customer Ed25519 signing public key — it
4
+ authorizes destructive operations on your data (Principle 2: the network can't
5
+ delete your data without your signature). The PRIVATE half is generated here and
6
+ never leaves your machine; only the public key (obk_sig_…) is sent at signup.
7
+ """
8
+
9
+ import base64
10
+
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
+ from cryptography.hazmat.primitives.serialization import (
13
+ Encoding, NoEncryption, PrivateFormat, PublicFormat,
14
+ )
15
+
16
+ from obsideo_core import config
17
+
18
+ SIGNING_KEY_FILE = config.CONFIG_DIR / "signing.key"
19
+
20
+
21
+ def _encode_pub(raw: bytes) -> str:
22
+ return "obk_sig_" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
23
+
24
+
25
+ def get_or_create_signing_pubkey() -> str:
26
+ """Return the Ed25519 signing public key as 'obk_sig_<43 chars>',
27
+ generating + persisting the private key locally (0600) on first use."""
28
+ if SIGNING_KEY_FILE.exists():
29
+ raw = bytes.fromhex(SIGNING_KEY_FILE.read_text().strip())
30
+ priv = Ed25519PrivateKey.from_private_bytes(raw)
31
+ else:
32
+ priv = Ed25519PrivateKey.generate()
33
+ raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
34
+ config.write_secret_file(SIGNING_KEY_FILE, raw.hex())
35
+ pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
36
+ return _encode_pub(pub)
@@ -1,65 +1,65 @@
1
- """Deterministic filename encryption (Level 1 metadata privacy).
2
-
3
- Object keys (folder paths + filenames) would otherwise reach Obsideo in the clear,
4
- leaking the *shape* of your data even though contents are encrypted. This encrypts
5
- each path component client-side with **AES-SIV** (deterministic, misuse-resistant
6
- authenticated encryption) keyed off your data key, so `photos/tax.pdf` is stored
7
- as opaque tokens.
8
-
9
- Why deterministic: identical input → identical token, which is what lets the
10
- server still do prefix listing — `ls` lists under the encrypted prefix and the
11
- client decrypts the returned tokens back to real names. Residual leak (accepted at
12
- Level 1): the same name encrypts to the same token, so Obsideo can see that two
13
- objects share a name, never what it is; structure + sizes still show.
14
-
15
- SCHEME (so other clients — e.g. the SDK — can interop, same account same names):
16
- name_key = HKDF-SHA256(ikm=data_key, salt="", info="obsideo-name-key-v1", len=64)
17
- token = base64url-nopad( AES-SIV-encrypt(name_key, utf8(component), aad=none) )
18
- path = "/".join(token(c) for c in path.split("/") if c)
19
- """
20
-
21
- import base64
22
-
23
- from cryptography.hazmat.primitives import hashes
24
- from cryptography.hazmat.primitives.ciphers.aead import AESSIV
25
- from cryptography.hazmat.primitives.kdf.hkdf import HKDF
26
-
27
- from obsideo_core import crypto
28
-
29
- _INFO = b"obsideo-name-key-v1"
30
-
31
-
32
- def _name_key() -> bytes:
33
- # Derived fresh from the data key (cheap); stays correct if the key changes.
34
- return HKDF(algorithm=hashes.SHA256(), length=64, salt=None, info=_INFO).derive(crypto.data_key())
35
-
36
-
37
- def _b64e(b: bytes) -> str:
38
- return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
39
-
40
-
41
- def _b64d(s: str) -> bytes:
42
- return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
43
-
44
-
45
- def encrypt_name(name: str) -> str:
46
- return _b64e(AESSIV(_name_key()).encrypt(name.encode(), None))
47
-
48
-
49
- def decrypt_name(token: str) -> str:
50
- return AESSIV(_name_key()).decrypt(_b64d(token), None).decode()
51
-
52
-
53
- def encrypt_path(path: str) -> str:
54
- """Encrypt each '/'-separated component. Empty -> empty (root)."""
55
- return "/".join(encrypt_name(c) for c in path.split("/") if c)
56
-
57
-
58
- def safe_decrypt_name(token: str) -> tuple[str, bool]:
59
- """Decrypt a listed token. Returns (name, was_encrypted). Falls back to the
60
- raw token for legacy clear-name objects (so listings never crash on a mixed
61
- account)."""
62
- try:
63
- return decrypt_name(token), True
64
- except Exception:
65
- return token, False
1
+ """Deterministic filename encryption (Level 1 metadata privacy).
2
+
3
+ Object keys (folder paths + filenames) would otherwise reach Obsideo in the clear,
4
+ leaking the *shape* of your data even though contents are encrypted. This encrypts
5
+ each path component client-side with **AES-SIV** (deterministic, misuse-resistant
6
+ authenticated encryption) keyed off your data key, so `photos/tax.pdf` is stored
7
+ as opaque tokens.
8
+
9
+ Why deterministic: identical input → identical token, which is what lets the
10
+ server still do prefix listing — `ls` lists under the encrypted prefix and the
11
+ client decrypts the returned tokens back to real names. Residual leak (accepted at
12
+ Level 1): the same name encrypts to the same token, so Obsideo can see that two
13
+ objects share a name, never what it is; structure + sizes still show.
14
+
15
+ SCHEME (so other clients — e.g. the SDK — can interop, same account same names):
16
+ name_key = HKDF-SHA256(ikm=data_key, salt="", info="obsideo-name-key-v1", len=64)
17
+ token = base64url-nopad( AES-SIV-encrypt(name_key, utf8(component), aad=none) )
18
+ path = "/".join(token(c) for c in path.split("/") if c)
19
+ """
20
+
21
+ import base64
22
+
23
+ from cryptography.hazmat.primitives import hashes
24
+ from cryptography.hazmat.primitives.ciphers.aead import AESSIV
25
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
26
+
27
+ from obsideo_core import crypto
28
+
29
+ _INFO = b"obsideo-name-key-v1"
30
+
31
+
32
+ def _name_key() -> bytes:
33
+ # Derived fresh from the data key (cheap); stays correct if the key changes.
34
+ return HKDF(algorithm=hashes.SHA256(), length=64, salt=None, info=_INFO).derive(crypto.data_key())
35
+
36
+
37
+ def _b64e(b: bytes) -> str:
38
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
39
+
40
+
41
+ def _b64d(s: str) -> bytes:
42
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
43
+
44
+
45
+ def encrypt_name(name: str) -> str:
46
+ return _b64e(AESSIV(_name_key()).encrypt(name.encode(), None))
47
+
48
+
49
+ def decrypt_name(token: str) -> str:
50
+ return AESSIV(_name_key()).decrypt(_b64d(token), None).decode()
51
+
52
+
53
+ def encrypt_path(path: str) -> str:
54
+ """Encrypt each '/'-separated component. Empty -> empty (root)."""
55
+ return "/".join(encrypt_name(c) for c in path.split("/") if c)
56
+
57
+
58
+ def safe_decrypt_name(token: str) -> tuple[str, bool]:
59
+ """Decrypt a listed token. Returns (name, was_encrypted). Falls back to the
60
+ raw token for legacy clear-name objects (so listings never crash on a mixed
61
+ account)."""
62
+ try:
63
+ return decrypt_name(token), True
64
+ except Exception:
65
+ return token, False
@@ -28,6 +28,16 @@ _MULTIPART_CHUNK = 16 * 1024 * 1024 # 16 MiB
28
28
  # non-empty placeholder object rather than a zero-byte key.
29
29
  _FOLDER_MARKER = ".keep"
30
30
 
31
+ # Windows only: the socket send buffer for upload connections. With the OS
32
+ # default, one connection from a Windows desktop to s3.obsideo.io (62 ms RTT)
33
+ # moved 3.3 MB/s however fast the line was; 1 MiB gave 25-30 MB/s and 4 MiB
34
+ # 25-33 MB/s on the same part (measured 2026-10-17). 4 MiB keeps a far-away
35
+ # uploader (150 ms+) off the same ceiling. Not set elsewhere: on Linux a fixed
36
+ # SO_SNDBUF switches off the kernel's own tuning (and is capped by wmem_max),
37
+ # which can make it slower. OBSIDEO_SEND_BUFFER=<bytes> overrides; 0 leaves
38
+ # the OS default.
39
+ _SEND_BUFFER_WINDOWS = 4 * 1024 * 1024
40
+
31
41
 
32
42
  def _names_on() -> bool:
33
43
  return config.load_config().get("encrypt_names", True)
@@ -100,9 +110,45 @@ def _s3():
100
110
 
101
111
  _client = boto3.client("s3", endpoint_url=_endpoint(),
102
112
  aws_access_key_id=ak, aws_secret_access_key=sk, config=cfg)
113
+ _widen_send_buffer(_client)
103
114
  return _client
104
115
 
105
116
 
117
+ def _send_buffer() -> int:
118
+ env = os.environ.get("OBSIDEO_SEND_BUFFER", "").strip()
119
+ if env:
120
+ try:
121
+ return max(0, int(env))
122
+ except ValueError:
123
+ pass
124
+ return _SEND_BUFFER_WINDOWS if os.name == "nt" else 0
125
+
126
+
127
+ def _widen_send_buffer(client) -> None:
128
+ """Give the client's connections a larger SO_SNDBUF (see _SEND_BUFFER_WINDOWS).
129
+
130
+ botocore has no public setting for socket options, so this adds one to the
131
+ options its connection pool already uses (TCP_NODELAY, keepalive). Done
132
+ before the first request, so every connection gets it. If botocore's
133
+ internals ever move, this does nothing and uploads run at the old speed;
134
+ it never fails a call.
135
+ """
136
+ size = _send_buffer()
137
+ if not size:
138
+ return
139
+ import socket
140
+ try:
141
+ sess = client._endpoint.http_session
142
+ opts = [o for o in (sess._socket_options or []) if o[:2] != (socket.SOL_SOCKET, socket.SO_SNDBUF)]
143
+ opts.append((socket.SOL_SOCKET, socket.SO_SNDBUF, size))
144
+ sess._socket_options = opts
145
+ managers = [sess._manager, *getattr(sess, "_proxy_managers", {}).values()]
146
+ for m in managers:
147
+ m.connection_pool_kw["socket_options"] = opts
148
+ except Exception:
149
+ pass
150
+
151
+
106
152
  def reset_client() -> None:
107
153
  """Drop the cached client (e.g. after login swaps credentials)."""
108
154
  global _client
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.6.0"
7
+ version = "0.6.1.post1"
8
8
  description = "Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,11 @@ keywords = [
14
14
  "end-to-end encryption", "object storage", "free tier", "cli", "sync",
15
15
  ]
16
16
  dependencies = [
17
- "boto3>=1.28",
17
+ # botocore 1.42.43+ sends request bodies in 128 KB blocks; older ones use
18
+ # urllib3 2.x's 16 KB, which capped one Windows upload connection at
19
+ # ~3.3 MB/s (measured 2026-10-18). boto3 and botocore share version numbers.
20
+ "boto3>=1.42.43",
21
+ "botocore>=1.42.43",
18
22
  "cryptography>=41.0",
19
23
  "certifi",
20
24
  ]
@@ -0,0 +1,46 @@
1
+ """Upload speed: the Windows send buffer."""
2
+
3
+ import socket
4
+
5
+ import pytest
6
+
7
+ from obsideo_core import config, storage
8
+
9
+
10
+ @pytest.fixture
11
+ def creds(monkeypatch, tmp_path):
12
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
13
+ monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKTEST")
14
+ monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
15
+ monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
16
+ storage.reset_client()
17
+ yield tmp_path
18
+ storage.reset_client()
19
+
20
+
21
+ def test_send_buffer_windows_only(monkeypatch):
22
+ monkeypatch.delenv("OBSIDEO_SEND_BUFFER", raising=False)
23
+ monkeypatch.setattr(storage.os, "name", "nt")
24
+ assert storage._send_buffer() == 4 * 1024 * 1024
25
+ monkeypatch.setattr(storage.os, "name", "posix")
26
+ assert storage._send_buffer() == 0
27
+ monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "1048576")
28
+ assert storage._send_buffer() == 1048576
29
+ monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "0")
30
+ monkeypatch.setattr(storage.os, "name", "nt")
31
+ assert storage._send_buffer() == 0
32
+
33
+
34
+ def test_client_connections_get_the_send_buffer(creds, monkeypatch):
35
+ monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "2097152")
36
+ sess = storage._s3()._endpoint.http_session
37
+ want = (socket.SOL_SOCKET, socket.SO_SNDBUF, 2097152)
38
+ assert want in sess._socket_options
39
+ assert want in sess._manager.connection_pool_kw["socket_options"]
40
+ # botocore's own options (TCP_NODELAY) are kept
41
+ assert any(o[1] == socket.TCP_NODELAY for o in sess._socket_options)
42
+
43
+
44
+ def test_widen_never_raises_on_unknown_client(monkeypatch):
45
+ monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "1024")
46
+ storage._widen_send_buffer(object())
@@ -1,3 +0,0 @@
1
- boto3>=1.28
2
- cryptography>=41.0
3
- certifi
File without changes
File without changes
File without changes