Chapter 13: Modules and Packages
What you'll learn. How files become modules, the from … import (…) and import … as … forms, pub visibility across files, barrel modules with pub from, the import rule between web.compiled and everything else, circular imports and defer import, the .lppkg manifest that loup-bootstrap init writes, and what the package tooling can and cannot do in Loup 1.25.
Why it matters. Up to now every program has been one file. Real projects are split across files and directories, and in the web modes the import rule decides which code can reach the standard library at all.
A project with more than one file
Start with the scaffold from Chapter 1 and give it a second directory. Here is a small shop project with a cart package of two modules:
shop/
shop.lppkg
src/
main.lpweb
cart/
money.lpweb
discount.lpweb
tests/
Every source file is a module. Its import path is its location under src/, with directories joined by dots and the extension dropped: src/cart/money.lpweb is the module cart.money. Here is that module:
@mode(web.compiled)
pub struct Money {
pub cents: int;
}
pub func format(m: Money): string {
let rest = m.cents % 100;
let dollars = (m.cents - rest) / 100;
let pad = if (rest < 10) { "0" } else { "" };
return "$" + str(dollars) + "." + pad + str(rest);
}
func checkCents(cents: int): void {
if (cents < 0) {
throw ValueError("negative amount");
}
}
pub func dollars(d: int, c: int): Money {
checkCents(c);
return Money(cents = d * 100 + c);
}
Listing 13-1: src/cart/money.lpweb, a module with three public names and one private helper
The module is declarations only, as every web.compiled file must be (Chapter 2). pub marks what other modules may import: the struct Money, its field cents, and the functions format and dollars. checkCents has no pub, so it is private to this file. Struct and class fields are private to their module unless they are pub too; without pub cents, code in another module could build a Money but not read price.cents (error[E_FIELD_PRIVATE]: member 'cents' of struct 'Money' is private).
The second module uses a relative import. A leading dot means "next to this file":
@mode(web.compiled)
from .money import (Money);
pub func applyPercent(m: Money, percent: int): Money {
let off = m.cents * percent / 100;
return Money(cents = m.cents - off);
}
Listing 13-2: src/cart/discount.lpweb imports a sibling module with a relative path
The entry file imports from both:
@mode(web.compiled)
from cart.money import (Money, format, dollars);
from cart.discount import (applyPercent);
func main(): void {
let price = dollars(19, 99);
let sale = applyPercent(price, 15);
println(format(price) + " -> " + format(sale));
}
Listing 13-3: src/main.lpweb imports from two modules of the project
Listings 13-2 and 13-3 are marked ignore only because the book's listing checker compiles one file at a time; the project as a whole was run with the 1.25.1 toolchain:
$ loup run src/main.lpweb
$19.99 -> $17.00
A web.compiled module can import another web.compiled module, both under loup run and in a Wasm build. loup-bootstrap build --target browser compiles all three modules into one build/shop.wasm, which loup-launcher runs with the same output:
$ loup-bootstrap build --target browser
Resolving dependencies...
Compiling 3 module(s)...
Generating code...
Build complete: …/shop/build/shop.wasm (21953 bytes)
…/shop/build/shop.wasm
$ loup-launcher --run-bare build/shop.wasm
$19.99 -> $17.00
Importing a private name
If main.lpweb reaches for the private helper:
@mode(web.compiled)
from cart.money import (dollars, checkCents);
func main(): void {
checkCents(-1);
println(dollars(1, 0).cents);
}
the compiler refuses the import and every use of the name:
src/main.lpweb:3:34: error[E0001]: cannot import private symbol `checkCents` from module
note: add `pub` to the declaration to make it importable
src/main.lpweb:6:5: error[E0001]: undefined identifier `checkCents`
The fix is the one the note gives — add pub to checkCents — or, better here, leave it private: dollars already calls it, and callers outside the module have no reason to.
Loup has two levels of visibility: pub (importable) and private (the declaring module only). There is no pub(package) or pub(module); writing one is a parse error.
The import forms
All imports go at the top of the file, after @mode(...) and an optional namespace line. They cannot appear inside a function. Wildcard imports (import *) do not exist: every imported name is written out.
| Form | Binds |
|---|---|
from cart.money import (Money, format); |
the names Money and format |
from cart.money import (format as formatMoney); |
formatMoney only |
from .money import (Money); |
a name from a sibling module |
from cart { money import (Money); discount import (applyPercent); } |
grouped imports under one prefix |
import cart.money as money; |
the module, used as money.format(…) |
import cart.money; |
the module under its full path, cart.money.format(…) |
The import list always has parentheses, even for one name. The next listing is a web.scripted file, which can import the standard library (more on that below), and it leaves the parentheses out:
@mode(web.scripted)
from core.math import sqrt, floor;
println(sqrt(16.0));
println(floor(2.7));
main.lpweb:3:23: error[E0001]: expected LParen, found Identifier
main.lpweb:3:34: error[E0001]: expected RParen, found Semicolon
"Expected LParen" points at sqrt: the parser wanted ( after import. Add the parentheses:
@mode(web.scripted)
from core.math import (sqrt, floor);
println(sqrt(16.0));
println(floor(2.7));
Listing 13-4: An import list is always parenthesized
4
2
An alias replaces the name
as binds the imported symbol under the new name instead of the original. A reader who renames sqrt and then keeps using sqrt gets:
@mode(web.scripted)
from core.math import (sqrt as root);
println(sqrt(16.0));
main.lpweb:5:9: error[E0001]: undefined identifier `sqrt`
Use the alias, or import the name twice if you want both spellings (from core.math import (sqrt as root, sqrt);):
@mode(web.scripted)
from core.math import (sqrt as root, floor);
println(root(16.0));
println(floor(2.7));
4
2
Importing a module
import path as name; binds the whole module. Its public names are reached through the alias, which keeps it obvious at each call site where a name comes from:
@mode(web.scripted)
import core.math as math;
let r = 3.0;
println(math.PI * r * r);
println(math.sqrt(2.0));
Listing 13-5: A module import, with members reached as math.PI and math.sqrt
28.274333882308138
1.4142135623730951
A module alias names a scope, not a value: math on its own is an error, and only the module's pub names are members of it.
Barrel modules with pub from
A directory can have an index.lpweb that re-exports what the rest of the program should use. Importing the directory's path (from cart import …) finds that index:
@mode(web.compiled)
pub from .money import (Money, format, dollars);
pub from .discount import (applyPercent);
Listing 13-6: src/cart/index.lpweb, a barrel module
Callers no longer need to know which file declares what (this listing and the barrel are run as part of the project, like Listing 13-3):
@mode(web.compiled)
from cart import (dollars, format, applyPercent);
func main(): void {
let sale = applyPercent(dollars(19, 99), 15);
println(format(sale));
}
$17.00
A re-export can rename (pub from .money import (format as formatMoney);), and it can only re-export pub names. Re-exporting checkCents from the barrel fails in the barrel itself with error[E0001]: cannot re-export private symbol checkCents from module … and the note only pub symbols can be re-exported.
namespace declarations
A file may declare a namespace right after its @mode line:
@mode(web.compiled)
namespace shop.cart;
pub func lineTotal(unitCents: int, qty: int): int {
return unitCents * qty;
}
func main(): void {
println(lineTotal(450, 3));
}
1350
The Language Reference (§1-13.0) says a file's public names are exposed under its namespace path. In Loup 1.25 the resolver still finds modules by file path only: with that line in src/cart/pricing.lpweb, from cart.pricing import (…) works and from shop.cart import (…) fails with unresolved package import. Until that changes, either leave namespace out or make it match the file's path.
The import rule for web.compiled
A web.compiled module may import only other web.compiled modules (Language Reference §3-1.2). Every module of the standard library is a host.scripted module, so in Loup 1.25 a web.compiled file cannot import any of them. The prelude — println, str, repr, Ok, Some, the exception classes and the other names Chapter 2 lists — needs no import and is available everywhere.
This is the mistake you will make first:
@mode(web.compiled)
from core.math import (sqrt);
func main(): void {
println(sqrt(2.0));
}
main.lpweb:3:1: error[E0001]: `@mode(web.compiled)` module cannot import `@mode(host.scripted)` module
note: `@mode(web.compiled)` modules can only import other `@mode(web.compiled)` modules
note: error code: E_COMPILED_IMPORT_SCRIPTED
The code is E_COMPILED_IMPORT_SCRIPTED, the third line says so. There are two ways out. If the program needs the library module, write it in web.scripted, which may import the standard library and runs its top-level statements in order (no main):
@mode(web.scripted)
from core.math import (sqrt);
println(sqrt(2.0));
Listing 13-7: The same program in web.scripted, which can import core.math
1.4142135623730951
If the program must stay web.compiled, keep library calls out of it, or write the small piece you need yourself in a web.compiled module of your own.
The same rule applies to your own files. If src/cart/tax.lpweb is a web.scripted module, importing it from main.lpweb (a web.compiled module) fails with the same code:
src/main.lpweb:3:1: error[E0001]: `@mode(web.compiled)` module cannot import `@mode(web.scripted)` module
note: `@mode(web.compiled)` modules can only import other `@mode(web.compiled)` modules
note: error code: E_COMPILED_IMPORT_SCRIPTED
The other direction is allowed. A web.scripted file can import your web.compiled modules and the standard library, which makes it the natural home for a tool that glues the two together. This one reads a price list with data.csv and prints it with the cart package:
@mode(web.scripted)
from data.csv import (parse);
from cart import (dollars, format, applyPercent);
let rows = parse("tea,4,50\nmug,12,0", ",");
for (row in rows) {
let price = dollars(row[1].to_int(), row[2].to_int());
let sale = applyPercent(price, 10);
println(row[0] + ": " + format(price) + " (sale " + format(sale) + ")");
}
Listing 13-8: src/pricelist.lpweb, a web.scripted tool that imports both the project's web.compiled modules and the standard library
$ loup run src/pricelist.lpweb
tea: $4.50 (sale $4.05)
mug: $12.00 (sale $10.80)
web.scripted ───────────► web.compiled allowed
web.scripted ───────────► standard library allowed
web.compiled ───────────► web.compiled allowed
web.compiled ─ ─ ─ ─ ─ ─► web.scripted E_COMPILED_IMPORT_SCRIPTED
web.compiled ─ ─ ─ ─ ─ ─► standard library E_COMPILED_IMPORT_SCRIPTED (1.25)
⚠ Planned. The Language Reference describes the browser modules (
web.dom,web.storage,web.workers,ui.web,lang.interop.js, …) and says web code gets the full standard library apart from host capabilities. None of theweb.*orui.*modules ships in Loup 1.25 — chapters 16–18 describe them as design previews.
Reserved namespaces
The roots core, io, data, net, sync, concurrent, web, ui, native, lang, debug and structures, and the loup. prefix, are reserved for the standard library and first-party packages (Language Reference §1-13.6). An import under a reserved root must name a module the standard library actually has (or one your own src/ tree supplies). Importing a planned browser module therefore fails even though the name is "right":
@mode(web.compiled)
from web.dom import (query);
func main(): void {
println("ready");
}
main.lpweb:3:6: error[E0001]: 'web.dom' is not a standard-library module (namespace 'web' is reserved for the standard library)
There is no fix in 1.25 other than not using the module; the list of modules that ship is in the standard library status page of the Loup repository (docs/stdlib-status.md).
Circular imports and defer import
Two modules that import each other form a cycle, and the compiler rejects it in every mode (E_CIRCULAR_IMPORT, Language Reference §1-13.10). An employee knows its team and a team lists its employees:
@mode(web.compiled)
from org.team import (Team);
pub class Employee {
pub let name: string;
pub var team: Option[Team] = None;
new(name: string) {
this.name = name;
}
}
Listing 13-9: src/org/employee.lpweb imports Team
@mode(web.compiled)
from org.employee import (Employee);
pub class Team {
pub let title: string;
pub var members: list[Employee] = [];
new(title: string) {
this.title = title;
}
}
Listing 13-10: src/org/team.lpweb imports Employee
A program that imports either module stops at the cycle (paths shortened from the absolute paths the compiler prints):
src/staff.lpweb:3:1: error[E_CIRCULAR_IMPORT]: import cycle detected: src/org/team.lpweb → src/org/employee.lpweb → src/org/team.lpweb
note: circular imports are not allowed; consider restructuring your modules
The usual fixes are to move the shared types into a third module that both import, or to merge the two modules. When the two types really do refer to each other, one side can import the other's name as a deferred type stub: change the first line of employee.lpweb to
from org.team defer import (Team);
defer import gives Team a type identity at once and binds its definition after module initialization, which breaks the cycle. With that one change this program runs:
@mode(web.compiled)
from org.team import (Team);
from org.employee import (Employee);
func main(): void {
let web = Team("Web");
let ann = Employee("Ann");
safe {
ann.team = Some(web);
web.members.push(ann);
}
println(web.title + " has " + str(web.members.len()) + " member(s)");
match ann.team {
Some(t) => println(ann.name + " is on " + t.title),
None => println(ann.name + " has no team"),
}
}
Listing 13-11: src/staff.lpweb uses both classes once the cycle is broken with defer import
Web has 1 member(s)
Ann is on Web
(The safe { … } block is there because writing a var field of a class instance needs a safe context; Chapter 9 explains why.) Listings 13-9 to 13-11 are marked ignore because they span several files; they were run together as a project with the 1.25.1 toolchain. A deferred cycle is still a hint that the modules might be split differently.
Module dunders
Every module can read three string constants: __name__, __file__ and __execmode__. They are handy in log lines:
@mode(web.compiled)
func whereAmI(): string {
return __name__ + " in " + __file__ + " (" + __execmode__ + ")";
}
func main(): void {
println(whereAmI());
}
__main__ in main.lpweb (interpreted)
Do not branch on these values yet. The Language Reference says that in the web modes __name__ is the module's dotted name and never "__main__", and that __execmode__ is the mode string ("web.compiled"). Under loup run in 1.25 __name__ is "__main__", __execmode__ is "interpreted", and all three report the entry file's values even inside an imported module: moved into src/cart/locate.lpweb and called from src/d.lpweb, whereAmI() prints __main__ in src/d.lpweb (interpreted).
The package manifest
A project is a directory with a .lppkg manifest. loup-bootstrap init writes one (Chapter 1 explains why this book uses loup-bootstrap for init, check and build):
$ loup-bootstrap init shop
created project at …/shop
It creates shop/shop.lppkg, shop/src/main.loup and an empty shop/tests/. The scaffold is a host.dual program; for a web project, rename src/main.loup to src/main.lpweb, change its first line to @mode(web.compiled), and point entry at it. The top of the manifest then reads:
name = "shop"
version = "0.1.0"
loup = "1.20.0"
authors = []
[build]
entry = "src/main.lpweb"
delivery = "cli"
profile = "release"
strict_deps = false
separate = false
loup-bootstrap init also writes empty [test], [dependencies], [dev_dependencies], [delivery], [stdlib], [modes], [linker], [freestanding], [options], [const_eval] and [exports] tables, which you can leave alone. The keys you will touch:
name,version— the package's name (lowercase, hyphen-separated segments) and its semantic version.loupis the minimum toolchain version.[build] entry— the fileloup-bootstrap buildstarts from.[build] delivery— the artifact shape:cliby default,browserorwasmfor a web module.loup-bootstrap build --delivery browserand its older spellingloup-bootstrap build --target browseroverride it; manifests may likewise still saytargetfordeliveryand[target.X]for[delivery.X]. Today every one of these shapes produces a barebuild/<name>.wasm(Chapter 1).[modes]and[build] mode— project defaults for files that declare no mode. Ruling MODE-2 says every file should declare its own mode, so you should not need them.
The full schema is in the Language Reference §1-13.3.
Dependencies and LOUPPATH
⚠ Planned. The package registry is not available in Loup 1.25.
loup add,loup pkg,loup publishandloup linkdo not exist (error: unknown command 'add'),loup.lockis never written, and[dev-overrides]and the Core Package Set (loup.http,loup.db, …) are described in the Language Reference and the Packages Reference but not usable.
Declaring a registry dependency in [dependencies] makes loup install stop with:
error: 1 external dependency declared, but registry-based package installation is not yet available in this release
note: the package registry and the `loup install`/`add`/`publish` network paths are planned; see the Packages Reference
What does work is LOUPPATH. It is a colon-separated list (semicolons on Windows) of roots: directories that contain packages. A package named acme.units lives at <root>/acme/units/ beside its manifest acme.units.lppkg:
/home/you/vendor/ # a root on LOUPPATH
acme/ # namespace directory, no manifest
units/
acme.units.lppkg # name = "acme.units"
length.lpweb # module acme.units.length
@mode(web.compiled)
pub func cmToInches(cm: float): float {
return cm / 2.54;
}
Listing 13-12: length.lpweb in the acme.units package
With the root on LOUPPATH, from acme.units.length import (cmToInches); resolves from the shop project:
$ LOUPPATH=/home/you/vendor loup run src/main.lpweb
10
Without it, the same run fails with unresolved package import 'acme/units/length': searched package roots ['src'] …. In 1.25 loup run and loup-bootstrap build honour LOUPPATH; loup-bootstrap check does not yet (it reports E-CHECK-DEP). A path dependency in [dependencies] ("acme.units" = { path = "../vendor/acme/units" }) is the reverse: loup-bootstrap build uses it and loup run does not. Prefer LOUPPATH for now.
prefer compiled
The Language Reference describes module fallback pairs (§1-13.7): a package ships a compiled artifact and a scripted source for the same module, and an import written from codec import (Encoder) prefer compiled; loads the compiled one when it can. In web.compiled the clause is meant to be redundant (the spec gives it a prefer-compiled-redundant lint).
⚠ Planned. Loup 1.25 parses
prefer compiledand ignores it — no lint, no fallback search, no.wasmartifact lookup. Write plain imports.
A project layout
Putting the chapter together:
shop/
shop.lppkg # manifest: [build] entry = "src/main.lpweb"
src/
main.lpweb # web.compiled entry, func main()
pricelist.lpweb # web.scripted tool: imports cart + data.csv
staff.lpweb # web.compiled
cart/
index.lpweb # barrel: pub from .money / .discount
money.lpweb
discount.lpweb
org/
employee.lpweb # from org.team defer import (Team)
team.lpweb
tests/
build/ # loup-bootstrap build output: shop.wasm
src/ holds the program's modules; everything in it is found by path. Files that need the standard library are web.scripted; everything that will be compiled to the Wasm module is web.compiled and imports only other web.compiled files.
Summary
- Every source file is a module whose import path is its location under
src/. Imports are module-level, list every name in parentheses, and come infrom … import (…), relative (from .money), grouped, andimport … as mforms. An alias replaces the original name. pubmakes a declaration, or a struct/class field, importable; everything else is private to its file. There is nopub(package).pub from .x import (…)builds barrel modules; onlypubnames can be re-exported.- A
web.compiledmodule imports onlyweb.compiledmodules (E_COMPILED_IMPORT_SCRIPTED). In 1.25 that excludes the whole standard library; useweb.scriptedfor code that needs it.web.scriptedmay import both. web.*andui.*modules are planned; importing one is an error today.- Import cycles are
E_CIRCULAR_IMPORT;defer importbreaks a cycle between types. loup-bootstrap initwrites the.lppkgmanifest;[build] entryanddeliveryare the keys a web project changes. The registry,loup add,loup.lockandprefer compiledresolution are planned;LOUPPATHworks withloup runandloup-bootstrap build.