Skip to main content

Pyouter

Pyouter is a tasks router by hierarchy and layered tasks, which may come from command line or http restful api

Deepwki: https://deepwiki.com/fanfeilong/pyouter

pyouter

节点 runtime 启动后会打印 web http://<ip>:<port>。用浏览器打开这个地址就是上面这张地图:层有自己的颜色,当前这台机器上的层标着「你在这里」,绿色是可达,红色是不可达。

INSTALL

install pyouter

pip install pyouter

examples

minimal example code for pyouoter:

from typing import Any
from pyouter.app import App
from pyouter.router import Router

import json
import asyncio
import time


async def hello(config, options):
    print("hello function")
    
def hello_sync(config, options):
    print("hello sync")

class Hello:
    def __init__(self, value) -> None:
        self.value = value
    
    # Router class SHOULD provider method: `run(self, config, options)`
    async def run(self, config, options) -> Any:
        self.config = config
        self.options = options
        await self.inner()
    
    async def inner(self):
        print(f"run Hello, {self.value}")
        await asyncio.sleep(2)
        print(f"hello class object, self.options.debug:{self.options.debug}, sleep 2s")
        if self.options.debug:
            print(f'debug, {self.value}')
            
class Hello_Sync:
    def __init__(self, value) -> None:
        self.value = value
    
    # Router class SHOULD provider method: `run(self, config, options)`
    def run(self, config, options) -> Any:
        self.config = config
        self.options = options
        self.inner()
    
    def inner(self):
        print(f"run Hello_Sync, {self.value}")
        time.sleep(self.value)
        print(f"hello class object, self.options.debug:{self.options.debug}, sleep {self.value}s")
        if self.options.debug:
            print(f'debug, {self.value}')

if __name__=="__main__":
    '''
    Usage:
        ## start pyouter, then route into that process:
        * python test.py
        * python test.py test.hello.func
        * python test.py test.hello.obj
        * python test.py test
        
        ## dump router path only:
        * python test.py test.hello -i
        * python test.py test.hello --insepect
        
        ## dump router path and execute:
        * python test.py test.hello -v
        * python test.py test.hello --view

        ## browse and run in a local web ui:
        * python test.py --web
        * python test.py test.hello --web --no-browser
    '''
    
    app = App(
        config='config.json',
    )
    
    app.option(
        '-d', '--debug',
        dest='debug',
        action="store_true",
        help='debug'
    )
    
    app.use(
        router=Router(
            test=Router(
                hello=Router(
                    func=hello,
                    obj=Hello("world"),
                    obj2=Hello_Sync(10)
                ),
                hello2=hello,
                hello3=Router(
                    func=hello_sync,
                    obj=Hello("world"),
                    obj2=Hello_Sync(20),
                )
            )
        )
    )
    
    app.run()
    
    

framework

An application is a router tree plus one context per run. use() replaces the tree. include() mounts another tree, optionally under a prefix. A leaf is still a function or an object with run. A single parameter receives a Context; two parameters keep the (config, options) form. An object's run method follows the same rule.

from pyouter.app import App
from pyouter.router import Router

async def hello(ctx):
    ctx.log(ctx.config["name"])
    return ctx.options.debug

app = App(config={"name": "pyouter"})
app.include(Router(hello=hello))
app.run()
python app.py
python app.py hello

python app.py starts one process and leaves it running. Every later command routes a node into that process, prints the result, and exits. A function, a router, a web service, and a web endpoint are the same kind of step: the process you started is what executes the node. The command delivers the path.

python app.py hello runs hello there. An object keeps its state across commands, because those commands stay on the worker that first ran it. A second python app.py prints pyouter already running. python app.py --worker joins the pools as another process. A command issued before the first process exists fails and tells you to start it. --tasks and --tree only read the route tree, so they run in the command itself.

Calling run() after you have set options executes in the current process. Tests and embedded callers use that path.

After a run, app.result.exit_code is 1 when a node fails, and run() raises SystemExit. A context action's return value is app.result.root.value when that action is the node that was executed.

Router nodes run their children in parallel. policy="sequence" runs them in order. Child failures are collected into app.result and the process exits with code 1. ctx.cancelled is set when a local web run is stopped.

local web

https://cursor.com/artifacts/v/art-1cdde7f3-5756-49a3-97ac-2d54dc193da9

Open the current router in a browser. The server binds to 127.0.0.1:8765 and uses only the Python standard library.

