marianoguerra/pure-py/eval does not have a README file

    Dispatch

    What a match statement does with a value.

    Host

    pub struct Host {
    write : (String) -> Unit
    argv : Array[String]
    modules : Map[String,
    HashMap
    [String,
    Value
    ]]
    call : async (String, Array[
    Value
    ]) ->
    Outcome
    noraise
    }

    What the host supplies: everything about a PurePy run that is not pure.

    PurePy is a pure language, but a RUN is not a pure thing: it prints, it reads its arguments, it loads modules, and -- when it is embedded -- it calls out to whatever the application wants to expose. Those four are the whole of it, and they are all here, so an embedder can see the surface at once instead of discovering it a call at a time.

    • Output. write receives what print produced, one call per print, while the run is still going. A host that streams to a terminal writes it out; a host that wants a transcript accumulates.
    • Arguments. argv is what sys.argv reads. argv[0] is the program's own name, as in Python.
    • Modules. modules are importable modules the host defines, beyond the five the specification predefines. Their members are values, and a member may be a function the host implements.
    • Foreign calls. call answers a call to one of those functions. It is given the name the host registered and the arguments the guest passed, and returns an Outcome: a value, an abort, or -- for anything it does not recognise or cannot do -- an operation the semantics does not cover.

      It is async, so it may also answer LATER: a host that has to read a socket, await a promise or ask a person suspends, and the whole run parks where it stood -- mid-expression, inside a call, anywhere -- and resumes on the value the host eventually supplies. Nothing about the guest changes: PurePy has no await, and a program cannot tell a call that answered late from one that answered at once. See run_with for how a synchronous embedder drives a run that can park.

    Everything else about a run is already a value: the program is a SourceTree, so a host that keeps its guest code in a database, a zip file or a string never touches the filesystem.

    Note what is NOT here. There is no way for the host to mutate a guest value, because there is no mutation; no way to install a callback the guest invokes implicitly, because there are no hooks; and no ambient authority at all -- a guest can reach exactly the modules the host handed it. A host that gives no modules gives a program that can only compute and print.

    Suspension adds nothing to that list. A parked run is not a concurrent one: there is one guest, it is at exactly one point, and the host holds the only continuation. Resuming it twice is the host resuming it twice, which is the host's bug and not an escape from the semantics.

    Host::member_names

    fn Host::member_names(self : Host) -> Map[String, Array[String]]

    The names of each host module's members.

    The CHECKER needs these and not the values: a from hostmod import f has to type-check before it can run, and the checker has no business knowing what f is. This is the one place the two sides of a host module meet.

    Host::module_names

    fn Host::module_names(self : Host) -> Array[String]

    The modules a program may import that have no source: the five the specification predefines, and the host's own.

    Host::new

    fn Host::new(write? : (String) -> Unit, argv? : Array[String], modules? : Array[HostModule], call? : async (String, Array[
    Value
    ]) ->
    Outcome
    noraise) -> Host

    A host.

    Every part has a default that does nothing observable: output is dropped, sys.argv is empty, no modules are defined, and a foreign call is an operation the semantics does not cover. A host supplies the parts it wants.

    A call that answers on the spot is written exactly as it was before it could do otherwise: a plain function is a valid async one.

    HostModule

    pub(all) struct HostModule {
    name : String
    members : Array[(String,
    Value
    )]
    }

    A module the host defines: a name the guest can import, and its members.

    A member is any Value. @value.host_fn("name") makes one the guest can call and the host answers by name.

    Interp

    pub struct Interp {
    tree :
    SourceTree

    host : Host
    loaded : Map[String,
    HashMap
    [String,
    Value
    ]]
    loading : Array[String]
    depth : Int
    max_depth : Int
    in_module : String
    abort_at : Site?
    }

    Interp::apply

    eval-call-lambda, eval-call-def, eval-call-prim and their arity rules.

    Interp::dispatch

    dispatch(ρ, v, p⃗, s⃗): the first case whose pattern matches, with its bindings; no bindings and pass if none does.

    Interp::eval_expr

    Expression evaluation: Figures 4.5 to 4.10.

    Every rule that needs a condition -- if, and, or, a conditional expression, assert -- requires an actual True or False. Python's truthiness is not PurePy's, so if 5: has no rule and the run is undefined. That is one of the conformance suite's dynamically excluded tests, and it is the reason Stuck is threaded everywhere.

    Where an abort happened is recorded by sited, at each of the six places an expression can produce one. Not by wrapping this function: it recurses and it is async, so a step after the recursive call is a continuation allocated per expression rather than a tail call, and that measured 21% on a program that does nothing but call.

    Interp::eval_exprs

    A sequence of expressions, left to right, stopping at the first that does not yield a value (Figure 4.7).

    Interp::eval_quals

    Comprehension qualifiers (Figure 4.10): a list of environments, one per binding the generators produce.

    The enclosing environment is not extended by the result: a comprehension's variables are local to it.

    Interp::eval_seq

    Statement evaluation: Figures 4.3 and 4.4.

    A sequence's result is the bindings the SEQUENCE made, not the whole environment: eval-seq composes assigns ρ' with what follows, and the caller overrides its own environment with the result. That is what keeps a function body's locals out of its caller.

    Interp::load

    Module and program evaluation: Figures 4.11 to 4.13.

    Loading mirrors the static rules: the import prefix first, then the body, under builtins. An import loads the ancestors of its target from the root down, each before its descendant.

    The one departure is the cache, and it is about OUTPUT rather than values. The spec says loading is deterministic and needs no cache, which is true of what a module loads TO; it is not true of what loading PRINTS. Two imports of a module that prints would print twice, and CPython prints once. The suite has a test for it.

    Interp::match_pattern

    Pattern matching: Figure 4.2.

    The rules are narrower than "anything else fails", and the narrowness is deliberate. eval-pat-list-no fires when the value is NEITHER a list nor a tuple, or is a list of the wrong length -- so a list pattern against a TUPLE is covered by no rule at all and the run is undefined, while a tuple pattern against an integer is an honest no-match. A literal pattern against a list reaches eq between unrelated kinds and is undefined too. The conformance suite has a test for each of the three.

    Interp::new

    fn Interp::new(tree :
    SourceTree
    , host? : Host, max_depth? : Int) -> Interp

    Interp::predefined

    The environment a predefined module loads to, or None if q is not one.

    typing exposes Any alone. Figure 2.7 also lists Callable; the reference checker does not, and the signature and the environment have to agree or a from-import would type-check and then fail to run.

    Interp::resolve_class

    resolve-class(ρ, q): the class a qualified name stands for.

    Interp::run

    async fn Interp::run(self : Interp) -> RunResult noraise

    ⇒ κ (Figure 4.13): evaluate a program by loading its main module.

    RunResult

    pub(all) enum RunResult {
    Finished
    Terminated(
    Termination
    , Site?)
    Undefined(String)
    Suspended
    } derive(
    Debug
    )

    How a run ended, or that it has not ended yet.

    Sink

    pub struct Sink {
    buf : StringBuilder
    }

    Somewhere to put output when the caller just wants the transcript.

    run_program uses one of these; run_with does not, because a host that supplies its own write has somewhere better to put it.

    Sink::new

    fn Sink::new() -> Sink

    Sink::text

    fn Sink::text(self : Sink) -> String

    Sink::write

    fn Sink::write(self : Sink, text : String) -> Unit

    Site

    pub(all) struct Site {
    in_module : String
    span :
    Span

    } derive(Eq,
    Debug
    )

    Where an abort happened: the module, and the span within it.

    The specification says an implementation "must agree on which kind a run yields, though how it reports one is not prescribed" (operational-semantics.tex). This is reporting, then, and not semantics: a Site never changes which Termination a run yields, and two runs that abort at different places with the same kind still agree.

    Site::to_display

    fn Site::to_display(self : Site) -> String

    module:line:col, the site as a person reads it.

    default_max_depth

    let default_max_depth : Int

    How deep a PurePy call stack may go by default, on a native thread.

    Half Python's own limit of a thousand. Evaluation is async, so that a host function may suspend, and the compiler's transform makes each guest call cost several machine frames where a direct call cost one. Measured on a native thread's default 8 MB, over a program that recurses with a small expression in each frame:

    buildceiling
    debugabout 1050 guest calls
    releaseover 6000

    The limit exists to turn a stack overflow into an answer a person can read, so it has to sit below the tightest ceiling a consumer might build, and a debug build is one anybody can build. A release build holds ten times this: a caller that knows what it built should raise it.

    The JavaScript-hosted backends get a number of their own, in depth_js.mbt, because their ceiling is two orders of magnitude lower.

    region_bindings

    {f⃗: v⃗}: every definition of a region, as closures over the same environment.

    in_module is the module the region was written in, which the closures carry so that an abort inside one is reported against the right file.

    run_program

    fn run_program(tree :
    SourceTree
    , argv? : Array[String], max_depth? : Int) -> (String, RunResult)

    Evaluate a program and answer with its transcript and how it ended.

    The simple path: output is collected and handed back. A caller that wants output as it happens, its own modules, or its own functions supplies a Host and uses run_with.

    The host this builds answers no foreign call, so this run cannot suspend and the result is never Suspended. The transcript is complete.

    run_with

    fn run_with(tree :
    SourceTree
    , host : Host, max_depth? : Int, done? : (RunResult) -> Unit) -> RunResult

    Evaluate a program against a host, from a synchronous caller.

    Output goes wherever the host sends it, so nothing is returned but how the run ended.

    A host call may suspend -- park the run and answer later -- and a synchronous caller cannot wait for it. So this returns when the run ends OR when it suspends, whichever comes first, and there are two ways to learn the answer:

    • The return value is the answer when the run ended here, and Suspended when it did not.
    • done is called exactly once with the answer whenever the run really ends: before this returns when nothing suspended, and from inside the host's own continuation when something did.

    A host that always answers immediately can ignore done and read the return value, which is what every caller before suspension existed did. A host that suspends should pass done, because the value returned here is not the answer.

    An embedder that is itself asynchronous does not need any of this: Interp::run is an async function and can simply be awaited.