Compare commits
No commits in common. "master" and "v0.4.4" have entirely different histories.
19
README.md
19
README.md
@ -3,8 +3,6 @@
|
|||||||
simple event-based model over core Python utilities like `ThreadPoolExecutor` to
|
simple event-based model over core Python utilities like `ThreadPoolExecutor` to
|
||||||
facilitate reactivity and manage concurrent responses.
|
facilitate reactivity and manage concurrent responses.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
There are a few top-level classes exposed by the package:
|
There are a few top-level classes exposed by the package:
|
||||||
|
|
||||||
- **Router**: Central event routing object. Routers facilitate route registration,
|
- **Router**: Central event routing object. Routers facilitate route registration,
|
||||||
@ -19,20 +17,3 @@ There are a few top-level classes exposed by the package:
|
|||||||
iNotify to dynamically respond to file events.
|
iNotify to dynamically respond to file events.
|
||||||
- **Server**: Long-running process manager for listeners and optional live-reloading via
|
- **Server**: Long-running process manager for listeners and optional live-reloading via
|
||||||
HTTP. Interfaces with listener `start()` and `shutdown()` for graceful interruption.
|
HTTP. Interfaces with listener `start()` and `shutdown()` for graceful interruption.
|
||||||
|
|
||||||
# Install
|
|
||||||
```sh
|
|
||||||
pip install execlib
|
|
||||||
```
|
|
||||||
|
|
||||||
# Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
```sh
|
|
||||||
pip install execlib[docs]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
```sh
|
|
||||||
pip install execlib[tests]
|
|
||||||
```
|
|
||||||
|
BIN
docs/_static/execlib.png
vendored
BIN
docs/_static/execlib.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 331 KiB |
@ -32,6 +32,5 @@ reference/documentation/index
|
|||||||
```
|
```
|
||||||
|
|
||||||
```{include} ../README.md
|
```{include} ../README.md
|
||||||
:relative-docs: docs/
|
|
||||||
:relative-images:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -860,15 +860,14 @@ class Router[E: Event]:
|
|||||||
|
|
||||||
# manually track and cancel pending futures b/c `.shutdown(cancel_futures=True)`
|
# manually track and cancel pending futures b/c `.shutdown(cancel_futures=True)`
|
||||||
# is misleading, and will cause an outer `as_completed` loop to hang
|
# is misleading, and will cause an outer `as_completed` loop to hang
|
||||||
if self._active_futures:
|
for future in tqdm(
|
||||||
for future in tqdm(
|
list(self._active_futures),
|
||||||
list(self._active_futures),
|
desc=color_text(
|
||||||
desc=color_text(
|
f'Cancelling {len(self._active_futures)} pending futures...',
|
||||||
f'Cancelling {len(self._active_futures)} pending futures...',
|
Fore.BLACK, Back.RED),
|
||||||
Fore.BLACK, Back.RED),
|
colour='red',
|
||||||
colour='red',
|
):
|
||||||
):
|
future.cancel()
|
||||||
future.cancel()
|
|
||||||
|
|
||||||
if self._thread_pool_2 is not None:
|
if self._thread_pool_2 is not None:
|
||||||
# cancel pending futures (i.e., those not started)
|
# cancel pending futures (i.e., those not started)
|
||||||
@ -990,21 +989,6 @@ class ChainRouter[E: Event](Router[E]):
|
|||||||
return self.running_events.pop(event_idx, None)
|
return self.running_events.pop(event_idx, None)
|
||||||
|
|
||||||
def get_listener(self, listener_cls=None):
|
def get_listener(self, listener_cls=None):
|
||||||
'''
|
|
||||||
Gets the listener for the entire router chain.
|
|
||||||
|
|
||||||
Builds a single listener from the registered endpoints of each router, allowing
|
|
||||||
a global iNotify instance to serve all routes. This provides a clean target for
|
|
||||||
outer server context managers.
|
|
||||||
|
|
||||||
Note that having multiple iNotify instances, even those watching the same
|
|
||||||
directories, in general shouldn't pose an issue. iNotify instances are
|
|
||||||
independent, and the kernel will send events to each listening instance when one
|
|
||||||
occurs. This is why we consolidate the listener for chained routers: we want to
|
|
||||||
respond to events sequentially according to router order, rather than have each
|
|
||||||
router expose a listener that can independently hear and respond to its own
|
|
||||||
events.
|
|
||||||
'''
|
|
||||||
if listener_cls is None:
|
if listener_cls is None:
|
||||||
for router in self.ordered_routers:
|
for router in self.ordered_routers:
|
||||||
if router.listener_cls is not None:
|
if router.listener_cls is not None:
|
||||||
|
@ -36,58 +36,59 @@ class Server:
|
|||||||
'''
|
'''
|
||||||
Wraps up a development static file server and live reloader.
|
Wraps up a development static file server and live reloader.
|
||||||
'''
|
'''
|
||||||
def __init__(self):
|
|
||||||
self.server = None
|
|
||||||
|
|
||||||
# MT/MP server implementations can check this variable for graceful shutdowns
|
|
||||||
self.should_shutdown = False
|
|
||||||
self.started = False
|
|
||||||
|
|
||||||
# used to isolate server creation logic, when applicable
|
|
||||||
self._init_server()
|
|
||||||
|
|
||||||
def _init_server(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self.started = True
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
self.should_shutdown = True
|
|
||||||
logger.debug("Server shutdown request received")
|
|
||||||
|
|
||||||
class ProcessServer(Server):
|
|
||||||
'''
|
|
||||||
general subprocess start, shutdown, output catching logic
|
|
||||||
'''
|
|
||||||
pass
|
|
||||||
|
|
||||||
class HTTPServer(Server):
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
root,
|
root,
|
||||||
loop = None,
|
static : bool = False,
|
||||||
|
livereload : bool = False,
|
||||||
|
managed_listeners : list | None = None,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Parameters:
|
Parameters:
|
||||||
host: host server address (either 0.0.0.0, 127.0.0.1, localhost)
|
host: host server address (either 0.0.0.0, 127.0.0.1, localhost)
|
||||||
port: port at which to start the server
|
port: port at which to start the server
|
||||||
|
root: base path for static files _and_ where router bases are attached (i.e.,
|
||||||
|
when files at this path change, a reload event will be
|
||||||
|
propagated to a corresponding client page)
|
||||||
|
static: whether or not to start a static file server
|
||||||
|
livereload: whether or not to start a livereload server
|
||||||
|
managed_listeners: auxiliary listeners to "attach" to the server process, and to
|
||||||
|
propagate the shutdown signal to when the server receives an
|
||||||
|
interrupt.
|
||||||
'''
|
'''
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.loop = loop
|
self.root = root
|
||||||
|
self.static = static
|
||||||
|
self.livereload = livereload
|
||||||
|
|
||||||
self.userver = None
|
if managed_listeners is None:
|
||||||
|
managed_listeners = []
|
||||||
|
self.managed_listeners = managed_listeners
|
||||||
|
|
||||||
super().__init__()
|
self.listener = None
|
||||||
|
self.userver = None
|
||||||
|
self.server = None
|
||||||
|
self.server_text = ''
|
||||||
|
self.server_args = {}
|
||||||
|
|
||||||
def _init_server(self):
|
self.started = False
|
||||||
|
|
||||||
|
self.loop = None
|
||||||
|
self._server_setup()
|
||||||
|
|
||||||
|
def _wrap_static(self):
|
||||||
|
self.server.mount("/", StaticFiles(directory=self.root), name="static")
|
||||||
|
|
||||||
|
def _wrap_livereload(self):
|
||||||
|
self.server.websocket_route('/livereload')(LREndpoint)
|
||||||
|
#self.server.add_api_websocket_route('/livereload', LREndpoint)
|
||||||
|
|
||||||
|
def _server_setup(self):
|
||||||
'''
|
'''
|
||||||
Set up the FastAPI server and Uvicorn hook.
|
Set up the FastAPI server. Only a single server instance is used here, optionally
|
||||||
|
|
||||||
Only a single server instance is used here, optionally
|
|
||||||
mounting the static route (if static serving enabled) and providing a websocket
|
mounting the static route (if static serving enabled) and providing a websocket
|
||||||
endpoint (if livereload enabled).
|
endpoint (if livereload enabled).
|
||||||
|
|
||||||
@ -96,34 +97,57 @@ class HTTPServer(Server):
|
|||||||
behave appropriately, even when remounting the root if serving static files
|
behave appropriately, even when remounting the root if serving static files
|
||||||
(which, if done in the opposite order, would "eat up" the ``/livereload`` endpoint).
|
(which, if done in the opposite order, would "eat up" the ``/livereload`` endpoint).
|
||||||
'''
|
'''
|
||||||
if self.loop is None:
|
|
||||||
self.loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(self.loop)
|
|
||||||
|
|
||||||
# enable propagation and clear handlers for uvicorn internal loggers;
|
# enable propagation and clear handlers for uvicorn internal loggers;
|
||||||
# allows logging messages to propagate to my root logger
|
# allows logging messages to propagate to my root logger
|
||||||
log_config = uvicorn.config.LOGGING_CONFIG
|
log_config = uvicorn.config.LOGGING_CONFIG
|
||||||
log_config['loggers']['uvicorn']['propagate'] = True
|
log_config['loggers']['uvicorn']['propagate'] = True
|
||||||
log_config['loggers']['uvicorn']['handlers'] = []
|
log_config['loggers']['uvicorn']['handlers'] = []
|
||||||
log_config['loggers']['uvicorn.access']['propagate'] = True
|
log_config['loggers']['uvicorn.access']['propagate'] = True
|
||||||
log_config['loggers']['uvicorn.access']['handlers'] = []
|
log_config['loggers']['uvicorn.access']['handlers'] = []
|
||||||
log_config['loggers']['uvicorn.error']['propagate'] = False
|
log_config['loggers']['uvicorn.error']['propagate'] = False
|
||||||
log_config['loggers']['uvicorn.error']['handlers'] = []
|
log_config['loggers']['uvicorn.error']['handlers'] = []
|
||||||
|
|
||||||
server_args = {}
|
self.server_args['log_config'] = log_config
|
||||||
server_args['log_config'] = log_config
|
self.server_args['host'] = self.host
|
||||||
server_args['host'] = self.host
|
self.server_args['port'] = self.port
|
||||||
server_args['port'] = self.port
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
yield
|
yield
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
|
|
||||||
self.server = FastAPI(lifespan=lifespan)
|
if self.static or self.livereload:
|
||||||
|
self.server = FastAPI(lifespan=lifespan)
|
||||||
|
#self.server.on_event('shutdown')(self.shutdown)
|
||||||
|
|
||||||
uconfig = uvicorn.Config(app=self.server, loop=self.loop, **self.server_args)
|
if self.livereload:
|
||||||
self.userver = uvicorn.Server(config=uconfig)
|
self._wrap_livereload()
|
||||||
|
self._listener_setup()
|
||||||
|
self.server_text += '+reload'
|
||||||
|
|
||||||
|
if self.static:
|
||||||
|
self._wrap_static()
|
||||||
|
self.server_text += '+static'
|
||||||
|
|
||||||
|
def _listener_setup(self):
|
||||||
|
'''
|
||||||
|
flags.MODIFY okay since we don't need to reload non-existent pages
|
||||||
|
'''
|
||||||
|
if self.loop is None:
|
||||||
|
self.loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self.loop)
|
||||||
|
|
||||||
|
#self.listener = listener.WatchFS(loop=self.loop)
|
||||||
|
self.router = PathRouter(loop=self.loop)
|
||||||
|
self.router.register(
|
||||||
|
path=str(self.root),
|
||||||
|
func=LREndpoint.reload_clients,
|
||||||
|
delay=100,
|
||||||
|
flags=flags.MODIFY,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.listener = self.router.get_listener()
|
||||||
|
self.managed_listeners.append(self.listener)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
'''
|
'''
|
||||||
@ -196,12 +220,24 @@ class HTTPServer(Server):
|
|||||||
and shut down their thread pools, gracefully close up the Uvicorn server and
|
and shut down their thread pools, gracefully close up the Uvicorn server and
|
||||||
allow the serve coroutine to complete, and finally close down the event loop.
|
allow the serve coroutine to complete, and finally close down the event loop.
|
||||||
'''
|
'''
|
||||||
super().start()
|
if self.loop is None:
|
||||||
|
self.loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self.loop)
|
||||||
|
|
||||||
logger.info(f'Starting HTTP server @ http://{self.host}:{self.port}')
|
for listener in self.managed_listeners:
|
||||||
|
#loop.run_in_executor(None, partial(self.listener.start, loop=loop))
|
||||||
|
if not listener.started:
|
||||||
|
listener.start()
|
||||||
|
|
||||||
self.loop.run_until_complete(self.userver.serve())
|
self.started = False
|
||||||
self.loop.close()
|
|
||||||
|
if self.server:
|
||||||
|
logger.info(f'Server{self.server_text} @ http://{self.host}:{self.port}')
|
||||||
|
|
||||||
|
uconfig = uvicorn.Config(app=self.server, loop=self.loop, **self.server_args)
|
||||||
|
self.userver = uvicorn.Server(config=uconfig)
|
||||||
|
self.loop.run_until_complete(self.userver.serve())
|
||||||
|
self.loop.close()
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
'''
|
'''
|
||||||
@ -243,99 +279,7 @@ class HTTPServer(Server):
|
|||||||
task should be considered "completed," at which point the event loop can be closed
|
task should be considered "completed," at which point the event loop can be closed
|
||||||
successfully.
|
successfully.
|
||||||
'''
|
'''
|
||||||
# stop FastAPI server if started
|
logger.info("Shutting down server...")
|
||||||
if self.userver is not None:
|
|
||||||
def set_should_exit():
|
|
||||||
self.userver.should_exit = True
|
|
||||||
|
|
||||||
self.loop.call_soon_threadsafe(set_should_exit)
|
|
||||||
|
|
||||||
class StaticHTTPServer(Server):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root,
|
|
||||||
*args,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
'''
|
|
||||||
Parameters:
|
|
||||||
root: base path for static files _and_ where router bases are attached (i.e.,
|
|
||||||
when files at this path change, a reload event will be propagated to a
|
|
||||||
corresponding client page)
|
|
||||||
'''
|
|
||||||
self.root = root
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def _init_server(self):
|
|
||||||
super()._init_server()
|
|
||||||
|
|
||||||
self.server.mount(
|
|
||||||
"/",
|
|
||||||
StaticFiles(directory=self.root),
|
|
||||||
name="static"
|
|
||||||
)
|
|
||||||
|
|
||||||
class LiveReloadHTTPServer(Server):
|
|
||||||
def _init_server(self):
|
|
||||||
super()._init_server()
|
|
||||||
|
|
||||||
self.server.websocket_route('/livereload')(LREndpoint)
|
|
||||||
|
|
||||||
class ListenerServer(Server):
|
|
||||||
'''
|
|
||||||
Server abstraction to handle disparate listeners.
|
|
||||||
'''
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
managed_listeners : list | None = None,
|
|
||||||
):
|
|
||||||
'''
|
|
||||||
Parameters:
|
|
||||||
managed_listeners: auxiliary listeners to "attach" to the server process, and to
|
|
||||||
propagate the shutdown signal to when the server receives an
|
|
||||||
interrupt.
|
|
||||||
'''
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
if managed_listeners is None:
|
|
||||||
managed_listeners = []
|
|
||||||
|
|
||||||
self.managed_listeners = managed_listeners
|
|
||||||
|
|
||||||
def _listener_setup(self):
|
|
||||||
'''
|
|
||||||
flags.MODIFY okay since we don't need to reload non-existent pages
|
|
||||||
'''
|
|
||||||
if self.loop is None:
|
|
||||||
self.loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(self.loop)
|
|
||||||
|
|
||||||
#self.listener = listener.WatchFS(loop=self.loop)
|
|
||||||
self.router = PathRouter(loop=self.loop)
|
|
||||||
self.router.register(
|
|
||||||
path=str(self.root),
|
|
||||||
func=LREndpoint.reload_clients,
|
|
||||||
delay=100,
|
|
||||||
flags=flags.MODIFY,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.listener = self.router.get_listener()
|
|
||||||
self.managed_listeners.append(self.listener)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
super().start()
|
|
||||||
|
|
||||||
for listener in self.managed_listeners:
|
|
||||||
#loop.run_in_executor(None, partial(self.listener.start, loop=loop))
|
|
||||||
if not listener.started:
|
|
||||||
listener.start()
|
|
||||||
|
|
||||||
for listener in self.managed_listeners:
|
|
||||||
listener.join()
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
super().shutdown()
|
|
||||||
|
|
||||||
# stop attached auxiliary listeners, both internal & external
|
# stop attached auxiliary listeners, both internal & external
|
||||||
if self.managed_listeners:
|
if self.managed_listeners:
|
||||||
@ -343,3 +287,43 @@ class ListenerServer(Server):
|
|||||||
|
|
||||||
for listener in self.managed_listeners:
|
for listener in self.managed_listeners:
|
||||||
listener.stop()
|
listener.stop()
|
||||||
|
|
||||||
|
# stop FastAPI server if started
|
||||||
|
if self.userver is not None:
|
||||||
|
def set_should_exit():
|
||||||
|
self.userver.should_exit = True
|
||||||
|
|
||||||
|
self.loop.call_soon_threadsafe(set_should_exit)
|
||||||
|
|
||||||
|
class ListenerServer:
|
||||||
|
'''
|
||||||
|
Server abstraction to handle disparate listeners.
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
managed_listeners : list | None = None,
|
||||||
|
):
|
||||||
|
if managed_listeners is None:
|
||||||
|
managed_listeners = []
|
||||||
|
|
||||||
|
self.managed_listeners = managed_listeners
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
for listener in self.managed_listeners:
|
||||||
|
#loop.run_in_executor(None, partial(self.listener.start, loop=loop))
|
||||||
|
if not listener.started:
|
||||||
|
listener.start()
|
||||||
|
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
for listener in self.managed_listeners:
|
||||||
|
listener.join()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
# stop attached auxiliary listeners, both internal & external
|
||||||
|
if self.managed_listeners:
|
||||||
|
logger.info(f"Stopping {len(self.managed_listeners)} listeners...")
|
||||||
|
|
||||||
|
for listener in self.managed_listeners:
|
||||||
|
listener.stop()
|
||||||
|
@ -43,7 +43,6 @@ docs = [
|
|||||||
"furo",
|
"furo",
|
||||||
"myst-parser",
|
"myst-parser",
|
||||||
]
|
]
|
||||||
jupyter = ["ipykernel"]
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://doc.olog.io/execlib"
|
Homepage = "https://doc.olog.io/execlib"
|
||||||
|
Loading…
Reference in New Issue
Block a user