lumera 0.28.0.dev1__tar.gz → 0.29.0__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 (56) hide show
  1. {lumera-0.28.0.dev1 → lumera-0.29.0}/PKG-INFO +7 -2
  2. lumera-0.29.0/lumera/__init__.py +217 -0
  3. lumera-0.29.0/lumera/functions/__init__.py +118 -0
  4. lumera-0.29.0/lumera/functions/__main__.py +5 -0
  5. lumera-0.29.0/lumera/functions/_artifacts.py +52 -0
  6. lumera-0.29.0/lumera/functions/_config.py +110 -0
  7. lumera-0.29.0/lumera/functions/_context.py +405 -0
  8. lumera-0.29.0/lumera/functions/_contract.py +2070 -0
  9. lumera-0.29.0/lumera/functions/_errors.py +170 -0
  10. lumera-0.29.0/lumera/functions/_execution.py +107 -0
  11. lumera-0.29.0/lumera/functions/_registry.py +211 -0
  12. lumera-0.29.0/lumera/functions/_runner.py +344 -0
  13. lumera-0.29.0/lumera/functions/_runtime.py +753 -0
  14. lumera-0.29.0/lumera/functions/testing.py +109 -0
  15. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/netsuite.py +40 -3
  16. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera.egg-info/PKG-INFO +7 -2
  17. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera.egg-info/SOURCES.txt +10 -3
  18. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera.egg-info/requires.txt +7 -1
  19. {lumera-0.28.0.dev1 → lumera-0.29.0}/pyproject.toml +10 -2
  20. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_integrations_netsuite.py +27 -0
  21. lumera-0.29.0/tests/test_lazy_imports.py +345 -0
  22. lumera-0.28.0.dev1/lumera/__init__.py +0 -143
  23. lumera-0.28.0.dev1/lumera/functions/__init__.py +0 -55
  24. lumera-0.28.0.dev1/lumera/functions/__main__.py +0 -5
  25. lumera-0.28.0.dev1/lumera/functions/_bundle.py +0 -194
  26. lumera-0.28.0.dev1/lumera/functions/_core.py +0 -382
  27. lumera-0.28.0.dev1/lumera/functions/_runner.py +0 -163
  28. lumera-0.28.0.dev1/tests/test_functions.py +0 -528
  29. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/_live.py +0 -0
  30. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/_utils.py +0 -0
  31. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/agents.py +0 -0
  32. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/automations.py +0 -0
  33. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/email.py +0 -0
  34. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/exceptions.py +0 -0
  35. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/files.py +0 -0
  36. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/flags.py +0 -0
  37. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/google.py +0 -0
  38. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/__init__.py +0 -0
  39. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/bill.py +0 -0
  40. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/google.py +0 -0
  41. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/salesforce.py +0 -0
  42. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/integrations/slack.py +0 -0
  43. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/llm.py +0 -0
  44. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/pb.py +0 -0
  45. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/sdk.py +0 -0
  46. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/storage.py +0 -0
  47. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera/webhooks.py +0 -0
  48. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera.egg-info/dependency_links.txt +0 -0
  49. {lumera-0.28.0.dev1 → lumera-0.29.0}/lumera.egg-info/top_level.txt +0 -0
  50. {lumera-0.28.0.dev1 → lumera-0.29.0}/setup.cfg +0 -0
  51. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_agents_live.py +0 -0
  52. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_automations.py +0 -0
  53. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_flags.py +0 -0
  54. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_integrations_salesforce.py +0 -0
  55. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_sdk.py +0 -0
  56. {lumera-0.28.0.dev1 → lumera-0.29.0}/tests/test_webhooks.py +0 -0
@@ -1,17 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.28.0.dev1
3
+ Version: 0.29.0
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
7
7
  Requires-Dist: python-dotenv
8
8
  Requires-Dist: google-api-python-client==2.173.0
9
- Requires-Dist: pydantic<3.0.0,>=2.12.3
10
9
  Requires-Dist: simple-salesforce==1.12.6
11
10
  Requires-Dist: slack_sdk==3.35.0
12
11
  Provides-Extra: dev
13
12
  Requires-Dist: ruff; extra == "dev"
14
13
  Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pydantic<2.14.0,>=2.12.3; extra == "dev"
