horsies 0.1.0a1__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 (42) hide show
  1. horsies/__init__.py +115 -0
  2. horsies/core/__init__.py +0 -0
  3. horsies/core/app.py +552 -0
  4. horsies/core/banner.py +144 -0
  5. horsies/core/brokers/__init__.py +5 -0
  6. horsies/core/brokers/listener.py +444 -0
  7. horsies/core/brokers/postgres.py +864 -0
  8. horsies/core/cli.py +624 -0
  9. horsies/core/codec/serde.py +575 -0
  10. horsies/core/errors.py +535 -0
  11. horsies/core/logging.py +90 -0
  12. horsies/core/models/__init__.py +0 -0
  13. horsies/core/models/app.py +268 -0
  14. horsies/core/models/broker.py +79 -0
  15. horsies/core/models/queues.py +23 -0
  16. horsies/core/models/recovery.py +101 -0
  17. horsies/core/models/schedule.py +229 -0
  18. horsies/core/models/task_pg.py +307 -0
  19. horsies/core/models/tasks.py +332 -0
  20. horsies/core/models/workflow.py +1988 -0
  21. horsies/core/models/workflow_pg.py +245 -0
  22. horsies/core/registry/tasks.py +101 -0
  23. horsies/core/scheduler/__init__.py +26 -0
  24. horsies/core/scheduler/calculator.py +267 -0
  25. horsies/core/scheduler/service.py +569 -0
  26. horsies/core/scheduler/state.py +260 -0
  27. horsies/core/task_decorator.py +615 -0
  28. horsies/core/types/status.py +38 -0
  29. horsies/core/utils/imports.py +203 -0
  30. horsies/core/utils/loop_runner.py +44 -0
  31. horsies/core/worker/current.py +17 -0
  32. horsies/core/worker/worker.py +1967 -0
  33. horsies/core/workflows/__init__.py +23 -0
  34. horsies/core/workflows/engine.py +2344 -0
  35. horsies/core/workflows/recovery.py +501 -0
  36. horsies/core/workflows/registry.py +97 -0
  37. horsies/py.typed +0 -0
  38. horsies-0.1.0a1.dist-info/METADATA +31 -0
  39. horsies-0.1.0a1.dist-info/RECORD +42 -0
  40. horsies-0.1.0a1.dist-info/WHEEL +5 -0
  41. horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
  42. horsies-0.1.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,203 @@
