Compare commits

..

No commits in common. "master" and "v0.4.2" have entirely different histories.

30 changed files with 229 additions and 314 deletions

22
LICENSE
View File

@ -1,22 +0,0 @@
MIT License
Copyright (c) 2024 Sam Griesemer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,10 +1,8 @@
# Overview # Overview
`execlib` is a lightweight multi-threaded job framework written in Python. It implements a `execlog` is a lightweight multi-threaded job framework written in Python. It implements a
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.
![High-level execution flow diagram](docs/_static/execlib.png)
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]
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

View File

@ -6,7 +6,7 @@
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'execlib' project = 'execlog'
copyright = '2024, Sam Griesemer' copyright = '2024, Sam Griesemer'
author = 'Sam Griesemer' author = 'Sam Griesemer'

View File

@ -1,4 +1,4 @@
# `execlib` package docs # `execlog` package docs
{ref}`genindex` {ref}`genindex`
{ref}`modindex` {ref}`modindex`
{ref}`search` {ref}`search`
@ -10,18 +10,18 @@
:nosignatures: :nosignatures:
:recursive: :recursive:
execlib.Handler execlog.Handler
execlib.Listener execlog.Listener
execlib.Router execlog.Router
execlib.Server execlog.Server
execlib.listeners execlog.listeners
``` ```
## Auto-reference contents ## Auto-reference contents
```{toctree} ```{toctree}
:maxdepth: 3 :maxdepth: 3
_autoref/execlib.rst _autoref/execlog.rst
``` ```
```{toctree} ```{toctree}
@ -32,6 +32,5 @@ reference/documentation/index
``` ```
```{include} ../README.md ```{include} ../README.md
:relative-docs: docs/
:relative-images:
``` ```

View File

@ -1,10 +0,0 @@
from execlib import util
from execlib import routers
from execlib import syncers
from execlib import listeners
from execlib.server import Server
from execlib.handler import Handler
from execlib.listener import Listener
from execlib.event import Event, FileEvent
from execlib.router import Router, ChainRouter, Event, RouterBuilder, route

View File

@ -1 +0,0 @@
from execlib.listeners.path import PathListener

View File

@ -1 +0,0 @@
from execlib.routers.path import PathRouter

View File

@ -1 +0,0 @@
from execlib.syncers.router import PathDiffer, PathRouterSyncer

View File

@ -1,2 +0,0 @@
from execlib.util import path
from execlib.util import generic

10
execlog/__init__.py Normal file
View File

@ -0,0 +1,10 @@
from execlog import util
from execlog import routers
from execlog import syncers
from execlog import listeners
from execlog.server import Server
from execlog.handler import Handler
from execlog.listener import Listener
from execlog.event import Event, FileEvent
from execlog.router import Router, ChainRouter, Event, RouterBuilder, route

View File

@ -5,7 +5,7 @@ See also:
''' '''
import threading import threading
from execlib.event import Event from execlog.event import Event
class Listener[E: Event](threading.Thread): class Listener[E: Event](threading.Thread):

View File

@ -0,0 +1 @@
from execlog.listeners.path import PathListener

View File

@ -8,10 +8,10 @@ from collections import defaultdict
from colorama import Fore, Back, Style from colorama import Fore, Back, Style
from inotify_simple import INotify, Event as iEvent, flags as iflags, masks as imasks from inotify_simple import INotify, Event as iEvent, flags as iflags, masks as imasks
from execlib import util from execlog import util
from execlib.util.generic import color_text from execlog.util.generic import color_text
from execlib.event import FileEvent from execlog.event import FileEvent
from execlib.listener import Listener from execlog.listener import Listener
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -18,9 +18,9 @@ from concurrent.futures import ThreadPoolExecutor, wait, as_completed
from tqdm.auto import tqdm from tqdm.auto import tqdm
from execlib.event import Event from execlog.event import Event
from execlib.listener import Listener from execlog.listener import Listener
from execlib.util.generic import color_text, get_func_name from execlog.util.generic import color_text, get_func_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -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:

View File

@ -0,0 +1 @@
from execlog.routers.path import PathRouter

View File

@ -2,10 +2,10 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from execlib.router import Router from execlog.router import Router
from execlib.event import FileEvent from execlog.event import FileEvent
from execlib.util.path import glob_match from execlog.util.path import glob_match
from execlib.listeners.path import PathListener from execlog.listeners.path import PathListener
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -26,8 +26,8 @@ from inotify_simple import flags
from fastapi import FastAPI, WebSocket from fastapi import FastAPI, WebSocket
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from execlib.routers.path import PathRouter from execlog.routers.path import PathRouter
from execlib.handler import Handler as LREndpoint from execlog.handler import Handler as LREndpoint
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -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()

