artha-engine 0.1.1__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.
Files changed (75) hide show
  1. artha_engine/.DS_Store +0 -0
  2. artha_engine/__init__.py +241 -0
  3. artha_engine/app/__init__.py +71 -0
  4. artha_engine/app/actions.py +390 -0
  5. artha_engine/app/api_key_crypto.py +35 -0
  6. artha_engine/app/application.py +107 -0
  7. artha_engine/app/auth.py +195 -0
  8. artha_engine/app/authorization.py +42 -0
  9. artha_engine/app/build.py +179 -0
  10. artha_engine/app/cli.py +252 -0
  11. artha_engine/app/cli_config.py +72 -0
  12. artha_engine/app/config.py +126 -0
  13. artha_engine/app/context.py +26 -0
  14. artha_engine/app/http.py +234 -0
  15. artha_engine/app/managed_api_keys.py +224 -0
  16. artha_engine/app/mcp.py +324 -0
  17. artha_engine/app/remote_client.py +77 -0
  18. artha_engine/cli.py +380 -0
  19. artha_engine/decoders/__init__.py +79 -0
  20. artha_engine/decoders/base.py +16 -0
  21. artha_engine/decoders/core.py +122 -0
  22. artha_engine/decoders/llm.py +63 -0
  23. artha_engine/decoders/search.py +321 -0
  24. artha_engine/embeddings/__init__.py +33 -0
  25. artha_engine/embeddings/base.py +28 -0
  26. artha_engine/embeddings/deterministic.py +35 -0
  27. artha_engine/embeddings/fastembed.py +82 -0
  28. artha_engine/encoders/__init__.py +25 -0
  29. artha_engine/encoders/base.py +18 -0
  30. artha_engine/encoders/core.py +157 -0
  31. artha_engine/encoders/llm.py +57 -0
  32. artha_engine/lifecycle/__init__.py +16 -0
  33. artha_engine/lifecycle/base.py +32 -0
  34. artha_engine/lifecycle/core.py +79 -0
  35. artha_engine/llms/__init__.py +30 -0
  36. artha_engine/llms/base.py +20 -0
  37. artha_engine/llms/deterministic.py +69 -0
  38. artha_engine/llms/litellm.py +62 -0
  39. artha_engine/py.typed +0 -0
  40. artha_engine/representations/.DS_Store +0 -0
  41. artha_engine/representations/__init__.py +27 -0
  42. artha_engine/representations/arthaanu.py +102 -0
  43. artha_engine/retrieval/__init__.py +8 -0
  44. artha_engine/retrieval/bm25.py +74 -0
  45. artha_engine/retrieval/rrf.py +33 -0
  46. artha_engine/runtime/__init__.py +50 -0
  47. artha_engine/runtime/api/__init__.py +0 -0
  48. artha_engine/runtime/api/app.py +338 -0
  49. artha_engine/runtime/api/debug_context.py +22 -0
  50. artha_engine/runtime/api/schemas.py +168 -0
  51. artha_engine/runtime/auth.py +49 -0
  52. artha_engine/runtime/capabilities.py +37 -0
  53. artha_engine/runtime/component_registry.py +141 -0
  54. artha_engine/runtime/debug/__init__.py +19 -0
  55. artha_engine/runtime/debug/spans.py +122 -0
  56. artha_engine/runtime/engine.py +488 -0
  57. artha_engine/runtime/projections.py +19 -0
  58. artha_engine/runtime/registry.py +400 -0
  59. artha_engine/runtime/serde.py +16 -0
  60. artha_engine/store/__init__.py +42 -0
  61. artha_engine/store/api_key_store.py +296 -0
  62. artha_engine/store/api_keys_schema.sql +15 -0
  63. artha_engine/store/base.py +93 -0
  64. artha_engine/store/factory.py +39 -0
  65. artha_engine/store/memory.py +277 -0
  66. artha_engine/store/postgres.py +578 -0
  67. artha_engine/store/projection_sql.py +44 -0
  68. artha_engine/store/schema.sql +50 -0
  69. artha_engine/store/schema_postgres.sql +50 -0
  70. artha_engine/store/serde.py +24 -0
  71. artha_engine/store/sqlite.py +650 -0
  72. artha_engine-0.1.1.dist-info/METADATA +136 -0
  73. artha_engine-0.1.1.dist-info/RECORD +75 -0
  74. artha_engine-0.1.1.dist-info/WHEEL +4 -0
  75. artha_engine-0.1.1.dist-info/entry_points.txt +3 -0
