Update server

Application source: top-level update_server.py · no application version is declared

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

PathRole
update_server.pyRunning application; importing it starts the server.
modules.jsonPersistent mapping of module names to their source paths.
modules/*.pyPublished 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/*.htmlPublic 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.

RouteResponse
/Simple hello world! response.
/update/versionsJSON mapping of every registered module to its version string or null when no version is declared.
/update/manifestJSON with algorithm and a modules mapping containing versions, SHA-256 hashes, and optional signatures.
/update/{mod}/versionLegacy version string; unknown module returns 404 with Module not found. An unversioned module returns the string None.
/update/{mod}/moduleDistributed source bytes; unknown module returns 404. Replaces exact primary_mod=True bytes with primary_mod=False.
POST /update/{mod}/addAdds a module and persists its registry entry. Existing name: 403; wrong password: 401.
POST /update/{mod}/setReplaces an existing module after backing up its previous distributed content. Missing module: 404; wrong password: 403.
/resources/{name}/jsJavaScript from resources/{name}.js, including pm_socket and form_manager.
/resources/zip_countyZIP/county CSV. Other resource names return 404.
/docs, /docs/{name}Documentation index and extensionless guide URLs.
/sitemap.xml, /robots.txtDynamic 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())
  1. Set PMBLUE_SIGNING_PRIVATE_KEY only in the server's environment and restart the server.
  2. Check that /update/manifest reports algorithm: "ed25519" and signatures for versioned modules.
  3. Distribute the public key through a trusted channel and set PMBLUE_TRUSTED_PUBLIC_KEY before 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.

MemberContract
namePublic identifier used in /update/{mod}/... routes and manifest signatures.
file_pathSource path from the registry. Relative paths resolve from the server's working directory.
versionString 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.modulesIn-memory list of registered objects. Editing this list alone does not persist a registry change.
Module.modules_dbA 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 == nameThe 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

FunctionParameters, 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

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(name)

Source line 27

ParameterPassing conventionDefault / required
namepositional or keywordrequired
Module.__init__(self, name, file_path)

Source line 31

ParameterPassing conventionDefault / required
namepositional or keywordrequired
file_pathpositional or keywordrequired
Module.__str__(self)

Source line 36

No caller-supplied parameters are declared.

Module.__eq__(self, other)

Source line 39

ParameterPassing conventionDefault / required
otherpositional or keywordrequired
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

ParameterPassing conventionDefault / required
modulepositional or keywordrequired
keypositional or keywordrequired
replace_module(path, content)

Source line 71

ParameterPassing conventionDefault / required
pathpositional or keywordrequired
contentpositional or keywordrequired
get_version(mod_name, path='')

Source line 91

ParameterPassing conventionDefault / required
mod_namepositional or keywordrequired
pathpositional or keyword''
async index(request)

Source line 114

Decorators: @server.route('/')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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='*')

ParameterPassing conventionDefault / required
requestpositional or keywordrequired

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.