A base CLI entrypoint supporting Anaconda CLI plugins using Typer.
To develop a subcommand in a third-party package, first create a typer.Typer() app with one or more commands.
See this example. The commands defined in your package will be prefixed
with the subcommand you define when you register the plugin.
In your pyproject.toml subcommands can be registered as follows:
# In pyproject.toml
[project.entry-points."anaconda_cli.subcommand"]
auth = "anaconda_cloud_auth.cli:app"In the example above:
"anaconda_cloud_cli.subcommand"is the required string to use for registration. The quotes are important.authis the name of the new subcommand, i.e.anaconda auth- All
typer.Typercommands you define in your package are accessible the registered subcommand - i.e.
anaconda auth <command>.
- All
anaconda_cloud_auth.cli:appsignifies the object namedappin theanaconda_cloud_auth.climodule is the entry point for the subcommand.
By default any exception raised during CLI execution in your registered plugin will be caught and only a minimal message will be displayed to the user.
You can define a custom callback for individual exceptions that may be thrown from your subcommand. You can register handlers for standard library exceptions or custom defined exceptions. It may be best to use custom exceptions to avoid unintended consequences for other plugins.
To register the callback decorate a function that takes an exception as input, and return an integer error code. The error code will be sent back through the CLI and your subcommand will exit with that error code.
from typing import Type
from anaconda_cli_base.exceptions import register_error_handler
@register_error_handler(MyCustomException)
def better_exception_handling(e: Type[Exception]) -> int:
# do something or print useful information
return 1
@register_error_handler(AnotherException)
def just_ignore_it(e: Type[Exception])
# ignore the error and let the CLI exit successfully
return 0
@register_error_handler(YetAnotherException)
def fix_the_error_and_try_again(e: Type[Exception]) -> int:
# do something and retry the CLI command
return -1In the second example the handler returns -1. This means that the handler has attempted to correct the error
and the CLI subcommand should be re-tried. The handler could call another interactive command, like a login action,
before attempting the CLI subcommand again.
See the anaconda-cloud-auth plugin for an example custom handler.
If your plugin wants to utilize the Anaconda config file, default location ~/.anaconda/config.toml, to read configuration
parameters you can derive from anaconda_cli_base.config.AnacondaBaseSettings to add a section in the config file for
your plugin.
Each subclass of AnacondaBaseSettings
defines the section header. The base class is configured so that parameters defined in subclasses can be read in the
following priority from lowest to highest.
- default value in the subclass of
AnacondaBaseSettings - Global config file at ~/.anaconda/config.toml
ANACONDA_<PLUGIN-NAME>_<FIELD>variables defined in the .env file in your working directory- A file named
/run/secrets/anaconda_<plugin-name>_<field>, usually populated by a mounted Docker secret ANACONDA_<PLUGIN-NAME>_<FIELD>env variables set in your shell or on command invocation- value passed as kwarg when using the config subclass directly
Notes:
AnacondaBaseSettingsis a subclass ofBaseSettingsfrom pydantic-settings.- Nested pydantic models are also supported.
- Per pydantic defaults, both secret filenames and environment variables may be uppercase or lowercase.
Here's an example subclass:
from anaconda_cli_base.config import AnacondaBaseSettings
class MyPluginConfig(AnacondaBaseSettings, plugin_name="my_plugin"):
foo: str = "bar"To read the config value in your plugin according to the above priority:
config = MyPluginConfig()
assert config.foo == "bar"Since there is no value of foo in the config file it assumes the default value from the subclass definition.
The value of foo can now be written to the config file under the section my_plugin
# ~/.anaconda/config.toml
[plugin.my_plugin]
foo = "baz"Now that the config file has been written, the value of foo is read from the
config.toml file:
config = MyPluginConfig()
assert config.foo == "baz"The AnacondaBaseSettings supports nested Pydantic models.
from anaconda_cli_base.config import AnacondaBaseSettings
from pydantic import BaseModel
class Nested(BaseModel):
n1: int = 0
n2: int = 0
class MyPluginConfig(AnacondaBaseSettings, plugin_name="my_plugin"):
foo: str = "bar"
nested: Nested = Nested()In the ~/.anaconda/config.toml you can set values of nested fields as an in-line table
# ~/.anaconda/config.toml
[plugin.my_plugin]
foo = "baz"
nested = { n1 = 1, n2 = 2}Or as a separate table entry
# ~/.anaconda/config.toml
[plugin.my_plugin]
foo = "baz"
[plugin.my_plugin.nested]
n1 = 1
n2 = 2To set environment variables use the __ delimiter
ANACONDA_MY_PLUGIN_NESTED__N1=1
ANACONDA_MY_PLUGIN_NESTED__N2=2You can pass a tuple to plugin_name= in subclasses of AnacondaBaseSettings to nest whole plugins,
which may be defined in separate packages.
class Nested(BaseModel):
n1: int = 0
n2: int = 0
class MyPluginConfig(AnacondaBaseSettings, plugin_name="my_plugin"):
foo: str = "bar"
nested: Nested = Nested()Then in another package you can nest a new config into my_plugin.
class MyPluginExtrasConfig(AnacondaBaseSettings, plugin_name=("my_plugin", "extras")):
field: str = "default"The new config table is now nested in the config.toml
# ~/.anaconda/config.toml
[plugin.my_plugin]
foo = "baz"
nested = { n1 = 1, n2 = 2}
[plugin.my_plugin.extras]
field = "value"And can be set by env variable using the concatenation of plugin_name
ANACONDA_MY_PLUGIN_EXTRAS_FIELD="value"Plugin configurations can be written directly from subclasses of AnacondaBaseSettings with the
.write_config() member method. This method takes two arguments
preserve_existing_keys:- If True (default) updates to existing keys in the config.toml file, will not remove the key if set to the default value. If False fields set to default value are removed from the file
dry_run:- If True, displays a diff of proposed changes without writing to the file. If False (default), writes changes to config.toml.
Here are some key aspects of writing configuration
.write_config()will only update changed lines in the config.toml preserving all existing configuration and comments- toml does not support
Noneornull, any field set to the valueNonewill not be written to the config.toml - fields set to their default value are not written to the config.toml
- Except when an existing key in the config.toml is updated to its default value. The key will still be written
- This is disabled with
preserve_existing_keys=False
Let's start with the plugin defined earlier and an instance of the config object with all default values
from anaconda_cli_base.config import AnacondaBaseSettings
from pydantic import BaseModel
class Nested(BaseModel):
n1: int = 0
n2: int = 0
class MyPluginConfig(AnacondaBaseSettings, plugin_name="my_plugin"):
foo: str = "bar"
nested: Nested = Nested()
config = MyPluginConfig()If there is either no config.toml or the existing file does not have the [plugin.my_plugin] table attempting
to write the current state of the config will just add the table header since all values are default. Here is an
example of the dry_run output in the case where the config.toml file did not exist
>>> config.write_config(dry_run=True)
--- ~/.anaconda/config.toml
+++ ~/.anaconda/config.toml 01-06-26 09:40
@@ -0,0 +1 @@
+[plugin.my_plugin]
You can change the configuration either by passing kwargs to the initialization or by directly updating attributes.
config.foo = "baz"
config.nested.n1 = 1
config.nested.n2 = 2this will now write the configuration equivalent to what you saw above
>>> config.write_config(dry_run=True)
--- config.toml
+++ config.toml 01-06-26 09:44
@@ -0,0 +1,6 @@
+[plugin.my_plugin]
+foo = "baz"
+
+[plugin.my_plugin.nested]
+n1 = 1
+n2 = 2
Now with that configuration written to disk (using dry_run=False) we can re-read the configuration to confirm
the change.
>>> config = MyPluginConfig()
>>> print(config)
foo='baz' nested=Nested(n1=1, n2=2)
Let's change foo back to its default value. We can do that either by setting the attribute config.foo = "bar" or
by passing a kwarg to override the config.toml.
The dry-run output now only changes the foo key in the config.toml leaving all other lines unchanged
>>> config = MyPluginConfig(foo="bar")
>>> config.write_config(dry_run=True)
--- config.toml 01-06-26 09:53
+++ config.toml 01-06-26 09:56
@@ -1,5 +1,5 @@
[plugin.my_plugin]
-foo = "baz"
+foo = "bar"
[plugin.my_plugin.nested]
n1 = 1
If instead we wish to remove keys when set to their default value pass the preserve_existing_keys=False argument
>>> config.write_config(dry_run=True, preserve_existing_keys=False)
--- config.toml 01-06-26 09:53
+++ config.toml 01-06-26 09:57
@@ -1,5 +1,4 @@
[plugin.my_plugin]
-foo = "baz"
[plugin.my_plugin.nested]
n1 = 1
See the tests for more examples of reading and writing plugin configuration.
Ensure you have conda installed.
Then run:
make setupmake testmake tox