15
+ Requires-Dist: jsonschema[format-nongpl]<5.0.0,>=4.25.0; extra == "dev"
16
+ Provides-Extra: functions
17
+ Requires-Dist: pydantic<2.14.0,>=2.12.3; extra == "functions"
18
+ Requires-Dist: jsonschema[format-nongpl]<5.0.0,>=4.25.0; extra == "functions"
15
19
  Provides-Extra: full
16
20
  Requires-Dist: airtable-python-wrapper==0.15.3; extra == "full"
17
21
  Requires-Dist: boto3==1.39.3; extra == "full"
@@ -29,6 +33,7 @@ Requires-Dist: openai-agents<1.0.0,>=0.6.5; extra == "full"
29
33
  Requires-Dist: openpyxl==3.1.5; extra == "full"
30
34
  Requires-Dist: pandas==2.3.0; extra == "full"
31
35
  Requires-Dist: pdfplumber; extra == "full"
36
+ Requires-Dist: pydantic<3.0.0,>=2.12.3; extra == "full"
32
37
  Requires-Dist: PyGithub==2.6.1; extra == "full"
33
38
  Requires-Dist: python-dotenv==1.1.0; extra == "full"
34
39
  Requires-Dist: pyzmq; extra == "full"
@@ -0,0 +1,217 @@
1
+ """
2
+ Lumera Agent SDK
3
+
4
+ This SDK provides helpers for automations running within the Lumera environment
5
+ to interact with the Lumera API and define dynamic user interfaces.
6
+ """
7
+
8
+ from importlib import import_module as _import_module
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ # Keep the Functions authoring surface visible to static analyzers without
14
+ # eagerly importing it when users import the package root.
15
+ from .functions import (
16
+ ArtifactReference as ArtifactReference,
17
+ )
18
+ from .functions import (
19
+ FunctionContext as FunctionContext,
20
+ )
21
+ from .functions import (
22
+ FunctionError as FunctionError,
23
+ )
24
+ from .functions import (
25
+ HttpProjection as HttpProjection,
26
+ )
27
+ from .functions import (
28
+ function as function,
29
+ )
30
+
31
+ try:
32
+ __version__ = version("lumera")
33
+ except PackageNotFoundError:
34
+ __version__ = "unknown" # Not installed (e.g. running from source)
35
+
36
+ # Define what ``from lumera import *`` imports. Keep this list stable: callers
37
+ # have historically imported these modules, helpers, types, and exceptions from
38
+ # the package root.
39
+ __all__ = [
40
+ # Authentication & utilities
41
+ "Credentials",
42
+ "get_access_token",
43
+ "get_credentials",
44
+ "get_google_access_token", # Kept for backwards compatibility
45
+ "list_integration_connections",
46
+ "list_integrations",
47
+ "log_timed",
48
+ # Feature flags (read the sandbox snapshot)
49
+ "feature_flag",
50
+ "feature_flags",
51
+ "is_feature_enabled",
52
+ # Collections (low-level API)
53
+ "list_collections",
54
+ "get_collection",
55
+ "create_collection",
56
+ "update_collection",
57
+ "delete_collection",
58
+ # Records (low-level API)
59
+ "list_records",
60
+ "search_records",
61
+ "get_record",
62
+ "get_record_by_external_id",
63
+ "create_record",
64
+ "update_record",
65
+ "upsert_record",
66
+ "delete_record",
67
+ # Other operations
68
+ "replay_hook",
69
+ "query_sql",
70
+ "run_automation",
71
+ "get_automation_run",
72
+ "update_automation_run",
73
+ "upload_lumera_file",
74
+ "save_to_lumera",
75
+ # Type definitions
76
+ "CollectionField",
77
+ "HookReplayResult",
78
+ "LumeraFile",
79
+ "LumeraFiles",
80
+ # Exceptions
81
+ "LumeraAPIError",
82
+ "RecordNotUniqueError",
83
+ "IntegrationNotEnabledForProjectError",
84
+ "LumeraError",
85
+ "ValidationError",
86
+ "UniqueConstraintError",
87
+ "RecordNotFoundError",
88
+ # High-level modules (use as lumera.pb, lumera.storage, etc.)
89
+ "agents",
90
+ "automations",
91
+ "email",
92
+ "pb",
93
+ "storage",
94
+ "llm",
95
+ "exceptions",
96
+ "webhooks",
97
+ "integrations",
98
+ ]
99
+
100
+ # Importing the package root used to import every SDK module, including all
101
+ # feature-specific integration clients. Resolve the same public API on first
102
+ # access so existing import statements retain their behavior without paying
103
+ # that cost when the corresponding feature is unused.
104
+ _LAZY_MODULES = {
105
+ "_utils": "._utils",
106
+ "agents": ".agents",
107
+ "automations": ".automations",
108
+ "email": ".email",
109
+ "exceptions": ".exceptions",
110
+ "files": ".files",
111
+ "flags": ".flags",
112
+ "functions": ".functions",
113
+ "integrations": ".integrations",
114
+ "llm": ".llm",
115
+ "pb": ".pb",
116
+ "sdk": ".sdk",
117
+ "storage": ".storage",
118
+ "webhooks": ".webhooks",
119
+ }
120
+
121
+ _FUNCTIONS_OPTIONAL_DEPENDENCIES = frozenset({"jsonschema", "pydantic", "pydantic_core"})
122
+
123
+ _LAZY_ATTRIBUTES = {
124
+ # ._utils
125
+ "Credentials": ("._utils", "Credentials"),
126
+ "IntegrationNotEnabledForProjectError": (
127
+ "._utils",
128
+ "IntegrationNotEnabledForProjectError",
129
+ ),
130
+ "LumeraAPIError": ("._utils", "LumeraAPIError"),
131
+ "RecordNotUniqueError": ("._utils", "RecordNotUniqueError"),
132
+ "get_access_token": ("._utils", "get_access_token"),
133
+ "get_credentials": ("._utils", "get_credentials"),
134
+ "get_google_access_token": ("._utils", "get_google_access_token"),
135
+ "list_integration_connections": ("._utils", "list_integration_connections"),
136
+ "list_integrations": ("._utils", "list_integrations"),
137
+ "log_timed": ("._utils", "log_timed"),
138
+ # .exceptions
139
+ "LumeraError": (".exceptions", "LumeraError"),
140
+ "RecordNotFoundError": (".exceptions", "RecordNotFoundError"),
141
+ "UniqueConstraintError": (".exceptions", "UniqueConstraintError"),
142
+ "ValidationError": (".exceptions", "ValidationError"),
143
+ # .files
144
+ "LumeraFile": (".files", "LumeraFile"),
145
+ "LumeraFiles": (".files", "LumeraFiles"),
146
+ # .flags
147
+ "feature_flag": (".flags", "feature_flag"),
148
+ "feature_flags": (".flags", "feature_flags"),
149
+ "is_feature_enabled": (".flags", "is_feature_enabled"),
150
+ # .functions
151
+ "ArtifactReference": (".functions", "ArtifactReference"),
152
+ "function": (".functions", "function"),
153
+ "FunctionContext": (".functions", "FunctionContext"),
154
+ "FunctionError": (".functions", "FunctionError"),
155
+ "HttpProjection": (".functions", "HttpProjection"),
156
+ # .sdk
157
+ "CollectionField": (".sdk", "CollectionField"),
158
+ "HookReplayResult": (".sdk", "HookReplayResult"),
159
+ "create_collection": (".sdk", "create_collection"),
160
+ "create_record": (".sdk", "create_record"),
161
+ "delete_collection": (".sdk", "delete_collection"),
162
+ "delete_record": (".sdk", "delete_record"),
163
+ "get_automation_run": (".sdk", "get_automation_run"),
164
+ "get_collection": (".sdk", "get_collection"),
165
+ "get_record": (".sdk", "get_record"),
166
+ "get_record_by_external_id": (".sdk", "get_record_by_external_id"),
167
+ "list_collections": (".sdk", "list_collections"),
168
+ "list_records": (".sdk", "list_records"),
169
+ "query_sql": (".sdk", "query_sql"),
170
+ "replay_hook": (".sdk", "replay_hook"),
171
+ "run_automation": (".sdk", "run_automation"),
172
+ "save_to_lumera": (".sdk", "save_to_lumera"),
173
+ "search_records": (".sdk", "search_records"),
174
+ "update_automation_run": (".sdk", "update_automation_run"),
175
+ "update_collection": (".sdk", "update_collection"),
176
+ "update_record": (".sdk", "update_record"),
177
+ "upload_lumera_file": (".sdk", "upload_lumera_file"),
178
+ "upsert_record": (".sdk", "upsert_record"),
179
+ }
180
+
181
+
182
+ def __getattr__(name: str) -> object:
183
+ """Load a root SDK export the first time it is requested."""
184
+
185
+ module_name = _LAZY_MODULES.get(name)
186
+ if module_name is not None:
187
+ try:
188
+ value = _import_module(module_name, __name__)
189
+ except ModuleNotFoundError as exc:
190
+ # ``functions`` is intentionally advertised for interactive
191
+ # discovery even though its authoring dependencies live behind an
192
+ # extra. Attribute-based discovery should be able to skip that
193
+ # optional module on a base install. Preserve every other import
194
+ # failure so broken installations are not mistaken for an absent
195
+ # optional feature.
196
+ if name == "functions" and exc.name in _FUNCTIONS_OPTIONAL_DEPENDENCIES:
197
+ raise AttributeError(
198
+ f"module {__name__!r} has no available attribute {name!r}; {exc}"
199
+ ) from exc
200
+ raise
201
+ globals()[name] = value
202
+ return value
203
+
204
+ attribute = _LAZY_ATTRIBUTES.get(name)
205
+ if attribute is not None:
206
+ module_name, attribute_name = attribute
207
+ value = getattr(_import_module(module_name, __name__), attribute_name)
208
+ globals()[name] = value
209
+ return value
210
+
211
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
212
+
213
+
214
+ def __dir__() -> list[str]:
215
+ """Include lazily available exports in interactive discovery."""
216
+
217
+ return sorted(set(globals()) | set(__all__) | set(_LAZY_MODULES))
@@ -0,0 +1,118 @@
1
+ """Typed Lumera Functions authoring and local-composition API."""
2
+
3
+ # Keep the base SDK lightweight while making an accidental direct Functions
4
+ # import fail with installation guidance instead of an opaque dependency name.
5
+ try:
6
+ import jsonschema as _jsonschema
7
+ import pydantic as _pydantic
8
+ import pydantic_core as _pydantic_core
9
+ except ModuleNotFoundError as exc:
10
+ if exc.name not in {"jsonschema", "pydantic", "pydantic_core"}:
11
+ raise
12
+ raise ModuleNotFoundError(
13
+ "Lumera Functions dependencies are not installed; "
14
+ "install them with `pip install 'lumera[functions]'`",
15
+ name=exc.name,
16
+ ) from exc
17
+ else:
18
+ del _jsonschema, _pydantic, _pydantic_core
19
+
20
+ from ._artifacts import ArtifactReference
21
+ from ._context import (
22
+ ActorIdentity,
23
+ AgentIdentity,
24
+ CancellationToken,
25
+ FunctionCaller,
26
+ FunctionContext,
27
+ ScopedLumeraClient,
28
+ StructuredLogger,
29
+ TraceContext,
30
+ )
31
+ from ._contract import (
32
+ FUNCTION_MANIFEST_SCHEMA_VERSION,
33
+ FunctionDeclaration,
34
+ FunctionDefinition,
35
+ FunctionEffect,
36
+ FunctionManifest,
37
+ FunctionMetadata,
38
+ HttpProjection,
39
+ function,
40
+ )
41
+ from ._errors import (
42
+ DuplicateFunctionError,
43
+ DuplicateHttpProjectionError,
44
+ DurableTransportUnavailableError,
45
+ DurableWaitNotAllowedError,
46
+ FunctionCallModeError,
47
+ FunctionCancelledError,
48
+ FunctionCycleError,
49
+ FunctionDeadlineExceededError,
50
+ FunctionDefinitionError,
51
+ FunctionDepthExceededError,
52
+ FunctionDiscoveryError,
53
+ FunctionError,
54
+ FunctionInvocationError,
55
+ FunctionNotFoundError,
56
+ FunctionOutputSerializationError,
57
+ FunctionRuntimeClosedError,
58
+ FunctionsError,
59
+ FunctionValidationError,
60
+ InvalidDurableWaitError,
61
+ UnexpectedFunctionError,
62
+ )
63
+ from ._execution import (
64
+ DurableFunctionTransport,
65
+ ExecutionHandle,
66
+ ExecutionStatus,
67
+ StartedExecution,
68
+ )
69
+ from ._registry import FunctionRegistry, discover_modules
70
+ from ._runtime import LocalFunctionRuntime, invoke_local
71
+
72
+ __all__ = [
73
+ "ActorIdentity",
74
+ "AgentIdentity",
75
+ "ArtifactReference",
76
+ "CancellationToken",
77
+ "DuplicateFunctionError",
78
+ "DuplicateHttpProjectionError",
79
+ "DurableFunctionTransport",
80
+ "DurableTransportUnavailableError",
81
+ "DurableWaitNotAllowedError",
82
+ "ExecutionHandle",
83
+ "ExecutionStatus",
84
+ "FUNCTION_MANIFEST_SCHEMA_VERSION",
85
+ "FunctionCallModeError",
86
+ "FunctionCaller",
87
+ "FunctionCancelledError",
88
+ "FunctionContext",
89
+ "FunctionCycleError",
90
+ "FunctionDeadlineExceededError",
91
+ "FunctionDeclaration",
92
+ "FunctionDefinition",
93
+ "FunctionDefinitionError",
94
+ "FunctionDepthExceededError",
95
+ "FunctionDiscoveryError",
96
+ "FunctionEffect",
97
+ "FunctionError",
98
+ "FunctionInvocationError",
99
+ "FunctionManifest",
100
+ "FunctionMetadata",
101
+ "FunctionNotFoundError",
102
+ "FunctionOutputSerializationError",
103
+ "FunctionRegistry",
104
+ "FunctionRuntimeClosedError",
105
+ "FunctionsError",
106
+ "FunctionValidationError",
107
+ "HttpProjection",
108
+ "InvalidDurableWaitError",
109
+ "LocalFunctionRuntime",
110
+ "ScopedLumeraClient",
111
+ "StartedExecution",
112
+ "StructuredLogger",
113
+ "TraceContext",
114
+ "UnexpectedFunctionError",
115
+ "discover_modules",
116
+ "function",
117
+ "invoke_local",
118
+ ]
@@ -0,0 +1,5 @@
1
+ """Entry point for ``python -m lumera.functions``."""
2
+
3
+ from ._runner import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,52 @@
1
+ """Transport-neutral artifact references for Function boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class ArtifactReference(BaseModel):
11
+ """An opaque, non-authoritative reference to a Lumera-managed artifact.
12
+
13
+ Function payloads carry this JSON-safe envelope across local, API, durable,
14
+ and sharing boundaries. Runtime adapters are responsible for authorizing
15
+ and materializing the referenced artifact. Local paths, object-store keys,
16
+ URLs, and credentials are deliberately absent from the contract.
17
+ """
18
+
19
+ model_config = ConfigDict(extra="forbid", frozen=True)
20
+
21
+ kind: Literal["lumera_artifact"]
22
+ artifact_id: str = Field(
23
+ min_length=1,
24
+ max_length=512,
25
+ pattern=r"^\S+$",
26
+ description="Opaque Lumera artifact identifier",
27
+ )
28
+ filename: str | None = Field(
29
+ default=None,
30
+ min_length=1,
31
+ max_length=1024,
32
+ description="Non-authoritative display filename",
33
+ )
34
+ content_type: str | None = Field(
35
+ default=None,
36
+ min_length=1,
37
+ max_length=255,
38
+ description="Non-authoritative media type",
39
+ )
40
+ size_bytes: int | None = Field(
41
+ default=None,
42
+ ge=0,
43
+ description="Non-authoritative byte size",
44
+ )
45
+ sha256: str | None = Field(
46
+ default=None,
47
+ pattern=r"^[0-9a-f]{64}$",
48
+ description="Optional lowercase SHA-256 content digest",
49
+ )
50
+
51
+
52
+ __all__ = ["ArtifactReference"]
@@ -0,0 +1,110 @@
1
+ """Local project configuration for explicit Function module discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import tomllib
11
+
12
+ from ._errors import FunctionDiscoveryError
13
+
14
+ _MODULE_PATTERN = re.compile(r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FunctionProjectConfig:
19
+ """Resolved ``[tool.lumera.functions]`` configuration."""
20
+
21
+ project_root: Path
22
+ source_root: Path
23
+ modules: tuple[str, ...]
24
+ test_paths: tuple[Path, ...]
25
+
26
+
27
+ def load_project_config(
28
+ project_root: str | Path,
29
+ *,
30
+ module_overrides: list[str] | None = None,
31
+ ) -> FunctionProjectConfig:
32
+ root = Path(project_root).expanduser().resolve()
33
+ if not root.is_dir():
34
+ raise FunctionDiscoveryError(f"project root is not a directory: {root}")
35
+ config_path = root / "pyproject.toml"
36
+ if not config_path.is_file():
37
+ raise FunctionDiscoveryError(f"missing {config_path}; configure [tool.lumera.functions]")
38
+ try:
39
+ document = tomllib.loads(config_path.read_text(encoding="utf-8"))
40
+ except (OSError, tomllib.TOMLDecodeError) as exc:
41
+ raise FunctionDiscoveryError(f"could not read {config_path}") from exc
42
+
43
+ raw_config = _functions_config(document)
44
+ raw_source_root = raw_config.get("source-root", "platform")
45
+ if not isinstance(raw_source_root, str) or not raw_source_root.strip():
46
+ raise FunctionDiscoveryError("tool.lumera.functions.source-root must be a path")
47
+ source_root = (root / raw_source_root).resolve()
48
+ if not source_root.is_relative_to(root):
49
+ raise FunctionDiscoveryError("Function source-root must stay inside the project")
50
+ if not source_root.is_dir():
51
+ raise FunctionDiscoveryError(f"Function source-root does not exist: {source_root}")
52
+
53
+ raw_modules: Any = module_overrides if module_overrides else raw_config.get("modules", [])
54
+ if not isinstance(raw_modules, list) or not all(
55
+ isinstance(module, str) for module in raw_modules
56
+ ):
57
+ raise FunctionDiscoveryError("tool.lumera.functions.modules must be a string array")
58
+ modules: list[str] = []
59
+ for raw_module in raw_modules:
60
+ module = raw_module.strip()
61
+ if not module or not _MODULE_PATTERN.fullmatch(module):
62
+ raise FunctionDiscoveryError(f"invalid Function module name: {raw_module!r}")
63
+ _require_project_module(module, source_root)
64
+ if module not in modules:
65
+ modules.append(module)
66
+
67
+ raw_test_paths = raw_config.get("test-paths", ["tests/functions"])
68
+ if not isinstance(raw_test_paths, list) or not all(
69
+ isinstance(path, str) for path in raw_test_paths
70
+ ):
71
+ raise FunctionDiscoveryError("tool.lumera.functions.test-paths must be a string array")
72
+ test_paths: list[Path] = []
73
+ for raw_path in raw_test_paths:
74
+ test_path = (root / raw_path).resolve()
75
+ if not test_path.is_relative_to(root):
76
+ raise FunctionDiscoveryError("Function test paths must stay inside the project")
77
+ test_paths.append(test_path)
78
+
79
+ return FunctionProjectConfig(
80
+ project_root=root,
81
+ source_root=source_root,
82
+ modules=tuple(modules),
83
+ test_paths=tuple(test_paths),
84
+ )
85
+
86
+
87
+ def _functions_config(document: dict[str, Any]) -> dict[str, Any]:
88
+ value: Any = document
89
+ for key in ("tool", "lumera", "functions"):
90
+ if not isinstance(value, dict):
91
+ return {}
92
+ value = value.get(key, {})
93
+ if not isinstance(value, dict):
94
+ raise FunctionDiscoveryError("tool.lumera.functions must be a TOML table")
95
+ return value
96
+
97
+
98
+ def _require_project_module(module: str, source_root: Path) -> None:
99
+ """Require an explicit module to resolve to Python source under source-root."""
100
+
101
+ relative_module = Path(*module.split("."))
102
+ candidates = (
103
+ (source_root / relative_module).with_suffix(".py"),
104
+ source_root / relative_module / "__init__.py",
105
+ )
106
+ for candidate in candidates:
107
+ resolved = candidate.resolve()
108
+ if resolved.is_relative_to(source_root) and resolved.is_file():
109
+ return
110
+ raise FunctionDiscoveryError(f"Function module must resolve inside source-root: {module!r}")