Skip to content

Commit 8780f40

Browse files
authored
Merge pull request #289 from poissoncorp/RDBC-1045
RDBC-1045 Release Python 7.2.1
2 parents 889b715 + a328843 commit 8780f40

9 files changed

Lines changed: 226 additions & 27 deletions

File tree

.claude/skills/csharp-sync/CONVENTIONS.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
# Naming & Code Conventions
22

3+
See CLAUDE.md for base code style, JSON conventions, and common gotchas. This file covers only migration-specific conventions beyond the baseline.
4+
35
## Naming map
46

57
| C# | Python |
68
|---|---|
79
| `PascalCase` class | `PascalCase` class |
810
| `PascalCase` method | `snake_case` method |
911
| `PascalCase` property | `snake_case` attribute |
10-
| JSON keys in `ToJson()` | `"PascalCase"` in `to_json()` |
11-
| JSON keys in `FromJson()` | `"PascalCase"` in `from_json()` |
1212
| `IOperation<T>` | `IOperation[T]` |
1313
| `IMaintenanceOperation` | `IMaintenanceOperation` |
1414
| `IVoidMaintenanceOperation` | `VoidMaintenanceOperation` |
1515
| `null` | `None` |
1616
| `TimeSpan.FromDays(n)` | `timedelta(days=n)` |
17-
| `DateTime.UtcNow` | `datetime.datetime.now(datetime.timezone.utc)` — NEVER `utcnow()` |
17+
18+
## C# → Python pattern map
19+
20+
These C# patterns have no 1:1 equivalent — use the Python strategy shown:
21+
22+
| C# Pattern | Python Strategy |
23+
|-------------|----------------|
24+
| Method overloading | Optional params with `None` defaults + `if` guards |
25+
| `async/await` | **Synchronous** — this codebase is sync-only; use `concurrent.futures.ThreadPoolExecutor` where needed |
26+
| `IDisposable` | `__enter__`/`__exit__` context managers |
27+
| `event Action<T>` | `List[Callable[[EventArgs], None]]` with `add_`/`remove_` registration methods |
28+
| `lock(obj)` | `with self._lock:` using `threading.Lock`, `RLock`, or `Semaphore` |
29+
| Fluent builder API | Methods returning `self` for chaining |
30+
| `Span<T>`, `ReadOnlySpan` | Skip — no Python equivalent |
31+
| `Generic<T>` with constraints | `TypeVar("T", bound=Base)` |
32+
| Lazy initialization | Double-check locking with `threading.RLock` |
1833

1934
## Operation class pattern
2035

