Reference
Application Start
- ponty.startmeup(*, port, providers=(), route_tables=())
The main entrypoint for your application. Kicks off the server event loop.
- Parameters
port (int) – TCP/IP port for HTTP server. No default, must be provided
providers (Sequence[Provider]) – see Providers
route_tables (Sequence[RouteTableDef]) – Optional sequence of aiohttp.web.RouteTableDef instances
- Return type
None
Routes
Ponty relies on decorator-style routing. Simply wrap your handlers like so:
from ponty import get, render_json
@get("/hello")
@render_json
async def handler(_):
return {"greeting": "hello world"}
Ponty makes no effort to intercept the
aiohttp.web.Request
instance provided by aiohttp.
It does, however, provide a Request class of its own to simplify processing.
Ponty handlers may return
aiohttp.web.Response
instances directly, or they may take advantage of utilities such as
ponty.render_json() or ponty.raise_status().
- @ponty.route(method, path, **kw)
Bind a (method, path) pair to its handler, and register with the master route table.
PontyError’s are automatically trapped and handled here.- Parameters
method (str) – HTTP method, e.g. GET
path (str) – Relative path to resource. May contain braces {} for dynamic components, or references to
RouteParameterinstances in an f-stringkw – Additional arguments discussed here
- ponty.route_iter()
Iterator, over routes on the primary route table.
- Returns
(method, path) pairs
- Return type
Iterator[tuple[str, str]]
Request
- class ponty.Request(req)
Base class for request pre-processing. Instantiated by
expect().Request Fields for extracting components of the request should be stored as class variables on subclasses.
- Parameters
req (aiohttp.web.Request) – automatically supplied by
expect()
- @ponty.expect(cls, *, mimetype=None)
Preprocess the HTTP request, according to the rules configured in the
Requestsubclass.
For example,
from ponty import (
expect,
get,
render_json,
Request,
StringRouteParameter,
)
class HelloReq(Request):
name = StringRouteParameter()
@get(f"/hello/{HelloReq.name}") # evaluates to "/hello/{name:\w+}"
@expect(HelloReq)
@render_json
async def greet(name: str):
return {"greeting": f"hello, {name}!"}
$ curl localhost:8080/hello/you -v | python -m json.tool
> GET /hello/you HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 72
< Date: Thu, 11 Aug 2022 02:58:44 GMT
< Server: Python/3.9 aiohttp/3.7.3
<
{
"data": {
"greeting": "hello, you!"
},
"elapsed": 0,
"now": 1660186724077
}
Request Fields
So far we’ve looked at the overall request. Now let’s look at the individual fields on that request.
Ponty uses Python descriptors for extracting and processing
components of the HTTP request.
In conjunction with the ponty.expect() decorator,
request fields are parameterized directly into the decorated handler.
(Descriptor names are reused as parameter names.)
For a more detailed understanding of descriptors, see the Python docs.
Route Parameters
- class ponty.RouteParameter(*, pattern, cast_func=None)
The RouteParameter descriptor is used to identify and extract variable resources from the URI. It is generic on one variable.
When invoked from an instance, returns the variable part of the resolved route.
When invoked from a class, returns the {identifier:regex} rendering required for path matching. (See Variable Resources.) Particularly useful in an f-string (example below).
- Parameters
pattern (str) – custom regular expression to match the variable part
cast_func (Optional[Callable[[str], _T]]) – converts the variable part to type T
New reusable “matchers” can be created like so:
from ponty import ( expect, get, Request, RouteParameter, ) class FiveLetters(RouteParameter[str]): def __init__(self): super().__init__(pattern="[a-zA-Z]{5}") class MyReq(Request): id = FiveLetters() @get(f"/obj/{MyReq.id}") @expect(MyReq) async def handler(id: str): ...
- class ponty.PosIntRouteParameter
Inherits
RouteParameter. Matches on a sequence of digits,\d+.
- class ponty.StringRouteParameter
Inherits
RouteParameter. Matches on strings,\w+.
Query Parameters
- class ponty.QueryParameter(*, key: str = '', required: Literal[False] = False, default: _T, cast_func: Callable[[str], _T], values: Iterable[str] = ())
- class ponty.QueryParameter(*, key: str = '', required: Literal[True], cast_func: Callable[[str], _T], values: Iterable[str] = ())
Extracts the value of query parameter with name key from the URI, if it’s provided. Generic on one variable.
- Parameters
key (str) – if provided, query parameter key name. By default, uses descriptor __set_name__ to fetch the variable name
required (bool) – if True, the query parameter will be treated as a mandatory component of the request; an HTTP 400 will be raised in the event no value is supplied. Default False
default (_T) – default value, returned if the query param has not been provided. Set required=True instead if this should be treated as an error condition. One of required or default must be provided
cast_func (Callable[[str], _T]) – function to convert the string value provided to type T. If the function raises a ValueError when it fails, the error will be trapped and reraised as an HTTP 400
values (Iterable[str]) – if supplied, validates the captured query parameter against these legal values. Throws a 400 in the event of a mismatch
It’s easiest to use
StringQueryParameterorPosIntQueryParameterfor simple cases in practice.Use the
QueryParameterbase class to create new custom parsers, in the same way asRouteParameter:from ponty import ( expect, get, render_json, QueryParameter, Request, ) _boolish_vals: dict[str, bool] = { "1": True, "0": False, "yes": True, "no": False, "true": True, "false": False, "t": True, "f": False, } class BoolQueryParam(QueryParameter[bool]): def __init__(self, **kw): super().__init__( cast_func=_boolish_vals.__getitem__, values=_boolish_vals.keys(), **kw ) class HelloReq(Request): capitalize = BoolQueryParam(default=False) @get("/hello") @expect(HelloReq) @render_json async def greet(capitalize: bool): greeting = "hello world" if capitalize: greeting = greeting.upper() return {"greeting": greeting}
the default$ curl localhost:8080/hello | python -m json.tool { "data": { "greeting": "hello world" }, "elapsed": 0, "now": 1679444906510 }
providing a non-default option$ curl 'localhost:8080/hello?capitalize=yes' | python -m json.tool { "data": { "greeting": "HELLO WORLD" }, "elapsed": 0, "now": 1679444906739 }
providing an illegal value$ curl 'localhost:8080/hello?capitalize=blah' -v > GET /hello?capitalize=blah HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.79.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain; charset=utf-8 < Content-Length: 46 < Date: Wed, 22 Mar 2023 00:21:44 GMT < Server: Python/3.9 aiohttp/3.7.3 < capitalize must be one of {1,0,yes,no,true,false,t,f}
- class ponty.StringQueryParameter(**kw)
Inherits
QueryParameter. Treats the captured query param as a string.from ponty import ( expect, get, render_json, Request, StringQueryParameter, ) class HelloReq(Request): punc = StringQueryParameter(default="!") @get("/hello") @expect(HelloReq) @render_json async def greet(punc: str): return {"greeting": f"hello world{punc}"}
the default in action$ curl localhost:8080/hello | python -m json.tool { "data": { "greeting": "hello world!" }, "elapsed": 0, "now": 1660439305592 }
overriding the default$ curl 'localhost:8080/hello?punc=.' | python -m json.tool { "data": { "greeting": "hello world." }, "elapsed": 0, "now": 1660439305592 }
A similar example, using values:
from ponty import ( expect, get, render_json, Request, StringQueryParameter, StringRouteParameter, ) _hellos: dict[str, str] = { "en": "hello", "es": "hola", "no": "hallo", } class HelloReq(Request): name = StringRouteParameter() lang = StringQueryParameter( values=_hellos.keys(), default="en", ) @get(f"/hello/{HelloReq.name}") @expect(HelloReq) @render_json async def ahoy(name: str, lang: str): # no need to trap the KeyError here, the descriptor has already # validated `lang` is a key in `_hellos` hi = _hellos[lang] return {"greeting": f"{hi} {name}!"}
$ curl 'localhost:8080/hello/muchacho?lang=es' | python -m json.tool { "data": { "greeting": "hola muchacho!" }, "elapsed": 0, "now": 1660440618107 }
- class ponty.PosIntQueryParameter(**kw)
Inherits
QueryParameter. Casts the captured query param to an integer, and validates it is non-negative.from ponty import ( expect, get, render_json, Request, PosIntQueryParameter, ) class HelloReq(Request): num_exclamations = PosIntQueryParameter(key="bangs", default=1) @get("/hello") @expect(HelloReq) @render_json async def greet(num_exclamations: int): return {"greeting": f"hello world{'!' * num_exclamations}"}
$ curl localhost:8080/hello?bangs=7 | python -m json.tool { "data": { "greeting": "hello world!!!!!!!" }, "elapsed": 0 "now": 1681345852404, }
not an int$ curl localhost:8080/hello?bangs=abc > GET /hello?bangs=abc HTTP/1.1 > Host: localhost:8008 > User-Agent: curl/7.79.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain; charset=utf-8 < Content-Length: 21 < Date: Thu, 13 Apr 2023 00:31:40 GMT < Server: Python/3.9 aiohttp/3.7.3 < 'abc' could not be cast
Request Body
- class ponty.TextBody
Contains the raw request body.
- class ponty.JsonBody
Contains the deserialized request body.
- class ponty.ValidatedJsonBody(*, filepath=None)
Validates the deserialized request body against the given jsonschema. Invalid input immediately triggers a 400 response.
Inherits
JsonBody.- Parameters
filepath (Optional[str]) – jsonschema file location (absolute, relative to PYTHONPATH, or relative to working directory).
schema (Optional[dict[str, Any]]) –
validator.json{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "first_name": { "type": "string" }, "last_name": { "type": "string" } }, "required": ["first_name", "last_name"] }
from ponty import ( expect, post, render_json, Request, ValidatedJsonBody, ) class MyReq(Request): body = ValidatedJsonBody(filepath="validator.json") @post("/test") @expect(MyReq) @render_json async def handler(body): return {"name": f"{body['last_name']}, {body['first_name']}"}
success$ curl localhost:8080/test \ -H "content-type:application/json" \ -d '{"first_name": "Donald", "last_name": "Duck"}' | python -m json.tool { "data": { "name": "Duck, Donald", }, "elapsed": 0, "now": 1660505985510 }
error, “last_name” omitted$ curl localhost:8080/test \ -H "content-type:application/json" \ -d '{"first_name": "Donald"}' \ -v ... < HTTP/1.1 400 Bad Request < Content-Type: text/plain; charset=utf-8 ... 'last_name' is a required property
- class ponty.ParsedJsonBody(cls, *, filepath=None)
Holds the validated request body, structured as an instance of cls.
Inherits
ValidatedJsonBody.- Parameters
cls (dataclass) – dataclass, into which the request body is marshalled. A custom jsonschema validator is automatically created by
ponty.dataclass_to_jsonschema()filepath (Optional[str]) – if provided, path to the jsonschema file (see
ValidatedJsonBody). Supplants the dataclass-built default
from dataclasses import asdict, dataclass import typing from ponty import ( expect, post, render_json, Annotation, ParsedJsonBody, Request, ) @dataclass(frozen=True) class Person: first_name: str last_name: str favorite_color: typing.Optional[str] height: typing.Annotated[int, Annotation(description="cm")] class JsonSchema: annotation = Annotation(description="request shape for a person") class NewPersonReq(Request): person = ParsedJsonBody(Person) @post("/person") @expect(NewPersonReq, mimetype="application/json") @render_json async def handler(person: Person): return asdict(person)
the generated jsonschema{ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "first_name": { "type": "string" }, "last_name": { "type": "string" }, "favorite_color": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "height": { "type": "integer", "description": "cm" } }, "title": "Person", "type": "object", "description": "request shape for a person", "required": [ "first_name", "last_name", "favorite_color", "height" ] }
(note the highlighted lines, controlled by
Annotation)success, echo back the request body$ curl localhost:8080/person \ -H "content-type:application/json" \ -d '{"first_name": "Mickey", "last_name": "Mouse", "favorite_color": "blue", "height": 1000}' \ -v | python -m json.tool > POST /person HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.64.1 > Accept: */* > content-type:application/json > Content-Length: 83 > < HTTP/1.1 200 OK < Content-Type: application/json; charset=utf-8 < Content-Length: 129 < Date: Sun, 14 Aug 2022 21:04:35 GMT < Server: Python/3.9 aiohttp/3.7.3 < { "data": { "favorite_color": "blue", "first_name": "Mickey", "height": 1000, "last_name": "Mouse" }, "elapsed": 0, "now": 1660511075634 }
error, missing “height”$ curl localhost:8080/person \ -H "content-type:application/json" \ -d '{"first_name": "Mickey", "last_name": "Mouse", "favorite_color": "blue"}' \ -v ... < HTTP/1.1 400 Bad Request < Content-Type: text/plain; charset=utf-8 ... 'height' is a required property
error, wrong type for “height”$ curl localhost:8080/person \ -H "content-type:application/json" \ -d '{"first_name": "Mickey", "last_name": "Mouse", "favorite_color": "blue", "height": "abc"}' \ -v ... < HTTP/1.1 400 Bad Request ... 'abc' is not of type 'integer'
Request Headers
- class ponty.Header(*, key, required=False, default='')
Extracts request header key.
- Parameters
key (str) – header name
required (bool) – if True, throws a 400 if the request header is not provided
default (str) – default value, if the header does not appear on the request
Other
- class ponty.AIOHttpReq
Forwards along the aiohttp.web.Request.
- class ponty.Cookie(*, name, required=False, default='', errorcode=400)
Extracts request cookie name.
- Parameters
name (str) – cookie name
required (bool) – if True, throws a 400 if the cookie is not provided
default (str) – default value, if the cookie is not present
errorcode (int) – HTTP status code raised, if the cookie is required and not supplied
Schema Validation
- ponty.dataclass_to_jsonschema(cls)
Generate the jsonschema for a dataclass.
Optionally, fields can be enriched by passing jsonschema keywords to
Annotation.Additionally, the dataclass can be enriched with keywords by embedding a JsonSchema class (using
Annotationas well).- Parameters
cls (dataclass) – dataclass definition
- Raises
ValueError – raised if cls is not a dataclass
- Returns
the JSON schema
- Return type
dict[str, Any]
from dataclasses import dataclass import json import typing from ponty import Annotation, dataclass_to_jsonschema @dataclass class Model: name: str body_style: typing.Literal["coupe", "sedan", "hatchback", "convertible", "wagon", "suv"] year: typing.Annotated[int, Annotation(minimum=1886, maximum=2030)] msrp: typing.Annotated[int, Annotation(description="MSRP, dollars.")] cylinders: int = 4 @dataclass class Make: name: typing.Annotated[str, Annotation(description="Brand name")] models: list[Model] class JsonSchema: annotation = Annotation(description="Automotive brand", examples=["Toyota", "Honda"]) if __name__ == "__main__": schema = dataclass_to_jsonschema(Make) print(json.dumps(schema, indent=2))
generated jsonschema{ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "name": { "type": "string", "description": "Brand name" }, "models": { "type": "array", "items": { "allOf": [ { "$ref": "#/$defs/Model" } ] } } }, "title": "Make", "type": "object", "description": "Automotive brand", "examples": [ "Toyota", "Honda" ], "required": [ "name", "models" ], "$defs": { "Model": { "properties": { "name": { "type": "string" }, "body_style": { "enum": [ "coupe", "sedan", "hatchback", "convertible", "wagon", "suv" ] }, "year": { "type": "integer", "minimum": 1886, "maximum": 2030 }, "msrp": { "type": "integer", "description": "MSRP, dollars." }, "cylinders": { "type": "integer", "default": 4 } }, "title": "Model", "type": "object", "required": [ "name", "body_style", "year", "msrp" ] } } }
- class ponty.Annotation
Enrich member variable annotations with context-specific metadata. These annotations appear in the jsonschema as generic keywords; in some cases they are used for additional validation (e.g. array.minItems), while in others they simply help make the schema self-documenting (e.g. description).
Response
- @ponty.render_json
Encode the decorated function’s return value with json.dumps, and package it into an HTTP response. The JSON has the form:
{ "data": <function return value>, "elapsed": <time elapsed, in millis>, "now": <current epoch timestamp, in millis>, }e.g.,
from ponty import get, render_json @get("/hello") @render_json async def handler(_): return {"greeting": "hello world!"}
$ curl localhost:8080/hello | python -m json.tool { "data": { "greeting": "hello world!" }, "elapsed": 1, "now": 1660440618107 }
- ponty.raise_status(status, *, text=None, body=None, content_type=None, **kw)
Raise an aiohttp.web.HTTPException for the given status code.
- Parameters
status (int) – HTTP status code
text (Optional[str]) – response body
body (Optional[Any]) – json-serializable response body. Automatically sets the content-type header to ‘application/json’. Only one of text or body may be supplied.
content_type (Optional[str]) – value for the response’s content-type header
kw –
additional params for aiohttp.web.HTTPException
- Return type
NoReturn
@get("/fail") async def handler(_): raise_status(400, body={"a": 1})
$ curl localhost:8080/fail -v > GET /fail HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.64.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: application/json; charset=utf-8 < Content-Length: 8 < Date: Wed, 10 Aug 2022 02:28:55 GMT < Server: Python/3.9 aiohttp/3.7.3 < {"a": 1}
HTTP Errors
- exception ponty.PontyError(*, text=None, body=None, **kw)
PontyError’s may be raised anywhere within your program to immediately respond with an HTTP status code. Unhandled errors are reraised as aiohttp.web.HTTPException.
- Parameters
text (Optional[str]) – response body, as text/plain
body (Any) – json-serializable response body
kw – see parameter description for aiohttp.web.HTTPException
- exception ponty.DoesNotExist(*, text=None, body=None, **kw)
Returns a 404. Inherits
PontyError.- Parameters
text (Optional[str]) –
body (Any) –
- exception ponty.ValidationError(*, text=None, body=None, **kw)
Returns a 400. Inherits
PontyError.- Parameters
text (Optional[str]) –
body (Any) –
Providers
Providers manage shared assets, and are Ponty’s way of guaranteeing assets are cleaned up upon program exit 1.
Providers are asynchronous generators that take a single argument, an instance of aiohttp.web.Application. Code before the yield initializes the asset during startup, while code after the yield runs during teardown.
Providers must be passed to ponty.startmeup() for proper handling.
- ponty.http_client_provider(*, name='_default', timeout=5, concurrency=100, headers=None, cookies=None, **kw)
Builds an HTTP Client Session provider.
Wraps aiohttp.ClientSession.
- Parameters
name (str) – unique name, used by
lease_http_client()timeout (int) – total timeout for the complete operation, in seconds
concurrency (int) – maximum number of concurrent requests
headers (Optional[dict[str, Any]]) – headers to send with each request
cookies (Optional[dict[str, Any]]) – cookies to send with each request
kw – additional parameters, specified here
- Returns
a provider, which should be passed to
startmeup()- Return type
Callable[[Application], AsyncIterator[None]]
startmeup( port=8080, providers=( http_client_provider( timeout=30, concurrency=10, ), ), )
- ponty.lease_http_client(name='_default')
Context manager. Fetch the named http client session, leasing one of its request positions or blocking until one becomes available, then return it to the pool after the block is exited.
- Parameters
name (str) – see
http_client_provider()parametername- Raises
KeyError – if the named session was not registered (via
startmeup())- Yields
- Return type
AsyncIterator[ClientSession]
async with lease_http_client() as sess: async with sess.get("https://www.python.org/") as resp: html = await resp.text()
Utils
- @ponty.retry(*excs, max_retries=1, t1_ms=100, t2_ms=4000, backoff_factor=1, anticollision=False)
Retry the wrapped function if the specified exception(s) are raised.
Supports sync & async functions.
- Parameters
excs (type[Exception]) – Exception class(es) to trap
max_retries (int) – max number of retries to attempt
t1_ms (int) – initial interval between retries, in millis
t2_ms (int) – max interval between retries, in millis
backoff_factor (float) –
rate reduction factor for exponential backoff \(t = b^c\), where
t is the delay factor between callsb = backoff_factor is the basec is the number of failures observed so far\(b > 1\) will reduce the retry rate, \(b = 1\) will give consistent retry intervals, and \(b < 1\) will accelerate retries (unusual choice). Set to 2 for run-of-the-mill binary exponential backoff.
anticollision (bool) – a deterministic algorithm may be unsuitable when errors are caused by collisions, because each client will sleep for the same amount of time, leading to subsequent collisions ad infinitum. Pass True to treat \(b^c\) as an upper bound on the time delay; in this case we will sleep for \(k * t1\_ms\), where k is a random integer on \([0, \lfloor b^c \rfloor)\).
import random import time from ponty import retry @retry(KeyError, ValueError, max_retries=10, backoff_factor=2) def getval(): i = random.choice(range(5)) print(f"i = {i}, now = {time.time()}") if i == 0: return 42 if i % 2: raise KeyError raise ValueError
>>> print(getval()) i = 3, now = 1660102025.223419 i = 3, now = 1660102025.328576 i = 4, now = 1660102025.533844 i = 1, now = 1660102025.939289 i = 1, now = 1660102026.744708 i = 2, now = 1660102028.347733 i = 0, now = 1660102031.549533 42
Memoization
Ponty caches are extensible, async-friendly memoizers that help to limit stampedes. They are function decorators, like @functools.cache and @functools.lru_cache, and may be a good fit for cases where the builtins are insufficient (e.g. stampede concerns, the use-case demands a TTL, or perhaps the cache needs to be shared across a fleet of instances behind a load balancer).
In the box
- @ponty.memo.localcache(*, ttl_ms=0, maxwait_ms=1000, pulse_ms=50, maxsize=128, name='')
Process-RAM LRU memoizer with antistampede.
Good for small frequently-used datasets (high ttl) or volatile stampede-likely objects (low ttl).
- Parameters
ttl_ms (int) – millis to expiry. Use 0 (the default) for no expiry
maxwait_ms (int) – millis to wait for a lock to resolve. Throws
Stampedeerror when \((n * pulse\_ms) > maxwait\_ms\)pulse_ms (int) – antistampede recheck frequency
maxsize (int) – evict least recently used values once the cache reaches maxsize elements
name (str) – Optional. Providing a name registers the cache, so it can be used by
invalidate()
Here is an example that demonstrates stampede control. With maxwait_ms = 0, this operates as a mandatory lock:
import asyncio from ponty import get, render_json from ponty.memo import localcache @get("/cachetest") @render_json async def _locktest(_): return await _fetch() @localcache(maxwait_ms=0) async def _fetch(): await asyncio.sleep(1) # some expensive operation return {"key": "value"}
For five simultaneous requests, the first acquires the lock and begins work while the others are immediately declined:
$ for i in {1..5}; do curl localhost:8080/cachetest -v & done < HTTP/1.1 409 Conflict < Date: Tue, 23 Aug 2022 21:49:12 GMT < HTTP/1.1 409 Conflict < Date: Tue, 23 Aug 2022 21:49:12 GMT < HTTP/1.1 409 Conflict < Date: Tue, 23 Aug 2022 21:49:12 GMT < HTTP/1.1 409 Conflict < Date: Tue, 23 Aug 2022 21:49:12 GMT < HTTP/1.1 200 OK < Date: Tue, 23 Aug 2022 21:49:13 GMT {"now": 1661291352844, "data": {"key": "value"}, "elapsed": 1006}
Now that the item is cached, running this again gives five immediate cache hits:
$ for i in {1..5}; do curl localhost:8080/cachetest & done {"now": 1661291746863, "data": {"key": "value"}, "elapsed": 0} {"now": 1661291746863, "data": {"key": "value"}, "elapsed": 0} {"now": 1661291746863, "data": {"key": "value"}, "elapsed": 0} {"now": 1661291746863, "data": {"key": "value"}, "elapsed": 0} {"now": 1661291746863, "data": {"key": "value"}, "elapsed": 0}
Bump to maxwait_ms = 2000 and clear the cache:
$ for i in {1..5}; do curl localhost:8080/cachetest & done {"now": 1661396864744, "data": {"key": "value"}, "elapsed": 1003} {"now": 1661396864744, "data": {"key": "value"}, "elapsed": 1043} {"now": 1661396864744, "data": {"key": "value"}, "elapsed": 1044} {"now": 1661396864744, "data": {"key": "value"}, "elapsed": 1045} {"now": 1661396864744, "data": {"key": "value"}, "elapsed": 1045}
The first request is the only one doing work here; the others wait up to two seconds, checking in every pulse_ms = 50 milliseconds (the default) to see if a result is available.
- ponty.memo.invalidate(cachename, *a, **kw)
Context manager. Invalidates an item in the cache.
Waits for then holds the cache’s lock until the context manager exits. This prevents simultaneous fetches from re-cache-ing the item before mutations are committed. This also means functions calling ‘invalidate’ CANNOT be wrapped with a cache decorator, as the decorator will already hold the lock.
- Parameters
cachename (str) – name of the registered cache. Must match
cache()’s name parameter- Return type
AsyncIterator[None]
Note the *arg/**kwarg combo must match the arguments to the cache-decorated function EXACTLY in order to get a key hit. E.g.,
@localcache(name="foo") async def get_foo(foo_id): ... async def update_foo(foo_id, ...): async with invalidate("foo", foo_id): # this hits ... async with invalidate("foo", foo_id=foo_id): # this does not ...
Building blocks
Tools for building custom caching components.
- @ponty.memo.cache(store, antistampede, name='')
Cache the decorated function’s return value. Not thread-safe.
As a rule of thumb, storage mechanics for a cache are implemented in store, and synchronization rules are handled by antistampede. See the
localcache()source ↗ for an example.- Parameters
store (CacheStore) – container, implementing get/set mechanics for cached items
antistampede (Lock) – instance of
ponty.memo.Lock; blocks on simultaneous access to the requested key to avoid a stampedename (str) – if provided, registers the cache for use by
invalidate(). Must be unique
- class ponty.memo.CacheStore
Abstract Base Class. Container for cached items.
cache()expects two abstract methods,getandset, andinvalidate()expects one,remove. All three must return awaitables.Generic on one variable, the type T of the cached item.
- abstract async get(key)
Fetch the cached value.
Errors are not captured.
- abstract async remove(key)
Remove an item from the cache.
- Parameters
key (str) – unique id for the cached item
- Returns
True if the item is found and removed, False otherwise
- Return type
bool
- abstract async set(key, data)
Add an item to the cache.
- Parameters
key (str) – unique id for the item
data (_T) – the item to be cached
- Return type
None
- class ponty.memo.cachemiss
Sentinel value representing a cache miss.
Returned by subclasses of
CacheStore.
Locking
Ponty locks are primarily intended for stampede control in cache(),
but may also be used independently to enforce access limits on
e.g. shared state.
Whenever possible, simply use
asyncio.Lock.
As a rule of thumb, wrap the minimum amount of code necessary in lock context blocks. This will reduce the frequency and duration of one coroutine blocking another.
In the box
- ponty.memo.locallock(maxwait_ms=0, pulse_ms=100, timeout_error=Locked)
Mutex; coordinates across coroutines. Not thread-safe.
- Parameters
maxwait_ms – total milliseconds to wait, before raising an exception with the caller. Raises timeout_error when \(n * pulse\_ms > maxwait\_ms\). If the lock is already held, the default 0 will cause the timeout_error to be raised immediately on a simultaneous access attempt
pulse_ms – milliseconds between subsequent attempts to acquire the lock
timeout_error – exception class raised when maxwait_ms exceeded. If provided, must be a subclass of
Locked
- Return type
A simple example that sleeps whenever the lock is acquired, in order to run out the 5-second clock on other waiting requests:
import asyncio import random import time from ponty import get, render_json from ponty.memo import locallock, Locked mylock = locallock(maxwait_ms=5000) # concurrent requests wait 5 seconds max @get("/locktest") @render_json async def _locktest(_): secs = random.randint(1, 6) success = True try: async with mylock.lock("ponty"): # "ponty" is the id of the protected resource await asyncio.sleep(secs) except Locked: success = False return { "success": success, "secs": secs, }
The five requests below are issued in parallel, so attempt to grab the lock at approximately the same time. Note how the first three acquire it at \(t = 0\), \(t = 1\), and \(t = 4\), respectively - all beneath the 5-second timeout - while the last two time out at roughly the 5-second mark. (With a larger timeout, they would have captured the lock at approx \(t = 8\) and \(t = 10\).)
$ for i in {1..5}; do curl localhost:8080/locktest & done {"now": 1661271438402, "data": {"success": true, "secs": 1}, "elapsed": 1006} {"now": 1661271438402, "data": {"success": true, "secs": 3}, "elapsed": 4049} {"now": 1661271438402, "data": {"success": true, "secs": 4}, "elapsed": 8087} {"now": 1661271438403, "data": {"success": false, "secs": 2}, "elapsed": 5232} {"now": 1661271438404, "data": {"success": false, "secs": 5}, "elapsed": 5232}
Building blocks
Base classes for building a custom mutex.
- class ponty.memo.SentinelStore
Container for asset id’s currently protected by a
Lock.Abstact Base Class.
Lockexpects the three abstract methods below -exists,add,remove- to return awaitables, whether they perform IO or not. (NB no context switch occurs if the method does not perform a blocking operation.) This constraint may be relieved in a future version.- abstract async add(key)
Take the lock on the asset.
- Parameters
key (str) – unique id for the asset
- Return type
None
- abstract async exists(key)
Check if an asset is currently locked.
- Parameters
key (str) – unique id for the asset
- Returns
True if the asset is locked, False otherwise
- Return type
bool
- abstract async remove(key)
Release the lock on the asset.
- Parameters
key (str) – unique id for the asset
- Return type
None
- class ponty.memo.Lock(sentinels, maxwait_ms=0, pulse_ms=100, timeout_error=<class 'ponty.memo.lock.base.Locked'>)
Basic mechanic for locking / antistampede.
Operates as a mandatory lock by default. Control blocking by specifying the maxwait_ms parameter.
- Parameters
sentinels (SentinelStore) – set-like container, providing add/remove operations and existence checking. Must be an instance of
SentinelStoremaxwait_ms (int) – total milliseconds to wait, before raising an exception with the caller. Raises timeout_error when \(n * pulse\_ms > maxwait\_ms\)
pulse_ms (int) – milliseconds between subsequent attempts to acquire the lock
timeout_error (type[Locked]) – exception class raised when maxwait_ms exceeded. If provided, must be a subclass of
Locked
- lock(key)
Context manager. Holds the lock on key until the block exits, or waits to acquire it until maxwait_ms milliseconds have elapsed.
- Parameters
key (str) – unique identifier for the asset whose access requires synchronization
- Return type
AsyncIterator[None]
- exception ponty.memo.Locked(*, text=None, body=None, **kw)
Returns a 409. Inherits
PontyError.- Parameters
text (Optional[str]) –
body (Any) –
Footnotes
- 1
Under the hood, we’re using aiohttp.web.Application.cleanup_ctx for startup/cleanup handling.