artha_engine/.DS_Store ADDED
Binary file
@@ -0,0 +1,241 @@
1
+ from artha_engine.decoders import (
2
+ BlobRefDecoder,
3
+ Bm25SearchDecoder,
4
+ Bm25SearchParams,
5
+ CollectionDecodeParams,
6
+ CollectionDecoder,
7
+ CosineSimilarityDecoder,
8
+ Decoder,
9
+ EmbeddingCandidate,
10
+ EmbeddingSearchHit,
11
+ EmbeddingSearchParams,
12
+ EmbeddingSearchResult,
13
+ FusedSearchHit,
14
+ GraphEdgeOutput,
15
+ JsonDecoder,
16
+ LLMArthaanuSummarizeDecoder,
17
+ LLMTextFormatDecoder,
18
+ LexicalMatchDecoder,
19
+ LexicalSearchHit,
20
+ LexicalSearchParams,
21
+ LexicalSearchResult,
22
+ NoDecoderParams,
23
+ QueryEmbeddingDecoder,
24
+ QueryEmbeddingParams,
25
+ ReciprocalRankFusionDecoder,
26
+ ReciprocalRankFusionParams,
27
+ RelationEdgeDecoder,
28
+ TextCandidate,
29
+ TextDecoder,
30
+ )
31
+ from artha_engine.encoders import (
32
+ BlobRefEncoder,
33
+ ChunkEmbeddingEncoder,
34
+ CollectionEncoder,
35
+ DeterministicEmbeddingEncoder,
36
+ Encoder,
37
+ GraphNodeEncoder,
38
+ JsonEncoder,
39
+ LLMJsonExtractEncoder,
40
+ RelationEncoder,
41
+ TextEncoder,
42
+ )
43
+ from artha_engine.lifecycle import (
44
+ CORE_LIFECYCLES_BY_NAME,
45
+ CollectionDedupeLifecycle,
46
+ JsonMergeLifecycle,
47
+ Lifecycle,
48
+ MultiLifecycle,
49
+ TextNormalizationLifecycle,
50
+ )
51
+ from artha_engine.representations import (
52
+ ARTHAANU_TYPES_BY_VALUE_TYPE,
53
+ Arthaanu,
54
+ BlobRefArthaanu,
55
+ CollectionArthaanu,
56
+ EmbeddingArthaanu,
57
+ GraphNodeArthaanu,
58
+ JsonArthaanu,
59
+ RelationArthaanu,
60
+ RelationValue,
61
+ SUPPORTED_VALUE_TYPES,
62
+ TextArthaanu,
63
+ )
64
+ from artha_engine.runtime import (
65
+ ArthaEngine,
66
+ AuthContext,
67
+ ComponentRegistry,
68
+ DEFAULT_DATABASE_URL,
69
+ DEFAULT_DB_PATH,
70
+ InMemoryArthaStore,
71
+ PostgresArthaStore,
72
+ SqliteArthaStore,
73
+ arthaanu_from_dict,
74
+ arthaanu_to_dict,
75
+ build_core_registry,
76
+ create_store,
77
+ default_store,
78
+ list_decoders,
79
+ list_encoders,
80
+ list_lifecycles,
81
+ list_projections,
82
+ normalize_auth_context,
83
+ normalize_profile_capabilities,
84
+ Projection,
85
+ ProfileCapability,
86
+ resolve_database_target,
87
+ )
88
+ from artha_engine.store import ArthaStore
89
+ from artha_engine.llms import (
90
+ DEFAULT_LLM,
91
+ DEFAULT_LLM_MODEL,
92
+ DeterministicTextGenerator,
93
+ LiteLLMTextGenerator,
94
+ TemplateTextGenerator,
95
+ TextGenerator,
96
+ default_llm,
97
+ )
98
+ from artha_engine.retrieval import Bm25Index, reciprocal_rank_fusion
99
+ from artha_engine.embeddings import (
100
+ DEFAULT_EMBEDDER,
101
+ DEFAULT_MODEL,
102
+ DeterministicTextEmbedder,
103
+ FastEmbedTextEmbedder,
104
+ NOMIC_DOCUMENT_PREFIX,
105
+ NOMIC_QUERY_PREFIX,
106
+ TextEmbedder,
107
+ default_embedder,
108
+ )
109
+ from artha_engine.app import (
110
+ ActionContext,
111
+ ActionDefinition,
112
+ ActionManifest,
113
+ ActionRegistry,
114
+ AnonymousAuthProvider,
115
+ ApiKeyAuthProvider,
116
+ ArthaApplication,
117
+ AuthProvider,
118
+ ClerkAuthProvider,
119
+ HttpExposure,
120
+ JwtAuthProvider,
121
+ McpHttpCompatibilityMiddleware,
122
+ ScopeAuthorizationPolicy,
123
+ action,
124
+ auth_provider_from_env,
125
+ streamable_http_app,
126
+ )
127
+
128
+ __all__ = [
129
+ "ARTHAANU_TYPES_BY_VALUE_TYPE",
130
+ "Arthaanu",
131
+ "BlobRefArthaanu",
132
+ "CollectionArthaanu",
133
+ "TextArthaanu",
134
+ "EmbeddingArthaanu",
135
+ "JsonArthaanu",
136
+ "RelationArthaanu",
137
+ "RelationValue",
138
+ "SUPPORTED_VALUE_TYPES",
139
+ "GraphNodeArthaanu",
140
+ "BlobRefEncoder",
141
+ "ChunkEmbeddingEncoder",
142
+ "CollectionEncoder",
143
+ "DeterministicEmbeddingEncoder",
144
+ "Encoder",
145
+ "GraphNodeEncoder",
146
+ "JsonEncoder",
147
+ "LLMJsonExtractEncoder",
148
+ "RelationEncoder",
149
+ "TextEncoder",
150
+ "CORE_LIFECYCLES_BY_NAME",
151
+ "CollectionDedupeLifecycle",
152
+ "JsonMergeLifecycle",
153
+ "Lifecycle",
154
+ "MultiLifecycle",
155
+ "TextNormalizationLifecycle",
156
+ "Bm25Index",
157
+ "BlobRefDecoder",
158
+ "Bm25SearchDecoder",
159
+ "Bm25SearchParams",
160
+ "CollectionDecodeParams",
161
+ "CollectionDecoder",
162
+ "CosineSimilarityDecoder",
163
+ "Decoder",
164
+ "DEFAULT_LLM",
165
+ "DEFAULT_LLM_MODEL",
166
+ "DeterministicTextGenerator",
167
+ "EmbeddingCandidate",
168
+ "EmbeddingSearchHit",
169
+ "EmbeddingSearchParams",
170
+ "EmbeddingSearchResult",
171
+ "FusedSearchHit",
172
+ "GraphEdgeOutput",
173
+ "JsonDecoder",
174
+ "LLMArthaanuSummarizeDecoder",
175
+ "LLMJsonExtractEncoder",
176
+ "LLMTextFormatDecoder",
177
+ "LexicalMatchDecoder",
178
+ "LexicalSearchHit",
179
+ "LexicalSearchParams",
180
+ "LexicalSearchResult",
181
+ "LiteLLMTextGenerator",
182
+ "NoDecoderParams",
183
+ "QueryEmbeddingDecoder",
184
+ "QueryEmbeddingParams",
185
+ "ReciprocalRankFusionDecoder",
186
+ "ReciprocalRankFusionParams",
187
+ "RelationEdgeDecoder",
188
+ "TemplateTextGenerator",
189
+ "TextCandidate",
190
+ "TextDecoder",
191
+ "TextGenerator",
192
+ "default_llm",
193
+ "reciprocal_rank_fusion",
194
+ "ArthaEngine",
195
+ "ArthaStore",
196
+ "AuthContext",
197
+ "ComponentRegistry",
198
+ "DEFAULT_DATABASE_URL",
199
+ "DEFAULT_DB_PATH",
200
+ "InMemoryArthaStore",
201
+ "PostgresArthaStore",
202
+ "Projection",
203
+ "ProfileCapability",
204
+ "SqliteArthaStore",
205
+ "arthaanu_from_dict",
206
+ "arthaanu_to_dict",
207
+ "build_core_registry",
208
+ "create_store",
209
+ "default_store",
210
+ "resolve_database_target",
211
+ "list_decoders",
212
+ "list_encoders",
213
+ "list_lifecycles",
214
+ "list_projections",
215
+ "normalize_auth_context",
216
+ "normalize_profile_capabilities",
217
+ "DEFAULT_EMBEDDER",
218
+ "DEFAULT_MODEL",
219
+ "DeterministicTextEmbedder",
220
+ "FastEmbedTextEmbedder",
221
+ "NOMIC_DOCUMENT_PREFIX",
222
+ "NOMIC_QUERY_PREFIX",
223
+ "TextEmbedder",
224
+ "default_embedder",
225
+ "ActionContext",
226
+ "ActionDefinition",
227
+ "ActionManifest",
228
+ "ActionRegistry",
229
+ "AnonymousAuthProvider",
230
+ "ApiKeyAuthProvider",
231
+ "ArthaApplication",
232
+ "AuthProvider",
233
+ "ClerkAuthProvider",
234
+ "HttpExposure",
235
+ "JwtAuthProvider",
236
+ "McpHttpCompatibilityMiddleware",
237
+ "ScopeAuthorizationPolicy",
238
+ "action",
239
+ "auth_provider_from_env",
240
+ "streamable_http_app",
241
+ ]
@@ -0,0 +1,71 @@
1
+ from artha_engine.app.actions import (
2
+ ActionDefinition,
3
+ ActionKind,
4
+ ActionManifest,
5
+ ActionRegistry,
6
+ CliExposure,
7
+ HttpExposure,
8
+ McpExposure,
9
+ action,
10
+ )
11
+ from artha_engine.app.application import ArthaApplication
12
+ from artha_engine.app.auth import (
13
+ AnonymousAuthProvider,
14
+ ApiKeyAuthProvider,
15
+ AuthenticationError,
16
+ AuthConfigurationError,
17
+ AuthProvider,
18
+ ClerkAuthProvider,
19
+ JwtAuthProvider,
20
+ auth_provider_from_env,
21
+ )
22
+ from artha_engine.app.managed_api_keys import (
23
+ CompositeAuthProvider,
24
+ ManagedApiKeyAuthProvider,
25
+ enable_managed_api_keys,
26
+ register_api_key_actions,
27
+ )
28
+ from artha_engine.app.authorization import (
29
+ AuthorizationDenied,
30
+ AuthorizationPolicy,
31
+ ScopeAuthorizationPolicy,
32
+ )
33
+ from artha_engine.app.context import ActionContext
34
+ from artha_engine.app.mcp import (
35
+ McpHttpCompatibilityMiddleware,
36
+ create_mcp_server,
37
+ run_mcp,
38
+ streamable_http_app,
39
+ )
40
+
41
+ __all__ = [
42
+ "ActionContext",
43
+ "ActionDefinition",
44
+ "ActionKind",
45
+ "ActionManifest",
46
+ "ActionRegistry",
47
+ "AnonymousAuthProvider",
48
+ "ApiKeyAuthProvider",
49
+ "ArthaApplication",
50
+ "AuthenticationError",
51
+ "AuthConfigurationError",
52
+ "AuthProvider",
53
+ "AuthorizationDenied",
54
+ "AuthorizationPolicy",
55
+ "CliExposure",
56
+ "CompositeAuthProvider",
57
+ "ClerkAuthProvider",
58
+ "HttpExposure",
59
+ "JwtAuthProvider",
60
+ "ManagedApiKeyAuthProvider",
61
+ "McpExposure",
62
+ "McpHttpCompatibilityMiddleware",
63
+ "ScopeAuthorizationPolicy",
64
+ "action",
65
+ "auth_provider_from_env",
66
+ "create_mcp_server",
67
+ "enable_managed_api_keys",
68
+ "register_api_key_actions",
69
+ "run_mcp",
70
+ "streamable_http_app",
71
+ ]
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import re
5
+ import asyncio
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass, field
8
+ from types import ModuleType, UnionType
9
+ from typing import Any, Literal, get_args, get_origin, get_type_hints
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
12
+
13
+ from artha_engine.app.context import ActionContext
14
+ from artha_engine.runtime.auth import AuthContext
15
+ from artha_engine.runtime.engine import ArthaEngine
16
+
17
+ ActionKind = Literal["read", "write", "destructive"]
18
+ _ACTION_ATTRIBUTE = "__artha_action_spec__"
19
+ _NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]*$")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class HttpExposure:
24
+ method: str
25
+ path: str
26
+ status_code: int = 200
27
+
28
+ def __post_init__(self) -> None:
29
+ method = self.method.upper()
30
+ if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
31
+ raise ValueError(f"Unsupported action HTTP method: {self.method}")
32
+ if not self.path.startswith("/"):
33
+ raise ValueError("Action HTTP paths must begin with /")
34
+ object.__setattr__(self, "method", method)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class McpExposure:
39
+ name: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CliExposure:
44
+ command: tuple[str, ...]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ActionSpec:
49
+ name: str
50
+ description: str | None = None
51
+ version: str = "1"
52
+ kind: ActionKind = "read"
53
+ scopes: tuple[str, ...] = ()
54
+ public: bool = False
55
+ http: HttpExposure | None = None
56
+ mcp: McpExposure | None = None
57
+ cli: CliExposure | None = None
58
+ inject: dict[str, str] = field(default_factory=dict)
59
+ tags: tuple[str, ...] = ()
60
+
61
+
62
+ class ActionManifest(BaseModel):
63
+ model_config = ConfigDict(frozen=True)
64
+
65
+ name: str
66
+ description: str
67
+ version: str
68
+ kind: ActionKind
69
+ scopes: list[str] = Field(default_factory=list)
70
+ public: bool
71
+ input_schema: dict[str, Any]
72
+ output_schema: dict[str, Any]
73
+ http: dict[str, Any] | None = None
74
+ mcp: dict[str, Any] | None = None
75
+ cli: dict[str, Any] | None = None
76
+ tags: list[str] = Field(default_factory=list)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ActionDefinition:
81
+ spec: ActionSpec
82
+ function: Callable[..., Any]
83
+ input_model: type[BaseModel]
84
+ return_annotation: Any
85
+ public_parameters: tuple[str, ...]
86
+ model_parameter: str | None
87
+ injected_parameters: dict[str, str]
88
+
89
+ @property
90
+ def name(self) -> str:
91
+ return self.spec.name
92
+
93
+ @property
94
+ def description(self) -> str:
95
+ return self.spec.description or inspect.getdoc(self.function) or self.name
96
+
97
+ @property
98
+ def kind(self) -> ActionKind:
99
+ return self.spec.kind
100
+
101
+ @property
102
+ def scopes(self) -> tuple[str, ...]:
103
+ return self.spec.scopes
104
+
105
+ @property
106
+ def public(self) -> bool:
107
+ return self.spec.public
108
+
109
+ @property
110
+ def http(self) -> HttpExposure | None:
111
+ return self.spec.http
112
+
113
+ @property
114
+ def mcp(self) -> McpExposure | None:
115
+ return self.spec.mcp
116
+
117
+ @property
118
+ def cli(self) -> CliExposure | None:
119
+ return self.spec.cli
120
+
121
+ def manifest(self) -> ActionManifest:
122
+ output_schema: dict[str, Any] = {}
123
+ if self.return_annotation not in {Any, inspect.Signature.empty, None}:
124
+ output_schema = TypeAdapter(self.return_annotation).json_schema()
125
+ return ActionManifest(
126
+ name=self.name,
127
+ description=self.description,
128
+ version=self.spec.version,
129
+ kind=self.kind,
130
+ scopes=list(self.scopes),
131
+ public=self.public,
132
+ input_schema=self.input_model.model_json_schema(),
133
+ output_schema=output_schema,
134
+ http=(
135
+ {
136
+ "method": self.http.method,
137
+ "path": self.http.path,
138
+ "status_code": self.http.status_code,
139
+ }
140
+ if self.http
141
+ else None
142
+ ),
143
+ mcp={"name": self.mcp.name} if self.mcp else None,
144
+ cli={"command": list(self.cli.command)} if self.cli else None,
145
+ tags=list(self.spec.tags),
146
+ )
147
+
148
+ def build_arguments(
149
+ self,
150
+ payload: BaseModel | dict[str, Any] | None,
151
+ context: ActionContext,
152
+ ) -> dict[str, Any]:
153
+ validated = (
154
+ payload
155
+ if isinstance(payload, self.input_model)
156
+ else self.input_model.model_validate(payload or {})
157
+ )
158
+ arguments: dict[str, Any] = {}
159
+ if self.model_parameter is not None:
160
+ arguments[self.model_parameter] = validated
161
+ else:
162
+ for name in self.public_parameters:
163
+ arguments[name] = getattr(validated, name)
164
+
165
+ hints = get_type_hints(self.function)
166
+ for name, parameter in inspect.signature(self.function).parameters.items():
167
+ annotation = hints.get(name, parameter.annotation)
168
+ if annotation is ArthaEngine:
169
+ arguments[name] = context.engine
170
+ elif annotation is ActionContext:
171
+ arguments[name] = context
172
+ elif annotation is AuthContext:
173
+ arguments[name] = context.auth
174
+ elif name in self.injected_parameters:
175
+ arguments[name] = context.require_service(self.injected_parameters[name])
176
+ return arguments
177
+
178
+ async def invoke(
179
+ self,
180
+ payload: BaseModel | dict[str, Any] | None,
181
+ context: ActionContext,
182
+ ) -> Any:
183
+ arguments = self.build_arguments(payload, context)
184
+ if inspect.iscoroutinefunction(self.function):
185
+ result = self.function(**arguments)
186
+ else:
187
+ result = await asyncio.to_thread(self.function, **arguments)
188
+ if inspect.isawaitable(result):
189
+ result = await result
190
+ if self.return_annotation not in {Any, inspect.Signature.empty, None}:
191
+ result = TypeAdapter(self.return_annotation).validate_python(result)
192
+ return result
193
+
194
+
195
+ class ActionRegistry:
196
+ """Typed application functions exposed through HTTP, MCP, and CLI."""
197
+
198
+ def __init__(self) -> None:
199
+ self._actions: dict[str, ActionDefinition] = {}
200
+
201
+ def action(self, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
202
+ def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
203
+ decorated = action(**kwargs)(function)
204
+ self.register(decorated)
205
+ return decorated
206
+
207
+ return decorator
208
+
209
+ def register(self, function: Callable[..., Any]) -> ActionDefinition:
210
+ spec = getattr(function, _ACTION_ATTRIBUTE, None)
211
+ if not isinstance(spec, ActionSpec):
212
+ raise ValueError(f"Function is not decorated with @action: {function.__name__}")
213
+ if spec.name in self._actions:
214
+ raise ValueError(f"Action is already registered: {spec.name}")
215
+ definition = _build_definition(function, spec)
216
+ self._actions[spec.name] = definition
217
+ return definition
218
+
219
+ def discover(self, module: ModuleType) -> list[ActionDefinition]:
220
+ discovered: list[ActionDefinition] = []
221
+ for value in vars(module).values():
222
+ if callable(value) and isinstance(getattr(value, _ACTION_ATTRIBUTE, None), ActionSpec):
223
+ if getattr(value, _ACTION_ATTRIBUTE).name not in self._actions:
224
+ discovered.append(self.register(value))
225
+ return discovered
226
+
227
+ def get(self, name: str) -> ActionDefinition:
228
+ try:
229
+ return self._actions[name]
230
+ except KeyError as exc:
231
+ raise KeyError(f"Unknown action: {name}") from exc
232
+
233
+ def list(self) -> list[ActionDefinition]:
234
+ return [self._actions[name] for name in sorted(self._actions)]
235
+
236
+ def manifests(self) -> list[dict[str, Any]]:
237
+ return [item.manifest().model_dump(mode="json") for item in self.list()]
238
+
239
+
240
+ def action(
241
+ *,
242
+ name: str,
243
+ description: str | None = None,
244
+ version: str = "1",
245
+ kind: ActionKind = "read",
246
+ scopes: Iterable[str] = (),
247
+ public: bool = False,
248
+ http: bool | str | tuple[str, str] | HttpExposure = True,
249
+ mcp: bool | str | McpExposure = True,
250
+ cli: bool | str | Iterable[str] | CliExposure = True,
251
+ inject: Iterable[str] | dict[str, str] = (),
252
+ tags: Iterable[str] = (),
253
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
254
+ """Mark a typed function as an Artha application action."""
255
+
256
+ if not _NAME_PATTERN.fullmatch(name):
257
+ raise ValueError(
258
+ "Action names must start with a lowercase letter and use lowercase "
259
+ "letters, numbers, dots, dashes, or underscores"
260
+ )
261
+ if kind not in {"read", "write", "destructive"}:
262
+ raise ValueError(f"Unsupported action kind: {kind}")
263
+ spec = ActionSpec(
264
+ name=name,
265
+ description=description,
266
+ version=version,
267
+ kind=kind,
268
+ scopes=tuple(scopes),
269
+ public=public,
270
+ http=_normalize_http(name, http),
271
+ mcp=_normalize_mcp(name, mcp),
272
+ cli=_normalize_cli(name, cli),
273
+ inject=_normalize_injections(inject),
274
+ tags=tuple(tags),
275
+ )
276
+
277
+ def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
278
+ if hasattr(function, _ACTION_ATTRIBUTE):
279
+ raise ValueError(f"Function already has an Artha action: {function.__name__}")
280
+ setattr(function, _ACTION_ATTRIBUTE, spec)
281
+ return function
282
+
283
+ return decorator
284
+
285
+
286
+ def _build_definition(function: Callable[..., Any], spec: ActionSpec) -> ActionDefinition:
287
+ signature = inspect.signature(function)
288
+ hints = get_type_hints(function)
289
+ public: list[tuple[str, inspect.Parameter, Any]] = []
290
+ injected: dict[str, str] = dict(spec.inject)
291
+
292
+ for name, parameter in signature.parameters.items():
293
+ annotation = hints.get(name, parameter.annotation)
294
+ if annotation in {ArthaEngine, ActionContext, AuthContext}:
295
+ continue
296
+ if name in injected:
297
+ continue
298
+ if annotation is inspect.Signature.empty:
299
+ raise TypeError(f"Action parameter {spec.name}.{name} must have a type annotation")
300
+ public.append((name, parameter, annotation))
301
+
302
+ model_parameter: str | None = None
303
+ if len(public) == 1 and _is_model_type(public[0][2]):
304
+ model_parameter = public[0][0]
305
+ input_model = public[0][2]
306
+ public_names: tuple[str, ...] = ()
307
+ else:
308
+ fields: dict[str, tuple[Any, Any]] = {}
309
+ for name, parameter, annotation in public:
310
+ default: Any = ...
311
+ if parameter.default is not inspect.Signature.empty:
312
+ default = parameter.default
313
+ fields[name] = (annotation, default)
314
+ model_name = "".join(part.title() for part in re.split(r"[_.-]", spec.name)) + "Input"
315
+ input_model = create_model(model_name, **fields)
316
+ public_names = tuple(name for name, _, _ in public)
317
+
318
+ return ActionDefinition(
319
+ spec=spec,
320
+ function=function,
321
+ input_model=input_model,
322
+ return_annotation=hints.get("return", signature.return_annotation),
323
+ public_parameters=public_names,
324
+ model_parameter=model_parameter,
325
+ injected_parameters=injected,
326
+ )
327
+
328
+
329
+ def _normalize_http(
330
+ name: str,
331
+ value: bool | str | tuple[str, str] | HttpExposure,
332
+ ) -> HttpExposure | None:
333
+ if value is False:
334
+ return None
335
+ if isinstance(value, HttpExposure):
336
+ return value
337
+ if value is True:
338
+ return HttpExposure("POST", f"/actions/{name.replace('.', '/')}")
339
+ if isinstance(value, tuple):
340
+ return HttpExposure(value[0], value[1])
341
+ method, separator, path = value.partition(" ")
342
+ if not separator:
343
+ return HttpExposure("POST", value)
344
+ return HttpExposure(method, path)
345
+
346
+
347
+ def _normalize_mcp(name: str, value: bool | str | McpExposure) -> McpExposure | None:
348
+ if value is False:
349
+ return None
350
+ if isinstance(value, McpExposure):
351
+ return value
352
+ if isinstance(value, str):
353
+ return McpExposure(value)
354
+ return McpExposure(re.sub(r"[^a-zA-Z0-9_]", "_", name))
355
+
356
+
357
+ def _normalize_cli(
358
+ name: str,
359
+ value: bool | str | Iterable[str] | CliExposure,
360
+ ) -> CliExposure | None:
361
+ if value is False:
362
+ return None
363
+ if isinstance(value, CliExposure):
364
+ return value
365
+ if value is True:
366
+ return CliExposure(tuple(part for part in re.split(r"[.\s]+", name) if part))
367
+ if isinstance(value, str):
368
+ return CliExposure(tuple(part for part in value.split() if part))
369
+ return CliExposure(tuple(value))
370
+
371
+
372
+ def _normalize_injections(value: Iterable[str] | dict[str, str]) -> dict[str, str]:
373
+ if isinstance(value, dict):
374
+ return dict(value)
375
+ return {name: name for name in value}
376
+
377
+
378
+ def _is_model_type(annotation: Any) -> bool:
379
+ return inspect.isclass(annotation) and issubclass(annotation, BaseModel)
380
+
381
+
382
+ def unwrap_optional(annotation: Any) -> tuple[Any, bool]:
383
+ origin = get_origin(annotation)
384
+ if origin not in {UnionType, getattr(__import__("typing"), "Union")}:
385
+ return annotation, False
386
+ args = get_args(annotation)
387
+ without_none = tuple(arg for arg in args if arg is not type(None))
388
+ if len(without_none) == 1 and len(without_none) != len(args):
389
+ return without_none[0], True
390
+ return annotation, False