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.
- horsies/__init__.py +115 -0
- horsies/core/__init__.py +0 -0
- horsies/core/app.py +552 -0
- horsies/core/banner.py +144 -0
- horsies/core/brokers/__init__.py +5 -0
- horsies/core/brokers/listener.py +444 -0
- horsies/core/brokers/postgres.py +864 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +575 -0
- horsies/core/errors.py +535 -0
- horsies/core/logging.py +90 -0
- horsies/core/models/__init__.py +0 -0
- horsies/core/models/app.py +268 -0
- horsies/core/models/broker.py +79 -0
- horsies/core/models/queues.py +23 -0
- horsies/core/models/recovery.py +101 -0
- horsies/core/models/schedule.py +229 -0
- horsies/core/models/task_pg.py +307 -0
- horsies/core/models/tasks.py +332 -0
- horsies/core/models/workflow.py +1988 -0
- horsies/core/models/workflow_pg.py +245 -0
- horsies/core/registry/tasks.py +101 -0
- horsies/core/scheduler/__init__.py +26 -0
- horsies/core/scheduler/calculator.py +267 -0
- horsies/core/scheduler/service.py +569 -0
- horsies/core/scheduler/state.py +260 -0
- horsies/core/task_decorator.py +615 -0
- horsies/core/types/status.py +38 -0
- horsies/core/utils/imports.py +203 -0
- horsies/core/utils/loop_runner.py +44 -0
- horsies/core/worker/current.py +17 -0
- horsies/core/worker/worker.py +1967 -0
- horsies/core/workflows/__init__.py +23 -0
- horsies/core/workflows/engine.py +2344 -0
- horsies/core/workflows/recovery.py +501 -0
- horsies/core/workflows/registry.py +97 -0
- horsies/py.typed +0 -0
- horsies-0.1.0a1.dist-info/METADATA +31 -0
- horsies-0.1.0a1.dist-info/RECORD +42 -0
- horsies-0.1.0a1.dist-info/WHEEL +5 -0
- horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
- 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
|