1
+ """
2
+ Module import utilities.
3
+
4
+ This module provides explicit, deterministic import behavior:
5
+ - import_module_path(): Import using dotted module path (recommended)
6
+ - import_file_path(): Import from file path with explicit sys.path setup
7
+ - find_project_root(): Find pyproject.toml location for convenience
8
+
9
+ No implicit heuristics - the caller controls sys.path and module naming.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import importlib
16
+ import importlib.util
17
+ import os
18
+ import sys
19
+ from typing import Any
20
+
21
+ from horsies.core.logging import get_logger
22
+
23
+ logger = get_logger("imports")
24
+
25
+
26
+ def find_project_root(start_dir: str) -> str | None:
27
+ """
28
+ Check if start_dir itself contains pyproject.toml, setup.cfg, or setup.py.
29
+
30
+ NOTE: Does NOT traverse up - only checks the given directory.
31
+ This avoids dangerous behavior in monorepos where parent pyproject.toml
32
+ could cause module collisions between services.
33
+
34
+ Returns start_dir if it contains a marker file, None otherwise.
35
+ """
36
+ start_dir = os.path.abspath(start_dir)
37
+ for marker in ("pyproject.toml", "setup.cfg", "setup.py"):
38
+ if os.path.exists(os.path.join(start_dir, marker)):
39
+ return start_dir
40
+ return None
41
+
42
+
43
+ def setup_sys_path_from_cwd() -> str | None:
44
+ """
45
+ If cwd contains pyproject.toml (not parent dirs), add cwd to sys.path.
46
+
47
+ This is safe because:
48
+ - Only checks cwd, not parent directories
49
+ - User explicitly chose to run from this directory
50
+ - No risk of monorepo root collision
51
+
52
+ Returns cwd if it was added to sys.path, None otherwise.
53
+ """
54
+ cwd = os.getcwd()
55
+ if find_project_root(cwd) and cwd not in sys.path:
56
+ sys.path.insert(0, cwd)
57
+ logger.debug(f"Added cwd to sys.path: {cwd}")
58
+ return cwd
59
+ return None
60
+
61
+
62
+ def import_module_path(module_path: str) -> Any:
63
+ """
64
+ Import a module using its dotted path.
65
+
66
+ This is the recommended approach - like Celery, we expect the user to:
67
+ 1. Provide a valid dotted module path
68
+ 2. Have their PYTHONPATH/sys.path set up correctly
69
+
70
+ Args:
71
+ module_path: Dotted module path (e.g., "app.configs.horsies")
72
+
73
+ Returns:
74
+ The imported module
75
+
76
+ Raises:
77
+ ModuleNotFoundError: If the module cannot be found
78
+ """
79
+ return importlib.import_module(module_path)
80
+
81
+
82
+ def _compute_synthetic_module_name(path: str) -> str:
83
+ """
84
+ Generate stable synthetic module name for standalone files.
85
+
86
+ Uses sha256 hash of realpath to ensure:
87
+ - Same realpath → same hash → same module name (across processes)
88
+ - No collision with user code (under horsies._dynamic namespace)
89
+ - Deterministic (not random)
90
+ """
91
+ realpath = os.path.realpath(path)
92
+ hash_prefix = hashlib.sha256(realpath.encode()).hexdigest()[:12]
93
+ return f"horsies._dynamic.{hash_prefix}"
94
+
95
+
96
+ def import_file_path(
97
+ file_path: str,
98
+ module_name: str | None = None,
99
+ add_parent_to_path: bool = True,
100
+ ) -> Any:
101
+ """
102
+ Import a module from a file path.
103
+
104
+ This is explicit and simple:
105
+ 1. Optionally add parent directory to sys.path
106
+ 2. Load module with given name (or synthetic name if not provided)
107
+ 3. Execute and return module
108
+
109
+ Args:
110
+ file_path: Path to the Python file
111
+ module_name: Module name to use (default: synthetic name based on path hash)
112
+ add_parent_to_path: Whether to add the file's parent directory to sys.path
113
+
114
+ Returns:
115
+ The imported module
116
+
117
+ Raises:
118
+ FileNotFoundError: If the file doesn't exist
119
+ ImportError: If the module can't be loaded
120
+ """
121
+ file_path = os.path.realpath(file_path)
122
+
123
+ if not os.path.exists(file_path):
124
+ raise FileNotFoundError(f"Module file not found: {file_path}")
125
+
126
+ # Check if already loaded
127
+ for name, mod in list(sys.modules.items()):
128
+ mod_file = getattr(mod, "__file__", None)
129
+ if mod_file and os.path.realpath(mod_file) == file_path:
130
+ return mod
131
+
132
+ # Add parent directory to sys.path if requested
133
+ if add_parent_to_path:
134
+ parent_dir = os.path.dirname(file_path)
135
+ if parent_dir not in sys.path:
136
+ sys.path.insert(0, parent_dir)
137
+
138
+ # Use provided name or generate synthetic name
139
+ if module_name is None:
140
+ module_name = _compute_synthetic_module_name(file_path)
141
+
142
+ # Load the module
143
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
144
+ if spec is None or spec.loader is None:
145
+ raise ImportError(f"Could not load module from path: {file_path}")
146
+
147
+ mod = importlib.util.module_from_spec(spec)
148
+ sys.modules[module_name] = mod
149
+
150
+ # Also register under basename for compatibility
151
+ basename = os.path.splitext(os.path.basename(file_path))[0]
152
+ if basename != module_name and basename not in sys.modules:
153
+ sys.modules[basename] = mod
154
+
155
+ spec.loader.exec_module(mod)
156
+ return mod
157
+
158
+
159
+ # Legacy alias for backward compatibility
160
+ def import_by_path(path: str, module_name: str | None = None) -> Any:
161
+ """
162
+ Legacy function - prefer import_file_path() or import_module_path().
163
+
164
+ For file paths: delegates to import_file_path()
165
+ For module paths: delegates to import_module_path()
166
+ """
167
+ if path.endswith(".py") or os.path.sep in path:
168
+ return import_file_path(path, module_name)
169
+ else:
170
+ return import_module_path(path)
171
+
172
+
173
+ # Keep this for tests that import it directly
174
+ def compute_package_path_from_fs(file_path: str) -> tuple[str | None, str | None]:
175
+ """
176
+ Walk up directory tree looking for __init__.py to determine package structure.
177
+
178
+ NOTE: This function is kept for backward compatibility with tests.
179
+ New code should not rely on this for import resolution.
180
+
181
+ Returns (dotted_module_name, package_root) if a package chain is found,
182
+ otherwise (None, None).
183
+ """
184
+ file_path = os.path.realpath(file_path)
185
+ module_name = os.path.splitext(os.path.basename(file_path))[0]
186
+ current_dir = os.path.dirname(file_path)
187
+
188
+ components = [module_name]
189
+ while True:
190
+ init_path = os.path.join(current_dir, "__init__.py")
191
+ if not os.path.exists(init_path):
192
+ break
193
+ package_name = os.path.basename(current_dir)
194
+ components.append(package_name)
195
+ current_dir = os.path.dirname(current_dir)
196
+
197
+ if len(components) == 1:
198
+ return (None, None)
199
+
200
+ components.reverse()
201
+ dotted_name = ".".join(components)
202
+ package_root = current_dir
203
+ return (dotted_name, package_root)
@@ -0,0 +1,44 @@
1
+ # app/core/utils/looprunner.py
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import threading
5
+ from concurrent.futures import Future
6
+ from typing import Any, Awaitable, Callable
7
+ from horsies.core.logging import get_logger
8
+
9
+
10
+ class LoopRunner:
11
+ """Run async callables from sync code on a dedicated event loop thread."""
12
+
13
+ def __init__(self) -> None:
14
+ self.logger = get_logger('loop_runner')
15
+ self._loop = asyncio.new_event_loop()
16
+ self._thread = threading.Thread(
17
+ target=self._loop.run_forever, name='horsies-loop', daemon=True
18
+ )
19
+ self._started = False
20
+
21
+ def start(self) -> None:
22
+ if not self._started:
23
+ self._thread.start()
24
+ self._started = True
25
+
26
+ def stop(self) -> None:
27
+ if self._started:
28
+ self._loop.call_soon_threadsafe(self._loop.stop)
29
+ self._thread.join(timeout=2)
30
+ self._started = False
31
+
32
+ def call(
33
+ self, coro_fn: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any
34
+ ) -> Any:
35
+ """Run an async function and block until it completes, from sync code."""
36
+ if not self._started:
37
+ self.start()
38
+ self.logger.debug(
39
+ f'Calling {coro_fn.__name__} with args: {args} and kwargs: {kwargs}'
40
+ )
41
+ fut: Future[Any] = asyncio.run_coroutine_threadsafe(
42
+ coro_fn(*args, **kwargs), self._loop
43
+ )
44
+ return fut.result() # propagate exceptions
@@ -0,0 +1,17 @@
1
+ # app/core/worker/current.py
2
+ from __future__ import annotations
3
+ from typing import Optional
4
+ from horsies.core.app import Horsies
5
+
6
+ _current_app: Optional[Horsies] = None
7
+
8
+
9
+ def set_current_app(app: Horsies) -> None:
10
+ global _current_app
11
+ _current_app = app
12
+
13
+
14
+ def get_current_app() -> Horsies:
15
+ if _current_app is None:
16
+ raise RuntimeError('No current app set in this process')
17
+ return _current_app