python test.py --web
python test.py --web --port 9000
python test.py test.hello --web --no-browser

--web starts the same pyouter process and opens the browser on it. A later python test.py test.hello.func routes into that process. The left column is the route tree. The right panel shows the selected node's status, a map of the nearby routes, and the logs captured from that node's standard output and standard error. Click a node to inspect it. Use ▶, double-click, or 执行 to run it. Running a router runs its children, same as the command line.

app.web() starts the same page from Python. --host defaults to localhost; binding another interface exposes task execution on that interface.

web services

A route is a command (cli), a router (router), or a web endpoint (web). node_kind reports which. A Flask or FastAPI application used to sit in the tree as one opaque node, so the routes declared inside it were invisible. Mounting the application flattens each HTTP route into a pyouter node. The command line, --tasks, and the local web ui then see those routes.

Flask and FastAPI are not required to install pyouter. They are imported only by the application you mount.

from flask import Flask
from pyouter.app import App

service = Flask(__name__)

@service.get("/health")
def health():
    """service is up"""
    return {"ok": True}

@service.get("/users/<int:user_id>")
def user(user_id):
    return {"user_id": user_id}

app = App(config={})
app.option("--user-id", dest="user_id")
app.include(service, prefix="api")
app.run()
python app.py
python app.py api
python app.py api.health
python app.py api.users.user_id --user-id 7
python app.py --tasks

python app.py is the process that stays up. python app.py api routes to the service node inside it and waits there while Flask listens on 127.0.0.1:5000. python app.py api.health routes to that endpoint: one GET /health against the service already listening in the same process, then the command exits. api.users.user_id is GET /users/<int:user_id>, routed the same way. python app.py hello, for an ordinary function mounted beside the service, is also a route into this process.

A path parameter is taken from an option of the same name, from options.params, or from config. Several methods on one path become get, post, and the other method names. The request returns status, body, method, and path. A response of 400 or higher fails the node. If the service node is not running, the route fails and tells you to start it. Running the service node again, while it is already listening, reports that and leaves the service in place. Stopping the api command stops the service. The pyouter process keeps running.

app.include(service, prefix="api", port=5000) selects the address. flatten(service, port=5000) returns the same router when you want to mount it yourself. A FastAPI application listens on 127.0.0.1:8000 unless you pass a port, and starting it needs uvicorn. GET /users/{user_id} becomes users.user_id.

containers

A container is a node too. Commands and HTTP services inside it are child nodes, so a path such as a.b.c.d reaches through the container b into c and then d. Docker is not required to install pyouter. The docker executable is used only when a container node runs.

from pyouter.app import App
from pyouter.docker import Command, Container, Service
from pyouter.router import Router

box = Container(
    "python:3.12-slim",
    name="tools",
    workers=2,
    ports=("8080:80",),
    routes=Router(
        c=Router(
            d=Command("echo", "deep"),
        ),
        api=Service(
            "http://127.0.0.1:8080",
            health=("GET", "/health"),
        ),
    ),
)

app = App(config={})
app.include(Router(a=Router(b=box)))
app.run()
python app.py
python app.py a.b
python app.py a.b.c.d
python app.py a.b.api.health

python app.py a.b starts the container and waits. The container's main process is another pyouter, serving the routes you passed. workers=2 starts that many pyouter processes inside the container. a.b.c.d is handed to that inner pyouter, which runs echo deep on one of those processes. a.b.api.health sends GET /health to the published address. Both fail, and say to start the container, when a.b is not running. Running a.b again while it is running reports that and leaves the container in place. Stopping the a.b command removes the container it started. The outer pyouter process keeps running.

The image needs Python. pyouter mounts its source and the application script and starts them; you do not write a Dockerfile. command=("nginx", "-g", "daemon off;") replaces the inner runtime when the image itself is the service. A nested router such as c is only another path segment.

A Compose file is the same kind of node, one level up. Each service in the file is a child. Commands and HTTP routes are added onto a service by name. Mount the project with a prefix; that prefix is what starts the stack. A .yml file needs PyYAML. A .json Compose file does not.

from pyouter.docker import Command, Compose, Service
from pyouter.router import Router

stack = Compose(
    "compose.yaml",
    project="demo",
    routes=Router(
        web=Router(ping=Command("echo", "web")),
        api=Service("http://127.0.0.1:8080", health=("GET", "/health")),
    ),
)
app.include(stack, prefix="stack")
python app.py stack
python app.py stack.web
python app.py stack.web.ping
python app.py stack.api.health

