Chapter 11: Modules and Packages
What you'll learn: how one script uses code from another file, how pub decides what a file exports, the import forms Loup has (and the ones it deliberately lacks), what happens when a module is imported, how a project is laid out, and what the 1.25 toolchain offers for sharing packages.
Every File Is a Module
Any Loup source file can be imported by another. The import path is the file's path relative to the importing script's directory, with / written as . and the extension left off. With this layout:
wordtools/
├── main.lpscr
└── textutil/
├── slug.lpscr
└── stats.lpscr
main.lpscr imports textutil/slug.lpscr as textutil.slug. Inside a project (later in this chapter), paths are relative to the project's src/ directory instead.
Exporting with pub
Declarations are private to their file unless they are marked pub:
@mode(host.scripted)
## Lower-case `text` and join its words with hyphens.
pub func slugify(text: string): string {
let words = text.trim().toLowerCase().split(" ");
let kept = words.filter(func(w: string) => w.length() > 0);
return kept.join("-");
}
## Not exported: only this file can call it.
func isSeparator(c: string): bool {
return c == " " or c == "-";
}
Listing 11-1: textutil/slug.lpscr exports one function
pub works on functions, classes, structs, enums, interfaces and module-level let bindings. There is no export keyword and no private: a name is either pub or file-private.
@mode(host.scripted)
## Number of space-separated words in `text`.
pub func wordCount(text: string): int {
return text.split(" ").filter(func(w: string) => w.length() > 0).length();
}
Listing 11-2: textutil/stats.lpscr
Importing Names
from module import (names); binds the listed names in the importing file. as renames one:
@mode(host.scripted)
from textutil.slug import (slugify);
from textutil.stats import (wordCount as countWords);
let title = " Error Handling in Loup ";
println(slugify(title));
println(countWords(title));
Listing 11-3: main.lpscr imports from two sibling modules (a multi-file example)
loup run main.lpscr
error-handling-in-loup
4
The alias replaces the original name: after wordCount as countWords, only countWords is bound.
Two other forms are useful. import module as name; binds the whole module, and you reach its exports through the name. A grouped import shares a prefix:
@mode(host.scripted)
import core.math as m;
from core {
math import (sqrt, floor as roundDown);
}
println(m.sqrt(2.0));
println(sqrt(16.0));
println(roundDown(2.7));
Listing 11-4: A module import and a grouped import
1.4142135623730951
4
2
(sqrt(16.0) is the float 4.0; Loup prints a whole-number float without the .0.)
Imports may appear only at the top level of a file, never inside a function or block.
Mistake: an import list without parentheses
Python writes from math import sqrt, floor. Loup requires the parentheses:
@mode(host.scripted)
from core.math import sqrt;
println(sqrt(2.0));
main.lpscr:3:23: error[E0001]: expected LParen, found Identifier
main.lpscr:3:27: error[E0001]: expected RParen, found Semicolon
Write from core.math import (sqrt); — parentheses even around a single name.
Mistake: a wildcard import
@mode(host.scripted)
from core.math import *;
println(sqrt(2.0));
main.lpscr:3:23: error[E0001]: expected LParen, found Star
main.lpscr:3:23: error[E0007]: wildcard imports are not allowed
main.lpscr:3:24: error[E0001]: expected RParen, found Semicolon
Every imported name is listed, so a reader can always see where a name came from. If a list grows long, import the module instead: import core.math as m;.
Mistake: importing a private name
If main.lpscr asks textutil/slug.lpscr for its private helper:
@mode(host.scripted)
from textutil.slug import (isSeparator);
println(isSeparator(" "));
main.lpscr:3:28: error[E0001]: cannot import private symbol `isSeparator` from module
note: add `pub` to the declaration to make it importable
main.lpscr:5:9: error[E0001]: undefined identifier `isSeparator`
The note says what to do if the helper should be public. Often the better fix is to leave it private and use the public function that wraps it.
Re-Exports and Directory Modules
A directory can have an index module that gathers its contents into one import. pub from … import (…) re-exports names, optionally renamed:
@mode(host.scripted)
pub from .slug import (slugify);
pub from .stats import (wordCount as countWords);
Listing 11-5: textutil/index.lpscr re-exports from its sibling files
Importing the directory itself now finds the index:
@mode(host.scripted)
from textutil import (slugify, countWords);
println(slugify("Modules and Packages"));
println(countWords("Modules and Packages"));
modules-and-packages
3
The leading dot in from .slug makes the path relative to the file that contains the import. Relative imports keep a directory of modules movable: rename textutil/ and its index still works.
Only pub names can be re-exported (E_REEXPORT_NOT_PUB, §1-13.2). Rust's pub use and JavaScript's export … from do not exist in Loup; pub from is the one re-export form.
What Runs When a Module Is Imported
@mode(host.scripted)
println("loading settings");
pub let defaultPort: int = 8080;
pub let defaultHost: string = "localhost";
pub let baseUrl: string = i"http://{$ defaultHost $}:{$ defaultPort $}";
if (__name__ == "__main__") {
println(i"settings self-check: {$ baseUrl $}");
}
Listing 11-6: settings.lpscr, a module with a self-check
Run directly, every top-level statement runs:
loading settings
settings self-check: http://localhost:8080
Imported by another script:
@mode(host.scripted)
from settings import (baseUrl, defaultPort);
println(i"serving {$ baseUrl $} (port {$ defaultPort $})");
serving http://localhost:8080 (port 8080)
The imported module's let initializers ran — baseUrl has its value — but loading settings was not printed.
Module initialization today. The Language Reference (§1-13.4) says that importing a module runs all of its top-level statements once, in order, with
__name__set to the module's dotted name. In Loup 1.25,loup runcompiles the entry script and everything it imports as a single program: an imported module's declarations andlet/varinitializers take effect, but its other top-level statements do not run, and__name__is"__main__"in every module. The main guard still behaves as intended (the guarded block runs only in the file you run), but do not rely on an imported module's top-level statements for setup: put setup in a function and call it.
Give exported let bindings an explicit type, as Listing 11-6 does. Without one, an importer may be told type of 'defaultPort' is not yet known.
Namespaces
A file may declare the dotted path it represents, right after its @mode line:
@mode(host.scripted)
namespace textutil.slug;
pub func slugify(text: string): string {
let words = text.trim().toLowerCase().split(" ");
let kept = words.filter(func(w: string) => w.length() > 0);
return kept.join("-");
}
The declaration should match the file's location (§1-13.0); tools treat a mismatch as a lint warning. In the 1.25 toolchain, loup run still finds a module by its file path, so the declaration documents the path rather than changing it. Most scripts leave it out.
Circular Imports
Two modules that import each other are an error:
a.lpscr:2:1: error[E_CIRCULAR_IMPORT]: import cycle detected: …/a.lpscr → …/b.lpscr → …/a.lpscr
note: circular imports are not allowed; consider restructuring your modules
(The message prints the full paths; they are shortened here.) Usually the fix is to move what both need into a third module. When two types genuinely refer to each other, one side can use defer import, which gives the name a type immediately and binds its definition later (§1-13.10):
@mode(host.scripted)
from edges defer import (Edge);
pub class Node {
pub let name: string;
pub var edges: list[Edge] = [];
new(name: string) {
this.name = name;
}
}
Listing 11-7: nodes.lpscr breaks the cycle with defer import
@mode(host.scripted)
from nodes import (Node);
pub class Edge {
pub let src: Node;
pub let dst: Node;
new(src: Node, dst: Node) {
this.src = src;
this.dst = dst;
}
}
Listing 11-8: edges.lpscr imports Node normally
A script that imports both can build a graph: Edge(Node("a"), Node("b")).
prefer compiled
⚠ Planned.
from codec import (Encoder) prefer compiled;asks the runtime to load a compiled build of the module when one exists and fall back to the source otherwise; the declaration's__module__.__execmode__would then report which happened (§1-13.7). The 1.25 toolchain accepts the clause but always uses the source, and__module__is not implemented.
The Standard Library
The standard library is imported like any other module. These namespaces ship in 1.25:
| Namespace | Modules |
|---|---|
system |
json, text, regex, collections, env, time (the prelude — println, readText, args, … — is available without an import) |
core |
math, time, datetime, random, uuid, string_utils, shlex, waiting, complex, array, … |
io |
fs, path, proc, streams |
data |
csv, ini, toml, xml, yaml, query |
net |
tcp, addressing, http (a server; there is no HTTP client yet) |
structures |
case_insensitive_dict, ordered_set, priority_queue |
debug |
assert, profiler |
Some module names you may expect do not exist. JSON lives in system.json, not data.json:
@mode(host.scripted)
from data.json import (parse);
println(parse("[1, 2]"));
main.lpscr:3:6: error[E0001]: 'data.json' is not a standard-library module (namespace 'data' is reserved for the standard library)
main.lpscr:5:9: error[E0001]: undefined identifier `parse`
@mode(host.scripted)
from system.json import (parse, stringify);
let doc = parse("{\"name\": \"loup\", \"tags\": [\"a\", \"b\"]}").unwrap();
println(stringify(doc));
{"name":"loup","tags":["a","b"]}
The namespaces 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 (§1-13.6). Your package cannot be named after one of them. system is not reserved. The Language Reference describes more modules than ship today — sync.*, net.tls, core.reflect, every web.* and ui.* module — and importing one gives an error like the one above.
Projects
When a script grows into several files with tests, make it a project:
loup-bootstrap init notes
cd notes
(On macOS, Windows and Linux arm64, loup init is the same command; Chapter 1 explains the two binaries.) This creates:
notes/
├── notes.lppkg # the manifest
├── src/
│ └── main.loup # the entry point
└── tests/
A project's entry point is a host.dual program with a main(), and its modules are imported by their path under src/. Add src/text/slug.loup:
@mode(host.dual)
## Lower-case `text` and join its words with hyphens.
pub func slugify(text: string): string {
let words = text.trim().toLowerCase().split(" ");
let kept = words.filter(func(w: string) => w.length() > 0);
return kept.join("-");
}
Listing 11-9: src/text/slug.loup
and use it from src/main.loup:
@mode(host.dual)
from text.slug import (slugify);
func main(): int {
for (title in runtime.args()) {
println(slugify(title));
}
return 0;
}
Listing 11-10: src/main.loup imports text.slug from src/
loup run src/main.loup "Hello World" " Error Handling "
hello-world
error-handling
loup-bootstrap check, run anywhere inside the project, type-checks every module without running anything and prints nothing when all is well. Library modules in a project are usually host.dual too, so the whole project can later be compiled ahead of time; a host.dual module may contain only declarations and constant initializers at the top level.
The Manifest
The manifest is a TOML file named <project>.lppkg (or package.lppkg). loup-bootstrap init writes this, plus a number of empty tables:
name = "notes"
version = "0.1.0"
loup = "1.20.0"
authors = []
[build]
entry = "src/main.loup"
delivery = "cli"
profile = "release"
[test]
pattern = "**/*_test.loup"
[dependencies]
[dev_dependencies]
| Key | Meaning |
|---|---|
name, version |
the package's name (lowercase, hyphens allowed, dots for namespaced packages) and its semantic version |
loup |
the minimum toolchain version |
[build] entry |
the entry module |
[build] delivery |
what loup-bootstrap build produces: cli (a WebAssembly module run by loup-launcher), service, browser, desktop, native (older manifests spell it target) |
[build] mode |
the default @mode for files that declare none |
[test] |
where loup test looks for tests (Chapter 16) |
[dependencies], [dev_dependencies] |
packages the project needs |
The Language Reference's examples write package my-app and [deps] without TOML quoting; a manifest must be valid TOML, so use the form above.
Sharing Code Between Projects
⚠ Planned. The package registry and the commands that use it —
loup add,loup publish, a workingloup install, andloup.lock— are described in the Packages Reference but not available in Loup 1.25.loup addandloup publishare unknown commands, andloup installstops as soon as the manifest lists a dependency: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 works today is LOUPPATH: a list of directories (separated by :, or ; on Windows) that loup run searches for packages. Each entry is a root that contains packages; a package lives in a directory under it with its own manifest. With a word-tools package in ~/dev/packages/word_tools/ (a hyphen in a package name becomes _ in its directory):
~/dev/packages/
└── word_tools/
├── word-tools.lppkg # name = "word-tools"
└── count.loup # pub func wordCount(…)
any script can import it:
@mode(host.dual)
from word_tools.count import (wordCount);
func main(): void {
println(wordCount("one two three"));
}
LOUPPATH=~/dev/packages loup run src/main.loup
3
Without LOUPPATH, the import fails with unresolved package import 'word_tools/count', listing the roots it searched. In 1.25, loup-bootstrap check does not read LOUPPATH, so it reports such an import as E-CHECK-DEP … not found; use loup run to check a program that depends on one.
Summary
- Every source file is a module, imported by its path relative to the importing script (or to
src/in a project). pubexports a declaration; everything else is private to the file.from m import (a, b as c);always has parentheses;import m as name;binds a whole module;from p { m import (…); }groups imports. There are no wildcard imports and nopub useorexport.pub from .m import (…);re-exports; anindexfile makes a directory importable.- In 1.25, importing a module runs its declarations and
letinitializers but not its other top-level statements. - Circular imports are
E_CIRCULAR_IMPORT;defer importbreaks a cycle between types. - JSON is
system.json; the reserved namespaces are the standard library's. loup-bootstrap initcreates a project with a TOML.lppkgmanifest and ahost.dualentry point. The registry (add,publish,install) is planned; share local packages withLOUPPATH.
Try It Yourself
- Split Listing 10-9 (the inventory parser) into
inventory/parse.lpscrwithpub func parseInventoryand areport.lpscrthat imports it. - Add
inventory/index.lpscrthat re-exportsparseInventoryandItem, and changereport.lpscrto import frominventory. - Make two modules import each other and read the diagnostic. Then fix it by moving the shared struct into a third module.
- Create a project with
loup-bootstrap init, move the inventory code undersrc/, and runloup-bootstrap check.