pgframx 0.1.0__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.
- pgframx/__init__.py +7 -0
- pgframx/app.py +27 -0
- pgframx/blueprints/__init__.py +5 -0
- pgframx/blueprints/events.py +63 -0
- pgframx/blueprints/health.py +75 -0
- pgframx/blueprints/listeners.py +43 -0
- pgframx/cli.py +297 -0
- pgframx/config.py +29 -0
- pgframx/event.py +9 -0
- pgframx/hooks.py +51 -0
- pgframx/listeners.py +90 -0
- pgframx/orchestrator.py +134 -0
- pgframx/scheduler.py +90 -0
- pgframx/utils.py +23 -0
- pgframx-0.1.0.dist-info/METADATA +93 -0
- pgframx-0.1.0.dist-info/RECORD +19 -0
- pgframx-0.1.0.dist-info/WHEEL +4 -0
- pgframx-0.1.0.dist-info/entry_points.txt +2 -0
- pgframx-0.1.0.dist-info/licenses/LICENSE +21 -0
pgframx/__init__.py
ADDED
pgframx/app.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from flask import Flask
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pgframx.orchestrator import Orchestrator
|
|
5
|
+
from pgframx.config import Config
|
|
6
|
+
from pgframx.utils import setup_logging
|
|
7
|
+
from pgframx.blueprints import events_bp, listeners_bp, health_bp
|
|
8
|
+
|
|
9
|
+
def create_app(orchestrator: Orchestrator, config_class=Config) -> Flask:
|
|
10
|
+
"""Flask application factory. Sets up logging, blueprints, and configures the Orchestrator."""
|
|
11
|
+
app = Flask("pgframx")
|
|
12
|
+
|
|
13
|
+
# Load configuration
|
|
14
|
+
app.config.from_mapping(config_class.to_dict())
|
|
15
|
+
|
|
16
|
+
# Setup logging based on configured level
|
|
17
|
+
setup_logging(app.config.get("PGFRAMX_LOG_LEVEL", "INFO"))
|
|
18
|
+
|
|
19
|
+
# Store Orchestrator reference in app configuration for blueprints to access
|
|
20
|
+
app.config["ORCHESTRATOR"] = orchestrator
|
|
21
|
+
|
|
22
|
+
# Register blueprints with appropriate prefixes
|
|
23
|
+
app.register_blueprint(events_bp, url_prefix="/events")
|
|
24
|
+
app.register_blueprint(listeners_bp, url_prefix="/listeners")
|
|
25
|
+
app.register_blueprint(health_bp, url_prefix="/health")
|
|
26
|
+
|
|
27
|
+
return app
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from flask import Blueprint, jsonify, request, current_app
|
|
2
|
+
|
|
3
|
+
events_bp = Blueprint("events", __name__)
|
|
4
|
+
|
|
5
|
+
def get_orchestrator():
|
|
6
|
+
return current_app.config["ORCHESTRATOR"]
|
|
7
|
+
|
|
8
|
+
@events_bp.route("", methods=["GET"])
|
|
9
|
+
def list_events():
|
|
10
|
+
orc = get_orchestrator()
|
|
11
|
+
# Return list of distinct patterns registered in the listeners registry
|
|
12
|
+
patterns = list(set(listener.pattern for listener in orc.listeners.list_all()))
|
|
13
|
+
return jsonify({"events": patterns})
|
|
14
|
+
|
|
15
|
+
@events_bp.route("/emit", methods=["POST"])
|
|
16
|
+
def emit_event():
|
|
17
|
+
orc = get_orchestrator()
|
|
18
|
+
payload = request.get_json() or {}
|
|
19
|
+
|
|
20
|
+
name = payload.get("name")
|
|
21
|
+
if not name:
|
|
22
|
+
return jsonify({"error": "Event 'name' is required"}), 400
|
|
23
|
+
|
|
24
|
+
data = payload.get("data", {})
|
|
25
|
+
meta = payload.get("meta", {})
|
|
26
|
+
async_dispatch = bool(payload.get("async", False))
|
|
27
|
+
|
|
28
|
+
event = orc.emit(name, data=data, meta=meta, async_dispatch=async_dispatch)
|
|
29
|
+
|
|
30
|
+
return jsonify({
|
|
31
|
+
"name": event.name,
|
|
32
|
+
"data": event.data,
|
|
33
|
+
"meta": event.meta,
|
|
34
|
+
"status": event.status
|
|
35
|
+
}), 201
|
|
36
|
+
|
|
37
|
+
@events_bp.route("/<path:name>/listeners", methods=["GET"])
|
|
38
|
+
def get_event_listeners(name):
|
|
39
|
+
orc = get_orchestrator()
|
|
40
|
+
listeners = orc.listeners.get_listeners_for_event(name)
|
|
41
|
+
return jsonify({
|
|
42
|
+
"event": name,
|
|
43
|
+
"listeners": [l.to_dict() for l in listeners]
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
@events_bp.route("/log", methods=["GET"])
|
|
47
|
+
def get_event_log():
|
|
48
|
+
orc = get_orchestrator()
|
|
49
|
+
log_data = []
|
|
50
|
+
for event in orc.event_log:
|
|
51
|
+
log_data.append({
|
|
52
|
+
"name": event.name,
|
|
53
|
+
"data": event.data,
|
|
54
|
+
"meta": event.meta,
|
|
55
|
+
"status": event.status
|
|
56
|
+
})
|
|
57
|
+
return jsonify({"log": log_data})
|
|
58
|
+
|
|
59
|
+
@events_bp.route("/log", methods=["DELETE"])
|
|
60
|
+
def clear_event_log():
|
|
61
|
+
orc = get_orchestrator()
|
|
62
|
+
orc.event_log.clear()
|
|
63
|
+
return jsonify({"message": "Event log cleared successfully"})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from flask import Blueprint, jsonify, current_app
|
|
3
|
+
|
|
4
|
+
health_bp = Blueprint("health", __name__)
|
|
5
|
+
|
|
6
|
+
def get_orchestrator():
|
|
7
|
+
return current_app.config["ORCHESTRATOR"]
|
|
8
|
+
|
|
9
|
+
@health_bp.route("", methods=["GET"])
|
|
10
|
+
def health_check():
|
|
11
|
+
orc = get_orchestrator()
|
|
12
|
+
uptime = time.time() - orc.start_time
|
|
13
|
+
|
|
14
|
+
return jsonify({
|
|
15
|
+
"status": "healthy",
|
|
16
|
+
"uptime_seconds": round(uptime, 2),
|
|
17
|
+
"listeners_count": len(orc.listeners.list_all()),
|
|
18
|
+
"event_log_size": len(orc.event_log),
|
|
19
|
+
"scheduler_enabled": orc.scheduler_enabled,
|
|
20
|
+
"scheduler_running": orc.scheduler.scheduler.running if orc.scheduler else False
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
@health_bp.route("/scheduler", methods=["GET"])
|
|
24
|
+
def scheduler_status():
|
|
25
|
+
orc = get_orchestrator()
|
|
26
|
+
if not orc.scheduler:
|
|
27
|
+
return jsonify({
|
|
28
|
+
"scheduler_enabled": False,
|
|
29
|
+
"message": "Scheduler is disabled"
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
jobs = orc.scheduler.list_jobs()
|
|
33
|
+
return jsonify({
|
|
34
|
+
"scheduler_enabled": True,
|
|
35
|
+
"scheduler_running": orc.scheduler.scheduler.running,
|
|
36
|
+
"jobs": jobs
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
@health_bp.route("/scheduler/jobs/<string:job_id>", methods=["DELETE"])
|
|
40
|
+
def delete_scheduled_job(job_id):
|
|
41
|
+
orc = get_orchestrator()
|
|
42
|
+
if not orc.scheduler:
|
|
43
|
+
return jsonify({"error": "Scheduler is disabled"}), 400
|
|
44
|
+
|
|
45
|
+
success = orc.unschedule(job_id)
|
|
46
|
+
if success:
|
|
47
|
+
return jsonify({"message": f"Scheduled job {job_id} removed successfully"})
|
|
48
|
+
else:
|
|
49
|
+
return jsonify({"error": f"Scheduled job {job_id} not found"}), 404
|
|
50
|
+
|
|
51
|
+
@health_bp.route("/scheduler/jobs", methods=["POST"])
|
|
52
|
+
def add_scheduled_job():
|
|
53
|
+
orc = get_orchestrator()
|
|
54
|
+
if not orc.scheduler:
|
|
55
|
+
return jsonify({"error": "Scheduler is disabled"}), 400
|
|
56
|
+
|
|
57
|
+
from flask import request
|
|
58
|
+
payload = request.get_json() or {}
|
|
59
|
+
event_name = payload.get("name")
|
|
60
|
+
if not event_name:
|
|
61
|
+
return jsonify({"error": "Field 'name' (event name) is required"}), 400
|
|
62
|
+
|
|
63
|
+
cron = payload.get("cron")
|
|
64
|
+
interval = payload.get("interval")
|
|
65
|
+
data = payload.get("data", {})
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
job_id = orc.schedule(event_name, cron=cron, interval=interval, data=data)
|
|
69
|
+
return jsonify({
|
|
70
|
+
"message": "Job scheduled successfully",
|
|
71
|
+
"job_id": job_id,
|
|
72
|
+
"name": event_name
|
|
73
|
+
}), 201
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return jsonify({"error": str(e)}), 400
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from flask import Blueprint, jsonify, request, current_app
|
|
2
|
+
|
|
3
|
+
listeners_bp = Blueprint("listeners", __name__)
|
|
4
|
+
|
|
5
|
+
def get_orchestrator():
|
|
6
|
+
return current_app.config["ORCHESTRATOR"]
|
|
7
|
+
|
|
8
|
+
@listeners_bp.route("", methods=["GET"])
|
|
9
|
+
def list_listeners():
|
|
10
|
+
orc = get_orchestrator()
|
|
11
|
+
listeners = orc.listeners.list_all()
|
|
12
|
+
return jsonify({"listeners": [l.to_dict() for l in listeners]})
|
|
13
|
+
|
|
14
|
+
@listeners_bp.route("/register", methods=["POST"])
|
|
15
|
+
def register_listener():
|
|
16
|
+
orc = get_orchestrator()
|
|
17
|
+
payload = request.get_json() or {}
|
|
18
|
+
|
|
19
|
+
pattern = payload.get("pattern")
|
|
20
|
+
webhook_url = payload.get("webhook_url")
|
|
21
|
+
|
|
22
|
+
if not pattern:
|
|
23
|
+
return jsonify({"error": "Field 'pattern' is required"}), 400
|
|
24
|
+
if not webhook_url:
|
|
25
|
+
return jsonify({"error": "Field 'webhook_url' is required for dynamic listeners"}), 400
|
|
26
|
+
|
|
27
|
+
listener_id = orc.listeners.register(pattern=pattern, webhook_url=webhook_url)
|
|
28
|
+
|
|
29
|
+
return jsonify({
|
|
30
|
+
"message": "Listener registered dynamically",
|
|
31
|
+
"id": listener_id,
|
|
32
|
+
"pattern": pattern,
|
|
33
|
+
"webhook_url": webhook_url
|
|
34
|
+
}), 201
|
|
35
|
+
|
|
36
|
+
@listeners_bp.route("/<string:listener_id>", methods=["DELETE"])
|
|
37
|
+
def deregister_listener(listener_id):
|
|
38
|
+
orc = get_orchestrator()
|
|
39
|
+
success = orc.listeners.deregister(listener_id)
|
|
40
|
+
if success:
|
|
41
|
+
return jsonify({"message": f"Listener {listener_id} removed successfully"})
|
|
42
|
+
else:
|
|
43
|
+
return jsonify({"error": f"Listener {listener_id} not found"}), 404
|
pgframx/cli.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import requests
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import importlib.util
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from pgframx.config import Config
|
|
10
|
+
|
|
11
|
+
def get_base_url() -> str:
|
|
12
|
+
host = Config.PGFRAMX_HOST
|
|
13
|
+
# Map 0.0.0.0 to localhost for client requests
|
|
14
|
+
if host == "0.0.0.0":
|
|
15
|
+
host = "127.0.0.1"
|
|
16
|
+
return f"http://{host}:{Config.PGFRAMX_PORT}"
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
def main():
|
|
20
|
+
"""pgframx CLI — Manage and orchestrate events."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@main.command()
|
|
24
|
+
@click.option("--dir", "target_dir", default=".", help="Directory to scaffold pgframx project.")
|
|
25
|
+
def init(target_dir):
|
|
26
|
+
"""Scaffold a new pgframx project in cwd."""
|
|
27
|
+
click.echo(f"Scaffolding new pgframx project in '{target_dir}'...")
|
|
28
|
+
|
|
29
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# 1. Create main.py
|
|
32
|
+
main_py_path = os.path.join(target_dir, "main.py")
|
|
33
|
+
if os.path.exists(main_py_path):
|
|
34
|
+
click.confirm("main.py already exists. Overwrite?", abort=True)
|
|
35
|
+
|
|
36
|
+
main_py_content = """from pgframx import Orchestrator, create_app
|
|
37
|
+
|
|
38
|
+
# Instantiate the core event orchestrator
|
|
39
|
+
orc = Orchestrator()
|
|
40
|
+
|
|
41
|
+
# Register a basic listener
|
|
42
|
+
@orc.on("user.login")
|
|
43
|
+
def handle_login(event):
|
|
44
|
+
print(f"[Listener] User logged in: {event.data}")
|
|
45
|
+
|
|
46
|
+
# Register before hook
|
|
47
|
+
@orc.before("user.*")
|
|
48
|
+
def before_user_events(event):
|
|
49
|
+
print(f"[Before Hook] intercepting: {event.name}")
|
|
50
|
+
|
|
51
|
+
# Create the Flask application
|
|
52
|
+
app = create_app(orc)
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
# If run directly, run Flask dev server
|
|
56
|
+
app.run(host="0.0.0.0", port=5050, debug=True)
|
|
57
|
+
"""
|
|
58
|
+
with open(main_py_path, "w") as f:
|
|
59
|
+
f.write(main_py_content)
|
|
60
|
+
click.echo(" Created main.py")
|
|
61
|
+
|
|
62
|
+
# 2. Create .pgframx.env
|
|
63
|
+
env_path = os.path.join(target_dir, ".pgframx.env")
|
|
64
|
+
if not os.path.exists(env_path):
|
|
65
|
+
env_content = """PGFRAMX_HOST=0.0.0.0
|
|
66
|
+
PGFRAMX_PORT=5050
|
|
67
|
+
PGFRAMX_DEBUG=True
|
|
68
|
+
PGFRAMX_LOG_LEVEL=INFO
|
|
69
|
+
PGFRAMX_SCHEDULER_ENABLED=True
|
|
70
|
+
PGFRAMX_EVENT_LOG_MAX=1000
|
|
71
|
+
"""
|
|
72
|
+
with open(env_path, "w") as f:
|
|
73
|
+
f.write(env_content)
|
|
74
|
+
click.echo(" Created .pgframx.env")
|
|
75
|
+
|
|
76
|
+
# 3. Create README.md if it doesn't exist
|
|
77
|
+
readme_path = os.path.join(target_dir, "README.md")
|
|
78
|
+
if not os.path.exists(readme_path):
|
|
79
|
+
readme_content = """# pgframx Project
|
|
80
|
+
|
|
81
|
+
This is a pgframx event management orchestrator project scaffold.
|
|
82
|
+
|
|
83
|
+
## Running the Server
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pgframx run
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Emitting Events
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pgframx emit user.login --data '{"email": "user@example.com"}'
|
|
93
|
+
```
|
|
94
|
+
"""
|
|
95
|
+
with open(readme_path, "w") as f:
|
|
96
|
+
f.write(readme_content)
|
|
97
|
+
click.echo(" Created README.md")
|
|
98
|
+
|
|
99
|
+
click.echo("Scaffolding complete! Run 'pgframx run' to start.")
|
|
100
|
+
|
|
101
|
+
@main.command()
|
|
102
|
+
def run():
|
|
103
|
+
"""Start the Flask orchestrator server."""
|
|
104
|
+
sys.path.insert(0, os.getcwd())
|
|
105
|
+
|
|
106
|
+
# Try to load local main.py or app.py
|
|
107
|
+
app = None
|
|
108
|
+
orchestrator = None
|
|
109
|
+
|
|
110
|
+
for filename in ["main.py", "app.py"]:
|
|
111
|
+
if os.path.exists(filename):
|
|
112
|
+
click.echo(f"Loading local application configuration from '{filename}'...")
|
|
113
|
+
try:
|
|
114
|
+
# Load module dynamically
|
|
115
|
+
spec = importlib.util.spec_from_file_location("local_app", filename)
|
|
116
|
+
if spec and spec.loader:
|
|
117
|
+
module = importlib.util.module_from_spec(spec)
|
|
118
|
+
spec.loader.exec_module(module)
|
|
119
|
+
|
|
120
|
+
# Look for app or orc
|
|
121
|
+
if hasattr(module, "app"):
|
|
122
|
+
app = getattr(module, "app")
|
|
123
|
+
if hasattr(module, "orc"):
|
|
124
|
+
orchestrator = getattr(module, "orc")
|
|
125
|
+
elif hasattr(module, "orchestrator"):
|
|
126
|
+
orchestrator = getattr(module, "orchestrator")
|
|
127
|
+
break
|
|
128
|
+
except Exception as e:
|
|
129
|
+
click.echo(f"Warning: Failed to import '{filename}': {e}")
|
|
130
|
+
|
|
131
|
+
if not app:
|
|
132
|
+
click.echo("No local Flask app instance found in main.py/app.py. Starting default Orchestrator...")
|
|
133
|
+
from pgframx.orchestrator import Orchestrator
|
|
134
|
+
from pgframx.app import create_app
|
|
135
|
+
orchestrator = Orchestrator()
|
|
136
|
+
app = create_app(orchestrator)
|
|
137
|
+
|
|
138
|
+
click.echo(f"Starting server on {Config.PGFRAMX_HOST}:{Config.PGFRAMX_PORT}...")
|
|
139
|
+
try:
|
|
140
|
+
app.run(
|
|
141
|
+
host=Config.PGFRAMX_HOST,
|
|
142
|
+
port=Config.PGFRAMX_PORT,
|
|
143
|
+
debug=Config.PGFRAMX_DEBUG,
|
|
144
|
+
use_reloader=False # Avoid double startup of scheduler background threads
|
|
145
|
+
)
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
click.echo("\nStopping orchestrator server...")
|
|
148
|
+
if orchestrator:
|
|
149
|
+
orchestrator.shutdown()
|
|
150
|
+
|
|
151
|
+
@main.command()
|
|
152
|
+
@click.argument("event_name")
|
|
153
|
+
@click.option("--data", default="{}", help="JSON string data payload.")
|
|
154
|
+
@click.option("--async", "async_dispatch", is_flag=True, help="Emit event asynchronously.")
|
|
155
|
+
def emit(event_name, data, async_dispatch):
|
|
156
|
+
"""Emit event from CLI."""
|
|
157
|
+
url = f"{get_base_url()}/events/emit"
|
|
158
|
+
try:
|
|
159
|
+
parsed_data = json.loads(data)
|
|
160
|
+
except json.JSONDecodeError as e:
|
|
161
|
+
click.echo(f"Error parsing JSON payload: {e}", err=True)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
payload = {
|
|
165
|
+
"name": event_name,
|
|
166
|
+
"data": parsed_data,
|
|
167
|
+
"async": async_dispatch
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
response = requests.post(url, json=payload)
|
|
172
|
+
if response.status_code == 201:
|
|
173
|
+
click.echo(json.dumps(response.json(), indent=2))
|
|
174
|
+
else:
|
|
175
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
176
|
+
except requests.exceptions.ConnectionError:
|
|
177
|
+
click.echo(f"Error: Could not connect to pgframx server at {get_base_url()}. Is it running?", err=True)
|
|
178
|
+
|
|
179
|
+
@main.group(name="list")
|
|
180
|
+
def list_group():
|
|
181
|
+
"""List resources (events, listeners)."""
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
@list_group.command(name="events")
|
|
185
|
+
def list_events():
|
|
186
|
+
"""Print registered events."""
|
|
187
|
+
url = f"{get_base_url()}/events"
|
|
188
|
+
try:
|
|
189
|
+
response = requests.get(url)
|
|
190
|
+
if response.status_code == 200:
|
|
191
|
+
events = response.json().get("events", [])
|
|
192
|
+
if not events:
|
|
193
|
+
click.echo("No event patterns registered.")
|
|
194
|
+
for event in events:
|
|
195
|
+
click.echo(f"- {event}")
|
|
196
|
+
else:
|
|
197
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
198
|
+
except requests.exceptions.ConnectionError:
|
|
199
|
+
click.echo(f"Error: Could not connect to pgframx server. Is it running?", err=True)
|
|
200
|
+
|
|
201
|
+
@list_group.command(name="listeners")
|
|
202
|
+
def list_listeners():
|
|
203
|
+
"""Print registered listeners."""
|
|
204
|
+
url = f"{get_base_url()}/listeners"
|
|
205
|
+
try:
|
|
206
|
+
response = requests.get(url)
|
|
207
|
+
if response.status_code == 200:
|
|
208
|
+
listeners = response.json().get("listeners", [])
|
|
209
|
+
if not listeners:
|
|
210
|
+
click.echo("No listeners registered.")
|
|
211
|
+
for l in listeners:
|
|
212
|
+
click.echo(f"ID: {l['id']}")
|
|
213
|
+
click.echo(f" Pattern: {l['pattern']}")
|
|
214
|
+
click.echo(f" Dynamic: {l['is_dynamic']}")
|
|
215
|
+
if l['is_dynamic']:
|
|
216
|
+
click.echo(f" Webhook: {l['webhook_url']}")
|
|
217
|
+
else:
|
|
218
|
+
click.echo(f" Callback: {l['callback_name']}")
|
|
219
|
+
click.echo("")
|
|
220
|
+
else:
|
|
221
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
222
|
+
except requests.exceptions.ConnectionError:
|
|
223
|
+
click.echo(f"Error: Could not connect to pgframx server. Is it running?", err=True)
|
|
224
|
+
|
|
225
|
+
@main.group(name="schedule")
|
|
226
|
+
def schedule_group():
|
|
227
|
+
"""Manage scheduled jobs."""
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
@schedule_group.command(name="list")
|
|
231
|
+
def list_jobs():
|
|
232
|
+
"""List scheduled jobs."""
|
|
233
|
+
url = f"{get_base_url()}/health/scheduler"
|
|
234
|
+
try:
|
|
235
|
+
response = requests.get(url)
|
|
236
|
+
if response.status_code == 200:
|
|
237
|
+
data = response.json()
|
|
238
|
+
if not data.get("scheduler_enabled"):
|
|
239
|
+
click.echo("Scheduler is disabled.")
|
|
240
|
+
return
|
|
241
|
+
jobs = data.get("jobs", [])
|
|
242
|
+
if not jobs:
|
|
243
|
+
click.echo("No scheduled jobs found.")
|
|
244
|
+
for job in jobs:
|
|
245
|
+
click.echo(f"Job ID: {job['id']}")
|
|
246
|
+
click.echo(f" Event Name: {job['name']}")
|
|
247
|
+
click.echo(f" Trigger: {job['trigger']}")
|
|
248
|
+
click.echo(f" Next Run: {job['next_run_time']}")
|
|
249
|
+
click.echo("")
|
|
250
|
+
else:
|
|
251
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
252
|
+
except requests.exceptions.ConnectionError:
|
|
253
|
+
click.echo(f"Error: Could not connect to pgframx server. Is it running?", err=True)
|
|
254
|
+
|
|
255
|
+
@schedule_group.command(name="add")
|
|
256
|
+
@click.argument("event_name")
|
|
257
|
+
@click.option("--cron", default=None, help="Cron expression (e.g. '*/5 * * * *')")
|
|
258
|
+
@click.option("--interval", default=None, type=int, help="Interval in seconds")
|
|
259
|
+
@click.option("--data", default="{}", help="JSON string event data payload")
|
|
260
|
+
def add_job(event_name, cron, interval, data):
|
|
261
|
+
"""Schedule a recurring event."""
|
|
262
|
+
url = f"{get_base_url()}/health/scheduler/jobs"
|
|
263
|
+
try:
|
|
264
|
+
parsed_data = json.loads(data)
|
|
265
|
+
except json.JSONDecodeError as e:
|
|
266
|
+
click.echo(f"Error parsing JSON payload: {e}", err=True)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
payload = {
|
|
270
|
+
"name": event_name,
|
|
271
|
+
"cron": cron,
|
|
272
|
+
"interval": interval,
|
|
273
|
+
"data": parsed_data
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
response = requests.post(url, json=payload)
|
|
278
|
+
if response.status_code == 201:
|
|
279
|
+
click.echo(json.dumps(response.json(), indent=2))
|
|
280
|
+
else:
|
|
281
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
282
|
+
except requests.exceptions.ConnectionError:
|
|
283
|
+
click.echo(f"Error: Could not connect to pgframx server. Is it running?", err=True)
|
|
284
|
+
|
|
285
|
+
@schedule_group.command(name="remove")
|
|
286
|
+
@click.argument("job_id")
|
|
287
|
+
def remove_job(job_id):
|
|
288
|
+
"""Remove a scheduled job by ID."""
|
|
289
|
+
url = f"{get_base_url()}/health/scheduler/jobs/{job_id}"
|
|
290
|
+
try:
|
|
291
|
+
response = requests.delete(url)
|
|
292
|
+
if response.status_code == 200:
|
|
293
|
+
click.echo(response.json().get("message"))
|
|
294
|
+
else:
|
|
295
|
+
click.echo(f"Error ({response.status_code}): {response.text}", err=True)
|
|
296
|
+
except requests.exceptions.ConnectionError:
|
|
297
|
+
click.echo(f"Error: Could not connect to pgframx server. Is it running?", err=True)
|
pgframx/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
|
|
4
|
+
# Try loading from .pgframx.env first, fallback to standard .env
|
|
5
|
+
if os.path.exists(".pgframx.env"):
|
|
6
|
+
load_dotenv(".pgframx.env")
|
|
7
|
+
else:
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
PGFRAMX_HOST = os.getenv("PGFRAMX_HOST", "0.0.0.0")
|
|
12
|
+
PGFRAMX_PORT = int(os.getenv("PGFRAMX_PORT", "5050"))
|
|
13
|
+
PGFRAMX_DEBUG = os.getenv("PGFRAMX_DEBUG", "False").lower() in ("true", "1", "yes")
|
|
14
|
+
PGFRAMX_LOG_LEVEL = os.getenv("PGFRAMX_LOG_LEVEL", "INFO")
|
|
15
|
+
PGFRAMX_SCHEDULER_ENABLED = os.getenv("PGFRAMX_SCHEDULER_ENABLED", "True").lower() in ("true", "1", "yes")
|
|
16
|
+
PGFRAMX_EVENT_LOG_MAX = int(os.getenv("PGFRAMX_EVENT_LOG_MAX", "1000"))
|
|
17
|
+
PGFRAMX_THREAD_POOL_SIZE = int(os.getenv("PGFRAMX_THREAD_POOL_SIZE", "10"))
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def to_dict(cls) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"PGFRAMX_HOST": cls.PGFRAMX_HOST,
|
|
23
|
+
"PGFRAMX_PORT": cls.PGFRAMX_PORT,
|
|
24
|
+
"PGFRAMX_DEBUG": cls.PGFRAMX_DEBUG,
|
|
25
|
+
"PGFRAMX_LOG_LEVEL": cls.PGFRAMX_LOG_LEVEL,
|
|
26
|
+
"PGFRAMX_SCHEDULER_ENABLED": cls.PGFRAMX_SCHEDULER_ENABLED,
|
|
27
|
+
"PGFRAMX_EVENT_LOG_MAX": cls.PGFRAMX_EVENT_LOG_MAX,
|
|
28
|
+
"PGFRAMX_THREAD_POOL_SIZE": cls.PGFRAMX_THREAD_POOL_SIZE
|
|
29
|
+
}
|
pgframx/event.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Event:
|
|
6
|
+
name: str
|
|
7
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
8
|
+
meta: Dict[str, Any] = field(default_factory=dict) # timestamp, source, trace_id, etc.
|
|
9
|
+
status: str = "pending" # pending | dispatched | failed
|
pgframx/hooks.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from typing import Callable, Dict, List
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("pgframx.hooks")
|
|
6
|
+
|
|
7
|
+
class HookRegistry:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
# Maps patterns (str) to lists of callbacks
|
|
10
|
+
self.before_hooks: Dict[str, List[Callable]] = {}
|
|
11
|
+
self.after_hooks: Dict[str, List[Callable]] = {}
|
|
12
|
+
|
|
13
|
+
def register_before(self, pattern: str, callback: Callable):
|
|
14
|
+
if pattern not in self.before_hooks:
|
|
15
|
+
self.before_hooks[pattern] = []
|
|
16
|
+
self.before_hooks[pattern].append(callback)
|
|
17
|
+
logger.debug(f"Registered before hook on pattern '{pattern}': {callback.__name__}")
|
|
18
|
+
|
|
19
|
+
def register_after(self, pattern: str, callback: Callable):
|
|
20
|
+
if pattern not in self.after_hooks:
|
|
21
|
+
self.after_hooks[pattern] = []
|
|
22
|
+
self.after_hooks[pattern].append(callback)
|
|
23
|
+
logger.debug(f"Registered after hook on pattern '{pattern}': {callback.__name__}")
|
|
24
|
+
|
|
25
|
+
def run_before(self, event) -> bool:
|
|
26
|
+
"""Runs all before hooks matching the event name.
|
|
27
|
+
If a hook raises an exception, execution stops and we return False (failed).
|
|
28
|
+
Otherwise returns True.
|
|
29
|
+
"""
|
|
30
|
+
for pattern, callbacks in self.before_hooks.items():
|
|
31
|
+
if fnmatch.fnmatch(event.name, pattern):
|
|
32
|
+
for callback in callbacks:
|
|
33
|
+
try:
|
|
34
|
+
callback(event)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Before hook '{callback.__name__}' failed for event '{event.name}': {e}", exc_info=True)
|
|
37
|
+
event.status = "failed"
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def run_after(self, event):
|
|
42
|
+
"""Runs all after hooks matching the event name.
|
|
43
|
+
Failure in an after hook does not halt event execution but is logged.
|
|
44
|
+
"""
|
|
45
|
+
for pattern, callbacks in self.after_hooks.items():
|
|
46
|
+
if fnmatch.fnmatch(event.name, pattern):
|
|
47
|
+
for callback in callbacks:
|
|
48
|
+
try:
|
|
49
|
+
callback(event)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"After hook '{callback.__name__}' failed for event '{event.name}': {e}", exc_info=True)
|
pgframx/listeners.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import fnmatch
|
|
3
|
+
import requests
|
|
4
|
+
from typing import Callable, Dict, List, Any
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("pgframx.listeners")
|
|
8
|
+
|
|
9
|
+
class Listener:
|
|
10
|
+
def __init__(self, pattern: str, callback: Callable, listener_id: str = None, is_dynamic: bool = False, webhook_url: str = None):
|
|
11
|
+
self.id = listener_id or str(uuid.uuid4())
|
|
12
|
+
self.pattern = pattern
|
|
13
|
+
self.callback = callback
|
|
14
|
+
self.is_dynamic = is_dynamic
|
|
15
|
+
self.webhook_url = webhook_url
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
"id": self.id,
|
|
20
|
+
"pattern": self.pattern,
|
|
21
|
+
"callback_name": self.callback.__name__ if self.callback else None,
|
|
22
|
+
"is_dynamic": self.is_dynamic,
|
|
23
|
+
"webhook_url": self.webhook_url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def execute(self, event) -> bool:
|
|
27
|
+
"""Executes the listener's callback or sends a webhook if dynamic.
|
|
28
|
+
Returns True if successful, False otherwise.
|
|
29
|
+
"""
|
|
30
|
+
if self.webhook_url:
|
|
31
|
+
try:
|
|
32
|
+
# Dynamic webhook listener
|
|
33
|
+
response = requests.post(self.webhook_url, json={
|
|
34
|
+
"event_name": event.name,
|
|
35
|
+
"data": event.data,
|
|
36
|
+
"meta": event.meta,
|
|
37
|
+
"status": event.status
|
|
38
|
+
}, timeout=5)
|
|
39
|
+
if response.status_code >= 200 and response.status_code < 300:
|
|
40
|
+
return True
|
|
41
|
+
else:
|
|
42
|
+
logger.error(f"Webhook listener {self.id} returned status code {response.status_code}")
|
|
43
|
+
return False
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f"Webhook listener {self.id} failed: {e}", exc_info=True)
|
|
46
|
+
return False
|
|
47
|
+
elif self.callback:
|
|
48
|
+
try:
|
|
49
|
+
self.callback(event)
|
|
50
|
+
return True
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Callback listener {self.id} failed: {e}", exc_info=True)
|
|
53
|
+
return False
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ListenerRegistry:
|
|
58
|
+
def __init__(self):
|
|
59
|
+
# Dictionary of listener_id -> Listener
|
|
60
|
+
self.listeners: Dict[str, Listener] = {}
|
|
61
|
+
|
|
62
|
+
def register(self, pattern: str, callback: Callable = None, listener_id: str = None, webhook_url: str = None) -> str:
|
|
63
|
+
is_dynamic = webhook_url is not None
|
|
64
|
+
listener = Listener(
|
|
65
|
+
pattern=pattern,
|
|
66
|
+
callback=callback,
|
|
67
|
+
listener_id=listener_id,
|
|
68
|
+
is_dynamic=is_dynamic,
|
|
69
|
+
webhook_url=webhook_url
|
|
70
|
+
)
|
|
71
|
+
self.listeners[listener.id] = listener
|
|
72
|
+
logger.debug(f"Registered listener {listener.id} for pattern '{pattern}'")
|
|
73
|
+
return listener.id
|
|
74
|
+
|
|
75
|
+
def deregister(self, listener_id: str) -> bool:
|
|
76
|
+
if listener_id in self.listeners:
|
|
77
|
+
del self.listeners[listener_id]
|
|
78
|
+
logger.debug(f"Deregistered listener {listener_id}")
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def get_listeners_for_event(self, event_name: str) -> List[Listener]:
|
|
83
|
+
matched = []
|
|
84
|
+
for listener in self.listeners.values():
|
|
85
|
+
if fnmatch.fnmatch(event_name, listener.pattern):
|
|
86
|
+
matched.append(listener)
|
|
87
|
+
return matched
|
|
88
|
+
|
|
89
|
+
def list_all(self) -> List[Listener]:
|
|
90
|
+
return list(self.listeners.values())
|
pgframx/orchestrator.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
import logging
|
|
4
|
+
from collections import deque
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from pgframx.event import Event
|
|
10
|
+
from pgframx.listeners import ListenerRegistry
|
|
11
|
+
from pgframx.hooks import HookRegistry
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("pgframx.orchestrator")
|
|
14
|
+
|
|
15
|
+
class Orchestrator:
|
|
16
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
17
|
+
self.config = config or {}
|
|
18
|
+
self.start_time = time.time()
|
|
19
|
+
|
|
20
|
+
# Load config limits
|
|
21
|
+
self.event_log_max = int(self.config.get("PGFRAMX_EVENT_LOG_MAX", 1000))
|
|
22
|
+
self.scheduler_enabled = bool(self.config.get("PGFRAMX_SCHEDULER_ENABLED", True))
|
|
23
|
+
|
|
24
|
+
# Initialize registries
|
|
25
|
+
self.listeners = ListenerRegistry()
|
|
26
|
+
self.hooks = HookRegistry()
|
|
27
|
+
|
|
28
|
+
# Event dispatch history log
|
|
29
|
+
self.event_log = deque(maxlen=self.event_log_max)
|
|
30
|
+
|
|
31
|
+
# Thread Pool for async emissions
|
|
32
|
+
self.executor = ThreadPoolExecutor(max_workers=self.config.get("PGFRAMX_THREAD_POOL_SIZE", 10))
|
|
33
|
+
|
|
34
|
+
# Scheduler placeholder (initialized dynamically to avoid circular imports)
|
|
35
|
+
self.scheduler = None
|
|
36
|
+
if self.scheduler_enabled:
|
|
37
|
+
from pgframx.scheduler import EventScheduler
|
|
38
|
+
self.scheduler = EventScheduler(self)
|
|
39
|
+
self.scheduler.start()
|
|
40
|
+
|
|
41
|
+
# Decorators
|
|
42
|
+
def on(self, pattern: str):
|
|
43
|
+
"""Decorator to register an event listener."""
|
|
44
|
+
def decorator(func: Callable):
|
|
45
|
+
self.listeners.register(pattern, callback=func)
|
|
46
|
+
return func
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
49
|
+
def before(self, pattern: str):
|
|
50
|
+
"""Decorator to register a before hook."""
|
|
51
|
+
def decorator(func: Callable):
|
|
52
|
+
self.hooks.register_before(pattern, func)
|
|
53
|
+
return func
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
def after(self, pattern: str):
|
|
57
|
+
"""Decorator to register an after hook."""
|
|
58
|
+
def decorator(func: Callable):
|
|
59
|
+
self.hooks.register_after(pattern, func)
|
|
60
|
+
return func
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
# Scheduler delegation
|
|
64
|
+
def schedule(self, event_name: str, cron: Optional[str] = None, interval: Optional[int] = None, data: Optional[Dict[str, Any]] = None) -> str:
|
|
65
|
+
"""Schedules a recurring event using cron or interval."""
|
|
66
|
+
if not self.scheduler:
|
|
67
|
+
raise RuntimeError("Scheduler is disabled or not initialized.")
|
|
68
|
+
return self.scheduler.add_job(event_name, cron=cron, interval=interval, data=data)
|
|
69
|
+
|
|
70
|
+
def unschedule(self, job_id: str) -> bool:
|
|
71
|
+
"""Removes a scheduled job."""
|
|
72
|
+
if not self.scheduler:
|
|
73
|
+
raise RuntimeError("Scheduler is disabled or not initialized.")
|
|
74
|
+
return self.scheduler.remove_job(job_id)
|
|
75
|
+
|
|
76
|
+
# Core Dispatcher
|
|
77
|
+
def emit(self, name: str, data: Optional[Dict[str, Any]] = None, meta: Optional[Dict[str, Any]] = None, async_dispatch: bool = False) -> Event:
|
|
78
|
+
"""Emits an event and dispatches it to matched listeners."""
|
|
79
|
+
data = data or {}
|
|
80
|
+
meta = meta or {}
|
|
81
|
+
|
|
82
|
+
# Build meta defaults
|
|
83
|
+
meta.setdefault("timestamp", datetime.utcnow().isoformat() + "Z")
|
|
84
|
+
meta.setdefault("trace_id", str(uuid.uuid4()))
|
|
85
|
+
meta.setdefault("source", "pgframx")
|
|
86
|
+
|
|
87
|
+
event = Event(name=name, data=data, meta=meta, status="pending")
|
|
88
|
+
self.event_log.append(event)
|
|
89
|
+
|
|
90
|
+
if async_dispatch:
|
|
91
|
+
self.executor.submit(self._run_dispatch, event)
|
|
92
|
+
logger.debug(f"Submitted event '{name}' for async dispatch (trace_id: {meta['trace_id']})")
|
|
93
|
+
else:
|
|
94
|
+
self._run_dispatch(event)
|
|
95
|
+
|
|
96
|
+
return event
|
|
97
|
+
|
|
98
|
+
def _run_dispatch(self, event: Event):
|
|
99
|
+
logger.info(f"Dispatching event '{event.name}' (trace_id: {event.meta['trace_id']})")
|
|
100
|
+
|
|
101
|
+
# 1. Run before hooks
|
|
102
|
+
if not self.hooks.run_before(event):
|
|
103
|
+
logger.warning(f"Event '{event.name}' execution aborted by before hooks")
|
|
104
|
+
event.status = "failed"
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# 2. Find listeners
|
|
108
|
+
matched_listeners = self.listeners.get_listeners_for_event(event.name)
|
|
109
|
+
if not matched_listeners:
|
|
110
|
+
logger.debug(f"No listeners found matching event name '{event.name}'")
|
|
111
|
+
event.status = "dispatched"
|
|
112
|
+
# Still run after hooks
|
|
113
|
+
self.hooks.run_after(event)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# 3. Execute listeners
|
|
117
|
+
all_success = True
|
|
118
|
+
for listener in matched_listeners:
|
|
119
|
+
logger.debug(f"Executing listener '{listener.id}' for event '{event.name}'")
|
|
120
|
+
success = listener.execute(event)
|
|
121
|
+
if not success:
|
|
122
|
+
all_success = False
|
|
123
|
+
|
|
124
|
+
event.status = "dispatched" if all_success else "failed"
|
|
125
|
+
logger.info(f"Finished dispatching event '{event.name}'. Status: {event.status}")
|
|
126
|
+
|
|
127
|
+
# 4. Run after hooks
|
|
128
|
+
self.hooks.run_after(event)
|
|
129
|
+
|
|
130
|
+
def shutdown(self):
|
|
131
|
+
"""Clean shutdown of thread pool and scheduler."""
|
|
132
|
+
if self.scheduler:
|
|
133
|
+
self.scheduler.shutdown()
|
|
134
|
+
self.executor.shutdown(wait=False)
|
pgframx/scheduler.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
4
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
5
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("pgframx.scheduler")
|
|
8
|
+
|
|
9
|
+
class EventScheduler:
|
|
10
|
+
def __init__(self, orchestrator):
|
|
11
|
+
self.orchestrator = orchestrator
|
|
12
|
+
self.scheduler = BackgroundScheduler()
|
|
13
|
+
|
|
14
|
+
def start(self):
|
|
15
|
+
"""Starts the background scheduler thread."""
|
|
16
|
+
if not self.scheduler.running:
|
|
17
|
+
self.scheduler.start()
|
|
18
|
+
logger.info("Background event scheduler started.")
|
|
19
|
+
|
|
20
|
+
def shutdown(self):
|
|
21
|
+
"""Shuts down the scheduler."""
|
|
22
|
+
if self.scheduler.running:
|
|
23
|
+
self.scheduler.shutdown()
|
|
24
|
+
logger.info("Background event scheduler shut down.")
|
|
25
|
+
|
|
26
|
+
def add_job(self, event_name: str, cron: Optional[str] = None, interval: Optional[int] = None, data: Optional[Dict[str, Any]] = None) -> str:
|
|
27
|
+
"""Adds a job to emit an event periodically.
|
|
28
|
+
cron can be a standard 5-field cron string or a dictionary of cron parameters.
|
|
29
|
+
interval is the duration in seconds.
|
|
30
|
+
"""
|
|
31
|
+
data = data or {}
|
|
32
|
+
|
|
33
|
+
# Define the trigger function
|
|
34
|
+
def trigger_emit():
|
|
35
|
+
logger.info(f"Scheduled trigger fired for event '{event_name}'")
|
|
36
|
+
self.orchestrator.emit(event_name, data=data, meta={"source": "scheduler"})
|
|
37
|
+
|
|
38
|
+
# Build trigger
|
|
39
|
+
if cron:
|
|
40
|
+
# Simple 5-field cron parsing or direct string
|
|
41
|
+
if isinstance(cron, str):
|
|
42
|
+
parts = cron.split()
|
|
43
|
+
if len(parts) == 5:
|
|
44
|
+
trigger = CronTrigger(
|
|
45
|
+
minute=parts[0],
|
|
46
|
+
hour=parts[1],
|
|
47
|
+
day=parts[2],
|
|
48
|
+
month=parts[3],
|
|
49
|
+
day_of_week=parts[4]
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Try direct cron expression parsing
|
|
53
|
+
trigger = CronTrigger.from_crontab(cron)
|
|
54
|
+
else:
|
|
55
|
+
trigger = CronTrigger(**cron)
|
|
56
|
+
elif interval is not None:
|
|
57
|
+
trigger = IntervalTrigger(seconds=int(interval))
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError("Must specify either 'cron' or 'interval'")
|
|
60
|
+
|
|
61
|
+
job = self.scheduler.add_job(
|
|
62
|
+
trigger_emit,
|
|
63
|
+
trigger=trigger,
|
|
64
|
+
name=event_name,
|
|
65
|
+
replace_existing=True
|
|
66
|
+
)
|
|
67
|
+
logger.info(f"Added scheduled job {job.id} for event '{event_name}'")
|
|
68
|
+
return job.id
|
|
69
|
+
|
|
70
|
+
def remove_job(self, job_id: str) -> bool:
|
|
71
|
+
"""Removes a scheduled job by its ID."""
|
|
72
|
+
try:
|
|
73
|
+
self.scheduler.remove_job(job_id)
|
|
74
|
+
logger.info(f"Removed scheduled job {job_id}")
|
|
75
|
+
return True
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(f"Failed to remove job {job_id}: {e}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def list_jobs(self) -> List[Dict[str, Any]]:
|
|
81
|
+
"""Lists details of all currently scheduled jobs."""
|
|
82
|
+
jobs_list = []
|
|
83
|
+
for job in self.scheduler.get_jobs():
|
|
84
|
+
jobs_list.append({
|
|
85
|
+
"id": job.id,
|
|
86
|
+
"name": job.name,
|
|
87
|
+
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
|
|
88
|
+
"trigger": str(job.trigger)
|
|
89
|
+
})
|
|
90
|
+
return jobs_list
|
pgframx/utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
def setup_logging(level: str = "INFO"):
|
|
6
|
+
"""Sets up default logging console handlers for the package."""
|
|
7
|
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
|
8
|
+
|
|
9
|
+
# Configure root logger or package logger
|
|
10
|
+
logger = logging.getLogger("pgframx")
|
|
11
|
+
logger.setLevel(log_level)
|
|
12
|
+
|
|
13
|
+
# Check if handler already exists
|
|
14
|
+
if not logger.handlers:
|
|
15
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
16
|
+
handler.setLevel(log_level)
|
|
17
|
+
formatter = logging.Formatter(
|
|
18
|
+
'[%(asctime)s] %(levelname)s in %(name)s: %(message)s'
|
|
19
|
+
)
|
|
20
|
+
handler.setFormatter(formatter)
|
|
21
|
+
logger.addHandler(handler)
|
|
22
|
+
|
|
23
|
+
logger.info(f"Logging initialized at level: {level}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgframx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Flask-based event management orchestration framework
|
|
5
|
+
Project-URL: Homepage, https://github.com/pgwiz/pgframx
|
|
6
|
+
Project-URL: Repository, https://github.com/pgwiz/pgframx
|
|
7
|
+
Author-email: pgwiz <peter@wiptech.co.ke>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 pgwiz
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: events,flask,framework,orchestration,scheduler
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Framework :: Flask
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
37
|
+
Requires-Python: >=3.10
|
|
38
|
+
Requires-Dist: apscheduler>=3.10.0
|
|
39
|
+
Requires-Dist: click>=8.1.0
|
|
40
|
+
Requires-Dist: flask>=3.0.0
|
|
41
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
42
|
+
Requires-Dist: requests>=2.28.0
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# pgframx
|
|
46
|
+
|
|
47
|
+
**Flask-based event management orchestration framework.**
|
|
48
|
+
|
|
49
|
+
Orchestrate events, not chaos.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install pgframx
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pgframx import Orchestrator, create_app
|
|
61
|
+
|
|
62
|
+
orc = Orchestrator()
|
|
63
|
+
|
|
64
|
+
@orc.on("order.placed")
|
|
65
|
+
def handle_order(event):
|
|
66
|
+
print(f"Order received: {event.data}")
|
|
67
|
+
|
|
68
|
+
app = create_app(orc)
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
app.run(port=5050)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pgframx init # Create a new project scaffold in current directory
|
|
78
|
+
pgframx run # Start the Flask orchestrator server
|
|
79
|
+
pgframx emit <event> --data '{}' # Emit event from CLI to running server
|
|
80
|
+
pgframx list events # List all registered events
|
|
81
|
+
pgframx list listeners # List all registered listeners
|
|
82
|
+
pgframx schedule list # List scheduled cron/interval jobs
|
|
83
|
+
pgframx schedule add <event> --interval 10 # Schedule recurring events
|
|
84
|
+
pgframx schedule remove <job_id> # Remove scheduled jobs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Features
|
|
88
|
+
- **Decorators**: Event registration, before hooks, and after hooks.
|
|
89
|
+
- **Wildcard matching**: Match events using wildcards (`user.*`).
|
|
90
|
+
- **Scheduling**: Chronological/interval-based triggers via APScheduler.
|
|
91
|
+
- **REST API**: Mountable blueprints exposing route endpoints for events, listeners, and health checks.
|
|
92
|
+
- **Dynamic Webhooks**: Register webhooks dynamically via the REST API or CLI.
|
|
93
|
+
- **Full CLI**: Manage your event broker process from the terminal.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
pgframx/__init__.py,sha256=JzZZuDXXLU6qwoFD5m9MESXPNTRQdEKbxYtozwNZ1l4,187
|
|
2
|
+
pgframx/app.py,sha256=dFIGjLZ1OqA91kHsvJAgI88SDeSWqpxLnkZ9ES4Ypq4,1031
|
|
3
|
+
pgframx/cli.py,sha256=Kih3vLpeP3LFRStHldBG8x-jH0kn6wSZ_yvWBJmtamA,10362
|
|
4
|
+
pgframx/config.py,sha256=Ug37Iunr4P5bnfwfUjw7AhEYobNWxpyQHslAnEcoCjA,1221
|
|
5
|
+
pgframx/event.py,sha256=B_Zer7yOm_K0vQRVq9xljRNL1gklajU1HjQvBrCO170,317
|
|
6
|
+
pgframx/hooks.py,sha256=7sBjgj88OG81WU3RLLWZhtH9TaRR9jqYObjrk2Wwd1o,2216
|
|
7
|
+
pgframx/listeners.py,sha256=zjyz4aJnCjBqsbqLjkDwQudiMbanC2B91cT_ZW3ebMk,3322
|
|
8
|
+
pgframx/orchestrator.py,sha256=XN-ShoDstwk_9Czy6mOsJ7KSfWBFrEAICEbeZfmkl_4,5172
|
|
9
|
+
pgframx/scheduler.py,sha256=q4mNzUa9_m1GAa-qpHGG--3rJXvB-hcTh1FnhNm8QKg,3405
|
|
10
|
+
pgframx/utils.py,sha256=FqOZKY-Oqb-eYam_ZAL9TkeUGW5XnZ2SlERBUy0Ih_k,763
|
|
11
|
+
pgframx/blueprints/__init__.py,sha256=mjHMod2Wy7o3mm69lGLJ4bkJQek3dnW-GBA7kn9Sv7k,204
|
|
12
|
+
pgframx/blueprints/events.py,sha256=QiNTVYrNVgqzPFbP35gPKypBqXnKLKvA6SLkxOH7MBY,1909
|
|
13
|
+
pgframx/blueprints/health.py,sha256=guwZNivc2H51qtgjNGBg7x56bfm7I-lzs1S9yPaULzI,2415
|
|
14
|
+
pgframx/blueprints/listeners.py,sha256=tn2mUQMEJWS59jPTol0amL3YNsxB5y8-Ac_6Cn6oT6w,1473
|
|
15
|
+
pgframx-0.1.0.dist-info/METADATA,sha256=TlAQef4o-COuUuvPfw0VLb3ktNfLfHm8P9P-CICc6uE,3542
|
|
16
|
+
pgframx-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
pgframx-0.1.0.dist-info/entry_points.txt,sha256=rbr83kzoA-b00fru1k5aD2yldsIf6_WiIewK4bKt5FE,45
|
|
18
|
+
pgframx-0.1.0.dist-info/licenses/LICENSE,sha256=ENolhwlfECA8Dc-6NbN2jWAnhZEsbrjsfBO7HZUmIu8,1062
|
|
19
|
+
pgframx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pgwiz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|