@@ -87,12 +102,6 @@ class MyEnum(enum.Enum):
87102
VALUE_TWO = "ValueTwo"
88103
```
89104

90-
## Import style
91-
92-
- Absolute imports from `ravendb.*`.
93-
- Group: stdlib → third-party → project, separated by blank lines.
94-
- Use `TYPE_CHECKING` guard for circular-import-prone types.
95-
96105
## Verifying against C# source
97106

98107
When unsure about a field name, method signature, or serialization key, fetch the original C# file:

.claude/skills/csharp-sync/SKILL.md

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
---
22
name: csharp-sync
33
description: Migrates RavenDB C# client features to the Python client. Use when porting C# diffs, patches, or test files to Python, batching sync work, or writing migration tests.
4+
allowed-tools: "Read Edit Write Grep Glob Bash WebFetch Agent"
5+
argument-hint: "<patch-file-or-feature-description>"
6+
effort: max
7+
model: opus
8+
disable-model-invocation: true
49
---
510

611
# C# → Python Client Sync
712

813
Migrate features from the RavenDB C# client into this Python client codebase.
914
Input is a raw `.patch` / `.diff` from the C# repo. Output is working Python code with tests.
1015

16+
## Current state
17+
- Branch: !`git branch --show-current`
18+
- Recent syncs: !`git log --oneline -5 --grep="RDBC"`
19+
20+
## Input
21+
22+
If arguments were provided (`$ARGUMENTS`), use them as the starting point:
23+
- If it's a file path, read it as the diff/patch input
24+
- If it's a feature name, fetch the relevant C# source to begin triage
25+
1126
## Pipeline
1227

1328
1. **Triage** — read the diff, list every changed file, group into batches
14-
2. **Implement** — port each batch following [CONVENTIONS.md](CONVENTIONS.md)
15-
3. **Test** — migrate or write tests following [TESTING.md](TESTING.md)
29+
2. **Plan** — present the batch plan to the user and wait for approval before writing any code
30+
3. **Implement** — port each batch following [CONVENTIONS.md](CONVENTIONS.md)
31+
4. **Test** — migrate or write tests following [TESTING.md](TESTING.md)
1632

1733
## Reference sources
1834

@@ -67,31 +83,82 @@ Use docs to determine whether a C# change is:
6783
2. For unfamiliar changes, **fetch the full C# source** from GitHub to understand context.
6884
3. **Check RavenDB docs** to confirm the feature is documented and understand its scope.
6985
4. **Filter out** changes that are C#-specific or relate to features not yet in the Python client.
70-
5. Group remaining changes into **batches** by feature area. Good boundaries:
86+
5. **Search for existing Python equivalents** — for each changed C# file, Grep for the class name, operation name, or endpoint path in the Python codebase:
87+
- If a Python version exists, compare it with the C# version to identify only the delta to port.
88+
- If the Python version has intentional divergences (different architecture, Pythonic patterns), preserve them.
89+
6. Group remaining changes into **batches** by feature area. Good boundaries:
7190
- New operation class (e.g. `ConfigureRemoteAttachmentsOperation`)
7291
- New model / DTO cluster (e.g. settings + configuration classes)
7392
- New exception type + dispatcher wiring
7493
- Test migration for an already-implemented feature
75-
6. Create a **checklist** — one `- [ ]` per batch, ordered by dependency (models → operations → tests).
94+
7. Create a **checklist** — one `- [ ]` per batch, ordered by dependency (models → operations → tests).
95+
Use TodoWrite to create tasks for each batch. Mark each as completed before moving to the next.
7696

7797
### Batch sizing
7898

7999
- Target **≤ 300 lines of Python** per batch.
80100
- If a single C# file maps to > 300 lines, split by class or logical section.
81101
- Tests are their own batch, listed after the implementation batch they cover.
102+
- If a batch modifies existing models/DTOs that have downstream consumers, include the consumer updates in the same batch — even if it exceeds the 300-line target. A broken intermediate state is worse than a large batch.
103+
104+
## Step 2 — Present the plan for approval
105+
106+
Before writing any code, present the full migration plan to the user. The plan should include:
82107

83-
## Step 2 — Implement each batch
108+
1. **Summary** — what the diff covers and what will be ported vs. skipped.
109+
2. **Batch list** — numbered batches with:
110+
- Batch name / feature area
111+
- Which C# files map to which Python files (new or existing)
112+
- Estimated scope (new file, modify existing, add fields, etc.)
113+
- Any risks or ambiguities flagged during triage
114+
3. **Skipped items** — C# changes filtered out and why (C#-specific, dependency not ported, etc.)
115+
4. **Open questions** — anything that needs the user's decision before implementation.
116+
117+
**Wait for the user to approve the plan before proceeding to Step 3.** The user may reorder batches, split/merge them, or ask to skip certain items. Adjust the TodoWrite tasks accordingly.
118+
119+
## Step 3 — Implement each batch
120+
121+
Only proceed after user approval from Step 2.
84122

85123
Follow the naming rules and patterns in [CONVENTIONS.md](CONVENTIONS.md).
86124

125+
### Porting principle
126+
127+
Port the *behavior*, not the *syntax*. If the Python client already handles something in a Pythonic way (context managers, generators, keyword arguments), preserve that pattern even if the C# implementation looks different. The goal is feature parity, not code parity.
128+
87129
When porting a C# class, **always fetch the full source from GitHub** — diffs alone often lack constructor signatures, base classes, or `ToJson`/`FromJson` methods needed for a correct port.
88130

89131
For every edit:
90-
1. Use `codebase-retrieval` to find ALL downstream callers, implementations, and tests.
132+
1. Use Grep and Glob to find ALL downstream callers, implementations, and tests.
91133
2. Update every affected file — missing a downstream change is a critical failure.
92134
3. After editing, verify imports resolve and no existing tests are broken.
93135
4. If unsure about a field or method, check the **RavenDB docs** for the canonical behavior.
94136

137+
### When unsure
138+
139+
1. If the C# pattern has no direct Python equivalent → ask the user.
140+
2. If the feature partially exists → check `git log` for prior sync commits to understand how similar cases were handled.
141+
3. If a dependency isn't ported yet → skip and note it as a prerequisite in the batch checklist.
142+
143+
### Session changes require extra caution
144+
145+
The session subsystem (`ravendb/documents/session/`) spans 25+ modules and ~13K lines. Before porting session-related C# changes:
146+
1. Read the target Python module thoroughly — session modules are tightly coupled.
147+
2. Trace the full call chain from session API → command generation → request execution.
148+
3. Session batches should be smaller (≤150 lines) due to higher coupling risk.
149+
4. Always run the full session test suite after changes, not just new tests.
150+
151+
### Streaming operations
152+
153+
Streaming operations (bulk insert, subscriptions, query streaming) use generators and `concurrent.futures` — NOT the standard operation pattern. Fetch the full Python source for the existing streaming infrastructure before porting.
154+
155+
### Verification gate
156+
157+
After completing all batches, run:
158+
1. `black --check .` — fix any formatting issues.
159+
2. `python -m unittest <new_test_modules> -v` — all new tests must pass.
160+
3. `python -m unittest discover` on affected test directories — confirm no regressions.
161+
95162
### Key files to touch per feature
96163

97164
| What | Where |
@@ -106,8 +173,12 @@ For every edit:
106173
| Bulk insert changes | `ravendb/documents/bulk_insert_operation.py` |
107174
| Module exports | `ravendb/__init__.py` |
108175

109-
## Step 3 — Write tests
176+
## Step 4 — Write tests
110177

111178
Follow the infrastructure and patterns in [TESTING.md](TESTING.md).
112179

180+
## If a batch fails
113181

182+
- If tests fail due to a bug in the port → fix in the same batch.
183+
- If the feature can't be ported (missing dependency, server version mismatch) → revert the batch, add a `# todo: requires <dependency>` comment, and continue with remaining batches.
184+
- Never leave broken code committed — each batch must be independently valid.

