pine-assistant 0.3.0__tar.gz → 0.3.2__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 (39) hide show
  1. {pine_assistant-0.3.0/src/pine_assistant.egg-info → pine_assistant-0.3.2}/PKG-INFO +2 -2
  2. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/pyproject.toml +2 -2
  3. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/__init__.py +1 -1
  4. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/client.py +24 -3
  5. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/transport/socketio.py +51 -8
  6. {pine_assistant-0.3.0 → pine_assistant-0.3.2/src/pine_assistant.egg-info}/PKG-INFO +2 -2
  7. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant.egg-info/SOURCES.txt +3 -1
  8. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant.egg-info/requires.txt +1 -1
  9. pine_assistant-0.3.2/tests/test_device_id.py +57 -0
  10. pine_assistant-0.3.2/tests/test_socketio_connect.py +149 -0
  11. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/LICENSE +0 -0
  12. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/README.md +0 -0
  13. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/setup.cfg +0 -0
  14. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/auth.py +0 -0
  15. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/chat.py +0 -0
  16. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/__init__.py +0 -0
  17. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/auth.py +0 -0
  18. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/chat.py +0 -0
  19. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/main.py +0 -0
  20. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/sessions.py +0 -0
  21. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/cli/tasks.py +0 -0
  22. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/errors.py +0 -0
  23. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/__init__.py +0 -0
  24. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/envelope.py +0 -0
  25. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/events.py +0 -0
  26. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/form.py +0 -0
  27. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/payment.py +0 -0
  28. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/session.py +0 -0
  29. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/models/task.py +0 -0
  30. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/py.typed +0 -0
  31. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/sessions.py +0 -0
  32. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/transport/__init__.py +0 -0
  33. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/transport/envelope.py +0 -0
  34. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant/transport/http.py +0 -0
  35. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant.egg-info/dependency_links.txt +0 -0
  36. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant.egg-info/entry_points.txt +0 -0
  37. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/src/pine_assistant.egg-info/top_level.txt +0 -0
  38. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/tests/test_basics.py +0 -0
  39. {pine_assistant-0.3.0 → pine_assistant-0.3.2}/tests/test_chat_engine.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pine-assistant
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client.
5
5
  Author: Pine AI
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: python-socketio[asyncio]>=5.11.0
23
+ Requires-Dist: python-socketio>=5.11.0
24
24
  Requires-Dist: httpx>=0.27.0
25
25
  Requires-Dist: pydantic>=2.0.0
26
26
  Provides-Extra: cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pine-assistant"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "python-socketio[asyncio]>=5.11.0",
26
+ "python-socketio>=5.11.0",
27
27
  "httpx>=0.27.0",
28
28
  "pydantic>=2.0.0",
29
29
  ]
@@ -11,7 +11,7 @@ from pine_assistant.sessions import SessionsAPI
11
11
  from pine_assistant.errors import PineAIError, AuthError, SessionError, ConnectionError
12
12
  from pine_assistant.models.events import C2SEvent, S2CEvent, NotificationEvent
13
13
 