python app.py stack runs docker compose up -d and waits. stack.web is the web service from the file. stack.web.ping is docker compose exec inside that service. stack.api.health sends one request to the published address. Stopping the stack command runs docker compose down for the stack it started.

ssh

An SSH host is a node too. Commands on that host are child nodes, so a.b.c.d runs on the host named b. The ssh program is used only when the host node runs. The connection does not ask for a password; it uses the keys and agent already available to ssh.

from pyouter.app import App
from pyouter.docker import Service
from pyouter.router import Router
from pyouter.ssh import Remote, SSH

edge = SSH(
    "10.0.0.8",
    user="deploy",
    forwards=("8080:127.0.0.1:80",),
    routes=Router(
        c=Router(d=Remote("echo", "deep")),
        api=Service(
            "http://127.0.0.1:8080",
            health=("GET", "/health"),
        ),
    ),
)

app = App(config={})
app.include(Router(a=Router(b=edge)))
app.run()
python app.py
python app.py a.b
python app.py a.b.c.d
python app.py a.b.api.health

python app.py a.b opens one connection and waits. a.b.c.d runs echo deep on deploy@10.0.0.8. a.b.api.health sends GET /health through the local forward. Both fail, and say to start the ssh node, when a.b is not connected. Running a.b again while it is connected reports that and leaves the connection in place. Stopping the a.b command closes the connection it opened. The pyouter process keeps running.

Mount the host with a prefix, or place it under a name as b is placed above. forwards are ssh -L values.

layers

A route tree can nest any of these nodes. Four of them start a new load balancer, so crossing one is a new layer:

node layer what the parent pool does what this layer's pool does
container docker one worker starts and owns the container balances every call under the container
SSH host ssh one worker opens and owns the connection balances every call on that host
cluster cluster one worker asks the cluster to run the one pod balances every call inside that pod
web service server one worker starts and owns the listener balances every request the service handles

A listening server is a layer. It has its own worker pool. The cli process you start is the outermost pool, and it is not a crossing.

cli > docker > ssh > docker > server is four layers. A call that lands on a layer node stays on one worker of the parent. A call that goes past that node is balanced inside the layer.

from pyouter.docker import Container
from pyouter.flatten import flatten
from pyouter.ssh import SSH
from pyouter.router import Router

api = flatten(service, workers=4)
inner = Container("python:3.12-slim", name="inner", routes=Router(api=api), workers=3)
jump = SSH("10.0.0.9", routes=Router(box=inner), workers=2)
edge = Container("python:3.12-slim", name="edge", routes=Router(host=jump), workers=2)
app.include(Router(cli=Router(box=edge)))

cli.box is the outer container node: the cli pool owns it. cli.box.host.box.api.health is inside the server, so the server's 4 workers balance that call. Each layer on the way has already handed the call to the next pool.

place parameter default effect
container, SSH host, cluster, web service balancer uvicorn which provider starts that layer's pool
container, SSH host, cluster, web service workers 1 how many processes that pool starts. On a cluster this is the pool inside the one pod
container, SSH host, web service runtime http://host:port for a direct dial, or a peer id reached through the control process's super nodes

The provider is an internal detail of the pool. You set balancer and workers. Replacing the provider is register_provider(name, start) and passing that name as balancer.

deploy

The runtime is the pyouter package. deploy_tree installs that package on the machine named by runtime, then pushes the layer. A listener that already answers /health is left in place. A later push replaces the pool.

where the layer runs how the package gets there
runtime is on this machine (127.0.0.1) deploy_tree starts python -m pyouter.node here
an SSH host, or a layer reached through one the first contact copies the package to ~/.pyouter/runtime over that host's ssh and starts the listener
a container started from this machine starting the container mounts the package; no Dockerfile is written
a cluster layer deploy_tree builds an image from this tree, pushes it, and applies one Deployment
edge = Container("python:3.12-slim", routes=..., workers=2, runtime="http://127.0.0.1:7710")
jump = SSH("10.0.0.9", routes=..., workers=2, runtime="http://10.0.0.9:7710")
api = flatten(service, workers=4, runtime="http://10.0.0.9:7711")

deploy_tree(app.router) is the control call. A layer with no runtime is not deployed. A remote address that is not an SSH host, and is not reached through one, is refused: there is no path to copy the package there.

A runtime is also a peer. The section below is the full startup, the tree, and the checks.

