optimizely-opal.opal-tools-sdk 0.1.22.dev0__tar.gz → 0.1.24.dev0__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.
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/PKG-INFO +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/__init__.py +4 -2
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/decorators.py +131 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/models.py +7 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/proteus.py +10 -4
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/service.py +106 -1
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/pyproject.toml +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/setup.py +1 -1
- optimizely_opal_opal_tools_sdk-0.1.24.dev0/tests/test_integration.py +749 -0
- optimizely_opal_opal_tools_sdk-0.1.22.dev0/tests/test_integration.py +0 -352
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/README.md +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/_registry.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/auth.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/logging.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/ui.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/setup.cfg +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/tests/test_nested_schema.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/tests/test_proteus.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from .service import ToolsService
|
|
2
|
-
from .decorators import tool, resource
|
|
2
|
+
from .decorators import tool, resource, interaction
|
|
3
3
|
from .auth import requires_auth
|
|
4
4
|
from .logging import register_logger_factory
|
|
5
|
-
from .models import AuthData, AuthRequirement, Credentials, Environment, IslandConfig, IslandResponse
|
|
5
|
+
from .models import AuthData, AuthRequirement, Credentials, Environment, InteractionContext, IslandConfig, IslandResponse
|
|
6
6
|
from .proteus import UI
|
|
7
7
|
|
|
8
8
|
__version__ = "0.1.14-dev"
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
"ToolsService",
|
|
11
11
|
"tool",
|
|
12
12
|
"resource",
|
|
13
|
+
"interaction",
|
|
13
14
|
"requires_auth",
|
|
14
15
|
"register_logger_factory",
|
|
15
16
|
# Models
|
|
@@ -17,6 +18,7 @@ __all__ = [
|
|
|
17
18
|
"AuthRequirement",
|
|
18
19
|
"Credentials",
|
|
19
20
|
"Environment",
|
|
21
|
+
"InteractionContext",
|
|
20
22
|
"IslandConfig",
|
|
21
23
|
"IslandResponse",
|
|
22
24
|
# UI
|
|
@@ -279,6 +279,137 @@ def tool(
|
|
|
279
279
|
return decorator
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
def interaction(
|
|
283
|
+
name: str,
|
|
284
|
+
description: str = "",
|
|
285
|
+
):
|
|
286
|
+
"""Decorator to register a function as an interaction handler.
|
|
287
|
+
|
|
288
|
+
Interactions are app-only handlers — actions callable by the UI but hidden from the model.
|
|
289
|
+
They follow the MCP app-only tools pattern (visibility: ["app"]).
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
name: Name of the interaction. Must be unique across both tools and interactions
|
|
293
|
+
within the same service, since both may be exported as MCP tools.
|
|
294
|
+
description: Description of the interaction
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Decorator function
|
|
298
|
+
|
|
299
|
+
Note:
|
|
300
|
+
The handler's first parameter can be a Pydantic model defining the interaction's
|
|
301
|
+
input schema — the same pattern as @tool. The decorator introspects the model fields
|
|
302
|
+
to extract parameter definitions (type, description, required). If the first parameter
|
|
303
|
+
is typed as `dict`, no schema extraction is performed.
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
class TaskFormInput(BaseModel):
|
|
307
|
+
title: str = Field(description="Task title")
|
|
308
|
+
priority: str = Field(default="medium", description="Task priority")
|
|
309
|
+
assignee: Optional[str] = Field(default=None, description="Assignee email")
|
|
310
|
+
|
|
311
|
+
@interaction(
|
|
312
|
+
name="submit_task_form",
|
|
313
|
+
description="Handle task form submission"
|
|
314
|
+
)
|
|
315
|
+
async def handle_task_submission(parameters: TaskFormInput, context: InteractionContext):
|
|
316
|
+
# parameters is already validated and typed
|
|
317
|
+
task_id = await create_task(parameters.title, parameters.priority)
|
|
318
|
+
return {"task_id": task_id}
|
|
319
|
+
"""
|
|
320
|
+
def decorator(func: Callable):
|
|
321
|
+
from . import _registry
|
|
322
|
+
|
|
323
|
+
logger.info(f"Registering interaction {name}")
|
|
324
|
+
|
|
325
|
+
# Extract parameters from Pydantic model in function signature (same as @tool)
|
|
326
|
+
sig = inspect.signature(func)
|
|
327
|
+
type_hints = get_type_hints(func)
|
|
328
|
+
|
|
329
|
+
parameters: List[Parameter] = []
|
|
330
|
+
param_model = None
|
|
331
|
+
|
|
332
|
+
for param_name, param in sig.parameters.items():
|
|
333
|
+
if param_name in type_hints:
|
|
334
|
+
param_type = type_hints[param_name]
|
|
335
|
+
if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
|
|
336
|
+
param_model = param_type
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if param_model:
|
|
340
|
+
model_fields = getattr(param_model, 'model_fields', getattr(param_model, '__fields__', {}))
|
|
341
|
+
for field_name, field in model_fields.items():
|
|
342
|
+
field_info = field.field_info if hasattr(field, 'field_info') else field
|
|
343
|
+
|
|
344
|
+
if hasattr(field, 'outer_type_'):
|
|
345
|
+
field_type = field.outer_type_
|
|
346
|
+
elif hasattr(field, 'annotation'):
|
|
347
|
+
field_type = field.annotation
|
|
348
|
+
else:
|
|
349
|
+
field_type = str
|
|
350
|
+
|
|
351
|
+
type_args = getattr(field_type, '__args__', ())
|
|
352
|
+
is_optional = get_origin(field_type) is Union and type(None) in type_args
|
|
353
|
+
|
|
354
|
+
if is_optional and type_args:
|
|
355
|
+
field_type = next(
|
|
356
|
+
(arg for arg in type_args if arg is not type(None)),
|
|
357
|
+
field_type,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
param_type = ParameterType.string
|
|
361
|
+
if field_type is int:
|
|
362
|
+
param_type = ParameterType.integer
|
|
363
|
+
elif field_type is float:
|
|
364
|
+
param_type = ParameterType.number
|
|
365
|
+
elif field_type is bool:
|
|
366
|
+
param_type = ParameterType.boolean
|
|
367
|
+
elif field_type is list or get_origin(field_type) is list:
|
|
368
|
+
param_type = ParameterType.list
|
|
369
|
+
elif field_type is dict or get_origin(field_type) is dict:
|
|
370
|
+
param_type = ParameterType.dictionary
|
|
371
|
+
|
|
372
|
+
field_info_extra = getattr(field_info, "json_schema_extra") or {}
|
|
373
|
+
if "required" in field_info_extra:
|
|
374
|
+
required = field_info_extra["required"]
|
|
375
|
+
elif is_optional:
|
|
376
|
+
required = False
|
|
377
|
+
elif hasattr(field_info, 'is_required'):
|
|
378
|
+
required = field_info.is_required()
|
|
379
|
+
elif hasattr(field_info, 'default'):
|
|
380
|
+
required = field_info.default is ...
|
|
381
|
+
else:
|
|
382
|
+
required = True
|
|
383
|
+
|
|
384
|
+
description_text = ""
|
|
385
|
+
if hasattr(field_info, 'description'):
|
|
386
|
+
description_text = field_info.description
|
|
387
|
+
elif hasattr(field, 'description'):
|
|
388
|
+
description_text = field.description
|
|
389
|
+
|
|
390
|
+
parameters.append(Parameter(
|
|
391
|
+
name=field_name,
|
|
392
|
+
param_type=param_type,
|
|
393
|
+
description=description_text,
|
|
394
|
+
required=required,
|
|
395
|
+
))
|
|
396
|
+
|
|
397
|
+
if not _registry.services:
|
|
398
|
+
logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
|
|
399
|
+
|
|
400
|
+
for service in _registry.services:
|
|
401
|
+
service.register_interaction(
|
|
402
|
+
name=name,
|
|
403
|
+
description=description,
|
|
404
|
+
handler=func,
|
|
405
|
+
parameters=parameters,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return func
|
|
409
|
+
|
|
410
|
+
return decorator
|
|
411
|
+
|
|
412
|
+
|
|
282
413
|
def resource(
|
|
283
414
|
uri: str,
|
|
284
415
|
name: str,
|
|
@@ -71,6 +71,13 @@ class AuthData(TypedDict):
|
|
|
71
71
|
credentials: Credentials
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
@dataclass
|
|
75
|
+
class InteractionContext:
|
|
76
|
+
"""Context object passed to interaction handlers."""
|
|
77
|
+
|
|
78
|
+
auth_data: Optional[AuthData] = None
|
|
79
|
+
|
|
80
|
+
|
|
74
81
|
class Environment(TypedDict):
|
|
75
82
|
"""Execution environment for an Opal tool. Interactive will provide interaction islands, while headless will not."""
|
|
76
83
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: proteus-document-spec.json
|
|
3
|
-
# timestamp: 2026-
|
|
3
|
+
# timestamp: 2026-04-05T18:25:55+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -15,13 +15,13 @@ class OpalProteusDocumentSpecification(RootModel[Any]):
|
|
|
15
15
|
|
|
16
16
|
class ProteusEventHandler1(BaseModel):
|
|
17
17
|
"""
|
|
18
|
-
Server-side
|
|
18
|
+
Server-side interaction call
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
model_config = ConfigDict(
|
|
22
22
|
extra='forbid',
|
|
23
23
|
)
|
|
24
|
-
|
|
24
|
+
interaction: str = Field(..., description='Name of registered interaction to call')
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class Series(BaseModel):
|
|
@@ -2500,6 +2500,9 @@ class ProteusAction(BaseModel):
|
|
|
2500
2500
|
textAlign: SprinklePropTextAlign | None = None
|
|
2501
2501
|
textTransform: SprinklePropTextTransform | None = None
|
|
2502
2502
|
transition: SprinklePropTransition | None = None
|
|
2503
|
+
type: Literal['button'] | Literal['reset'] | Literal['submit'] | ProteusValue | None = Field(
|
|
2504
|
+
default=None, description='The default behavior of the button.'
|
|
2505
|
+
)
|
|
2503
2506
|
w: SprinklePropW | None = None
|
|
2504
2507
|
whiteSpace: SprinklePropWhiteSpace | None = None
|
|
2505
2508
|
z: SprinklePropZ | None = None
|
|
@@ -3212,6 +3215,9 @@ class ProteusInput(BaseModel):
|
|
|
3212
3215
|
appearance: Literal['number'] | Literal['default'] | ProteusValue | None = Field(
|
|
3213
3216
|
default=None, description='Control the appearance of the input.'
|
|
3214
3217
|
)
|
|
3218
|
+
autoFocus: bool | ProteusValue | None = Field(
|
|
3219
|
+
default=None, description='Whether the input should be focused on mount.'
|
|
3220
|
+
)
|
|
3215
3221
|
backgroundImage: SprinklePropBackgroundImage | None = None
|
|
3216
3222
|
bg: SprinklePropBg | None = None
|
|
3217
3223
|
border: SprinklePropBorder | None = None
|
|
@@ -3665,7 +3671,7 @@ class ProteusEventHandler(
|
|
|
3665
3671
|
):
|
|
3666
3672
|
root: ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 = Field(
|
|
3667
3673
|
...,
|
|
3668
|
-
description='Handler for user interactions - a server-side
|
|
3674
|
+
description='Handler for user interactions - a server-side interaction call, client-side message, or client-side component action',
|
|
3669
3675
|
)
|
|
3670
3676
|
|
|
3671
3677
|
|
|
@@ -6,7 +6,7 @@ from fastapi import FastAPI, APIRouter, HTTPException, Request
|
|
|
6
6
|
from fastapi.routing import APIRoute
|
|
7
7
|
from pydantic import BaseModel, ValidationError
|
|
8
8
|
|
|
9
|
-
from .models import Function, Parameter, AuthRequirement
|
|
9
|
+
from .models import Function, Parameter, AuthRequirement, InteractionContext
|
|
10
10
|
from .proteus import ProteusDocument, UI
|
|
11
11
|
from . import _registry
|
|
12
12
|
|
|
@@ -25,6 +25,7 @@ class ToolsService:
|
|
|
25
25
|
self.app = app
|
|
26
26
|
self.router = APIRouter()
|
|
27
27
|
self.functions: List[Function] = []
|
|
28
|
+
self.interactions: Dict[str, Dict[str, Any]] = {} # name -> {"handler": fn, "description": str, "parameters": List[Parameter]}
|
|
28
29
|
self.resource_handlers: Dict[str, Tuple[Callable, Optional[str]]] = {} # URI -> (handler, mime_type)
|
|
29
30
|
self._init_routes()
|
|
30
31
|
|
|
@@ -98,6 +99,67 @@ class ToolsService:
|
|
|
98
99
|
logger.error(traceback.format_exc())
|
|
99
100
|
raise HTTPException(status_code=500, detail=str(e))
|
|
100
101
|
|
|
102
|
+
@self.router.post("/interactions/execute")
|
|
103
|
+
async def execute_interaction(request: Request):
|
|
104
|
+
"""Execute an interaction by name with parameters."""
|
|
105
|
+
try:
|
|
106
|
+
body = await request.json()
|
|
107
|
+
name = body.get("name")
|
|
108
|
+
parameters = body.get("parameters", {})
|
|
109
|
+
|
|
110
|
+
if not name:
|
|
111
|
+
raise HTTPException(status_code=400, detail="Missing 'name' field in request body")
|
|
112
|
+
|
|
113
|
+
if name not in self.interactions:
|
|
114
|
+
raise HTTPException(status_code=404, detail=f"Interaction '{name}' not found")
|
|
115
|
+
|
|
116
|
+
interaction_info = self.interactions[name]
|
|
117
|
+
handler = interaction_info["handler"]
|
|
118
|
+
|
|
119
|
+
# Extract auth data if available
|
|
120
|
+
auth_data = body.get("auth")
|
|
121
|
+
|
|
122
|
+
# Build context object
|
|
123
|
+
context = InteractionContext(
|
|
124
|
+
auth_data=auth_data,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Introspect handler signature for Pydantic model (same pattern as tool endpoint)
|
|
128
|
+
sig = inspect.signature(handler)
|
|
129
|
+
type_hints = get_type_hints(handler)
|
|
130
|
+
|
|
131
|
+
param_model = None
|
|
132
|
+
for param_name, param in sig.parameters.items():
|
|
133
|
+
if param_name in type_hints:
|
|
134
|
+
param_type = type_hints[param_name]
|
|
135
|
+
if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
|
|
136
|
+
param_model = param_type
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
if param_model:
|
|
141
|
+
# Validate and deserialize parameters using the Pydantic model
|
|
142
|
+
model_instance = param_model(**parameters)
|
|
143
|
+
result = await handler(model_instance, context)
|
|
144
|
+
else:
|
|
145
|
+
# Fall back to raw dict
|
|
146
|
+
result = await handler(parameters, context)
|
|
147
|
+
return result
|
|
148
|
+
except ValidationError as e:
|
|
149
|
+
logger.warning(f"Invalid parameters for interaction '{name}': {str(e)}")
|
|
150
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Interaction '{name}' failed: {e}", exc_info=True)
|
|
153
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
154
|
+
|
|
155
|
+
except HTTPException:
|
|
156
|
+
raise
|
|
157
|
+
except Exception as e:
|
|
158
|
+
import traceback
|
|
159
|
+
logger.error(f"Error executing interaction: {str(e)}")
|
|
160
|
+
logger.error(traceback.format_exc())
|
|
161
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
162
|
+
|
|
101
163
|
# Include router in app
|
|
102
164
|
self.app.include_router(self.router)
|
|
103
165
|
|
|
@@ -148,6 +210,13 @@ class ToolsService:
|
|
|
148
210
|
"""
|
|
149
211
|
logger.info(f"Registering tool: {name} with endpoint: {endpoint}")
|
|
150
212
|
|
|
213
|
+
# Validate name uniqueness across tools and interactions
|
|
214
|
+
if name in self.interactions:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Tool name '{name}' conflicts with an existing interaction name. "
|
|
217
|
+
"Tool and interaction names must be unique because both are exported as MCP tools."
|
|
218
|
+
)
|
|
219
|
+
|
|
151
220
|
# Extract auth requirements from handler if decorated with @requires_auth
|
|
152
221
|
handler_auth_requirements = self._extract_auth_requirements(handler)
|
|
153
222
|
|
|
@@ -263,3 +332,39 @@ class ToolsService:
|
|
|
263
332
|
|
|
264
333
|
# Store the handler and mime_type as a tuple
|
|
265
334
|
self.resource_handlers[uri] = (handler, mime_type)
|
|
335
|
+
|
|
336
|
+
def register_interaction(
|
|
337
|
+
self,
|
|
338
|
+
name: str,
|
|
339
|
+
description: str,
|
|
340
|
+
handler: Callable,
|
|
341
|
+
parameters: Optional[List[Parameter]] = None,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Register an interaction handler.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
name: Name of the interaction. Must be unique across both tools and interactions.
|
|
347
|
+
description: Description of the interaction
|
|
348
|
+
handler: Async function that handles the interaction.
|
|
349
|
+
Signature: async def handler(parameters: Model|dict, context: InteractionContext) -> dict
|
|
350
|
+
parameters: Parameter definitions extracted from the handler's Pydantic model
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
ValueError: If the name conflicts with an existing tool or interaction name.
|
|
354
|
+
"""
|
|
355
|
+
# Validate name uniqueness across tools and interactions
|
|
356
|
+
tool_names = {f.name for f in self.functions}
|
|
357
|
+
if name in tool_names:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Interaction name '{name}' conflicts with an existing tool name. "
|
|
360
|
+
"Tool and interaction names must be unique because both are exported as MCP tools."
|
|
361
|
+
)
|
|
362
|
+
if name in self.interactions:
|
|
363
|
+
raise ValueError(f"Interaction '{name}' is already registered.")
|
|
364
|
+
|
|
365
|
+
self.interactions[name] = {
|
|
366
|
+
"handler": handler,
|
|
367
|
+
"description": description,
|
|
368
|
+
"parameters": parameters or [],
|
|
369
|
+
}
|
|
370
|
+
logger.info(f"Registered interaction: {name}")
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "optimizely-opal.opal-tools-sdk"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.24-dev"
|
|
8
8
|
description = "SDK for creating Opal-compatible tools services"
|
|
9
9
|
authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
|
|
10
10
|
readme = "README.md"
|