View File

@ -0,0 +1 @@
from execlog.syncers.router import PathDiffer, PathRouterSyncer

View File

@ -10,10 +10,10 @@ from inotify_simple import flags as iflags
from co3.resources import DiskResource from co3.resources import DiskResource
from co3 import Differ, Syncer, Database from co3 import Differ, Syncer, Database
from execlib.event import Event from execlog.event import Event
from execlib.router import CancelledFrameError from execlog.router import CancelledFrameError
from execlib.routers import PathRouter from execlog.routers import PathRouter
from execlib.util.generic import color_text from execlog.util.generic import color_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

2
execlog/util/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from execlog.util import path
from execlog.util import generic

View File

@ -46,8 +46,8 @@ class ColorFormatter(logging.Formatter):
formatter = self.FORMATS[submodule] formatter = self.FORMATS[submodule]
name = record.name name = record.name
if package == 'execlib': if package == 'execlog':
name = f'execlib.{subsubmodule}' name = f'execlog.{subsubmodule}'
limit = 26 limit = 26
name = name[:limit] name = name[:limit]

View File

@ -1,57 +1,25 @@
[build-system] [build-system]
requires = ["setuptools", "wheel", "setuptools-git-versioning>=2.0,<3"] requires = ["setuptools"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
# populates dynamically set version with latest git tag
[tool.setuptools-git-versioning]
enabled = true
[project] [project]
name = "execlib" name = "execlog"
version = "0.4.1"
authors = [
{ name="Sam Griesemer", email="samgriesemer@gmail.com" },
]
description = "Lightweight multi-threaded job framework" description = "Lightweight multi-threaded job framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dynamic = ["version"]
#license = {file = "LICENSE"}
authors = [
{ name="Sam Griesemer", email="samgriesemer+git@gmail.com" },
]
keywords = ["concurrent", "async", "inotify"]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
] ]
dependencies = [ dependencies = [
"tqdm", "tqdm"
"wcmatch",
"uvicorn",
"fastapi",
"colorama",
"starlette",
"inotify_simple",
] ]
[project.optional-dependencies]
tests = ["pytest", "websockets"]
docs = [
"sphinx",
"sphinx-togglebutton",
"sphinx-autodoc-typehints",
"furo",
"myst-parser",
]
jupyter = ["ipykernel"]
[project.urls]
Homepage = "https://doc.olog.io/execlib"
Documentation = "https://doc.olog.io/execlib"
Repository = "https://git.olog.io/olog/execlib"
Issues = "https://git.olog.io/olog/execlib/issues"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["execlib*"] # pattern to match package names include = ["execlog*"] # pattern to match package names

21
requirements.txt Normal file
View File

@ -0,0 +1,21 @@
# -- package-specific --
fastapi
starlette
uvicorn
inotify_simple
tqdm
wcmatch
websockets
# -- logging --
colorama
# -- sphinx docs --
sphinx
sphinx-togglebutton
furo
myst-parser
sphinx-autodoc-typehints
# -- testing ---
pytest

View File

@ -3,10 +3,10 @@ import logging
from pathlib import Path from pathlib import Path
from functools import partial from functools import partial
from execlib import util from execlog import util
from execlib import ChainRouter, Event from execlog import ChainRouter, Event
from execlib.routers import PathRouter from execlog.routers import PathRouter
from execlib.listeners import PathListener from execlog.listeners import PathListener
logger = logging.getLogger() logger = logging.getLogger()

View File

@ -3,10 +3,10 @@ from pathlib import Path
from functools import partial from functools import partial
from concurrent.futures import wait from concurrent.futures import wait
from execlib import util from execlog import util
from execlib import ChainRouter, Event from execlog import ChainRouter, Event
from execlib.routers import PathRouter from execlog.routers import PathRouter
from execlib.listeners import PathListener from execlog.listeners import PathListener
logger = logging.getLogger() logger = logging.getLogger()

View File

@ -4,8 +4,8 @@ import threading
import logging import logging
from pathlib import Path from pathlib import Path
from execlib import Server from execlog import Server
from execlib.routers import PathRouter from execlog.routers import PathRouter
logger = logging.getLogger() logger = logging.getLogger()