pyouter Kubernetes
what is placed one layer of the tree a pod
who chooses the machine the runtime written on that layer the scheduler
what is already on the machine a way to reach it: this process, or ssh a kubelet reconciling pod specs
what starts the work the installed runtime starts that layer's pool the kubelet starts containers from images
what a second push does replaces the pool serving that slice reconciles toward the new spec

One measured run after a slice was pushed to a node runtime. Four concurrent calls each occupy a process for 0.3s:

workers calls hold elapsed processes used speedup
1 4 0.3s 1.205s 1 1
4 4 0.3s 0.305s 4 3.95

Four processes finish the same concurrent work in about a quarter of the time. One process runs the calls one after another. The test requires the larger pool to be at least 1.5 times faster.

peers

A runtime is one peer. Start the super node, start each layer's runtime with that super node's multiaddr, copy the peer id it prints into runtime, then call deploy_tree. The layer does not store the super node. The control process carries the list.

runtime="http://127.0.0.1:7711" still dials that URL and does not use the list.

start

On the machine that other peers can reach, start the super node. --identity is the key file. The same file is the same peer id after a restart. The default file is ~/.pyouter/peer/identity.

python -m pyouter.node --port 7710 --identity ~/.pyouter/peer/sn --sn-listen 127.0.0.1:4001
node 7710
peer 12D3KooWSnA...
sn /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA...

node is the HTTP port for a direct runtime. peer is the id you copy into a layer when this process is the one that should receive the slice. sn is the multiaddr every other runtime passes to --sn.

Start the runtime for one layer, and point it at that multiaddr:

python -m pyouter.node --port 7711 --identity ~/.pyouter/peer/api \
  --sn /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA...
node 7711
peer 12D3KooWApi...
joined 12D3KooWSnA...

joined is the super node this runtime reserved on. joined none means every --sn failed. A later deploy to this peer id does not deliver the slice.

A second super node is another process. A runtime connects to every entry that answers:

python -m pyouter.node --port 7720 --identity ~/.pyouter/peer/sn-b --sn-listen 127.0.0.1:4002 \
  --sn /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA...
node 7720
peer 12D3KooWSnB...
sn /ip4/127.0.0.1/tcp/4002/p2p/12D3KooWSnB...
joined 12D3KooWSnA...

The second super node is given the first super node's multiaddr, so the two see each other. They do not elect a leader and they do not store the tree. A worker then receives both:

python -m pyouter.node --port 7711 --identity ~/.pyouter/peer/api \
  --sn /ip4/127.0.0.1/tcp/1/p2p/12D3KooWClosed... \
  --sn /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA...

The closed address fails. The live one answers. The runtime prints joined 12D3KooWSnA... and no line for the closed address.

flag default effect
--port 7710 HTTP port for a direct runtime URL. 0 picks a free port
--host 127.0.0.1 HTTP bind address
--identity ~/.pyouter/peer/identity key file for this peer id
--sn-listen host:port that makes this process a super node
--sn multiaddr of one super node. Repeat the flag for each one

the tree

Four layers, four runtimes, two super nodes. Each runtime is already up and has printed joined for at least one of these super nodes. Copy each peer line into runtime. The ids below stand in for those lines. sn is the same pair of multiaddrs the runtimes joined. It is an argument of deploy_tree, not a field on a layer.

from flask import Flask

from pyouter.app import App
from pyouter.docker import Container
from pyouter.flatten import flatten
from pyouter.node import deploy_tree
from pyouter.router import Router
from pyouter.ssh import SSH

service = Flask("api")


@service.get("/health")
def health():
    return {"ok": True}


api = flatten(service, workers=4, runtime="12D3KooWApi...")
inner = Container(
    "python:3.12-slim",
    name="inner",
    routes=Router(api=api),
    workers=3,
    runtime="12D3KooWInner...",
)
jump = SSH("10.0.0.9", routes=Router(box=inner), workers=2, runtime="12D3KooWJump...")
edge = Container(
    "python:3.12-slim",
    name="edge",
    routes=Router(host=jump),
    workers=2,
    runtime="12D3KooWEdge...",
)

app = App(config={})
app.include(Router(cli=Router(box=edge)))
sent = deploy_tree(
    app.router,
    sn=[
        "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA...",
        "/ip4/127.0.0.1/tcp/4002/p2p/12D3KooWSnB...",
    ],
)

sent is one record per layer, outer path first. The worker count is the count written on that layer. port is the free port that layer's pool bound on its peer. It is not --port of pyouter.node, and the four pools do not share a port. The numbers below are the shape of that record; a real deploy fills in whichever free ports the pools bound:

[
    {"path": "cli.box", "kind": "docker", "balancer": "uvicorn", "workers": 2, "port": 52431},
    {"path": "cli.box.host", "kind": "ssh", "balancer": "uvicorn", "workers": 2, "port": 53102},
    {"path": "cli.box.host.box", "kind": "docker", "balancer": "uvicorn", "workers": 3, "port": 54880},
    {"path": "cli.box.host.box.api", "kind": "server", "balancer": "uvicorn", "workers": 4, "port": 55114},
]

A second deploy_tree with the same list replaces the pool on the same peer. The new record carries the port the replacement pool bound.

Leave sn out and the control process reads PYOUTER_SN, a comma-separated list of the same multiaddrs. PYOUTER_PEER_IDENTITY is the control peer's key file. Unset, that file is ~/.pyouter/peer/identity.

Hole punching runs after the super node has opened the stream to the peer. A failed punch still leaves the slice on that stream.

A peer id with no super node in the call and no PYOUTER_SN is refused before a slice is sent. A peer that printed joined none, or that joined some other super node, is refused when the control process tries to reach it. The error says the peer has not joined a super node.

checks

The suite is src/tests/test_peer.py. It starts real python -m pyouter.node processes, reads the lines they print, and calls deploy_tree.

PYTHONPATH=src python3 -m unittest src.tests.test_peer -v
test what it does
test_a_peer_id_stays_the_same_across_reads two reads of one identity file return the same id; a second file returns another
test_a_peer_id_without_a_super_node_is_refused deploy_tree with a peer id and no sn raises before a slice is sent
test_a_slice_reaches_a_peer_that_joined_the_super_node a worker that printed joined receives the slice, and a second push delivers to the same peer
test_a_peer_that_has_not_joined_is_not_given_the_slice --sn pointed at a closed port prints joined none; deploy raises has not joined
test_a_peer_joins_the_super_node_that_answers one closed multiaddr and one live super node; the worker joins the live one and receives the slice

The slice in those tests is one server layer:

from pyouter.node import deploy_tree
from pyouter.router import Router

def health(ctx):
    return None

api = Router(health=health)
api.serves = True
api.layer = "server"
api.balancer = "uvicorn"
api.workers = 2
api.runtime = worker_peer_id  # the "peer " line of the runtime that printed "joined"

sent = deploy_tree(Router(api=api), sn=[sn_multiaddr])
# sent[0]["path"] == "api"
# sent[0]["kind"] == "server"
# sent[0]["workers"] == 2
# sent[0]["port"] is the free port that peer's pool bound

worker_peer_id and sn_multiaddr come from the two processes in start. sn_multiaddr is the sn line. worker_peer_id is the peer line of the runtime that printed joined for that super node. The same call with workers=2 a second time returns a record of the same path, kind, and worker count.

cluster

A cloud account does not hand you the Kubernetes nodes. You push an image; the cluster starts the container. That container is one pod, and the pod is one peer. workers is the process pool inside the pod. The Deployment's replica count is 1.

from flask import Flask

from pyouter.app import App
from pyouter.cluster import Cluster
from pyouter.flatten import flatten
from pyouter.node import deploy_tree
from pyouter.router import Router

service = Flask("api")


@service.get("/health")
def health():
    return {"ok": True}


api = flatten(service, workers=4)
edge = Cluster(
    "registry.example.com/app:1.2",
    name="edge",
    routes=Router(api=api),
    workers=4,
)

app = App(config={})
app.include(Router(cli=Router(box=edge)))
sent = deploy_tree(
    app.router,
    sn=["/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWSnA..."],
)

registry.example.com/app:1.2 is the name that gets built and pushed. pyouter writes the image from this tree: the pyouter package, the application, and an entrypoint of python -m pyouter.node. No Dockerfile is written by hand.

The pod mounts a Secret at /var/pyouter/identity. That file is created on the control machine under ~/.pyouter/cluster and reused, so a restart of the pod is the same peer id. The container args are --sn for each super node passed to this deploy_tree call. The layer does not store that list. imagePullPolicy is Always. The pod template carries a hash of the tree, so a new image rolls out a new pod.

sent is one record per layer, outer path first. The cluster record and the server inside it come back from that one peer. port is the free port that layer's pool bound inside the pod:

