uptimely-core 0.2.0.dev1__tar.gz → 0.4.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 (136) hide show
  1. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/PKG-INFO +1 -1
  2. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/pyproject.toml +1 -1
  3. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/__init__.py +0 -8
  4. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/common.py +2 -2
  5. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/engine.py +2 -1
  6. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/project.py +4 -4
  7. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/callable_node.py +3 -3
  8. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/compiler.py +6 -1
  9. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/execution_graph.py +11 -28
  10. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/contracts.py +8 -3
  11. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/backend.py +1 -1
  12. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/engine.py +20 -6
  13. uptimely_core-0.4.0/src/uptimely/entrypoint.py +210 -0
  14. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/graph_render.py +1 -0
  15. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/lineage.py +10 -2
  16. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/plan_render.py +13 -2
  17. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/spec_render.py +55 -16
  18. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/mcp/server.py +65 -22
  19. uptimely_core-0.4.0/src/uptimely/project/build/__init__.py +32 -0
  20. uptimely_core-0.4.0/src/uptimely/project/build/builder.py +227 -0
  21. uptimely_core-0.4.0/src/uptimely/project/build/bundle.py +178 -0
  22. uptimely_core-0.4.0/src/uptimely/project/build/errors.py +50 -0
  23. uptimely_core-0.4.0/src/uptimely/project/build/features.py +23 -0
  24. uptimely_core-0.4.0/src/uptimely/project/build/locking.py +67 -0
  25. uptimely_core-0.4.0/src/uptimely/project/build/models.py +130 -0
  26. uptimely_core-0.4.0/src/uptimely/project/build/source.py +123 -0
  27. uptimely_core-0.4.0/src/uptimely/project/imports.py +20 -0
  28. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/__init__.py +1 -6
  29. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/api.py +3 -23
  30. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/catalog.py +3 -0
  31. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/feature_graph/graph.py +33 -69
  32. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/feature_graph/models.py +20 -16
  33. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/model/functions.py +4 -0
  34. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/dataset.py +1 -0
  35. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/ref.py +14 -2
  36. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/references.py +63 -7
  37. uptimely_core-0.4.0/src/uptimely/spec/validation/__init__.py +19 -0
  38. uptimely_core-0.4.0/src/uptimely/spec/validation/bindings.py +49 -0
  39. uptimely_core-0.4.0/src/uptimely/spec/validation/collapse_inputs.py +82 -0
  40. uptimely_core-0.4.0/src/uptimely/spec/validation/context.py +655 -0
  41. uptimely_core-0.4.0/src/uptimely/spec/validation/dataset.py +58 -0
  42. uptimely_core-0.4.0/src/uptimely/spec/validation/dependencies.py +41 -0
  43. uptimely_core-0.4.0/src/uptimely/spec/validation/dimensions.py +40 -0
  44. uptimely_core-0.4.0/src/uptimely/spec/validation/functions.py +42 -0
  45. uptimely_core-0.4.0/src/uptimely/spec/validation/mapping_contracts.py +197 -0
  46. uptimely_core-0.4.0/src/uptimely/spec/validation/mapping_inputs.py +76 -0
  47. uptimely_core-0.4.0/src/uptimely/spec/validation/mapping_outputs.py +139 -0
  48. uptimely_core-0.4.0/src/uptimely/spec/validation/row_context.py +50 -0
  49. uptimely_core-0.4.0/src/uptimely/spec/validation/spec.py +171 -0
  50. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/execution/data_lineage.html.j2 +13 -5
  51. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/feature_graph_shared.html.j2 +4 -1
  52. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/callable_graph.html.j2 +5 -3
  53. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/index.html.j2 +5 -2
  54. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/dataset_list.html.j2 +12 -3
  55. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/entities.html.j2 +6 -2
  56. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/functions.html.j2 +31 -3
  57. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/overview.html.j2 +2 -2
  58. uptimely_core-0.2.0.dev1/src/uptimely/entrypoint.py +0 -172
  59. uptimely_core-0.2.0.dev1/src/uptimely/project/build.py +0 -623
  60. uptimely_core-0.2.0.dev1/src/uptimely/spec/resolution.py +0 -78
  61. uptimely_core-0.2.0.dev1/src/uptimely/spec/resolved.py +0 -60
  62. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/__init__.py +0 -20
  63. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/context.py +0 -174
  64. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/dependencies.py +0 -23
  65. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/dimensions.py +0 -111
  66. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/functions.py +0 -150
  67. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/mappings.py +0 -434
  68. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/prepared.py +0 -155
  69. uptimely_core-0.2.0.dev1/src/uptimely/spec/validation/spec.py +0 -38
  70. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/LICENSE +0 -0
  71. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/readme.md +0 -0
  72. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/__init__.py +0 -0
  73. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/cli.py +0 -0
  74. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/examples.py +0 -0
  75. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/explorer.py +0 -0
  76. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/plan.py +0 -0
  77. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/cli/spec.py +0 -0
  78. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/__init__.py +0 -0
  79. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/plan/__init__.py +0 -0
  80. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/plan/models.py +0 -0
  81. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/compiler/plan/serialization.py +0 -0
  82. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/__init__.py +0 -0
  83. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/bindings.py +0 -0
  84. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/config.py +0 -0
  85. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/dataframe.py +0 -0
  86. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/result.py +0 -0
  87. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/engine/state.py +0 -0
  88. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/__init__.py +0 -0
  89. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/explorer/explorer.py +0 -0
  90. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/mcp/__init__.py +0 -0
  91. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/project/__init__.py +0 -0
  92. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/project/paths.py +0 -0
  93. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/py.typed +0 -0
  94. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/render.py +0 -0
  95. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/row_context.py +0 -0
  96. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/feature_graph/__init__.py +0 -0
  97. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/json/__init__.py +0 -0
  98. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/json/codec.py +0 -0
  99. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/model/__init__.py +0 -0
  100. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/model/common.py +0 -0
  101. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/model/dataset.py +0 -0
  102. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/model/spec.py +0 -0
  103. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/__init__.py +0 -0
  104. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/bind.py +0 -0
  105. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/conversion.py +0 -0
  106. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/dimension.py +0 -0
  107. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/entity.py +0 -0
  108. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/feature.py +0 -0
  109. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/mappings.py +0 -0
  110. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/row_context.py +0 -0
  111. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/python/spec.py +0 -0
  112. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/spec/validation/errors.py +0 -0
  113. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/storage.py +0 -0
  114. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/base.html.j2 +0 -0
  115. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/base.html.j2 +0 -0
  116. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/feature_graph.html.j2 +0 -0
  117. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/feature_renderer_2d.js.j2 +0 -0
  118. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/feature_renderer_3d.js.j2 +0 -0
  119. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/view_controller.js.j2 +0 -0
  120. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/graph/view_controls.html.j2 +0 -0
  121. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/icons.html.j2 +0 -0
  122. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/index.html.j2 +0 -0
  123. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/callable_renderer_2d.js.j2 +0 -0
  124. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/graph.html.j2 +0 -0
  125. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/overview.html.j2 +0 -0
  126. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/plan/stages.html.j2 +0 -0
  127. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/datasets.html.j2 +0 -0
  128. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/dimensions.html.j2 +0 -0
  129. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/entities.html.j2 +0 -0
  130. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/features.html.j2 +0 -0
  131. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/functions.html.j2 +0 -0
  132. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/index.html.j2 +0 -0
  133. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/overview.html.j2 +0 -0
  134. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/section_header.html.j2 +0 -0
  135. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/dimensions.html.j2 +0 -0
  136. {uptimely_core-0.2.0.dev1 → uptimely_core-0.4.0}/src/uptimely/templates/spec/sections/entity_list.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uptimely-core