.claude/skills/csharp-sync/TESTING.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# Testing Conventions
22

3+
See CLAUDE.md for base testing infrastructure and common gotchas. This file covers only migration-specific testing patterns.
4+
35
## Infrastructure
46

57
- All tests extend `TestBase` from `ravendb/tests/test_base.py`.
68
- `self.store` is pre-configured with a fresh database per test — no manual setup needed.
7-
- Run tests: `.venv1\Scripts\python.exe -m unittest <module.path> -v`
9+
- Run tests: `python -m unittest <module.path> -v`
810

911
## Assertion helpers
1012

@@ -60,11 +62,7 @@ class _IndexResult:
6062

6163
Then query with `select_fields(_IndexResult, "Id", "Errors")`.
6264

63-
## Common gotchas
65+
## Migration-specific testing notes
6466

65-
- **Datetime**: always `datetime.datetime.now(datetime.timezone.utc)`, never `utcnow()`.
66-
- **Empty vs None**: server may return `[]` instead of `null` for empty collections — use `assertFalse`/`assertTrue` not `assertIsNone`/`assertIsNotNone`.
67-
- **Exception messages**: the dispatcher wraps the full server error (message + stack trace) into the exception string — always use `assertRaisesWithMessageContaining` with a meaningful substring.
68-
- **`RavenException.__init__`**: stores message as plain string in `args[0]`, not `(message, cause)` tuple.
6967
- **Async operations**: use `store.maintenance.send_async(op)``op.wait_for_completion()``op.fetch_operations_status()["Result"]`.
7068

ravendb/documents/ai/ai_conversation.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AiAgentActionResponse,
1313
AiConversationCreationOptions,
1414
)
15+
from ravendb.exceptions.exceptions import InvalidOperationException
1516
from ravendb.documents.operations.ai.agents.run_conversation_operation import AiAgentArtificialActionResponse
1617