14
- __version__ = "0.3.0"
14
+ __version__ = "0.3.2"
15
15
  __all__ = [
16
16
  "PineAI",
17
17
  "AsyncPineAI",
@@ -3,6 +3,8 @@ PineAI / AsyncPineAI — main SDK clients.
3
3
  """
4
4
 
5
5
  import asyncio
6
+ import logging
7
+ import os
6
8
  import uuid
7
9
  from pathlib import Path
8
10
  from typing import Any, AsyncGenerator, Generator, Optional
@@ -16,20 +18,39 @@ from pine_assistant.errors import ConnectionError
16
18
  from pine_assistant.models.events import C2SEvent
17
19
 
18
20
  DEVICE_ID_FILE = Path.home() / ".pine" / "device_id"
21
+ DEVICE_ID_ENV = "PINE_DEVICE_ID"
22
+
23
+ _logger = logging.getLogger(__name__)
19
24
 
20
25
 
21
26
  def _get_or_create_device_id(provided: Optional[str] = None) -> str:
27
+ """Resolve a stable device_id.
28
+
29
+ Precedence: explicit argument → PINE_DEVICE_ID env var → ~/.pine/device_id
30
+ → a new random UUID persisted to ~/.pine/device_id.
31
+
32
+ If persistence fails, a warning is logged so callers running in sandboxed
33
+ or read-only environments learn why their device_id rotates on each launch
34
+ — set PINE_DEVICE_ID to pin it.
35
+ """
22
36
  if provided:
23
37
  return provided
38
+ env_value = os.environ.get(DEVICE_ID_ENV)
39
+ if env_value:
40
+ return env_value.strip()
24
41
  try:
25
42
  return DEVICE_ID_FILE.read_text().strip()
26
- except FileNotFoundError:
43
+ except (FileNotFoundError, OSError):
27
44
  device_id = str(uuid.uuid4())
28
45
  try:
29
46
  DEVICE_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
30
47
  DEVICE_ID_FILE.write_text(device_id)
31
- except OSError:
32
- pass
48
+ except OSError as exc:
49
+ _logger.warning(
50
+ "Could not persist device_id to %s (%s). "
51
+ "Set the %s env var to pin a stable device_id.",
52
+ DEVICE_ID_FILE, exc, DEVICE_ID_ENV,
53
+ )
33
54
  return device_id
34
55
 
35
56
 
@@ -11,9 +11,24 @@ from typing import Any, Callable, Optional
11
11
 
12
12
  import socketio
13
13
 
14
+ from pine_assistant.errors import ConnectionError as PineConnectionError
15
+
14
16
  SOCKETIO_PATH = "/api/v2/socket.io/"
15
17
 
16
18
 
19
+ def _format_connect_error(data: Any) -> str:
20
+ """Render a server-supplied connect_error payload into a human-readable string."""
21
+ if data is None:
22
+ return "no payload"
23
+ if isinstance(data, str):
24
+ return data
25
+ if isinstance(data, dict):
26
+ msg = data.get("message") or data.get("error") or data.get("reason")
27
+ if msg:
28
+ return str(msg)
29
+ return repr(data)
30
+
31
+
17
32
  class SocketIOManager:
18
33
  def __init__(
19
34
  self,
@@ -66,11 +81,16 @@ class SocketIOManager:
66
81
 
67
82
  self._sio = socketio.AsyncClient()
68
83
  ready_event = asyncio.Event()
84
+ connect_error_data: dict[str, Any] = {}
69
85
 
70
86
  @self._sio.event
71
87
  async def connect() -> None:
72
88
  pass
73
89
 
90
+ @self._sio.event
91
+ async def connect_error(data: Any) -> None:
92
+ connect_error_data["payload"] = data
93
+
74
94
  @self._sio.on("ready")
75
95
  async def on_ready(*_args: Any) -> None:
76
96
  self._connected = True
@@ -93,19 +113,36 @@ class SocketIOManager:
93
113
  async def disconnect(_reason: str = "") -> None:
94
114
  self._connected = False
95
115
 
96
- await self._sio.connect(
97
- self._base_url,
98
- auth={"token": self._token},
99
- transports=self._transports,
100
- socketio_path=SOCKETIO_PATH,
101
- wait_timeout=self._ready_timeout,
102
- )
116
+ try:
117
+ await self._sio.connect(
118
+ self._base_url,
119
+ auth={"token": self._token},
120
+ transports=self._transports,
121
+ socketio_path=SOCKETIO_PATH,
122
+ wait_timeout=self._ready_timeout,
123
+ )
124
+ except Exception as exc:
125
+ # python-socketio raises the generic "One or more namespaces
126
+ # failed to connect" when the server replies with connect_error.
127
+ # Surface the actual server-supplied reason if we captured one.
128
+ if "payload" in connect_error_data:
129
+ reason = _format_connect_error(connect_error_data["payload"])
130
+ raise PineConnectionError(
131
+ f"Socket.IO connect rejected by server: {reason}"
132
+ ) from exc
133
+ raise PineConnectionError(f"Socket.IO connect failed: {exc}") from exc
103
134
 
104
135
  try:
105
136
  await asyncio.wait_for(ready_event.wait(), timeout=self._ready_timeout)
106
137
  except asyncio.TimeoutError:
107
138
  await self._sio.disconnect()
108
- raise TimeoutError(f"Timed out waiting for 'ready' event after {self._ready_timeout}s")
139
+ # The Pine backend accepts the WebSocket but only emits 'ready' after
140
+ # its own auth check. A timeout here almost always means the token or
141
+ # user_id is rejected — surface that hint instead of a generic timeout.
142
+ raise PineConnectionError(
143
+ f"Socket.IO connected but no 'ready' event after {self._ready_timeout}s. "
144
+ "This usually means access_token or user_id is invalid/expired — re-run the auth flow."
145
+ )
109
146
 
110
147
  def emit(
111
148
  self,
@@ -136,6 +173,12 @@ class SocketIOManager:
136
173
  )
137
174
 
138
175
  async def _do_emit() -> None:
176
+ # The socket may be torn down between scheduling and execution —
177
+ # e.g. user calls leave_session() then disconnect() back-to-back.
178
+ # In that case the emit can't possibly succeed; skip silently
179
+ # instead of logging a misleading "/ is not a connected namespace".
180
+ if not self._sio or not self._sio.connected:
181
+ return
139
182
  try:
140
183
  await self._sio.emit(event_type, envelope) # type: ignore[union-attr]
141
184
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pine-assistant
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client.
5
5
  Author: Pine AI
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: python-socketio[asyncio]>=5.11.0
23
+ Requires-Dist: python-socketio>=5.11.0
24
24
  Requires-Dist: httpx>=0.27.0
25
25
  Requires-Dist: pydantic>=2.0.0
26
26
  Provides-Extra: cli
@@ -32,4 +32,6 @@ src/pine_assistant/transport/envelope.py
32
32
  src/pine_assistant/transport/http.py
33
33
  src/pine_assistant/transport/socketio.py
34
34
  tests/test_basics.py
35
- tests/test_chat_engine.py
35
+ tests/test_chat_engine.py
36
+ tests/test_device_id.py
37
+ tests/test_socketio_connect.py
@@ -1,4 +1,4 @@
1
- python-socketio[asyncio]>=5.11.0
1
+ python-socketio>=5.11.0
2
2
  httpx>=0.27.0
3
3
  pydantic>=2.0.0
4
4
 
@@ -0,0 +1,57 @@
1
+ """Unit tests for device_id resolution — env var, explicit arg, file persistence."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pine_assistant.client as client_module
7
+ from pine_assistant.client import _get_or_create_device_id
8
+
9
+
10
+ def test_explicit_arg_wins(monkeypatch, tmp_path):
11
+ monkeypatch.setenv("PINE_DEVICE_ID", "from-env")
12
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", tmp_path / "device_id")
13
+ assert _get_or_create_device_id("explicit") == "explicit"
14
+
15
+
16
+ def test_env_var_wins_over_file(monkeypatch, tmp_path):
17
+ device_file = tmp_path / "device_id"
18
+ device_file.write_text("from-file")
19
+ monkeypatch.setenv("PINE_DEVICE_ID", "from-env")
20
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", device_file)
21
+ assert _get_or_create_device_id() == "from-env"
22
+
23
+
24
+ def test_env_var_is_stripped(monkeypatch, tmp_path):
25
+ monkeypatch.setenv("PINE_DEVICE_ID", " trimmed \n")
26
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", tmp_path / "device_id")
27
+ assert _get_or_create_device_id() == "trimmed"
28
+
29
+
30
+ def test_falls_back_to_file(monkeypatch, tmp_path):
31
+ monkeypatch.delenv("PINE_DEVICE_ID", raising=False)
32
+ device_file = tmp_path / "device_id"
33
+ device_file.write_text("from-file")
34
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", device_file)
35
+ assert _get_or_create_device_id() == "from-file"
36
+
37
+
38
+ def test_creates_and_persists_when_missing(monkeypatch, tmp_path):
39
+ monkeypatch.delenv("PINE_DEVICE_ID", raising=False)
40
+ device_file = tmp_path / "nested" / "device_id"
41
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", device_file)
42
+ first = _get_or_create_device_id()
43
+ assert device_file.read_text() == first
44
+ # A second call returns the persisted value, not a new UUID.
45
+ assert _get_or_create_device_id() == first
46
+
47
+
48
+ def test_unwritable_dir_warns_and_returns_random(monkeypatch, tmp_path, caplog):
49
+ monkeypatch.delenv("PINE_DEVICE_ID", raising=False)
50
+ # Point at an unwritable location: a file used as a "directory".
51
+ blocker = tmp_path / "blocker"
52
+ blocker.write_text("not a directory")
53
+ monkeypatch.setattr(client_module, "DEVICE_ID_FILE", blocker / "device_id")
54
+ with caplog.at_level("WARNING", logger="pine_assistant.client"):
55
+ device_id = _get_or_create_device_id()
56
+ assert device_id # got *some* id back
57
+ assert any("PINE_DEVICE_ID" in rec.message for rec in caplog.records)
@@ -0,0 +1,149 @@
1
+ """Unit tests for SocketIOManager.connect() — connect_error surfacing."""
2
+
3
+ import asyncio
4
+ from typing import Any
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from pine_assistant.errors import ConnectionError as PineConnectionError
10
+ from pine_assistant.transport.socketio import (
11
+ SocketIOManager,
12
+ _format_connect_error,
13
+ )
14
+
15
+
16
+ def test_format_connect_error_string():
17
+ assert _format_connect_error("bad token") == "bad token"
18
+
19
+
20
+ def test_format_connect_error_dict_message():
21
+ assert _format_connect_error({"message": "invalid token"}) == "invalid token"
22
+
23
+
24
+ def test_format_connect_error_dict_error_key():
25
+ assert _format_connect_error({"error": "unauthorized"}) == "unauthorized"
26
+
27
+
28
+ def test_format_connect_error_none():
29
+ assert _format_connect_error(None) == "no payload"
30
+
31
+
32
+ def test_format_connect_error_unknown():
33
+ assert _format_connect_error([1, 2]) == "[1, 2]"
34
+
35
+
36
+ class _FakeAsyncClient:
37
+ """Minimal stand-in for socketio.AsyncClient that lets a test drive the
38
+ connect_error path."""
39
+
40
+ def __init__(self, connect_error_payload: Any = None) -> None:
41
+ self._handlers: dict[str, Any] = {}
42
+ self._connect_error_payload = connect_error_payload
43
+ self.connected = False
44
+
45
+ def event(self, fn):
46
+ self._handlers[fn.__name__] = fn
47
+ return fn
48
+
49
+ def on(self, name):
50
+ def deco(fn):
51
+ self._handlers[name] = fn
52
+ return fn
53
+ return deco
54
+
55
+ async def connect(self, *_args, **_kwargs):
56
+ # Simulate the server replying with connect_error, then python-socketio
57
+ # raising the generic ConnectionError after wait_timeout.
58
+ handler = self._handlers.get("connect_error")
59
+ if handler is not None:
60
+ await handler(self._connect_error_payload)
61
+ raise ConnectionError("One or more namespaces failed to connect")
62
+
63
+ async def disconnect(self):
64
+ self.connected = False
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_connect_error_payload_surfaces_in_pine_connection_error():
69
+ fake = _FakeAsyncClient(connect_error_payload={"message": "invalid token"})
70
+ mgr = SocketIOManager(
71
+ base_url="https://example.test",
72
+ token="bad-token",
73
+ user_id="u1",
74
+ device_id="d1",
75
+ ready_timeout=0.1,
76
+ )
77
+ with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake):
78
+ with pytest.raises(PineConnectionError) as excinfo:
79
+ await mgr.connect()
80
+ msg = str(excinfo.value)
81
+ assert "rejected by server" in msg
82
+ assert "invalid token" in msg
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_connect_error_without_payload_falls_back_to_generic():
87
+ fake = _FakeAsyncClient(connect_error_payload=None)
88
+ # Override: don't invoke the connect_error handler at all — simulate a
89
+ # transport-level failure where the server never sent connect_error.
90
+ async def connect_no_event(*_args, **_kwargs):
91
+ raise ConnectionError("transport closed")
92
+ fake.connect = connect_no_event # type: ignore[method-assign]
93
+
94
+ mgr = SocketIOManager(
95
+ base_url="https://example.test",
96
+ token="t",
97
+ user_id="u",
98
+ device_id="d",
99
+ ready_timeout=0.1,
100
+ )
101
+ with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake):
102
+ with pytest.raises(PineConnectionError) as excinfo:
103
+ await mgr.connect()
104
+ assert "transport closed" in str(excinfo.value)
105
+
106
+
107
+ class _SilentStallClient:
108
+ """Stand-in that accepts the connect but never fires the 'ready' event —
109
+ matches Pine backend's behavior when access_token/user_id is rejected."""
110
+
111
+ def __init__(self) -> None:
112
+ self._handlers: dict[str, Any] = {}
113
+ self.connected = False
114
+
115
+ def event(self, fn):
116
+ self._handlers[fn.__name__] = fn
117
+ return fn
118
+
119
+ def on(self, name):
120
+ def deco(fn):
121
+ self._handlers[name] = fn
122
+ return fn
123
+ return deco
124
+
125
+ async def connect(self, *_args, **_kwargs):
126
+ self.connected = True
127
+
128
+ async def disconnect(self):
129
+ self.connected = False
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_silent_stall_raises_diagnostic_pine_error():
134
+ """When the WS connects but no 'ready' event arrives, surface a hint that
135
+ points the user at re-auth, not a generic asyncio TimeoutError."""
136
+ fake = _SilentStallClient()
137
+ mgr = SocketIOManager(
138
+ base_url="https://example.test",
139
+ token="t",
140
+ user_id="u",
141
+ device_id="d",
142
+ ready_timeout=0.1,
143
+ )
144
+ with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake):
145
+ with pytest.raises(PineConnectionError) as excinfo:
146
+ await mgr.connect()
147
+ msg = str(excinfo.value)
148
+ assert "no 'ready' event" in msg
149
+ assert "re-run the auth flow" in msg
File without changes
File without changes
File without changes