spoe-forge 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.3
2
+ Name: spoe-forge
3
+ Version: 0.0.1
4
+ Summary: A pure Python framework for building HAProxy SPOE agents
5
+ Keywords: haproxy,spoe,spoa,spop
6
+ Author: Mike O'Donnell
7
+ Author-email: Mike O'Donnell <mike@devferret.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: pre-commit>=4.1.0 ; extra == 'dev'
15
+ Requires-Dist: ruff==0.14.2 ; extra == 'dev'
16
+ Requires-Dist: pytest==9.0.1 ; extra == 'test'
17
+ Requires-Dist: pytest-asyncio==1.3.0 ; extra == 'test'
18
+ Requires-Dist: coveralls==4.0.2 ; extra == 'test'
19
+ Requires-Dist: coverage==7.13.0 ; extra == 'test'
20
+ Requires-Dist: pytest-cov==7.0.0 ; extra == 'test'
21
+ Requires-Python: >=3.12
22
+ Project-URL: Homepage, https://github.com/mwodonnell/spoe-forge
23
+ Project-URL: Repository, https://github.com/mwodonnell/spoe-forge
24
+ Provides-Extra: dev
25
+ Provides-Extra: test
26
+ Description-Content-Type: text/markdown
27
+
28
+ # SPOE Forge
29
+
30
+ A pure Python framework for building SPOE (Stream Processing Offload Engine) agents that communicate
31
+ with HAProxy using the SPOA protocol.
32
+
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
35
+ [![Coverage Status](https://coveralls.io/repos/github/mwodonnell/spoe-forge/badge.svg?branch=unit-tests)](https://coveralls.io/github/mwodonnell/spoe-forge?branch=unit-tests)
36
+
37
+ ## Overview
38
+
39
+ SPOE Forge provides a clean, decorator-based API for creating agents that process HAProxy messages and return
40
+ actions. Built with async/await throughout, it's designed for high-performance production environments. *Or at least as
41
+ performant as python will allow.*
42
+
43
+ ### Why SPOE Forge?
44
+
45
+ Originally created to power a Google OAuth2 authentication backend for HAProxy, it became clear the project
46
+ could be converted to an abstracted framework. I noticed during the development of this project that there
47
+ was a lack of well-maintained, easily understood implementations of the SPOA protocol in python.
48
+
49
+ ### Key Features
50
+
51
+ - **Simple decorator-based API** - Register message handlers with `@agent.message()`
52
+ - **Full SPOP protocol support** - Complete implementation of the SPOA protocol
53
+ - **Health check support** - Built-in HAProxy health check handling
54
+
55
+ ## Installation
56
+
57
+ Install from PyPI:
58
+
59
+ ```bash
60
+ pip install spoe-forge
61
+ ```
62
+
63
+ ## Quick Start
64
+
65
+ ### Basic Example
66
+
67
+ ```python
68
+ from spoe_forge import (
69
+ SpoeForge,
70
+ AgentContext,
71
+ SetVarAction,
72
+ ActionScope
73
+ )
74
+
75
+ # Create an agent
76
+ agent = SpoeForge(name="my-agent", debug=False)
77
+
78
+ # Register a message handler
79
+ @agent.message("check-request")
80
+ def handle_request(ctx: AgentContext) -> list[SetVarAction]:
81
+ """Process incoming request and set HAProxy variables"""
82
+
83
+ # Get message arguments from HAProxy
84
+ client_ip = ctx.get_arg("client_ip")
85
+ request_path = ctx.get_arg("path")
86
+
87
+ # Your business logic here
88
+ is_allowed = check_access(client_ip, request_path)
89
+
90
+ # Return actions to set HAProxy variables
91
+ return [
92
+ SetVarAction(
93
+ scope=ActionScope.TRANSACTION,
94
+ name="access_allowed",
95
+ value=is_allowed
96
+ )
97
+ ]
98
+
99
+ # Start the server
100
+ if __name__ == "__main__":
101
+ agent.run(host="0.0.0.0", port=12345)
102
+ ```
103
+
104
+ ### HAProxy Configuration
105
+
106
+ SPOE Forge works with HAProxy's SPOE configuration. For details on configuring HAProxy to communicate with your
107
+ agent, see the [official HAProxy SPOE documentation](https://www.haproxy.org/download/3.3/doc/SPOE.txt).
108
+
109
+ ## Local Development
110
+
111
+ ### Running with Docker
112
+
113
+ A complete local development environment is provided using Docker Compose, including a sample SPOE agent,
114
+ HAProxy, and a test backend service.
115
+
116
+ **Quick start:**
117
+
118
+ ```bash
119
+ cd docker
120
+ docker compose up --build
121
+ ```
122
+
123
+ This starts three services:
124
+ - **SPOA Agent** (`spoa`) - Sample SPOE Forge agent running on port 8500
125
+ - **Whoami** (`whoami`) - Simple backend service for testing
126
+ - **HAProxy** (`haproxy`) - Configured to communicate with the WhoAmI example BE Service, the SPOA agent, and is listening on port 8080
127
+
128
+ **Test the setup:**
129
+
130
+ ```bash
131
+ # Open logs
132
+ docker compose logs
133
+
134
+ # Visit the dev url in your browser
135
+ http://localhost:8080
136
+ ```
137
+
138
+ Check both the docker logs and the `X-Test-Arg` header displayed on the WhoAmI page.
139
+
140
+ Make any updates to the HAProxy configs or the sample_server.py files in `./docker/` to support
141
+ your testing.
142
+
143
+ ## Roadmap
144
+
145
+ Future enhancements under consideration with no timeline guaranteed:
146
+
147
+ - Middleware support
148
+ - Much more extended documentation and examples
149
+
150
+ ## License
151
+
152
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
153
+
154
+ ## Contributing
155
+
156
+ Any and all contributions welcome.
157
+
158
+ ## Support
159
+
160
+ For issues and questions, please [file an issue on GitHub](https://github.com/mwodonnell/spoe-forge/issues).
161
+
162
+ ## Acknowledgments
163
+
164
+ Built to solve real-world production needs for HAProxy SPOA agents. Special thanks to the HAProxy team for
165
+ excellent documentation of the SPOE protocol.
166
+
167
+ Extra shoutout to [Christopher Faulet](https://github.com/capflam) for responding to some questions about a
168
+ few hiccups along the way.
@@ -0,0 +1,141 @@
1
+ # SPOE Forge
2
+
3
+ A pure Python framework for building SPOE (Stream Processing Offload Engine) agents that communicate
4
+ with HAProxy using the SPOA protocol.
5
+
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
8
+ [![Coverage Status](https://coveralls.io/repos/github/mwodonnell/spoe-forge/badge.svg?branch=unit-tests)](https://coveralls.io/github/mwodonnell/spoe-forge?branch=unit-tests)
9
+
10
+ ## Overview
11
+
12
+ SPOE Forge provides a clean, decorator-based API for creating agents that process HAProxy messages and return
13
+ actions. Built with async/await throughout, it's designed for high-performance production environments. *Or at least as
14
+ performant as python will allow.*
15
+
16
+ ### Why SPOE Forge?
17
+
18
+ Originally created to power a Google OAuth2 authentication backend for HAProxy, it became clear the project
19
+ could be converted to an abstracted framework. I noticed during the development of this project that there
20
+ was a lack of well-maintained, easily understood implementations of the SPOA protocol in python.
21
+
22
+ ### Key Features
23
+
24
+ - **Simple decorator-based API** - Register message handlers with `@agent.message()`
25
+ - **Full SPOP protocol support** - Complete implementation of the SPOA protocol
26
+ - **Health check support** - Built-in HAProxy health check handling
27
+
28
+ ## Installation
29
+
30
+ Install from PyPI:
31
+
32
+ ```bash
33
+ pip install spoe-forge
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ### Basic Example
39
+
40
+ ```python
41
+ from spoe_forge import (
42
+ SpoeForge,
43
+ AgentContext,
44
+ SetVarAction,
45
+ ActionScope
46
+ )
47
+
48
+ # Create an agent
49
+ agent = SpoeForge(name="my-agent", debug=False)
50
+
51
+ # Register a message handler
52
+ @agent.message("check-request")
53
+ def handle_request(ctx: AgentContext) -> list[SetVarAction]:
54
+ """Process incoming request and set HAProxy variables"""
55
+
56
+ # Get message arguments from HAProxy
57
+ client_ip = ctx.get_arg("client_ip")
58
+ request_path = ctx.get_arg("path")
59
+
60
+ # Your business logic here
61
+ is_allowed = check_access(client_ip, request_path)
62
+
63
+ # Return actions to set HAProxy variables
64
+ return [
65
+ SetVarAction(
66
+ scope=ActionScope.TRANSACTION,
67
+ name="access_allowed",
68
+ value=is_allowed
69
+ )
70
+ ]
71
+
72
+ # Start the server
73
+ if __name__ == "__main__":
74
+ agent.run(host="0.0.0.0", port=12345)
75
+ ```
76
+
77
+ ### HAProxy Configuration
78
+
79
+ SPOE Forge works with HAProxy's SPOE configuration. For details on configuring HAProxy to communicate with your
80
+ agent, see the [official HAProxy SPOE documentation](https://www.haproxy.org/download/3.3/doc/SPOE.txt).
81
+
82
+ ## Local Development
83
+
84
+ ### Running with Docker
85
+
86
+ A complete local development environment is provided using Docker Compose, including a sample SPOE agent,
87
+ HAProxy, and a test backend service.
88
+
89
+ **Quick start:**
90
+
91
+ ```bash
92
+ cd docker
93
+ docker compose up --build
94
+ ```
95
+
96
+ This starts three services:
97
+ - **SPOA Agent** (`spoa`) - Sample SPOE Forge agent running on port 8500
98
+ - **Whoami** (`whoami`) - Simple backend service for testing
99
+ - **HAProxy** (`haproxy`) - Configured to communicate with the WhoAmI example BE Service, the SPOA agent, and is listening on port 8080
100
+
101
+ **Test the setup:**
102
+
103
+ ```bash
104
+ # Open logs
105
+ docker compose logs
106
+
107
+ # Visit the dev url in your browser
108
+ http://localhost:8080
109
+ ```
110
+
111
+ Check both the docker logs and the `X-Test-Arg` header displayed on the WhoAmI page.
112
+
113
+ Make any updates to the HAProxy configs or the sample_server.py files in `./docker/` to support
114
+ your testing.
115
+
116
+ ## Roadmap
117
+
118
+ Future enhancements under consideration with no timeline guaranteed:
119
+
120
+ - Middleware support
121
+ - Much more extended documentation and examples
122
+
123
+ ## License
124
+
125
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
126
+
127
+ ## Contributing
128
+
129
+ Any and all contributions welcome.
130
+
131
+ ## Support
132
+
133
+ For issues and questions, please [file an issue on GitHub](https://github.com/mwodonnell/spoe-forge/issues).
134
+
135
+ ## Acknowledgments
136
+
137
+ Built to solve real-world production needs for HAProxy SPOA agents. Special thanks to the HAProxy team for
138
+ excellent documentation of the SPOE protocol.
139
+
140
+ Extra shoutout to [Christopher Faulet](https://github.com/capflam) for responding to some questions about a
141
+ few hiccups along the way.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.17,<0.10.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [tool.uv.build-backend]
6
+ module-name = "spoe_forge"
7
+ module-root = ""
8
+
9
+ [project]
10
+ name = "spoe-forge"
11
+ version = "0.0.1"
12
+ description = "A pure Python framework for building HAProxy SPOE agents"
13
+ authors = [
14
+ {name = "Mike O'Donnell", email = "mike@devferret.com"},
15
+ ]
16
+ readme = "README.md"
17
+ license = {text = "MIT"}
18
+ requires-python = ">=3.12"
19
+ keywords = ["haproxy", "spoe", "spoa", "spop"]
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.12",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/mwodonnell/spoe-forge"
30
+ Repository = "https://github.com/mwodonnell/spoe-forge"
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pre-commit>=4.1.0",
35
+ "ruff==0.14.2",
36
+ ]
37
+ test = [
38
+ "pytest==9.0.1",
39
+ "pytest-asyncio==1.3.0",
40
+ "coveralls==4.0.2",
41
+ "coverage==7.13.0",
42
+ "pytest-cov==7.0.0",
43
+ ]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ asyncio_mode = "auto"
48
+ addopts = [
49
+ "--verbose",
50
+ "--cov=spoe_forge",
51
+ "--cov-report=term-missing",
52
+ "--cov-report=html"
53
+ ]
@@ -0,0 +1,15 @@
1
+ """SPOE Forge - A Python framework for building HAProxy SPOA agents."""
2
+
3
+ from spoe_forge.spoe_forge import SpoeForge
4
+ from spoe_forge.spop.spop_types import SetVarAction, UnsetVarAction, Action
5
+ from spoe_forge.spop.constants import ActionScope
6
+ from spoe_forge.agent.context import AgentContext
7
+
8
+ __all__ = [
9
+ "SpoeForge",
10
+ "AgentContext",
11
+ "Action",
12
+ "ActionScope",
13
+ "SetVarAction",
14
+ "UnsetVarAction",
15
+ ]
File without changes
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ from spoe_forge.spop.spop_types import SpoaDataType
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class AgentContext:
9
+ """
10
+ Context object passed to message handlers containing message data.
11
+
12
+ Provides access to message name and arguments from HAProxy NOTIFY frames.
13
+ """
14
+
15
+ def __init__(self, message: str, args: dict[str, SpoaDataType]):
16
+ """
17
+ Create a new agent context.
18
+
19
+ :param str message: Message name from NOTIFY frame
20
+ :param dict[str, SpoaDataType] args: Message arguments
21
+ """
22
+ self._message = message
23
+ self._args = args
24
+
25
+ def get_arg(self, arg: str, default: SpoaDataType = None) -> SpoaDataType:
26
+ """
27
+ Retrieve a message argument by name.
28
+
29
+ :param str arg: Argument name to retrieve
30
+ :param default: Default value to return if argument is not present.
31
+ :return: Argument value, or None if not found
32
+ """
33
+ try:
34
+ return self._args[arg]
35
+ except KeyError:
36
+ return default
37
+
38
+ def get_args(self) -> dict[str, SpoaDataType]:
39
+ """
40
+ Retrieve all message arguments.
41
+
42
+ :return: Dictionary mapping argument names to values
43
+ """
44
+ return self._args
45
+
46
+ def has_arg(self, arg: str) -> bool:
47
+ """
48
+ Check if an argument exists in the message.
49
+
50
+ :param str arg: Argument name to check
51
+ :return: True if argument exists, False otherwise
52
+ """
53
+ return arg in self._args
@@ -0,0 +1,7 @@
1
+ from spoe_forge.exception import SpoeForgeError
2
+
3
+
4
+ class SpoeAgentError(SpoeForgeError):
5
+ """Exception raised when agent message processing fails."""
6
+
7
+ pass
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Callable
4
+
5
+ from spoe_forge.agent.context import AgentContext
6
+ from spoe_forge.agent.exceptions import SpoeAgentError
7
+ from spoe_forge.spop.spop_types import SpoaDataType, Action
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ MessageHandlerFunc = Callable[[AgentContext], list[Action]]
12
+
13
+
14
+ class AgentRegistry:
15
+ """
16
+ Internal registry mapping message names to handler functions.
17
+
18
+ Note: Users don't interact with this directly - it's used by the Agent class.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize a new agent registry."""
23
+ self._handlers: dict[str, MessageHandlerFunc] = {}
24
+ self._validation_cache: dict[str, bool] = {}
25
+
26
+ def register(self, message_name: str, handler: MessageHandlerFunc) -> None:
27
+ """
28
+ Register a handler for a message type.
29
+
30
+ :param str message_name: Message name to handle
31
+ :param MessageHandlerFunc handler: User-defined handler function
32
+ """
33
+ if message_name in self._handlers:
34
+ logger.warning(f"Overwriting existing handler for message '{message_name}'")
35
+
36
+ self._handlers[message_name] = handler
37
+ logger.debug(f"Registered handler for message '{message_name}'")
38
+
39
+ async def handle_message(
40
+ self, message: str, args: dict[str, SpoaDataType]
41
+ ) -> list[Action]:
42
+ """
43
+ Route a message to its registered handler.
44
+
45
+ Wraps synchronous handlers with asyncio.to_thread for async compatibility.
46
+
47
+ :param str message: Message name to handle
48
+ :param dict[str, SpoaDataType] args: Message arguments
49
+ :return: List of actions from handler (empty if no handler registered)
50
+ :raises SpoeAgentError: If handler raises an exception
51
+ """
52
+ handler = self._handlers.get(message)
53
+
54
+ if handler is None:
55
+ logger.warning(f"No handler registered for message '{message}'")
56
+ return []
57
+
58
+ ctx = AgentContext(message, args)
59
+ try:
60
+ return await asyncio.to_thread(handler, ctx)
61
+ except Exception as e:
62
+ logger.error(
63
+ f"Error in handler for message '{message}': {e}", exc_info=True
64
+ )
65
+ # Convert to our own error - will be caught in our own handlers to
66
+ # cause a graceful kill of the connection
67
+ raise SpoeAgentError(e)
@@ -0,0 +1,4 @@
1
+ class SpoeForgeError(Exception):
2
+ """Base exception class for all SPOE Forge errors."""
3
+
4
+ pass
@@ -0,0 +1,60 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ _default_handler = logging.StreamHandler(sys.stderr)
6
+ _default_handler.setFormatter(
7
+ logging.Formatter("%(levelname)s | %(asctime)s | %(name)s | %(message)s")
8
+ )
9
+
10
+
11
+ def _has_level_handler(logger: logging.Logger) -> bool:
12
+ """
13
+ Check if there is a handler in the logging chain that will handle the
14
+ given logger's effective level.
15
+
16
+ This traverses the logger hierarchy to check if any parent logger
17
+ has a handler configured. Based on Flask's implementation.
18
+
19
+ :param logger: Logger to check
20
+ :return: True if a handler exists in the chain, False otherwise
21
+ """
22
+ level = logger.getEffectiveLevel()
23
+ current = logger
24
+
25
+ while current:
26
+ if any(handler.level <= level for handler in current.handlers):
27
+ return True
28
+
29
+ if not current.propagate:
30
+ break
31
+
32
+ current = current.parent
33
+
34
+ return False
35
+
36
+
37
+ def create_logger(debug: bool = False) -> logging.Logger:
38
+ """
39
+ Get the SPOE Forge logger and configure it if needed.
40
+
41
+ This follows Flask's pattern:
42
+ - Uses 'spoe_forge' as the logger name
43
+ - Only adds default handler if no handler exists in the hierarchy
44
+ - Respects existing logging configuration
45
+
46
+ Internal function - called by spoe_forge.py at import time.
47
+
48
+ :return: Configured logger instance
49
+ """
50
+ logger = logging.getLogger("spoe_forge")
51
+
52
+ # Only add handler if none exists in the hierarchy
53
+ if not _has_level_handler(logger):
54
+ logger.addHandler(_default_handler)
55
+
56
+ # Set default level if not already set
57
+ if not logger.level:
58
+ logger.setLevel(logging.DEBUG if debug else logging.INFO)
59
+
60
+ return logger
File without changes