1718
if TYPE_CHECKING:
@@ -56,7 +57,7 @@ def __init__(
5657
self._change_vector = change_vector
5758

5859
self._prompt_parts: List[ContentPart] = []
59-
self._action_responses: List[AiAgentActionResponse] = []
60+
self._action_responses: Dict[str, AiAgentActionResponse] = {}
6061
self._artificial_actions: List[AiAgentArtificialActionResponse] = []
6162
self._action_requests: Optional[List[AiAgentActionRequest]] = None
6263

@@ -114,15 +115,25 @@ def add_action_response(self, action_id: str, action_response: str) -> None:
114115
Args:
115116
action_id: The ID of the action to respond to
116117
action_response: The response content
118+
119+
Raises:
120+
InvalidOperationException: If a response for the given tool-id was already added
117121
"""
118122
from ravendb.documents.operations.ai.agents import AiAgentActionResponse
119123

124+
if action_id in self._action_responses:
125+
raise InvalidOperationException(
126+
f"An action response for tool-id '{action_id}' was already added. "
127+
f"Each tool call must have exactly one response. "
128+
f"If you're using handle, return the value from the handler (don't call add_action_response manually)."
129+
)
130+
120131
response = AiAgentActionResponse(tool_id=action_id)
121132

122133
if isinstance(action_response, str):
123134
response.content = action_response
124135

125-
self._action_responses.append(response)
136+
self._action_responses[action_id] = response
126137

127138
def add_artificial_action_with_response(self, tool_id: str, action_response) -> None:
128139
"""
@@ -211,7 +222,7 @@ def _run_internal(
211222
agent_id=self._agent_id,
212223
conversation_id=self._conversation_id,
213224
prompt_parts=self._prompt_parts, # Always send list, even if empty
214-
action_responses=self._action_responses, # Always send list, even if empty
225+
action_responses=list(self._action_responses.values()), # Always send list, even if empty
215226
artificial_actions=self._artificial_actions, # Always send list, even if empty
216227
options=self._options,
217228
change_vector=self._change_vector,

ravendb/documents/operations/ai/gen_ai_configuration.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(
3333
queries: List[AiAgentToolQuery] = None,
3434
enable_tracing: bool = False,
3535
expiration_in_sec: int = None,
36+
version: int = None,
3637
disabled: bool = False,
3738
mentor_node: str = None,
3839
pin_to_mentor_node: bool = False,
@@ -60,6 +61,7 @@ def __init__(
6061
self.queries: List[AiAgentToolQuery] = queries or []
6162
self.enable_tracing = enable_tracing
6263
self.expiration_in_sec: Optional[int] = expiration_in_sec
64+
self.version: Optional[int] = version
6365

6466
self._transforms: Optional[List[Transformation]] = None
6567

@@ -179,6 +181,8 @@ def to_json(self) -> Dict[str, Any]:
179181
"ExpirationInSec": self.expiration_in_sec,
180182
}
181183
)
184+
if self.version is not None:
185+
result["Version"] = self.version
182186
return result
183187

184188
@classmethod
@@ -201,6 +205,7 @@ def from_json(cls, json_dict: Dict[str, Any]) -> "GenAiConfiguration":
201205
queries=[AiAgentToolQuery.from_json(q) for q in queries_data] if queries_data else None,
202206
enable_tracing=json_dict.get("EnableTracing", False),
203207
expiration_in_sec=json_dict.get("ExpirationInSec"),
208+
version=json_dict.get("Version"),
204209
disabled=json_dict.get("Disabled", False),
205210
mentor_node=json_dict.get("MentorNode"),
206211
pin_to_mentor_node=json_dict.get("PinToMentorNode", False),

ravendb/http/request_executor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
class RequestExecutor:
5656
__INITIAL_TOPOLOGY_ETAG = -2
5757
__GLOBAL_APPLICATION_IDENTIFIER = uuid.uuid4()
58-
CLIENT_VERSION = "7.2.0"
58+
CLIENT_VERSION = "7.2.1"
5959
logger = logging.getLogger("request_executor")
6060

6161
# todo: initializer should take also cryptography certificates

ravendb/tests/ai_agent_tests/test_ai_agents_conversation_mock.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,47 @@ def test_duplicate_action_handler_raises(self):
438438
with self.assertRaises(ValueError):
439439
chat.handle("store-result", lambda args: None, AiHandleErrorStrategy.SEND_ERRORS_TO_MODEL)
440440

441+
def test_action_responses_cleared_between_turns(self):
442+
"""After a server round-trip, action_responses are cleared so the same tool-id can be reused."""
443+
responses = iter(
444+
[
445+
_action_result("store-result", "tool-x", {"result": "a"}),
446+
_action_result("store-result", "tool-x", {"result": "b"}),
447+
_done_result(response={"answer": "ok"}),
448+
]
449+
)
450+
451+
with patch.object(self.store.maintenance, "send", side_effect=self._patched_send(lambda: next(responses))):
452+
chat = self.store.ai.conversation(AGENT_ID, "conversations/")
453+
chat.set_user_prompt("Multi-turn with same tool-id")
454+
chat.handle("store-result", lambda args: "handled", AiHandleErrorStrategy.SEND_ERRORS_TO_MODEL)
455+
result = chat.run()
456+
457+
self.assertEqual(AiConversationStatus.DONE, result.status)
458+
459+
460+
class TestAiConversationActionResponseValidation(unittest.TestCase):
461+
"""Pure client-side tests — no server or license required."""
462+
463+
def test_duplicate_action_response_raises(self):
464+
"""Adding two action responses for the same tool-id must raise."""
465+
from ravendb.documents.ai.ai_conversation import AiConversation
466+
from ravendb.exceptions.exceptions import InvalidOperationException
467+
468+
chat = AiConversation(store=None, agent_id="dummy")
469+
chat.add_action_response("tool-1", "first response")
470+
with self.assertRaises(InvalidOperationException):
471+
chat.add_action_response("tool-1", "second response")
472+
473+
def test_different_tool_ids_allowed(self):
474+
"""Different tool-ids should not conflict."""
475+
from ravendb.documents.ai.ai_conversation import AiConversation
476+
477+
chat = AiConversation(store=None, agent_id="dummy")
478+
chat.add_action_response("tool-1", "response-1")
479+
chat.add_action_response("tool-2", "response-2")
480+
self.assertEqual(2, len(chat._action_responses))
481+
441482

442483
if __name__ == "__main__":
443484
unittest.main()

0 commit comments

Comments
 (0)