[
    {"path": "cli.box", "kind": "cluster", "balancer": "uvicorn", "workers": 4, "port": 52431},
    {"path": "cli.box.api", "kind": "server", "balancer": "uvicorn", "workers": 4, "port": 53102},
]

A layer written under the cluster with no runtime is pushed to this same peer. A layer that sets runtime to an http:// URL or a peer id is installed on that runtime instead, and is not sent to the pod.

A second deploy_tree with the same tree does not build or roll. It pushes the slices again, and the peer replaces each layer's pool. Changing workers is that same push: the image stays. Changing the tree, or the image name, builds, pushes, and rolls the Deployment. The new pod joins, then the slices are pushed to it.

A cluster layer with no super node in the call and no PYOUTER_SN is refused before an image is built. The error says the cluster needs a super node.

PYTHONPATH=src python3 -m unittest src.tests.test_cluster -v
test what it does
test_a_cluster_image_must_name_a_registry a bare image name is refused
test_a_cluster_without_a_super_node_is_refused deploy_tree with no sn raises before docker runs
test_a_cluster_manifest_is_one_pod the Deployment has one replica, the identity mount, and --sn; workers is not a replica count
test_a_cluster_layer_is_one_pod_and_one_peer the slice reaches the peer that joined; a second push and a workers change skip the build; a new image name builds again
test_a_layer_with_its_own_runtime_is_not_sent_to_the_pod a child that names runtime is pushed there, and the pod receives the cluster slice

What's new

0.11.0

  • a cluster layer is one pod and one peer; workers is the pool inside that pod
  • deploy_tree builds an image from the tree, pushes it, and applies one Deployment
  • the pod joins the super nodes passed to that call, and a second push of the same tree does not build again

0.10.0

  • a runtime is a peer; runtime may be a peer id as well as http://host:port
  • --sn-listen makes that runtime a super node, and --sn joins every reachable super node in the list
  • deploy_tree delivers a peer-id layer through the control process's super nodes

0.9.0

  • a node runtime stays up on a physical node and receives the layer pushed to it
  • deploy_tree installs the runtime on first contact, then starts that layer's pool
  • a loopback runtime is started here; an SSH host receives the package over ssh

0.8.0

  • a container, an SSH host, and a web service are layers, each with its own balancer and workers
  • the default balancer spreads concurrent work across that layer's processes

0.7.0

  • an SSH host is a node; commands and forwarded HTTP routes under it are child nodes

0.6.0

  • a Compose file flattens into a project node and one node per service

0.5.0

  • a container is a node; commands and published HTTP routes under it are child nodes

0.4.0

  • python app.py starts the process that executes every node
  • a later command routes into that process, for a function, a router, or a web route

0.3.1

  • a flattened web service stays listening; route nodes send requests to it

0.3.0

  • a route is cli, router, or web
  • Flask and FastAPI applications flatten into pyouter nodes

0.2.0

  • one dispatcher shared by the command line and the local web ui
  • Context and RunResult for each run; context actions take ctx
  • include() mounts routers; policy="sequence" runs children in order
  • a failed child sets exit code 1

0.1.2

  • add a local web ui (--web) to browse, visualize, and run routers

0.1.0

  • fix executor bug

0.0.9

  • improve -v, --view, -i, --insepect option for path debug

0.0.8

  • add app.option support
  • improve App(config=), support config patth
  • add aysnc support
  • will execute all routers pallarent by using async ThreadPoolExecutor

0.0.7

  • add pyouter self completion of bash and fish

0.0.6

  • fixed bug in fish completion install

0.0.5

  • pass config if need

0.04

  • custom parser

0.0.3

  • support pyouter completion
  • pyouter as entry points of package

0.0.2

  • rename plugin to pyouter

0.0.1

  • project init
  • add pypi settings
  • add installer

Metadata

Release files for pyouter 0.11.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for pyouter 0.11.0
File Interpreter ABI Platform Actions
pyouter-0.11.0-py3-none-any.whl Python 3 none any Details for pyouter-0.11.0-py3-none-any.whl

Release files / pyouter-0.11.0-py3-none-any.whl

Download URL pyouter-0.11.0-py3-none-any.whl
Size 127.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
befa3a735a6b78abd45f3e8fe70d8fc9301522aae3eee040e3bca15d207a6688
BLAKE2b-256 checksum
How to use checksums
2fd4a46748b95cf8ff99b8d47fb128c5aec48140a04f63b27ca17432e3f02258
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.12.4

Security for pyouter 0.11.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page