Python bindings and runtime for KoiLang, a markup language designed for narrative content, interactive fiction, and dialogue-driven applications.
KoiLang separates data (story content, dialogue, commands) from instructions (how your application handles those commands). koilang-py provides two layers:
- Core Layer (
koilang.core): High-performance Native Python bindings for thekoicoreRust kernel. Includes the streaming parser and writer. - Runtime Layer (
koilang.runtime): A high-level, decoupled runtime featuring middleware support, environment stacks, and command caching for advanced control flow (jumps, loops).
pip install koilangThe Runtime layer manages state and dispatches commands to a stack of environments.
import io
from koilang.runtime import Runtime
class MyGame:
def do_character(self, name, text):
print(f"{name}: {text}")
def at_text(self, text):
print(f"[Narrative]: {text}")
runtime = Runtime()
runtime.env_enter(MyGame())
runtime.execute(io.StringIO("#character Alice \"Hi!\"\nRegular text here."))The Writer class allows you to generate KoiLang code programmatically.
from koilang.runtime import Writer
with Writer("story.koi") as w:
w.do_character("Alice", "Hello World")
w.at_text("This is a story about a girl named Alice.")Note: The Writer class also supports advanced formatting options and indentation management. See the Advanced Usage section for details.
You can run KoiLang files directly using the command line interface:
python -m koilang story.koiNote: If the file path is not provided, the CLI will enter interactive mode (requires prompt_toolkit for enhanced REPL experience).
-e,--env: Specify the root environment object (format:module:Attribute).--command-threshold: Minimum number of#to identify a command (default: 1).--fail-on-unknown-command: Raise error if a command handler is not found.--skip-annotations: Skip all annotation lines during parsing.--preserve-empty-lines: Preserve empty lines as empty text commands.--preserve-indent: Preserve leading indentation in text sections.
Example:
python -m koilang story.koi -e my_game:GameEnv --command-threshold 0When no file is provided, or when using the -i/--interactive flag, the CLI enters interactive mode with a rich REPL experience:
# Enter interactive mode directly
python -m koilang
# Enter interactive mode after executing a file
python -m koilang story.koi -i
# Load an environment and enter interactive mode
python -m koilang -e my_game:GameEnvFeatures:
- Enhanced REPL: Powered by
prompt_toolkitwith syntax highlighting, auto-completion, and command history - Multi-line Input: Support for multi-line commands using backslash continuation
- Built-in Commands:
#exitor#quit: Exit interactive modectrl+d: Also exits interactive mode
- Environment Stack: Dynamically manage environments during the session
- Session Lifecycle: Automatically handles
at_startandat_endhooks
Example Session:
$ python -m koilang
KoiLang 2.0.0b1 on Python 3.10.18 (main, Jun 4 2025, 17:36:27) [Clang 20.1.4 ]
Type "#exit/#quit" or "ctrl+d" to exit interactive mode
koi> #character Alice "Hello!"
2026-03-14 23:44:56,642 koilang.__main__ WARNING - command 'character' not found
koi> Hello World
2026-03-14 23:45:16,474 koilang.__main__ INFO - text: 'Hello World'
koi> #exitInstallation for Interactive Mode:
To use the enhanced interactive mode with prompt_toolkit, install with the interactive extra:
pip install koilang[interactive]Without prompt_toolkit, the CLI will fall back to basic stdin mode when input is piped.
KoiLang is designed to be human-readable and expressive. For a full reference, see the koicore documentation.
There are three types of lines in KoiLang, distinguished by their syntax:
- Commands: Lines starting with
#(by default).#character Alice "Hello" - Text: Lines without a
#prefix.This is a regular text line. - Annotations: Lines starting with
##(or more#characters).## This is a comment
Important Concept: In KoiLang, text lines and annotation lines are essentially special commands. They correspond to command names
@textand@annotationrespectively, and can be captured and handled through their corresponding handler methodsat_textandat_annotation.
KoiLang supports rich parameter types that map naturally to Python:
Basic Syntax:
#command_name [param1] [param2] ...
Parameter Types:
-
Positional:
#cmd 1 "string" 3.14Python:
do_cmd(1, "string", 3.14) -
Named (Composite):
#cmd key(value)Python:
do_cmd(key="value") -
Lists:
#cmd list(1, 2, 3)Python:
do_cmd(list=[1, 2, 3]) -
Dicts:
#cmd dict(a: 1, b: 2)Python:
do_cmd(dict={"a": 1, "b": 2})
Text lines (lines without # prefix) are special commands in KoiLang with the command name @text.
Handling:
In your environment class, handle text lines using the at_text method:
class MyGame:
def at_text(self, text):
"""Handle text line content.
Corresponds to text lines in KoiLang (lines without # prefix)
Command name: @text
Args:
text: The text line content
"""
print(f"[Narrative]: {text}")Example:
#character Alice "Hello!"
This is a text line. → triggers at_text("This is a text line.")
Another line here. → triggers at_text("Another line here.")
Note: Each text line triggers a separate at_text call. If you have multiple consecutive text lines, each line will call at_text individually unless the parser is configured to preserve empty lines or indentation.
Annotation lines (lines starting with ## or more #) are also special commands in KoiLang with the command name @annotation.
Handling:
In your environment class, you can capture annotation lines using the at_annotation method (though annotations are usually ignored):
class MyGame:
def at_annotation(self, text):
"""Handle annotation line content.
Corresponds to annotation lines in KoiLang (lines starting with ##)
Command name: @annotation
Args:
text: The annotation content (without ## prefix)
"""
print(f"[Comment]: {text}")Annotation Behavior:
## This is a single-line annotation → Command name: @annotation
### This is also an annotation → Command name: @annotation
#### Multi-level annotations work too → Command name: @annotation
#command arg ## Inline annotations are NOT supported
Default Behavior:
By default, annotation lines are ignored by the parser (no handler is triggered). You can control this behavior via ParserConfig:
from koilang.model import ParserConfig
from koilang.runtime import Runtime
# Skip all annotations (improves performance for annotation-heavy files)
config = ParserConfig(skip_annotations=True)
runtime = Runtime(config=config)Note: Unlike some languages, KoiLang does not support inline annotations. Annotations must be on their own line and start at the beginning (after any indentation).
The command_threshold parameter determines how KoiLang identifies line types based on the number of # characters:
| Threshold | #text |
##text |
###text |
####text |
no-prefix |
|---|---|---|---|---|---|
| 0 | Annotation | Annotation | Annotation | Annotation | Command |
| 1 (default) | Command | Annotation | Annotation | Annotation | Text |
| 2 | Text | Command | Annotation | Annotation | Text |
| 3 | Text | Text | Command | Annotation | Text |
- Lines with < threshold
#characters → Text line (triggers@textcommand) - Lines with = threshold
#characters → Command (triggersdo_<name>handler) - Lines with > threshold
#characters → Annotation line (triggers@annotationcommand)
Use Cases:
threshold=1: Standard KoiLang syntax (default)threshold=2: Allows embedding KoiLang in languages where#has special meaning (single#prefix treated as text)threshold=3: Strict command parsing for complex nested structures
Example:
from koilang.model import ParserConfig
from koilang.runtime import Runtime
# Use threshold=2 for embedding in Markdown
config = ParserConfig(command_threshold=2)
runtime = Runtime(config=config)
# In this mode:
# # This is text (1 # = text line → @text)
# ##command arg (2 # = command → do_command)
# ###comment (3 # = annotation line → @annotation)The Core layer provides direct bindings to the Rust parser. It works with file-like objects or filenames.
import io
from koilang.core import Parser
# Parse from a string using io.StringIO
content = io.StringIO("#character Alice \"Hello, world!\"\nThis is regular text.")
parser = Parser(content)
for command in parser:
print(f"Command: {command.name}, Args: {command.args}, Kwargs: {command.kwargs}")from koilang.runtime import Runtime, Middleware
import time
# Middleware to log command execution timing
def logger_middleware(runtime, cmd, next_handler):
start = time.time()
result = next_handler(cmd)
print(f"Executed #{cmd.name} in {time.time() - start:.4f}s")
return result
class Scene:
def at_start(self): print("Scene started")
def do_bg(self, name): print(f"Background: {name}")
class Character:
def do_say(self, text): print(f"Alice: {text}")
runtime = Runtime(middleware=[logger_middleware])
runtime.env_enter(Scene())
runtime.env_enter(Character()) # Stack: [Scene, Character]
# Character environment handles 'say', Scene handles 'bg'
runtime.execute(io.StringIO("#bg Forest\n#say \"Wait!\""))You can also register environments dynamically during command execution, enabling more flexible control flow:
from koilang.runtime import Runtime, env_enter, env_exit
import io
class DialogManager:
"""Manages dialog contexts dynamically."""
def do_enter_dialog(self, character_name):
"""Enter a dialog context for a specific character."""
# Dynamically push a new environment onto the stack
env_enter(CharacterDialog(character_name))
def do_exit_dialog(self):
"""Exit the current dialog context."""
# Note: In real usage, you'd need to track the env instance
# This is a simplified example
pass
class CharacterDialog:
"""Environment for a specific character's dialog."""
def __init__(self, name):
self.name = name
def do_say(self, text):
print(f"{self.name}: {text}")
def do_emote(self, emotion):
print(f"[{self.name} {emotion}]")
def do_end(self):
"""Exit this dialog environment."""
env_exit(self)
runtime = Runtime()
runtime.env_enter(DialogManager())
script = """
#enter_dialog Alice
#say "Hello there!"
#emote smiles
#end
#enter_dialog Bob
#say "Hi Alice!"
#end
"""
runtime.execute(io.StringIO(script))The env_enter() and env_exit() functions allow you to manage the environment stack from within command handlers, enabling dynamic scoping and context management.
With caching enabled, you can jump around the script.
from koilang.runtime import Runtime, context
import io
class FlowControl:
def do_label(self, name):
context.register_label(name)
def do_jump(self, target):
context.jump_to_label(target)
runtime = Runtime()
runtime.enable_cache()
runtime.env_enter(FlowControl())
script = """
#jump Target
#character Alice "This will be skipped"
#label Target
#character Alice "Hello from the future!"
"""
runtime.execute(io.StringIO(script))The Executor provides a programmatic interface for executing commands within a Runtime:
from koilang.runtime import Runtime
import io
class GameEnv:
def do_move(self, direction):
print(f"Moving {direction}")
def do_attack(self, target):
print(f"Attacking {target}")
runtime = Runtime()
runtime.env_enter(GameEnv())
# Get an executor to programmatically trigger commands
executor = runtime.get_executor()
# Execute commands as if they came from a KoiLang file
executor.do_move("north") # Same as: runtime.execute("#move north")
executor.do_attack("dragon") # Same as: runtime.execute("#attack dragon")Targeted Execution:
You can also execute commands on specific environments in the stack:
class Player:
def do_status(self):
print("Player status: OK")
class Enemy:
def do_status(self):
print("Enemy status: Dead")
runtime = Runtime()
runtime.env_enter(Player())
runtime.env_enter(Enemy())
executor = runtime.get_executor()
# Execute on the most recent Player environment
executor[Player].do_status() # "Player status: OK"
# Execute on the most recent Enemy environment
executor[Enemy].do_status() # "Enemy status: Dead"
# Execute on a specific instance by index
executor[Player, 0].do_status() # First Player instance
executor[Player, -1].do_status() # Last Player instanceThe run_session() context manager groups multiple executions into a single lifecycle session:
from koilang.runtime import Runtime
import io
class GameEnv:
def at_start(self):
print("Game started")
def at_end(self):
print("Game ended")
runtime = Runtime()
runtime.env_enter(GameEnv())
# Lifecycle hooks (at_start/at_end) are only called once
with runtime.run_session():
runtime.execute(io.StringIO("#cmd1"))
runtime.execute(io.StringIO("#cmd2"))
# Prints: "Game started" (once) and "Game ended" (once)This is useful when you want to execute multiple files or inputs while ensuring lifecycle hooks are only called at the beginning and end of the entire session.
The Writer class supports fine-grained formatting control:
from koilang.runtime import Writer
import io
# Basic usage
output = io.StringIO()
with Writer(output) as w:
w.do_heading("Title")
w.at_text("Content here")Indentation Management:
output = io.StringIO()
with Writer(output) as w:
w.do_parent()
# Increase indentation
w.inc_indent()
w.do_child1()
w.do_child2()
# Decrease indentation
w.dec_indent()
w.do_sibling()
# Or use context manager
with w.indent():
w.do_nested()
w.do_content()Temporary Formatting Options:
output = io.StringIO()
with Writer(output) as w:
w.do_cmd1(1, 2)
# Apply compact formatting to a block of commands
with w.with_options(compact=True):
w.do_cmd2(3, 4)
w.do_cmd3(5, 6)
# Back to default formatting
w.do_cmd4(7, 8)
# Fluent API for single commands
w.with_options(compact=True).do_tight_cmd(1, 2)
# Target specific commands
with w.with_options(compact=True, target_commands=["cmd1", "cmd2"]):
w.do_cmd1(1, 2) # Uses compact formatting
w.do_cmd2(3, 4) # Uses compact formatting
w.do_cmd3(5, 6) # Uses default formattingAvailable Formatting Options:
The with_options() method accepts any of the following parameters:
| Option | Type | Description |
|---|---|---|
indent |
int | Number of spaces for indentation |
use_tabs |
bool | Use tabs instead of spaces |
compact |
bool | Remove unnecessary whitespace |
newline_before |
bool | Add newline before command |
newline_after |
bool | Add newline after command |
force_quotes_for_vars |
bool | Force quotes around literals |
number_format |
str | Custom format for integers |
float_format |
str | Custom format for floats |
newline_before_param |
bool | Newline before each parameter |
newline_after_param |
bool | Newline after each parameter |
For advanced configuration, you can also pass a WriterConfig object to the Writer constructor.
koilang-py is the successor to the legacy kola module. This guide helps you migrate from the old kola API to the new koilang API.
| Feature | Legacy kola |
New koilang |
|---|---|---|
| Main Class | KoiLang |
Runtime |
| Decorators | @kola_command, @kola_text |
Convention-based (do_name, at_name) |
| Parsing | parse(), parse_file() |
execute() (supports IO and files) |
| Extension | Inheritance based | Composition (Runtime + Env Stack) |
| Text Handler | @kola_text decorator |
at_text() method |
| Number Commands | @kola_number decorator |
do_114(), do_1919() methods |
| Environment | Nested Environment class |
Any Python object with do_/at_ methods |
| CLI | python -m kola file.kola |
python -m koilang file.koi |
Legacy kola code:
from kola import KoiLang, kola_command, kola_text
class MyScript(KoiLang):
@kola_command
def greet(self, name):
print(f"Hello, {name}!")
@kola_text
def handle_text(self, text):
print(f"Text: {text}")
# Usage
script = MyScript()
script.parse_file("script.kola")New koilang code:
from koilang.runtime import Runtime
class MyEnv:
def do_greet(self, name):
print(f"Hello, {name}!")
def at_text(self, text):
print(f"Text: {text}")
# Usage
runtime = Runtime()
runtime.env_enter(MyEnv())
runtime.execute("script.koi")Legacy decorators:
from kola import kola_command, kola_text, kola_number
class OldStyle(KoiLang):
@kola_command("custom_name")
def my_func(self): ...
@kola_text
def handle_text(self, text): ...
@kola_number
def handle_number(self, num): ...New convention-based approach:
class NewStyle:
# Method name becomes command name
def do_custom_name(self): ...
# Text handler uses at_text
def at_text(self, text): ...
# Number commands use do_<number>
def do_114(self): ... # Handles #114
def do_1919(self): ... # Handles #1919Legacy nested environment:
from kola import KoiLang, Environment, kola_env_enter, kola_env_exit
class Main(KoiLang):
class SubEnv(Environment):
@kola_env_enter("enter")
def enter(self): ...
@kola_env_exit("exit")
def exit(self): ...New environment stack approach:
from koilang.runtime import Runtime, env_enter, env_exit
class Main:
def do_enter(self):
env_enter(SubEnv())
def do_exit(self):
# Get current env and exit it
pass
class SubEnv:
pass
runtime = Runtime()
runtime.env_enter(Main())Legacy:
@kola_command("open")
def file(self, path): ...New:
Simply name your method with the desired command name:
def do_open(self, path): ... # Handles #openOr use the standard name if it matches:
def do_file(self, path): ... # Handles #fileLegacy:
from kola import KoiLang
class MyParser(KoiLang):
def __init__(self):
super().__init__()
self.command_threshold = 2New:
from koilang.runtime import Runtime
from koilang.model import ParserConfig
config = ParserConfig(command_threshold=2)
runtime = Runtime(config=config)Legacy:
from kola.writer import FileWriter, StringWriter
# File output
with FileWriter("output.kola") as w:
w.write_command("cmd", arg1, arg2)
w.write_text("Some text")
# String output
sw = StringWriter()
sw.write_command("cmd", arg1)
result = sw.getvalue()New:
from koilang.runtime import Writer
import io
# File output
with Writer("output.koi") as w:
w.do_cmd(arg1, arg2)
w.at_text("Some text")
# String output
output = io.StringIO()
with Writer(output) as w:
w.do_cmd(arg1)
result = output.getvalue()Here's a complete migration example based on the file generator from the legacy docs:
Legacy kola:
import os
from kola import KoiLang, kola_command, kola_text
class FastFile(KoiLang):
@kola_command
def file(self, path: str, encoding: str = "utf-8") -> None:
if self._file:
self._file.close()
path_dir = os.path.dirname(path)
if path_dir:
os.makedirs(path_dir, exist_ok=True)
self._file = open(path, "w", encoding=encoding)
@kola_command
def end(self) -> None:
if self._file:
self._file.close()
self._file = None
@kola_text
def text(self, text: str) -> None:
if not self._file:
raise OSError("write texts before the file open")
self._file.write(text)
def at_start(self) -> None:
self._file = None
def at_end(self) -> None:
self.end()
# Usage
FastFile().parse_file("makefiles.kola")New koilang:
import os
from koilang.runtime import Runtime
class FastFile:
def __init__(self):
self._file = None
def at_start(self):
self._file = None
def at_end(self):
self.do_end()
def do_file(self, path: str, encoding: str = "utf-8") -> None:
if self._file:
self._file.close()
path_dir = os.path.dirname(path)
if path_dir:
os.makedirs(path_dir, exist_ok=True)
self._file = open(path, "w", encoding=encoding)
def do_end(self) -> None:
if self._file:
self._file.close()
self._file = None
def at_text(self, text: str) -> None:
if not self._file:
raise OSError("write texts before the file open")
self._file.write(text)
# Usage
runtime = Runtime()
runtime.env_enter(FastFile())
runtime.execute("makefiles.koi")- No more inheritance: Instead of inheriting from
KoiLang, you create plain Python classes - Convention over configuration: Use
do_prefix for commands,at_prefix for special handlers - Runtime-centric: All execution goes through a
Runtimeinstance - Environment stack: Use
env_enter()/env_exit()instead of nested environment classes - Unified parsing:
execute()method handles both strings and file-like objects - Simpler writer: More intuitive API with method-based command generation