Skip to main content
The platform executes a logic.code component’s code in a namespace with no __import__ at all, so a surviving import statement fails at runtime with ImportError: __import__ not found. Code that runs there has to be one flat namespace with every import already resolved. The compiler is what turns an ordinary Python package into that namespace. It runs inside build and deploy, and in memory inside diff. It parses with ast and never imports, executes or evaluates your code, so compiling is safe on a package you have not run. Everything up to the port is a pure function of the files on disk and the import allowlist. Nothing here talks to the platform.

The walk

Starting from entry_point, each module is parsed and every import / from ... import in it is classified — including the ones nested inside functions and classes, which are classified exactly where they sit rather than hoisted. Resolution is by module path, not by bare file existence. from a.b import c looks for a/b.py, then a/b/__init__.py; landing on a/b/c.py instead means c is a module, not a name, and that is LOCAL_MODULE_IMPORT_FORM rather than a resolution. Relative imports resolve against the importing module’s package. from . import x inside that package’s own __init__.py resolves to the sibling x.py, never to __init__.py itself, which would report a self-cycle. A path that resolves outside base_path — including an entry_point that is not under it at all — is BASE_PATH_ESCAPE. Where a local module shadows a pre-bound stdlib name, local wins, because the sandbox’s own merge order does the same thing. The LOCAL_SHADOWS_SANDBOX_MODULE advisory names the shadow.

Emission

Modules are ordered topologically: dependencies first, the entry point last. Each becomes one content[] part, headed by a frozen marker naming the file it came from:
That marker is what makes eject possible, and it is part of the contract rather than a comment. The module body is the source with every import statement replaced in place — by exact character span, so several statements on one physical line are each spliced separately and the surrounding indentation and text are untouched. Nothing else is reformatted or reordered.

The import-rewrite table

What replaces an import depends on what the runtime actually binds, which is the module’s own name, never the names inside it. The sandbox pre-binds json, datetime, re and the rest as module objects, and an allowlisted third-party package is merged in under its literal allowlist name the same way. A local import is different: once its target is inlined, the names it exports are already bound flatly under their own names. Getting this wrong would be worse than a NameError. Stripping from datetime import datetime to nothing leaves the name datetime bound to the module rather than the class, so datetime.now() fails with a confusing attribute error — or silently resolves against the wrong object. The rebinding line is what keeps the flat namespace behaving the way the source reads. It has one sharp edge of its own, in Limitations below: that same rebinding takes over the module’s name. A block left empty by the rewrite gets a pass, so a function whose whole body was an import stays parseable — def configure(): followed only by import json compiles to def configure(): followed by pass.

Hard checks

Each of these stops the command before anything is written, and each prevents a failure that would otherwise surface only at execution time, with no plausible cause. Dotted imports of non-local modules are fine — only local ones are rejected by LOCAL_MODULE_IMPORT_FORM.

Determinism

Compilation is a pure function of the sources, and the writer touches no key it does not own:
  • content_hash — SHA-256 over the canonical compiled content[]: the JSON array serialized with sorted keys, ensure_ascii=False and no insignificant whitespace.
  • attributes.iduuid5 of that hash under a fixed namespace, lower-cased. Identical content always yields the identical id.
  • attributes.created_at — carried forward from whatever is already in the definition file, and stamped with the current time only when there is nothing to carry.
  • attributes.syntax_mode — always written as python.
  • Any other attribute key already present is preserved.
So a rebuild over unchanged sources leaves the definition file byte-identical and git status clean. A code port this CLI has never built changes once, on its first build, and is stable from the second onward. “Byte-for-byte” is anchored after newline translation. Module sources are read in universal-newlines mode, so \r\n and \r become \n before the compiler sees them. A CRLF-authored file therefore deploys as LF and hashes identically to its LF-authored twin — which is the point, since the same package must compile the same way whatever checked it out — but the deployed text is not literally the file’s bytes, and an eject round trip will not restore CRLF endings.

The remote-edit gate

