Lazy Loading
Lazy loading allows you to register commands using import path strings instead of direct function references. This defers importing command modules until they are actually executed, which could significantly improve CLI startup time for large applications that have expensive per-command imports.
Basic Usage
Instead of importing and registering a function directly:
from cyclopts import App
from myapp.commands.users import create, delete, list_users # Imported immediately
app = App()
user_app = App(name="user")
user_app.command(create)
user_app.command(delete)
user_app.command(list_users, name="list")
app.command(user_app)
Use an import path string:
from cyclopts import App
app = App()
user_app = App(name="user")
# No imports! Modules loaded only when commands execute
user_app.command("myapp.commands.users:create")
user_app.command("myapp.commands.users:delete")
user_app.command("myapp.commands.users:list_users", name="list")
app.command(user_app)
The import path format is "module.path:function_name", similar to setuptools entry points.
Lazy commands are resolved/imported in these situations:
Command Execution - When the user runs that specific command
Subcommand Help - When displaying
--helpfor the specific lazy command (e.g.,myapp lazy-cmd --help)Direct Access - When accessing via
app["command_name"]
Parent --help (e.g., myapp --help) displays lazy commands using metadata provided at registration time
(help=, group=, show=, sort_key=) without resolving them.
In order to benefit from lazy loading, you have to make sure that the files are not imported by other means when your CLI starts up.
Import Path Format
The import path string has two parts separated by a colon (:):
- Module Path (before the
:) The Python module to import, using dot notation (e.g.,
myapp.commands.users).- Attribute Name (after the
:) The function or App to get from the module using
getattr().
Examples:
# Simple function in a module
app.command("myapp.commands:create_user")
# Nested module path
app.command("myapp.admin.database.operations:migrate")
# Import an App instance, exposed to the CLI as "admin"
app.command("myapp.admin:admin_app", name="admin")
Note
The attribute name (after :) is the actual Python name, not the CLI command name.
Use the name parameter to specify the CLI command name.
Name vs Function Name
The name parameter specifies how the command appears in the CLI, while the import path
specifies what code to execute. They can be completely different:
from cyclopts import App
user_app = App(name="user")
# Function name: "list_users"
# CLI command name: "list"
user_app.command("myapp.commands.users:list_users", name="list")
# Function name: "delete"
# CLI command name: "remove"
user_app.command("myapp.commands.users:delete", name="remove")
$ myapp user list --limit 10
# Imports and runs myapp.commands.users:list_users
$ myapp user remove --username alice
# Imports and runs myapp.commands.users:delete
If name is not specified, Cyclopts derives it from the function name with
App.name_transform applied (typically converting underscores to hyphens).
Error Handling
If an import path/configuration is invalid, the error occurs when the command is executed, not when it's registered:
from cyclopts import App
app = App()
# This won't error immediately - registration succeeds
app.command("nonexistent.module:func")
app()
$ myapp func
# Now the error occurs:
ImportError: Cannot import module 'nonexistent.module'
To catch import errors early, you can access the command during testing:
import pytest
from cyclopts import App
def test_lazy_commands_are_importable():
app = App()
app.command("myapp.commands:create")
# This will trigger the import and fail if path is wrong
resolved = app["create"]
assert resolved is not None
Groups and Lazy Loading
Tip
TL;DR: Pass group= at registration time, and define Group objects in your main CLI module, NOT in lazy-loaded modules.
Lazy commands support group= at registration time, so they appear in the correct group in --help output without being resolved.
However, Group objects defined only inside unresolved lazy modules won't be available until those modules are imported.
To avoid this, define Group objects in non-lazy modules.
# myapp/cli.py (always imported)
from cyclopts import App, Group
# Define Group objects here
admin_group = Group("Admin Commands", validator=require_admin_role)
db_group = Group("Database", default_parameter=Parameter(envvar_prefix="DB_"))
app = App()
# Lazy commands can reference the Group objects
app.command("myapp.admin:create_user", group=admin_group)
app.command("myapp.admin:delete_user", group=admin_group)
app.command("myapp.db:migrate", group=db_group)
What to avoid: Defining Group objects inside lazy-loaded modules:
# myapp/admin.py (lazy-loaded)
from cyclopts import App, Group
# BAD: This Group won't be available to other commands until this module is imported
admin_group = Group("Admin Commands", validator=require_admin_role)
def create_user():
...
If you reference a group by string (e.g., group="Admin Commands") and the Group object
with that name is only defined in an unresolved lazy module, the group won't be available
until that lazy module is imported. This means that:
Validators defined on the lazy-loaded
Groupwon't be applied to commands in other modules.Group.default_parameterand other settings won't be inherited by commands referencing the group by string.
Once the lazy module is imported (e.g., by executing one of its commands), the Group object
becomes available and subsequent operations will use it correctly.