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 ADDED
@@ -0,0 +1,7 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from pgframx.orchestrator import Orchestrator
4
+ from pgframx.event import Event
5
+ from pgframx.app import create_app
6
+
7
+ __all__ = ["Orchestrator", "Event", "create_app"]
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,5 @@
1
+ from pgframx.blueprints.events import events_bp
2
+ from pgframx.blueprints.listeners import listeners_bp
3
+ from pgframx.blueprints.health import health_bp
4
+
5
+ __all__ = ["events_bp", "listeners_bp", "health_bp"]
@@ -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())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pgframx = pgframx.cli:main
@@ -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.