Update server
The server publishes Python files registered in modules.json, serves browser resources and documentation, and accepts authenticated module uploads. It uses the top-level aiohttp_ws.py wrapper and listens on port 80. Cloudflare provides the public HTTPS proxy for https://update.pmblue.us.
Files and startup
| Path | Role |
|---|---|
update_server.py | Running application; importing it starts the server. |
modules.json | Persistent mapping of module names to their source paths. |
modules/*.py | Published module source. The modules/update_server.py file is not registered in the current mapping and is not the running application. |
backups/ | Previous distributed content saved before a replacement upload. |
resources/ | JavaScript files and the ZIP/county CSV. |
docs/*.html | Public HTML guides used by the documentation index and sitemap. |
Start the server from the project directory: its registry, source, resources, documentation, and backup paths are relative to the current working directory. Startup reads every registered module's __version__ without importing those files. The application's own imports can still trigger their existing updater side effects. Updating the application source requires a process restart.
HTTP routes
Read routes accept any method in the current wrapper; GET is the intended retrieval method. Upload routes require POST.
| Route | Response |
|---|---|
/ | Simple hello world! response. |
/update/versions | JSON mapping of every registered module to its version string or null when no version is declared. |
/update/manifest | JSON with algorithm and a modules mapping containing versions, SHA-256 hashes, and optional signatures. |
/update/{mod}/version | Legacy version string; unknown module returns 404 with Module not found. An unversioned module returns the string None. |
/update/{mod}/module | Distributed source bytes; unknown module returns 404. Replaces exact primary_mod=True bytes with primary_mod=False. |
POST /update/{mod}/add | Adds a module and persists its registry entry. Existing name: 403; wrong password: 401. |
POST /update/{mod}/set | Replaces an existing module after backing up its previous distributed content. Missing module: 404; wrong password: 403. |
/resources/{name}/js | JavaScript from resources/{name}.js, including pm_socket and form_manager. |
/resources/zip_county | ZIP/county CSV. Other resource names return 404. |
/docs, /docs/{name} | Documentation index and extensionless guide URLs. |
/sitemap.xml, /robots.txt | Dynamic XML sitemap and crawler discovery information. |
Uploading and publishing
POST form fields are auth (the upload password configured by the server administrator) and data (base64-encoded Python source). Successful uploads return Success!. Invalid base64 or Python syntax returns 400 before the live module is replaced. Always check the HTTP status, including 403.
import base64
from getpass import getpass
from pathlib import Path
import requests
source = Path("example.py").read_bytes()
response = requests.post(
"https://update.pmblue.us/update/example/set",
data={"auth": getpass("Upload password: "),
"data": base64.b64encode(source).decode("ascii")},
timeout=30,
)
response.raise_for_status()
print(response.text)
Use /add for a new name. Before publishing changes, increase the file's __version__ so clients recognize it as newer. Version metadata is refreshed after successful uploads; edits made directly on disk require restarting the server to refresh its in-memory versions.
Replacement uses a same-directory temporary file, flush, fsync, and os.replace. Existing permission bits are preserved. The backup, registry persistence, and multiple module uploads are not one combined transaction. The server validates syntax using its own Python interpreter; it does not test the module against every client Python version.
Signing setup
Signing is optional for compatibility. The server requires cryptography only when PMBLUE_SIGNING_PRIVATE_KEY is configured. The updater client requires only requests and Python 3.7 standard-library facilities.
Generate a key pair once in a trusted environment and save the private output in the server's secret configuration:
import base64
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
key = Ed25519PrivateKey.generate()
private = key.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
public = key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw,
)
print("PMBLUE_SIGNING_PRIVATE_KEY=" + base64.b64encode(private).decode())
print("PMBLUE_TRUSTED_PUBLIC_KEY=" + base64.b64encode(public).decode())
- Set
PMBLUE_SIGNING_PRIVATE_KEYonly in the server's environment and restart the server. - Check that
/update/manifestreportsalgorithm: "ed25519"and signatures for versioned modules. - Distribute the public key through a trusted channel and set
PMBLUE_TRUSTED_PUBLIC_KEYbefore client processes import the updater.
Each signature covers the module name, version, and SHA-256 digest in the format documented in the client guide. Hashes use the transformed bytes actually sent to clients, not the publisher's original bytes. An unversioned module has a digest but no signature. Without a signing key the manifest uses algorithm: null and omits signatures.
Existing unsigned clients still use the versions and module endpoints. A client with a trusted key rejects missing or invalid signed metadata. There is currently one key per server/client configuration; key rotation must coordinate those configurations.
Server objects and functions
Module: one registry entry and its source file
Module(name, file_path) stores the module's public name, source path, and parsed version. Constructing it reads the source declaration but does not add it to the registry. Startup builds these objects from modules.json; successful upload routes rebuild the in-memory list after writing files.
| Member | Contract |
|---|---|
name | Public identifier used in /update/{mod}/... routes and manifest signatures. |
file_path | Source path from the registry. Relative paths resolve from the server's working directory. |
version | String extracted from __version__ at construction, or None. It is a cached declaration, not a live file property. |
read() | Opens the source file on each call, returns bytes, and applies the primary_mod=True to primary_mod=False replacement. It does not execute the module. |
Module.get(name) | Searches the class-level Module.modules list and returns the first matching Module or None. Call it on the class; it is not an instance lookup method. |
Module.modules | In-memory list of registered objects. Editing this list alone does not persist a registry change. |
Module.modules_db | A top-level jsondb2.JsonFile backing modules.json, configured with autosave. The server's local jsondb2 copy can differ from the distributable module. |
str(module), module == name | The string form is name vversion. Equality compares names for a Module or string operand, enabling name membership checks against the list. |
The following is an application-internal example, suitable inside an existing server hook or handler after startup. Importing update_server.py from a separate script would start the server.
# Context: code inside the running update_server application.
module = Module.get("pmblue_update")
if module is not None:
published_bytes = module.read()
print(module.name, module.version, len(published_bytes))
Publication and signing helpers
| Function | Parameters, result, and side effects |
|---|---|
get_version(mod_name, path="") | mod_name is a filename, including its extension. Reads a UTF-8 file, finds a line beginning with __version__= after removing spaces, strips surrounding whitespace/quotes, and returns a string. Missing files or declarations return None; the source is not imported. |
replace_module(path, content) | content is Python source bytes. Compiles it without executing, writes a same-directory temporary file, then atomically replaces the destination. Returns None. It does not create a backup, refresh registry metadata, or reload an imported module; those are separate responsibilities. |
signing_key() | Reads the private-key environment setting and returns an Ed25519 private-key object, or None if signing is unconfigured. Invalid base64, key length, or missing cryptography support raises an error. |
module_metadata(module, key) | Reads the Module's distributed bytes and returns a dictionary with version and sha256. Adds a base64 signature when both a key and a version exist. |
startup() | An async startup hook that appends Module objects from the JSON registry. It is intended to run once during normal server startup. |
Requests and responses
Route handlers receive an aiohttp.web.Request. Path variables come from request.match_info; upload handlers use await request.post() for the form mapping. Read handlers return strings, bytes, dictionaries, or an explicit response. The wrapper turns dictionaries into JSON and (body, status) tuples into the requested HTTP status. Documentation and sitemap routes use explicit response content types.
The server instance owns the route registry and starts the event loop through server.run(startup()). Application handler names such as all_versions and update_manifest describe HTTP entry points; remote clients call their URLs rather than importing them. See the aiohttp wrapper guide for its object model, noting that this application imports its separate top-level copy.
Client examples
Read the full version inventory with one request
This uses only requests and does not import the auto-updating client. Null means that the registered file has no declared version.
import requests
response = requests.get("https://update.pmblue.us/update/versions", timeout=10)
response.raise_for_status()
versions = response.json()
for name, version in sorted(versions.items()):
print(name, version if version is not None else "no declared version")
Distinguish a missing module from a transport failure
import requests
name = "toolbox"
try:
response = requests.get(
"https://update.pmblue.us/update/" + name + "/version", timeout=10,
)
if response.status_code == 404:
print("The server has no module named", name)
else:
response.raise_for_status()
print("Server declaration:", response.text.strip())
except requests.RequestException as error:
print("The version check could not complete:", error)
Inspect a signed manifest without installing anything
Checking that fields are present is useful for deployment diagnostics. It does not verify a cryptographic signature; use the updater's signed installation path for that.
import requests
response = requests.get("https://update.pmblue.us/update/manifest", timeout=10)
response.raise_for_status()
manifest = response.json()
print("Signing algorithm:", manifest["algorithm"])
for name, record in sorted(manifest["modules"].items()):
print(name, record["version"], record["sha256"],
"signature present:", "signature" in record)
Documentation and sitemap
The site discovers public .html files in docs/. The index uses each page's title and description and links to extensionless URLs. Legacy /docs/name.html links redirect to /docs/name; /docs/, /docs/index, and /docs/index.html redirect to the index. The sitemap lists the same guides plus the homepage and index. Sitemap modification timestamps come from each guide file's modification time. PMBLUE_SITE_URL overrides the canonical origin (default https://update.pmblue.us).
Guide content is edited directly as HTML. After changing module source, update the explanatory guide and run python3 tools/build_api_reference.py with Python 3.9 or newer to refresh class/member navigation, constructor and field summaries, parameter/default tables, and source docstrings without importing modules. The generator does not infer behavior or refresh examples; those need source review. The server discovers added and removed guides on subsequent requests.
Browse all guides · XML sitemap
Complete source API
Generated from update_server.py; no module version is declared. Includes public functions, classes, directly declared methods, properties, and Python protocol methods. Conditional APIs may require optional dependencies. Inherited members and dynamically assigned attributes are explained in the guide where relevant.
Signatures and docstrings are extracted without importing or running the module. A property or AsyncProperty decorator changes how a member is accessed; see its decorators and the guide.
Classes and objects
Module functions
signing_keymodule_metadatareplace_moduleget_versionindexversion_checkall_versionsupdate_manifestget_moduleset_moduleadd_moduleget_javascriptget_resourcesitemaprobotsdocs_indexdocs_index_redirectget_docsstartup
class Module
Source line 24
Construct: Module(name, file_path)
Fields assigned by the constructor: file_path, name, version. Some assignments may be conditional; see the object guide for meaning and lifecycle.
Declared functions, properties, and nested objects:
Module.get— methodModule.__init__— methodModule.__str__— methodModule.__eq__— methodModule.read— method
Module.get(name)
Source line 27
| Parameter | Passing convention | Default / required |
|---|---|---|
name | positional or keyword | required |
Module.__init__(self, name, file_path)
Source line 31
| Parameter | Passing convention | Default / required |
|---|---|---|
name | positional or keyword | required |
file_path | positional or keyword | required |
Module.__str__(self)
Source line 36
No caller-supplied parameters are declared.
Module.__eq__(self, other)
Source line 39
| Parameter | Passing convention | Default / required |
|---|---|---|
other | positional or keyword | required |
Module.read(self)
Source line 45
No caller-supplied parameters are declared.
signing_key()
Source line 52
No caller-supplied parameters are declared.
module_metadata(module, key)
Source line 60
| Parameter | Passing convention | Default / required |
|---|---|---|
module | positional or keyword | required |
key | positional or keyword | required |
replace_module(path, content)
Source line 71
| Parameter | Passing convention | Default / required |
|---|---|---|
path | positional or keyword | required |
content | positional or keyword | required |
get_version(mod_name, path='')
Source line 91
| Parameter | Passing convention | Default / required |
|---|---|---|
mod_name | positional or keyword | required |
path | positional or keyword | '' |
async index(request)
Source line 114
Decorators: @server.route('/')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async version_check(request)
Source line 118
Decorators: @server.route('/update/{mod}/version')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async all_versions(request)
Source line 126
Decorators: @server.route('/update/versions')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async update_manifest(request)
Source line 130
Decorators: @server.route('/update/manifest')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async get_module(request)
Source line 136
Decorators: @server.route('/update/{mod}/module')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async set_module(request)
Source line 147
Decorators: @server.route('/update/{mod}/set', method='POST')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async add_module(request)
Source line 178
Decorators: @server.route('/update/{mod}/add', method='POST')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async get_javascript(request)
Source line 206
Decorators: @server.route('/resources/{name}/js', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async get_resource(request)
Source line 218
Decorators: @server.route('/resources/{name}', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async sitemap(request)
Source line 261
Decorators: @server.route('/sitemap.xml', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async robots(request)
Source line 278
Decorators: @server.route('/robots.txt', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async docs_index(request)
Source line 285
Decorators: @server.route('/docs', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async docs_index_redirect(request)
Source line 311
Decorators: @server.route('/docs/', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async get_docs(request)
Source line 315
Decorators: @server.route('/docs/{name}', method='*')
| Parameter | Passing convention | Default / required |
|---|---|---|
request | positional or keyword | required |
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.
async startup()
Source line 331
No caller-supplied parameters are declared.
Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.