d365fo-client 0.2.3__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- d365fo_client/__init__.py +7 -1
- d365fo_client/auth.py +9 -21
- d365fo_client/cli.py +25 -13
- d365fo_client/client.py +8 -4
- d365fo_client/config.py +52 -30
- d365fo_client/credential_sources.py +5 -0
- d365fo_client/main.py +1 -1
- d365fo_client/mcp/__init__.py +3 -1
- d365fo_client/mcp/auth_server/__init__.py +5 -0
- d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
- d365fo_client/mcp/auth_server/auth/auth.py +372 -0
- d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
- d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
- d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
- d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
- d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
- d365fo_client/mcp/auth_server/dependencies.py +136 -0
- d365fo_client/mcp/client_manager.py +16 -67
- d365fo_client/mcp/fastmcp_main.py +358 -0
- d365fo_client/mcp/fastmcp_server.py +598 -0
- d365fo_client/mcp/fastmcp_utils.py +431 -0
- d365fo_client/mcp/main.py +40 -13
- d365fo_client/mcp/mixins/__init__.py +24 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
- d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
- d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
- d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
- d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
- d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
- d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
- d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
- d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
- d365fo_client/mcp/prompts/action_execution.py +1 -1
- d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
- d365fo_client/mcp/tools/crud_tools.py +3 -3
- d365fo_client/mcp/tools/sync_tools.py +1 -1
- d365fo_client/mcp/utilities/__init__.py +1 -0
- d365fo_client/mcp/utilities/auth.py +34 -0
- d365fo_client/mcp/utilities/logging.py +58 -0
- d365fo_client/mcp/utilities/types.py +426 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
- d365fo_client/metadata_v2/sync_session_manager.py +7 -7
- d365fo_client/models.py +139 -139
- d365fo_client/output.py +2 -2
- d365fo_client/profile_manager.py +62 -27
- d365fo_client/profiles.py +118 -113
- d365fo_client/settings.py +355 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
- d365fo_client-0.3.0.dist-info/RECORD +84 -0
- d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.3.dist-info/RECORD +0 -56
- d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,426 @@
|
|
1
|
+
"""Common types used across FastMCP."""
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import inspect
|
5
|
+
import mimetypes
|
6
|
+
import os
|
7
|
+
from collections.abc import Callable
|
8
|
+
from functools import lru_cache
|
9
|
+
from pathlib import Path
|
10
|
+
from types import EllipsisType, UnionType
|
11
|
+
from typing import (
|
12
|
+
Annotated,
|
13
|
+
Any,
|
14
|
+
Protocol,
|
15
|
+
TypeAlias,
|
16
|
+
Union,
|
17
|
+
get_args,
|
18
|
+
get_origin,
|
19
|
+
get_type_hints,
|
20
|
+
)
|
21
|
+
|
22
|
+
import mcp.types
|
23
|
+
from mcp.types import Annotations, ContentBlock, ModelPreferences, SamplingMessage
|
24
|
+
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints
|
25
|
+
from typing_extensions import TypeVar
|
26
|
+
|
27
|
+
T = TypeVar("T", default=Any)
|
28
|
+
|
29
|
+
# sentinel values for optional arguments
|
30
|
+
NotSet = ...
|
31
|
+
NotSetT: TypeAlias = EllipsisType
|
32
|
+
|
33
|
+
|
34
|
+
def get_fn_name(fn: Callable[..., Any]) -> str:
|
35
|
+
return fn.__name__ # ty: ignore[unresolved-attribute]
|
36
|
+
|
37
|
+
|
38
|
+
class FastMCPBaseModel(BaseModel):
|
39
|
+
"""Base model for FastMCP models."""
|
40
|
+
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
42
|
+
|
43
|
+
|
44
|
+
@lru_cache(maxsize=5000)
|
45
|
+
def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
|
46
|
+
"""
|
47
|
+
TypeAdapters are heavy objects, and in an application context we'd typically
|
48
|
+
create them once in a global scope and reuse them as often as possible.
|
49
|
+
However, this isn't feasible for user-generated functions. Instead, we use a
|
50
|
+
cache to minimize the cost of creating them as much as possible.
|
51
|
+
"""
|
52
|
+
# For functions, process annotations to handle forward references and convert
|
53
|
+
# Annotated[Type, "string"] to Annotated[Type, Field(description="string")]
|
54
|
+
if inspect.isfunction(cls) or inspect.ismethod(cls):
|
55
|
+
if hasattr(cls, "__annotations__") and cls.__annotations__:
|
56
|
+
try:
|
57
|
+
# Resolve forward references first
|
58
|
+
resolved_hints = get_type_hints(cls, include_extras=True)
|
59
|
+
except Exception:
|
60
|
+
# If forward reference resolution fails, use original annotations
|
61
|
+
resolved_hints = cls.__annotations__
|
62
|
+
|
63
|
+
# Process annotations to convert string descriptions to Fields
|
64
|
+
processed_hints = {}
|
65
|
+
|
66
|
+
for name, annotation in resolved_hints.items():
|
67
|
+
# Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")]
|
68
|
+
if (
|
69
|
+
get_origin(annotation) is Annotated
|
70
|
+
and len(get_args(annotation)) == 2
|
71
|
+
and isinstance(get_args(annotation)[1], str)
|
72
|
+
):
|
73
|
+
base_type, description = get_args(annotation)
|
74
|
+
processed_hints[name] = Annotated[
|
75
|
+
base_type, Field(description=description)
|
76
|
+
]
|
77
|
+
else:
|
78
|
+
processed_hints[name] = annotation
|
79
|
+
|
80
|
+
# Create new function if annotations changed
|
81
|
+
if processed_hints != cls.__annotations__:
|
82
|
+
import types
|
83
|
+
|
84
|
+
# Handle both functions and methods
|
85
|
+
if inspect.ismethod(cls):
|
86
|
+
actual_func = cls.__func__
|
87
|
+
code = actual_func.__code__ # ty: ignore[unresolved-attribute]
|
88
|
+
globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
|
89
|
+
name = actual_func.__name__ # ty: ignore[unresolved-attribute]
|
90
|
+
defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
|
91
|
+
closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
|
92
|
+
else:
|
93
|
+
code = cls.__code__
|
94
|
+
globals_dict = cls.__globals__
|
95
|
+
name = cls.__name__
|
96
|
+
defaults = cls.__defaults__
|
97
|
+
closure = cls.__closure__
|
98
|
+
|
99
|
+
new_func = types.FunctionType(
|
100
|
+
code,
|
101
|
+
globals_dict,
|
102
|
+
name,
|
103
|
+
defaults,
|
104
|
+
closure,
|
105
|
+
)
|
106
|
+
new_func.__dict__.update(cls.__dict__)
|
107
|
+
new_func.__module__ = cls.__module__
|
108
|
+
new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
|
109
|
+
new_func.__annotations__ = processed_hints
|
110
|
+
|
111
|
+
if inspect.ismethod(cls):
|
112
|
+
new_method = types.MethodType(new_func, cls.__self__)
|
113
|
+
return TypeAdapter(new_method)
|
114
|
+
else:
|
115
|
+
return TypeAdapter(new_func)
|
116
|
+
|
117
|
+
return TypeAdapter(cls)
|
118
|
+
|
119
|
+
|
120
|
+
def issubclass_safe(cls: type, base: type) -> bool:
|
121
|
+
"""Check if cls is a subclass of base, even if cls is a type variable."""
|
122
|
+
try:
|
123
|
+
if origin := get_origin(cls):
|
124
|
+
return issubclass_safe(origin, base)
|
125
|
+
return issubclass(cls, base)
|
126
|
+
except TypeError:
|
127
|
+
return False
|
128
|
+
|
129
|
+
|
130
|
+
def is_class_member_of_type(cls: Any, base: type) -> bool:
|
131
|
+
"""
|
132
|
+
Check if cls is a member of base, even if cls is a type variable.
|
133
|
+
|
134
|
+
Base can be a type, a UnionType, or an Annotated type. Generic types are not
|
135
|
+
considered members (e.g. T is not a member of list[T]).
|
136
|
+
"""
|
137
|
+
origin = get_origin(cls)
|
138
|
+
# Handle both types of unions: UnionType (from types module, used with | syntax)
|
139
|
+
# and typing.Union (used with Union[] syntax)
|
140
|
+
if origin is UnionType or origin == Union:
|
141
|
+
return any(is_class_member_of_type(arg, base) for arg in get_args(cls))
|
142
|
+
elif origin is Annotated:
|
143
|
+
# For Annotated[T, ...], check if T is a member of base
|
144
|
+
args = get_args(cls)
|
145
|
+
if args:
|
146
|
+
return is_class_member_of_type(args[0], base)
|
147
|
+
return False
|
148
|
+
else:
|
149
|
+
return issubclass_safe(cls, base)
|
150
|
+
|
151
|
+
|
152
|
+
def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
|
153
|
+
"""
|
154
|
+
Find the name of the kwarg that is of type kwarg_type.
|
155
|
+
|
156
|
+
Includes union types that contain the kwarg_type, as well as Annotated types.
|
157
|
+
"""
|
158
|
+
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
159
|
+
fn = fn.__func__
|
160
|
+
|
161
|
+
# Try to get resolved type hints
|
162
|
+
try:
|
163
|
+
# Use include_extras=True to preserve Annotated metadata
|
164
|
+
type_hints = get_type_hints(fn, include_extras=True)
|
165
|
+
except Exception:
|
166
|
+
# If resolution fails, use raw annotations if they exist
|
167
|
+
type_hints = getattr(fn, "__annotations__", {})
|
168
|
+
|
169
|
+
sig = inspect.signature(fn)
|
170
|
+
for name, param in sig.parameters.items():
|
171
|
+
# Use resolved hint if available, otherwise raw annotation
|
172
|
+
annotation = type_hints.get(name, param.annotation)
|
173
|
+
if is_class_member_of_type(annotation, kwarg_type):
|
174
|
+
return name
|
175
|
+
return None
|
176
|
+
|
177
|
+
|
178
|
+
class Image:
|
179
|
+
"""Helper class for returning images from tools."""
|
180
|
+
|
181
|
+
def __init__(
|
182
|
+
self,
|
183
|
+
path: str | Path | None = None,
|
184
|
+
data: bytes | None = None,
|
185
|
+
format: str | None = None,
|
186
|
+
annotations: Annotations | None = None,
|
187
|
+
):
|
188
|
+
if path is None and data is None:
|
189
|
+
raise ValueError("Either path or data must be provided")
|
190
|
+
if path is not None and data is not None:
|
191
|
+
raise ValueError("Only one of path or data can be provided")
|
192
|
+
|
193
|
+
self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
|
194
|
+
self.data = data
|
195
|
+
self._format = format
|
196
|
+
self._mime_type = self._get_mime_type()
|
197
|
+
self.annotations = annotations
|
198
|
+
|
199
|
+
def _get_mime_type(self) -> str:
|
200
|
+
"""Get MIME type from format or guess from file extension."""
|
201
|
+
if self._format:
|
202
|
+
return f"image/{self._format.lower()}"
|
203
|
+
|
204
|
+
if self.path:
|
205
|
+
suffix = self.path.suffix.lower()
|
206
|
+
return {
|
207
|
+
".png": "image/png",
|
208
|
+
".jpg": "image/jpeg",
|
209
|
+
".jpeg": "image/jpeg",
|
210
|
+
".gif": "image/gif",
|
211
|
+
".webp": "image/webp",
|
212
|
+
}.get(suffix, "application/octet-stream")
|
213
|
+
return "image/png" # default for raw binary data
|
214
|
+
|
215
|
+
def to_image_content(
|
216
|
+
self,
|
217
|
+
mime_type: str | None = None,
|
218
|
+
annotations: Annotations | None = None,
|
219
|
+
) -> mcp.types.ImageContent:
|
220
|
+
"""Convert to MCP ImageContent."""
|
221
|
+
if self.path:
|
222
|
+
with open(self.path, "rb") as f:
|
223
|
+
data = base64.b64encode(f.read()).decode()
|
224
|
+
elif self.data is not None:
|
225
|
+
data = base64.b64encode(self.data).decode()
|
226
|
+
else:
|
227
|
+
raise ValueError("No image data available")
|
228
|
+
|
229
|
+
return mcp.types.ImageContent(
|
230
|
+
type="image",
|
231
|
+
data=data,
|
232
|
+
mimeType=mime_type or self._mime_type,
|
233
|
+
annotations=annotations or self.annotations,
|
234
|
+
)
|
235
|
+
|
236
|
+
|
237
|
+
class Audio:
|
238
|
+
"""Helper class for returning audio from tools."""
|
239
|
+
|
240
|
+
def __init__(
|
241
|
+
self,
|
242
|
+
path: str | Path | None = None,
|
243
|
+
data: bytes | None = None,
|
244
|
+
format: str | None = None,
|
245
|
+
annotations: Annotations | None = None,
|
246
|
+
):
|
247
|
+
if path is None and data is None:
|
248
|
+
raise ValueError("Either path or data must be provided")
|
249
|
+
if path is not None and data is not None:
|
250
|
+
raise ValueError("Only one of path or data can be provided")
|
251
|
+
|
252
|
+
self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
|
253
|
+
self.data = data
|
254
|
+
self._format = format
|
255
|
+
self._mime_type = self._get_mime_type()
|
256
|
+
self.annotations = annotations
|
257
|
+
|
258
|
+
def _get_mime_type(self) -> str:
|
259
|
+
"""Get MIME type from format or guess from file extension."""
|
260
|
+
if self._format:
|
261
|
+
return f"audio/{self._format.lower()}"
|
262
|
+
|
263
|
+
if self.path:
|
264
|
+
suffix = self.path.suffix.lower()
|
265
|
+
return {
|
266
|
+
".wav": "audio/wav",
|
267
|
+
".mp3": "audio/mpeg",
|
268
|
+
".ogg": "audio/ogg",
|
269
|
+
".m4a": "audio/mp4",
|
270
|
+
".flac": "audio/flac",
|
271
|
+
}.get(suffix, "application/octet-stream")
|
272
|
+
return "audio/wav" # default for raw binary data
|
273
|
+
|
274
|
+
def to_audio_content(
|
275
|
+
self,
|
276
|
+
mime_type: str | None = None,
|
277
|
+
annotations: Annotations | None = None,
|
278
|
+
) -> mcp.types.AudioContent:
|
279
|
+
if self.path:
|
280
|
+
with open(self.path, "rb") as f:
|
281
|
+
data = base64.b64encode(f.read()).decode()
|
282
|
+
elif self.data is not None:
|
283
|
+
data = base64.b64encode(self.data).decode()
|
284
|
+
else:
|
285
|
+
raise ValueError("No audio data available")
|
286
|
+
|
287
|
+
return mcp.types.AudioContent(
|
288
|
+
type="audio",
|
289
|
+
data=data,
|
290
|
+
mimeType=mime_type or self._mime_type,
|
291
|
+
annotations=annotations or self.annotations,
|
292
|
+
)
|
293
|
+
|
294
|
+
|
295
|
+
class File:
|
296
|
+
"""Helper class for returning audio from tools."""
|
297
|
+
|
298
|
+
def __init__(
|
299
|
+
self,
|
300
|
+
path: str | Path | None = None,
|
301
|
+
data: bytes | None = None,
|
302
|
+
format: str | None = None,
|
303
|
+
name: str | None = None,
|
304
|
+
annotations: Annotations | None = None,
|
305
|
+
):
|
306
|
+
if path is None and data is None:
|
307
|
+
raise ValueError("Either path or data must be provided")
|
308
|
+
if path is not None and data is not None:
|
309
|
+
raise ValueError("Only one of path or data can be provided")
|
310
|
+
|
311
|
+
self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
|
312
|
+
self.data = data
|
313
|
+
self._format = format
|
314
|
+
self._mime_type = self._get_mime_type()
|
315
|
+
self._name = name
|
316
|
+
self.annotations = annotations
|
317
|
+
|
318
|
+
def _get_mime_type(self) -> str:
|
319
|
+
"""Get MIME type from format or guess from file extension."""
|
320
|
+
if self._format:
|
321
|
+
fmt = self._format.lower()
|
322
|
+
# Map common text formats to text/plain
|
323
|
+
if fmt in {"plain", "txt", "text"}:
|
324
|
+
return "text/plain"
|
325
|
+
return f"application/{fmt}"
|
326
|
+
|
327
|
+
if self.path:
|
328
|
+
mime_type, _ = mimetypes.guess_type(self.path)
|
329
|
+
if mime_type:
|
330
|
+
return mime_type
|
331
|
+
|
332
|
+
return "application/octet-stream"
|
333
|
+
|
334
|
+
def to_resource_content(
|
335
|
+
self,
|
336
|
+
mime_type: str | None = None,
|
337
|
+
annotations: Annotations | None = None,
|
338
|
+
) -> mcp.types.EmbeddedResource:
|
339
|
+
if self.path:
|
340
|
+
with open(self.path, "rb") as f:
|
341
|
+
raw_data = f.read()
|
342
|
+
uri_str = self.path.resolve().as_uri()
|
343
|
+
elif self.data is not None:
|
344
|
+
raw_data = self.data
|
345
|
+
if self._name:
|
346
|
+
uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}"
|
347
|
+
else:
|
348
|
+
uri_str = f"file:///resource.{self._mime_type.split('/')[1]}"
|
349
|
+
else:
|
350
|
+
raise ValueError("No resource data available")
|
351
|
+
|
352
|
+
mime = mime_type or self._mime_type
|
353
|
+
UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)]
|
354
|
+
uri = TypeAdapter(UriType).validate_python(uri_str)
|
355
|
+
|
356
|
+
if mime.startswith("text/"):
|
357
|
+
try:
|
358
|
+
text = raw_data.decode("utf-8")
|
359
|
+
except UnicodeDecodeError:
|
360
|
+
text = raw_data.decode("latin-1")
|
361
|
+
resource = mcp.types.TextResourceContents(
|
362
|
+
text=text,
|
363
|
+
mimeType=mime,
|
364
|
+
uri=uri,
|
365
|
+
)
|
366
|
+
else:
|
367
|
+
data = base64.b64encode(raw_data).decode()
|
368
|
+
resource = mcp.types.BlobResourceContents(
|
369
|
+
blob=data,
|
370
|
+
mimeType=mime,
|
371
|
+
uri=uri,
|
372
|
+
)
|
373
|
+
|
374
|
+
return mcp.types.EmbeddedResource(
|
375
|
+
type="resource",
|
376
|
+
resource=resource,
|
377
|
+
annotations=annotations or self.annotations,
|
378
|
+
)
|
379
|
+
|
380
|
+
|
381
|
+
def replace_type(type_, type_map: dict[type, type]):
|
382
|
+
"""
|
383
|
+
Given a (possibly generic, nested, or otherwise complex) type, replaces all
|
384
|
+
instances of old_type with new_type.
|
385
|
+
|
386
|
+
This is useful for transforming types when creating tools.
|
387
|
+
|
388
|
+
Args:
|
389
|
+
type_: The type to replace instances of old_type with new_type.
|
390
|
+
old_type: The type to replace.
|
391
|
+
new_type: The type to replace old_type with.
|
392
|
+
|
393
|
+
Examples:
|
394
|
+
```python
|
395
|
+
>>> replace_type(list[int | bool], {int: str})
|
396
|
+
list[str | bool]
|
397
|
+
|
398
|
+
>>> replace_type(list[list[int]], {int: str})
|
399
|
+
list[list[str]]
|
400
|
+
```
|
401
|
+
"""
|
402
|
+
if type_ in type_map:
|
403
|
+
return type_map[type_]
|
404
|
+
|
405
|
+
origin = get_origin(type_)
|
406
|
+
if not origin:
|
407
|
+
return type_
|
408
|
+
|
409
|
+
args = get_args(type_)
|
410
|
+
new_args = tuple(replace_type(arg, type_map) for arg in args)
|
411
|
+
|
412
|
+
if origin is UnionType:
|
413
|
+
return Union[new_args] # type: ignore # noqa: UP007
|
414
|
+
else:
|
415
|
+
return origin[new_args]
|
416
|
+
|
417
|
+
|
418
|
+
class ContextSamplingFallbackProtocol(Protocol):
|
419
|
+
async def __call__(
|
420
|
+
self,
|
421
|
+
messages: str | list[str | SamplingMessage],
|
422
|
+
system_prompt: str | None = None,
|
423
|
+
temperature: float | None = None,
|
424
|
+
max_tokens: int | None = None,
|
425
|
+
model_preferences: ModelPreferences | str | list[str] | None = None,
|
426
|
+
) -> ContentBlock: ...
|
@@ -14,15 +14,15 @@ from ..models import (
|
|
14
14
|
EnumerationInfo,
|
15
15
|
LabelInfo,
|
16
16
|
PublicEntityInfo,
|
17
|
-
SyncResult,
|
18
|
-
SyncStrategy,
|
19
17
|
)
|
20
18
|
from ..sync_models import (
|
21
19
|
SyncActivity,
|
22
20
|
SyncPhase,
|
21
|
+
SyncResult,
|
23
22
|
SyncSession,
|
24
23
|
SyncSessionSummary,
|
25
24
|
SyncStatus,
|
25
|
+
SyncStrategy,
|
26
26
|
)
|
27
27
|
from .cache_v2 import MetadataCacheV2
|
28
28
|
|
@@ -282,13 +282,13 @@ class SyncSessionManager:
|
|
282
282
|
await self._complete_phase(session, SyncPhase.FINALIZING)
|
283
283
|
|
284
284
|
return SyncResult(
|
285
|
+
sync_type="full",
|
285
286
|
success=True,
|
286
|
-
error=None,
|
287
287
|
duration_ms=duration_ms,
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
288
|
+
entities_synced=entity_count,
|
289
|
+
actions_synced=action_count,
|
290
|
+
enumerations_synced=enumeration_count,
|
291
|
+
labels_synced=label_count
|
292
292
|
)
|
293
293
|
|
294
294
|
async def _sync_entities_with_progress(self, session: SyncSession):
|