3
- Version: 0.2.0.dev1
3
+ Version: 0.4.0
4
4
  Summary: Manage data-driven numerical analytics with complex dependencies at scale
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "uptimely-core"
7
- version = "0.2.0.dev1"
7
+ version = "0.4.0"
8
8
  description = "Manage data-driven numerical analytics with complex dependencies at scale"
9
9
  authors = [{ name = "Mikael Ahonen", email = "mikael.ahonen@uptimely.ai" }]
10
10
  license = "MIT"
@@ -3,36 +3,28 @@
3
3
  from .compiler import CallableGraph, CallableNode, Compiler, ExecutionPlan
4
4
  from .engine import Engine
5
5
  from .spec import (
6
- Call,
7
6
  Dimension,
8
7
  Entity,
9
8
  FeatureGraph,
10
- Function,
11
- PreparedSpec,
12
9
  PythonSpec,
13
10
  Spec,
14
11
  SubGraph,
15
12
  from_python,
16
13
  load_spec,
17
- prepare_spec,
18
14
  write_document,
19
15
  )
20
16
 
21
17
  __all__ = [
22
18
  "Spec",
23
- "Call",
24
19
  "CallableGraph",
25
20
  "CallableNode",
26
21
  "Compiler",
27
22
  "Dimension",
28
23
  "Entity",
29
24
  "ExecutionPlan",
30
- "Function",
31
25
  "PythonSpec",
32
- "PreparedSpec",
33
26
  "from_python",
34
27
  "load_spec",
35
- "prepare_spec",
36
28
  "write_document",
37
29
  "SubGraph",
38
30
  "FeatureGraph",
@@ -4,7 +4,7 @@ import argparse
4
4
  import pathlib
5
5
 
6
6
  from uptimely import spec
7
- from uptimely.project.build import prepare_project_root
7
+ from uptimely.project import imports
8
8
 
9
9
 
10
10
  def configure_project_arguments(parser: argparse.ArgumentParser) -> None:
@@ -34,5 +34,5 @@ def project_path(
34
34
 
35
35
  def load_spec(path: pathlib.Path, project_root: pathlib.Path) -> spec.Spec:
36
36
  """Load a spec document with project functions on the import path."""
37
- prepare_project_root(project_root)
37
+ imports.prepare_project_root(project_root)
38
38
  return spec.load_spec(path)
@@ -9,6 +9,7 @@ import pydantic
9
9
  from uptimely import engine as engine_module
10
10
  from uptimely import project
11
11
  from uptimely.cli import common, plan
12
+ from uptimely.project import imports
12
13
 
13
14
 
14
15
  class EngineSettings(pydantic.BaseModel):
@@ -47,7 +48,7 @@ def run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
47
48
  root = args.project_root or pathlib.Path.cwd()
48
49
  config = common.project_path(args.config, args.project_root, project.ENGINE_CONFIG_FILE)
49
50
  try:
50
- common.prepare_project_root(root)
51
+ imports.prepare_project_root(root)
51
52
  with project.read_build_bundle(root) as bundle:
52
53
  settings = EngineSettings.model_validate_json(config.read_text(encoding="utf-8"))
53
54
  execution_plan = bundle.execution_plan
@@ -15,12 +15,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
15
15
  )
16
16
  source = build.add_mutually_exclusive_group()
17
17
  source.add_argument(
18
- "--entrypoint",
18
+ "--python-spec",
19
19
  metavar="MODULE:ATTRIBUTE",
20
20
  help="Importable module and attribute containing a PythonSpec instance",
21
21
  )
22
22
  source.add_argument(
23
- "--spec",
23
+ "--json-spec",
24
24
  type=pathlib.Path,
25
25
  help="Source spec JSON path (overrides standard source discovery)",
26
26
  )
@@ -43,8 +43,8 @@ def run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
43
43
  try:
44
44
  bundle = project_build.build_project(
45
45
  root,
46
- entrypoint=args.entrypoint,
47
- source=args.spec,
46
+ python_spec=args.python_spec,
47
+ json_spec=args.json_spec,
48
48
  features=args.features,
49
49
  )
50
50
  except (
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
 
5
- from uptimely import render, row_context, spec
5
+ from uptimely import render, row_context
6
6
 
7
7
 
8
8
  @dataclasses.dataclass(frozen=True)
@@ -17,7 +17,7 @@ class CallableNode:
17
17
  mapping_id: str | None = None
18
18
  output_features: tuple[str, ...] = ()
19
19
  args: dict[str, object] = dataclasses.field(default_factory=dict, compare=False, hash=False)
20
- callable: spec.Call | None = dataclasses.field(default=None, compare=False, hash=False)
20
+ input_features: tuple[str, ...] = ()
21
21
  row_context: row_context.RowContext | None = dataclasses.field(
22
22
  default=None, compare=False, hash=False
23
23
  )
@@ -49,6 +49,6 @@ class CallableNode:
49
49
  feature=self.feature,
50
50
  output_features=list(self.output_features),
51
51
  depends_on_features=(
52
- self.callable.depends_on_features if self.callable is not None else None
52
+ list(self.input_features) if self.operation == "vectorize" else None
53
53
  ),
54
54
  )
@@ -167,7 +167,10 @@ class Compiler:
167
167
  if function is None or not function.entrypoint:
168
168
  continue
169
169
  try:
170
- signature = entrypoint.read_signature(function.entrypoint, pathlib.Path.cwd())
170
+ function_entrypoint = entrypoint.Entrypoint(function.entrypoint, pathlib.Path.cwd())
171
+ if not function.validate_signature:
172
+ continue
173
+ signature = function_entrypoint.function.signature
171
174
  except (ImportError, AttributeError, ValueError, TypeError) as error:
172
175
  raise ValueError(
173
176
  f"Could not read signature of function {function.id} "
@@ -183,6 +186,8 @@ class Compiler:
183
186
  ) -> None:
184
187
  """Require the Python return annotation to satisfy the function contract."""
185
188
  returns = function.returns
189
+ if returns == "any":
190
+ return
186
191
  if annotation is None:
187
192
  raise ValueError(
188
193
  f"Function {function.id} must annotate its return as "
@@ -6,7 +6,7 @@ import graphable as graphable_
6
6
  from graphable.views import texttree
7
7
 
8
8
  from uptimely import contracts, spec
9
- from uptimely.spec import api as spec_api
9
+ from uptimely.spec import validation
10
10
 
11
11
  from ..spec import references as bindings
12
12
  from . import callable_node as callable_node_module
@@ -25,16 +25,12 @@ class CallableGraph:
25
25
  spec: The spec document containing function declarations and invocations.
26
26
  """
27
27
  self.spec = spec
28
- prepared = spec_api.prepare_spec(spec)
28
+ validation.validate_spec(spec)
29
29
  self._datasets = {
30
30
  (entity_id, dataset.id): dataset
31
31
  for entity_id, entity in spec.entities.items()
32
32
  for dataset in entity.datasets
33
33
  }
34
- self.calls = prepared.calls
35
- self.functions = [function for function in prepared.functions if function.role == "spec"]
36
- # Index all callables by (function_id, entity, feature) to resolve dependencies
37
- self._callable_index: dict[tuple[str, str, str], callable_node_module.CallableNode] = {}
38
34
  # Map feature names to the callables that produce them
39
35
  self._feature_producers: dict[str, callable_node_module.CallableNode] = {}
40
36
  self._dataset_sources: dict[tuple[str, str], list[callable_node_module.CallableNode]] = {}
@@ -53,14 +49,6 @@ class CallableGraph:
53
49
  primary_feature = (
54
50
  mapping.output_features[0] if mapping.output_features else None
55
51
  )
56
- call = next(
57
- (
58
- call
59
- for call in self.calls
60
- if call.entity == entity_id and call.feature in mapping.output_features
61
- ),
62
- None,
63
- )
64
52
  node = callable_node_module.CallableNode(
65
53
  function_id=mapping.binding.function,
66
54
  entity=entity_id,
@@ -70,20 +58,18 @@ class CallableGraph:
70
58
  mapping_id=mapping.id,
71
59
  output_features=tuple(mapping.output_features),
72
60
  args=mapping.binding.args,
73
- callable=call,
61
+ input_features=tuple(mapping.input_features),
74
62
  row_context=mapping.row_context,
75
63
  )
76
64
  nodes[node.node_id] = graphable_.graphable.Graphable(node)
77
65
  if operation == "write":
78
66
  self._dataset_sinks.setdefault(dataset_key, []).append(node)
79
- elif operation != "vectorize":
67
+ elif operation in contracts.FRAGMENT_OPERATIONS:
80
68
  self._dataset_sources.setdefault(dataset_key, []).append(node)
81
69
  for feature in mapping.output_features:
82
70
  if feature in self.spec.dimensions:
83
71
  continue
84
72
  self._feature_producers[feature] = node
85
- if operation == "vectorize":
86
- self._callable_index[(node.function_id, entity_id, feature)] = node
87
73
  graph = graphable_.graph.Graph(set(nodes.values()), discover=False)
88
74
  # Source transforms follow reads in their dataset.
89
75
  for fragments in self._dataset_sources.values():
@@ -129,7 +115,10 @@ class CallableGraph:
129
115
  graph.add_edge(nodes[producer.node_id], graph_node)
130
116
 
131
117
  # Vectorize mappings consume the assembled dataset frame.
132
- for callable_node in set(self._callable_index.values()):
118
+ calculation_nodes = [
119
+ node.reference for node in nodes.values() if node.reference.operation == "vectorize"
120
+ ]
121
+ for callable_node in calculation_nodes:
133
122
  consumer_node = nodes[callable_node.node_id]
134
123
  dataset_key = (callable_node.entity, callable_node.dataset_id)
135
124
  for source in self._dataset_sources.get(dataset_key, []):
@@ -137,13 +126,9 @@ class CallableGraph:
137
126
 
138
127
  for dataset_key, sinks in self._dataset_sinks.items():
139
128
  sources = self._dataset_sources.get(dataset_key, [])
140
- calculations = list(
141
- dict.fromkeys(
142
- node
143
- for node in self._callable_index.values()
144
- if (node.entity, node.dataset_id) == dataset_key
145
- )
146
- )
129
+ calculations = [
130
+ node for node in calculation_nodes if (node.entity, node.dataset_id) == dataset_key
131
+ ]
147
132
  for sink in sinks:
148
133
  sink_node = nodes[sink.node_id]
149
134
  for source in sources:
@@ -220,8 +205,6 @@ class CallableGraph:
220
205
  subset = self.__class__.__new__(self.__class__)
221
206
  subset.spec = self.spec
222
207
  subset._datasets = self._datasets
223
- subset.functions = self.functions
224
- subset._callable_index = self._callable_index
225
208
  subset._feature_producers = self._feature_producers
226
209
  subset._dataset_sources = self._dataset_sources
227
210
  subset._dataset_sinks = self._dataset_sinks
@@ -5,13 +5,16 @@ import typing
5
5
  FUNCTION_OPERATIONS = (
6
6
  "read",
7
7
  "write",
8
+ "escape",
8
9
  "vectorize",
9
10
  "collapse",
10
11
  "expand",
11
12
  "conform",
12
13
  )
13
- FunctionOperation = typing.Literal["read", "write", "vectorize", "collapse", "expand", "conform"]
14
- FunctionReturns = typing.Literal["dataframe", "none"]
14
+ FunctionOperation = typing.Literal[
15
+ "read", "write", "escape", "vectorize", "collapse", "expand", "conform"
16
+ ]
17
+ FunctionReturns = typing.Literal["dataframe", "none", "any"]
15
18
  FRAGMENT_OPERATIONS: tuple[FunctionOperation, ...] = ("read", "conform", "expand", "collapse")
16
19
 
17
20
  FEATURE_PREFIX = "$features."
@@ -22,17 +25,19 @@ ID_SELECTOR = ".id"
22
25
  DATAFRAME_SELECTOR = ".dataframe"
23
26
  STRUCTURAL_PREFIXES = (FEATURE_PREFIX, DATASET_PREFIX, DIMENSION_PREFIX)
24
27
 
25
- DATAFRAME_INPUT_COUNTS: dict[FunctionOperation, int] = {
28
+ DATAFRAME_INPUT_COUNTS: dict[FunctionOperation, int | None] = {
26
29
  "read": 0,
27
30
  "collapse": 1,
28
31
  "conform": 2,
29
32
  "expand": 2,
30
33
  "vectorize": 1,
31
34
  "write": 1,
35
+ "escape": None,
32
36
  }
33
37
  OPERATION_RETURN_KINDS: dict[FunctionOperation, FunctionReturns] = {
34
38
  "read": "dataframe",
35
39
  "write": "none",
40
+ "escape": "any",
36
41
  "vectorize": "dataframe",
37
42
  "collapse": "dataframe",
38
43
  "expand": "dataframe",
@@ -155,4 +155,4 @@ class EntrypointLoader:
155
155
  """Default callable loader using the package entrypoint implementation."""
156
156
 
157
157
  def load(self, entrypoint: str) -> abc.Callable[..., typing.Any]:
158
- return entrypoint_module.load_callable(entrypoint, pathlib.Path.cwd())
158
+ return entrypoint_module.Entrypoint(entrypoint, pathlib.Path.cwd()).function.load()
@@ -78,13 +78,18 @@ class Engine:
78
78
  started = time.perf_counter()
79
79
  logger.debug("Running %s callable %s", callable.operation, callable.id)
80
80
  try:
81
- dataframe.validate_inputs(callable)
82
- if contracts.OPERATION_RETURN_KINDS.get(callable.operation) == "dataframe":
83
- self._execute_dataframe_mapping(callable, state, bindings, dataframe)
84
- elif contracts.OPERATION_RETURN_KINDS.get(callable.operation) == "none":
85
- self._execute_sink(callable, state, bindings)
81
+ if callable.operation == "escape":
82
+ self._execute_escape(callable, bindings)
86
83
  else:
87
- raise ValueError(f"Unsupported callable operation: {callable.operation}")
84
+ dataframe.validate_inputs(callable)
85
+ if contracts.OPERATION_RETURN_KINDS.get(callable.operation) == "dataframe":
86
+ self._execute_dataframe_mapping(callable, state, bindings, dataframe)
87
+ elif contracts.OPERATION_RETURN_KINDS.get(callable.operation) == "none":
88
+ self._execute_sink(callable, state, bindings)
89
+ else:
90
+ raise ValueError(
91
+ f"Unsupported callable operation: {callable.operation}"
92
+ )
88
93
  except Exception as error:
89
94
  statuses[callable.id] = "failed"
90
95
  failures[callable.id] = str(error)
@@ -133,6 +138,15 @@ class Engine:
133
138
  fragment, [*dimensions, output]
134
139
  )
135
140
 
141
+ def _execute_escape(
142
+ self,
143
+ callable: plan_module.Callable,
144
+ bindings: bindings_module.RuntimeBindings,
145
+ ) -> None:
146
+ """Invoke an uncontrolled mapping and discard its return value."""
147
+ kwargs = bindings.resolve_args(callable)
148
+ self.callable_loader.load(callable.entrypoint)(**kwargs)
149
+
136
150
  def _execute_sink(
137
151
  self,
138
152
  callable: plan_module.Callable,
@@ -0,0 +1,210 @@
1
+ """Load Python function entrypoints and read their annotated signatures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import hashlib
7
+ import importlib
8
+ import inspect
9
+ import pathlib
10
+ import sys
11
+ import types
12
+ import typing
13
+ from importlib import abc, util
14
+ from urllib import parse, request
15
+
16
+ from uptimely import storage as storage_module
17
+
18
+ REMOTE_SCHEMES = {"https", "s3"}
19
+ REMOTE_READ_TIMEOUT = 30
20
+
21
+
22
+ class _RemoteModuleLoader(abc.SourceLoader):
23
+ """Load one Python source file from a supported remote location."""
24
+
25
+ def __init__(self, location: str) -> None:
26
+ self.location = location
27
+
28
+ def get_filename(self, _fullname: str) -> str:
29
+ return self.location
30
+
31
+ def get_data(self, _path: str) -> bytes:
32
+ if parse.urlsplit(self.location).scheme == "s3":
33
+ return storage_module.Storage.read(self.location).encode("utf-8")
34
+ with request.urlopen(self.location, timeout=REMOTE_READ_TIMEOUT) as response:
35
+ if parse.urlsplit(response.geturl()).scheme != "https":
36
+ raise ValueError("HTTPS function references must not redirect to another scheme")
37
+ return response.read()
38
+
39
+
40
+ class Module:
41
+ """Represent and load a Python module from a file, import path, or URL."""
42
+
43
+ def __init__(self, location: str, root: pathlib.Path | None = None) -> None:
44
+ self.location = location
45
+ self.root = root
46
+
47
+ def load(self) -> types.ModuleType:
48
+ """Import the represented Python module."""
49
+ if self.root is not None and str(self.root) not in sys.path:
50
+ sys.path.insert(0, str(self.root))
51
+
52
+ scheme = parse.urlsplit(self.location).scheme
53
+ if scheme in REMOTE_SCHEMES:
54
+ return self.load_external()
55
+
56
+ path = pathlib.Path(self.location)
57
+ if path.is_absolute() or self.location.startswith(("./", "../")):
58
+ path = path if path.is_absolute() else (self.root or pathlib.Path.cwd()) / path
59
+ return self._load_module(str(path.resolve()))
60
+
61
+ module_path = self.location[:-3] if self.location.endswith(".py") else self.location
62
+ return importlib.import_module(module_path.replace("/", "."))
63
+
64
+ def load_external(self) -> types.ModuleType:
65
+ """Load the represented source file from HTTPS or S3."""
66
+ location = parse.urlsplit(self.location)
67
+ if location.scheme not in REMOTE_SCHEMES:
68
+ raise ValueError(f"Unsupported external module scheme: {location.scheme}")
69
+ if not location.netloc or not location.path.strip("/"):
70
+ raise ValueError(f"Function reference must point to a source file: {self.location}")
71
+ return self._load_module(self.location, _RemoteModuleLoader(self.location))
72
+
73
+ @staticmethod
74
+ def _load_module(location: str, loader: abc.Loader | None = None) -> types.ModuleType:
75
+ """Load a module once, removing failed imports from the cache."""
76
+ module_name = f"_uptimely_function_{hashlib.sha256(location.encode()).hexdigest()}"
77
+ if module_name in sys.modules:
78
+ return sys.modules[module_name]
79
+ module_spec = (
80
+ util.spec_from_loader(module_name, loader, origin=location)
81
+ if loader is not None
82
+ else util.spec_from_file_location(module_name, location)
83
+ )
84
+ if module_spec is None or module_spec.loader is None:
85
+ raise ImportError(f"Cannot load Python module from {location}")
86
+ module = util.module_from_spec(module_spec)
87
+ module.__file__ = location
88
+ sys.modules[module_name] = module
89
+ try:
90
+ module_spec.loader.exec_module(module)
91
+ except BaseException:
92
+ del sys.modules[module_name]
93
+ raise
94
+ return module
95
+
96
+
97
+ class TypeAnnotation:
98
+ """Format Python type annotations for display."""
99
+
100
+ @staticmethod
101
+ def name(annotation: object) -> str:
102
+ """Return a readable name for a type annotation."""
103
+ if annotation is None or annotation is type(None):
104
+ return "None"
105
+
106
+ origin = typing.get_origin(annotation)
107
+ if origin in (typing.Union, types.UnionType):
108
+ return " | ".join(TypeAnnotation.name(arg) for arg in typing.get_args(annotation))
109
+
110
+ if origin is not None:
111
+ arguments = ", ".join(TypeAnnotation.name(arg) for arg in typing.get_args(annotation))
112
+ return (
113
+ f"{TypeAnnotation.name(origin)}[{arguments}]"
114
+ if arguments
115
+ else TypeAnnotation.name(origin)
116
+ )
117
+
118
+ return getattr(annotation, "__name__", str(annotation))
119
+
120
+
121
+ @dataclasses.dataclass
122
+ class Signature:
123
+ """Annotated input and output types of a Python function."""
124
+
125
+ args: dict[str, str] = dataclasses.field(default_factory=dict)
126
+ returns: str | None = None
127
+
128
+ @classmethod
129
+ def read(cls, function: Function) -> Signature:
130
+ """Read the annotated argument and return types of a function."""
131
+ signature = inspect.signature(function.load(), eval_str=True)
132
+ return cls(
133
+ args={
134
+ name: TypeAnnotation.name(parameter.annotation)
135
+ for name, parameter in signature.parameters.items()
136
+ if parameter.annotation is not inspect.Parameter.empty
137
+ },
138
+ returns=(
139
+ TypeAnnotation.name(signature.return_annotation)
140
+ if signature.return_annotation is not inspect.Signature.empty
141
+ else None
142
+ ),
143
+ )
144
+
145
+
146
+ class Function:
147
+ """Represent a named callable in a Python module."""
148
+
149
+ def __init__(self, module: Module, name: str) -> None:
150
+ self.module = module
151
+ self.name = name
152
+
153
+ @property
154
+ def signature(self) -> Signature:
155
+ """Return the annotated signature of this function."""
156
+ return Signature.read(self)
157
+
158
+ def load(self) -> typing.Callable[..., typing.Any]:
159
+ """Import the callable from its module."""
160
+ module = self.module.load()
161
+ function = getattr(module, self.name)
162
+ if not callable(function):
163
+ raise TypeError(f"{self.name} in module {self.module.location} is not callable")
164
+ return function
165
+
166
+
167
+ class Entrypoint:
168
+ """Parse and expose the module and function addressed by an entrypoint."""
169
+
170
+ def __init__(self, entrypoint: str, root: pathlib.Path | None = None) -> None:
171
+ if ":" not in entrypoint:
172
+ raise ValueError(
173
+ f"Invalid entrypoint: {entrypoint}. "
174
+ "Expected 'module.path:function' or 'path/to/module.py:function'"
175
+ )
176
+
177
+ module_path, function_name = entrypoint.rsplit(":", 1)
178
+ scheme = parse.urlsplit(module_path).scheme
179
+ if not module_path.strip() or not function_name.isidentifier():
180
+ raise ValueError(f"Invalid function entrypoint: {entrypoint}")
181
+ if (scheme and scheme not in REMOTE_SCHEMES) or module_path.startswith("git@"):
182
+ raise ValueError(
183
+ f"Unsupported function reference scheme: {module_path}. "
184
+ "Use a Python module, local file, https:// URL, or s3:// URI."
185
+ )
186
+
187
+ self.root = root
188
+ self._module_path = module_path
189
+ self._function_name = function_name
190
+
191
+ @property
192
+ def module(self) -> Module:
193
+ """Return the module component of this entrypoint."""
194
+ return Module(self._module_path, self.root)
195
+
196
+ @property
197
+ def function(self) -> Function:
198
+ """Return the function component of this entrypoint."""
199
+ return Function(self.module, self._function_name)
200
+
201
+
202
+ __all__ = [
203
+ "Entrypoint",
204
+ "Function",
205
+ "Module",
206
+ "REMOTE_READ_TIMEOUT",
207
+ "REMOTE_SCHEMES",
208
+ "Signature",
209
+ "TypeAnnotation",
210
+ ]
@@ -23,6 +23,7 @@ class GraphStyle:
23
23
  function_colors: dict[str, str] = dataclasses.field(
24
24
  default_factory=lambda: {
25
25
  "read": "#4c78a8",
26
+ "escape": "#a78bfa",
26
27
  "vectorize": "#f58518",
27
28
  "collapse": "#e45756",
28
29
  "conform": "#72b7b2",
@@ -18,6 +18,7 @@ class DataLineageColumn:
18
18
  role: str
19
19
  color: str
20
20
  producer: int | None
21
+ external: bool = False
21
22
 
22
23
 
23
24
  @dataclasses.dataclass(frozen=True)
@@ -89,8 +90,14 @@ def _build_stages(
89
90
  for name in dimensions
90
91
  ]
91
92
  for role, features in (
92
- ("Input", callable.input_features),
93
- ("Output", callable.output_features),
93
+ (
94
+ "Declared input" if callable.operation == "escape" else "Input",
95
+ callable.input_features,
96
+ ),
97
+ (
98
+ "Declared external output" if callable.operation == "escape" else "Output",
99
+ callable.output_features,
100
+ ),
94
101
  ):
95
102
  for feature in features:
96
103
  if feature in dimensions:
@@ -107,6 +114,7 @@ def _build_stages(
107
114
  if feature in spec_definition.dimensions
108
115
  else "#cbd5e1",
109
116
  numbers[producer.id] if producer else None,
117
+ producer is not None and producer.operation == "escape",
110
118
  )
111
119
  )
112
120
  dataset = datasets.get(callable.dataset_id)
@@ -75,11 +75,14 @@ def _render_step(
75
75
  "dataset": callable.dataset_id,
76
76
  "feature": (
77
77
  ", ".join(callable.output_features)
78
- if callable.operation == "collapse"
78
+ if callable.operation in {"collapse", "escape"}
79
79
  else callable.output
80
80
  if callable.operation == "vectorize"
81
81
  else None
82
82
  ),
83
+ "input_features": callable.input_features,
84
+ "output_features": callable.output_features,
85
+ "uncontrolled": callable.operation == "escape",
83
86
  "row_context": callable.row_context.to_dict() if callable.row_context else None,
84
87
  "depends_on_callables": [
85
88
  {
@@ -155,7 +158,15 @@ def _create_website_pages(
155
158
  "function_id": callable.function_id,
156
159
  "entity": callable.entity_id,
157
160
  "dataset_id": callable.dataset_id,
158
- "feature": callable.output if callable.operation == "vectorize" else None,
161
+ "feature": (
162
+ ", ".join(callable.output_features)
163
+ if callable.operation == "escape"
164
+ else callable.output
165
+ if callable.operation == "vectorize"
166
+ else None
167
+ ),
168
+ "input_features": callable.input_features,
169
+ "output_features": callable.output_features,
159
170
  "dependency_ids": sorted(callable.dependency_ids),
160
171
  }
161
172
  for callable in sorted(plan.callables, key=lambda callable: callable.id)