content_hash is what lets the CLI refuse to overwrite code someone edited in the visual editor. Before writing a code port that already has content: --force skips the classification. The other way out is eject, which brings the edited code onto disk so you can reconcile it by hand first. diff reports the same three states without writing anything. The scheme holds whichever way the visual editor treats an attribute it does not recognize: preserved, the hash goes stale and mismatches; regenerated, the hash disappears. Both stop and tell you, so the CLI never depends on the editor cooperating. Note which document is classified. build and deploy read the code port in the local definition file, because that is the only document they have; diff is the command that classifies the remote port.

Limitations

Stated plainly rather than omitted, because each one is a surprise the first time you meet it in deployed code. A rebinding can shadow the module it came from. from datetime import datetime, timezone emits datetime = datetime.datetime; timezone = datetime.timezone, and the first assignment rebinds datetime to the class before the second reads .timezone off it — an AttributeError at execution time. The same happens across modules: one inlined module doing from datetime import datetime breaks a later module’s from datetime import timezone. It bites whenever the imported name equals its module’s name, datetime being the common case. Either reach through the module, which needs no rebinding at all, or alias to a different name:
A submodule import does not inline the parent package’s __init__.py. Real Python executes a/__init__.py before a/b.py; this compiler resolves a.b straight to a/b.py and never visits a/__init__.py unless some other statement reaches it directly. Inlining every ancestor on every submodule import would drag unrelated package-level code into the flat namespace and manufacture spurious SYMBOL_COLLISIONs. The consequence: side effects or definitions living only in a parent __init__.py are silently absent at runtime. A conditional import pair reports as SYMBOL_COLLISION. try: from a import helper / except ImportError: from b import helper classifies both branches statically — the compiler never evaluates which would win — so both modules are inlined and their two helpers collide, and the message names two modules without mentioning the try. Rewrite to a single branch; the sandbox has no __import__ at all, so an ImportError fallback could never fire there anyway. A module with a top-level from x import * opts out of IMPORT_NAME_NOT_FOUND entirely. Its exports cannot be enumerated without evaluating __all__ or executing code, so every name requested from it is let through. The wildcard’s own source module is still inlined, so a genuinely re-exported name really is bound; this only widens what the compiler accepts, never invents a binding. A dotted non-local import is classified by its root, but rebinds through an attribute. import numpy.linalg as la is accepted when numpy is allowlisted and emits la = numpy.linalg — which resolves at runtime only if the sandbox actually imported the submodule, that is, only if numpy.linalg is itself an allowlist entry. See Sandbox for why a dotted entry is worth an advisory of its own.

The allowlist

workflow_component_code_imports is the one compilation input that lives on the server, and reading it needs an admin-scoped key — which the read and write keys the platform recommends for automation are not. So the compiler classifies against a local cache at ~/.blobhub/cache/allowlist/, keyed on the manifest’s own blob: reference. That is what keeps build networkless. With no cache entry, ALLOWLIST_UNREADABLE is raised as an advisory and an unresolved import degrades from IMPORT_UNAVAILABLE to the advisory IMPORT_UNVERIFIED; --strict-imports restores the hard failure, which is what a CI job should set. A cache entry older than seven days additionally raises ALLOWLIST_STALE. Everything that does not depend on the allowlist is enforced regardless: local resolution, cycles, collisions, name lookups and sandbox shadowing. blobhub blob limits covers the allowlist itself, the key that can read it, and how the cache is written.

Errors

Every code named on this page, with its remediation, is in Error codes. The compiler’s own family is IMPORT_CYCLE, SYMBOL_COLLISION, SANDBOX_NAME_SHADOWED, LOCAL_MODULE_IMPORT_FORM, IMPORT_NAME_NOT_FOUND, IMPORT_UNAVAILABLE, UNSUPPORTED_IMPORT_FORM, BASE_PATH_ESCAPE and ENTRY_POINT_NOT_FOUND, each naming the module and the statement that caused it.

See also