Package: pydantic-cli
Pydantic Commandline Tool InterfaceTurn Pydantic defined Data Models into CLI Tools and enable loading values from JSON filesRequires Pydantic>=1.5.1.Installationpipinstallpydantic-cliFeatures and RequirementsThin Schema driven interfaces constructed fromPydanticdefined data modelsValidation is performed in a single location as defined by Pydantic's validation model and defined typesCLI parsing is only structurally validating that the args or optional arguments are providedEnable loading config defined in JSON to override or set specific valuesClear interface between the CLI and your application codeLeverage the static analyzing toolmypyto catch type errors in your commandline toolEasy to test (due to reasons defined above)Motivating UsecasesQuick scrapy commandline tools for local development (e.g., webscraper CLI tool, or CLI application that runs a training algo)Internal tools driven by a Pydantic data model/schemaConfiguration heavy tools that are driven by either partial (i.e, "presets") or complete configuration files defined using JSONQuick StartTo create a commandline tool that takes an input file and max number of records to process as arguments:my-tool--input_file/path/to/file.txt--max_records1234This requires two components.Create Pydantic Data Model of typeTwrite a function that takes an instance ofTand returns the exit code (e.g., 0 for success, non-zero for failure).pass theTinto to theto_runnerfunction, or therun_and_exitExplicit example show below.importsysfrompydanticimportBaseModelfrompydantic_cliimportrun_and_exit,to_runnerclassMinOptions(BaseModel):input_file:strmax_records:intdefexample_runner(opts:MinOptions)->int:print(f"Mock example running with options{opts}")return0if__name__=='__main__':# to_runner will return a function that takes the args list to run and# will return an integer exit codesys.exit(to_runner(MinOptions,example_runner,version='0.1.0')(sys.argv[1:]))Or to implicitly usesys.argv[1:], leveragerun_and_exit(to_runneris also useful for testing).if__name__=='__main__':run_and_exit(MinOptions,example_runner,description="My Tool Description",version='0.1.0')Customizing Description and Commandline FlagsIf the Pydantic data model fields are reasonable well named (e.g., 'min_score', or 'max_records'), this can yield a good enough description when--helpis called.Customizing the commandline flags or the description can be done by leveragingdescriptionkeyword argument inFieldfrompydantic. SeeFieldmodel in Pydanticmore details.Custom 'short' or 'long' forms of the commandline args can be provided by using aTuple[str]orTuple2[str, str]. For example,cli=('-m', '--max-records')orcli=('--max-records',).Note, Pydantic interprets...as a "required" value when used inField.frompydanticimportBaseModel,Fieldfrompydantic_cliimportrun_and_exitclassMinOptions(BaseModel):input_file:str=Field(...,description="Path to Input H5 file",cli=('-i','--input-file'))max_records:int=Field(...,description="Max records to process",cli=('-m','--max-records'))debug:bool=Field(False,description="Enable debugging mode",cli=('-d','--debug'))defexample_runner(opts:MinOptions)->int:print(f"Mock example running with options{opts}")return0if__name__=='__main__':run_and_exit(MinOptions,example_runner,description="My Tool Description",version='0.1.0')WARNING: Data models that have boolean values and generated CLI flags (e.g.,--enable-filteror--disable-filter) require special attention. See the "Defining Boolean Flags" section for more details.LeveragingFieldis also useful for validating inputs.frompydanticimportBaseModel,FieldclassMinOptions(BaseModel):input_file:str=Field(...,description="Path to Input H5 file",cli=('-i','--input-file'))max_records:int=Field(...,gt=0,lte=1000,description="Max records to process",cli=('-m','--max-records'))Loading Configuration using JSONTools can also load entire models or partially defined Pydantic data models from JSON files.For example, given the following Pydantic data model:frompydanticimportBaseModelfrompydantic_cliimportrun_and_exit,DefaultConfigclassOpts(BaseModel):classConfig(DefaultConfig):CLI_JSON_ENABLE=Truehdf_file:strmax_records:int=10min_filter_score:floatalpha:floatbeta:floatdefrunner(opts:Opts):print(f"Running with opts:{opts}")return0if__name__=='__main__':run_and_exit(Opts,runner,description="My Tool Description",version='0.1.0')Can be run with a JSON file that defines all the (required) values.{"hdf_file":"/path/to/file.hdf5","max_records":5,"min_filter_score":1.5,"alpha":1.0,"beta":1.0}The tool can be executed as shown below. Note, options required at the commandline as defined in theOptsmodel (e.g., 'hdf_file', 'min_filter_score', 'alpha' and 'beta') are NO longer required values supplied to the commandline tool.my-tool--json-config/path/to/file.jsonTo override values in the JSON config file, or provide the missing required values, simply provide the values at the commandline.These valueswill overridevalues defined in the JSON config file. The provides a general mechanism of using configuration "preset" files.my-tool--json-config/path/to/file.json--alpha-1.8--max_records100Similarly, a partially described data model can be used combined with explict values provided at the commandline.In this example,hdf_fileandmin_filter_scoreare still required values that need to be provided to the commandline tool.{"max_records":10,"alpha":1.234,"beta":9.876}my-tool--json-config/path/to/file.json--hdf_file/path/to/file.hdf5--min_filter_score-12.34Catching Type Errors with mypyIf you've usedargparse, you've probably been bitten by anAttributeErrorexception raised on the Namespace instance returned from parsing the raw args.For example,importsysfromargparseimportArgumentParserdefto_parser()->ArgumentParser:p=ArgumentParser(description="Example")f=p.add_argumentf('hdf5_file',type=str,help="Path to HDF5 records")f("--num_records",required=True,type=int,help="Number of records to filter over")f('-f','-filter-score',required=True,type=float,default=1.234,help="Min filter score")f('-g','--enable-gamma-filter',action="store_true",help="Enable gamma filtering")returnpdefmy_library_code(path:str,num_records:float,min_filter_score,enable_gamma=True)->int:print("Mock running of code")return0defmain(argv)->int:p=to_parser()pargs=p.parse_args(argv)returnmy_library_code(pargs.hdf5_file,pargs.num_record,pargs.min_filter_score,pargs.enable_gamma_filter)if__name__=='__main__':sys.exit(main(sys.argv[1:]))The first error found at runtime is show below.Traceback(mostrecentcalllast):File"junk.py",line35,in<module>sys.exit(main(sys.argv[1:]))File"junk.py",line31,inmainreturnmy_library_code(pargs.hdf5_file,pargs.num_record,pargs.min_filter_score,pargs.enable_gamma_filter)AttributeError:'Namespace'objecthasnoattribute'num_record'The errors inpargs.num_recordsandpargs.filter_scoreare inconsistent with what is defined into_parsermethod. Each error will have to be manually hunted down.Withpydantic-cli, it's possible to catch these errors by runningmypy. This also enables you to refactor your code with more confidence.For example,frompydanticimportBaseModelfrompydantic_cliimportrun_and_exitclassOptions(BaseModel):input_file:strmax_records:intdefbad_func(n:int)->int:return2*ndefexample_runner(opts:Options)->int:print(f"Mock example running with{opts}")return0if__name__=="__main__":run_and_exit(Options,bad_func,version="0.1.0")Withmypy, it's possible to proactively catch this types of errors.mypypydantic_cli/examples/simple.py✘1pydantic_cli/examples/simple.py:36:error:Argument2to"run_and_exit"hasincompatibletype"Callable[[int], int]";expected"Callable[[Options], int]"Found1errorin1file(checked1sourcefile)Defining Boolean FlagsThere are a few common cases of boolean values:x:bool = True|FalseA bool field with a default valuex:boolA required bool fieldx:Optional[bool]orx:Optional[bool] = NoneAn optional boolean with a default value of Nonex:Optional[bool] = Field(...)a required boolean that can be set toNone,TrueorFalsein Pydantic.Case 1 is very common and the semantics of the custom CLI overrides (as a tuple)are different than the cases 2-4.
Case 4 has limitations. It isn't possible to setNonefrom the commandline when the default isTrueorFalse.Boolean Field with DefaultAs demonstrated in a previous example, the common case of defining a type asboolwith a default value work as expected.For example:frompydanticimportBaseModelclassMinOptions(BaseModel):debug:bool=FalseBy default, when defining a model with a boolean flag, an "enable" or "disable" prefix will be added to create the commandline flag depending on the default value.In this specific case, a commandline flag of--enable-debugwhich will setdebugin the Pydantic model toTrue.If the default was set toFalse, then a--disable-debugflag would be created and would setdebugtoFalsein the Pydantic data model.The CLI flag can be customized and provided as aTuple[str]orTuple[str, str]as (long, ) or (short, long) flags (respectively) to negate the default value.For example, running-dor--debugwill setdebugtoTruein the Pydantic data model.frompydanticimportBaseModel,FieldclassMinOptions(BaseModel):debug:bool=Field(False,description="Enable debug mode",cli=('-d','--debug'))If the default isTrue, running the example below with--disable-debugwill setdebugtoFalse.frompydanticimportBaseModel,FieldclassMinOptions(BaseModel):debug:bool=Field(True,description="Disable debug mode",cli=('-d','--disable-debug'))Boolean Required FieldRequired boolean fields are handled a bit different than cases where a boolean is provided with a default value.Specifically, the custom flagTuple[str, str]must be provided as a(--enable, --disable)format.frompydanticimportBaseModel,FieldclassMinOptions(BaseModel):debug:bool=Field(...,description="Enable/Disable debugging",cli=('--enable-debug','--disable-debug'))Currently, supplying the short form of each "enable" and "disable" is not supported.Optional Boolean FieldsSimilar to the required boolean fields case,Optional[bool]cases have the same (--enable, --disable) semantics.fromtypingimportOptionalfrompydanticimportBaseModel,FieldclassMinOptions(BaseModel):a:Optional[bool]b:Optional[bool]=Nonec:Optional[bool]=Field(None,cli=('--yes-c','--no-c'))d:Optional[bool]=Field(False,cli=('--enable-d','--disable-d'))e:Optional[bool]=Field(...,cli=('--enable-e','--disable-e'))Note, thatx:Optional[bool],x:Optional[bool] = None,x:Optional[bool] = Field(None)semantically mean the same thing in Pydantic.In each of the above cases, thecustom CLI flags must be provided as (--enable, --disable) format.Also, note it isn't possible to setNonefrom the commandline for theOptional[bool] = FalseorOptional[bool] = Field(...)case.Customizing default Enable/Disable Bool PrefixThe enable/disable prefix used for allbooloptions can be customized by setting theTuple[str, str]ofCLI_BOOL_PREFIXonConfigto the (positive, negative) of prefix flag.The default value ofConfig.CLI_BOOL_PREFIXis('--enable-', '--disable').frompydanticimportBaseModelclassOptions(BaseModel):classConfig:CLI_BOOL_PREFIX=('--yes-','--no-')debug:bool=FalseThis will generate an optional--yes-debugflag that will setdebugfrom the default (False) toTruein the Pydantic data model.In many cases,it's best to customize the commandline boolean flagsto avoid ambiguities or confusion.Customization and HooksIf thedescriptionis not defined and the Pydantic data model fields are tersely named (e.g., 'total', or 'n'), this can yield a call to--helpthat is quite minimal (due to the lack of metadata). However, verbosely named arguments can often be good enough to communicate the intent of the commandline interface.For customization of the CLI args, such as max number of records is-m 1234in the above example, there are two approaches.The first is thequickmethod that is a minor change to the coreConfigof the Pydantic Data model.The second method is use Pydantic's "Field" metadata model is to define richer set of metadata. SeeFieldmodel in Pydanticmore details.Hooks into the CLI ExecutionThere are three core hooks into the customization of CLI execution.exception handler (log or write to stderr and map specific exception classes to integer exit codes)prologue handler (pre-execution hook)epilogue handler (post-execution hook)Both of these cases can be customized to by passing in a function to the running/execution method.The exception handler should handle any logging or writing to stderr as well as mapping the specific exception to non-zero integer exit code.For example:importsysfrompydanticimportBaseModel,Fieldfrompydantic_cliimportrun_and_exitclassMinOptions(BaseModel):input_file:str=Field(...,cli=('-i',))max_records:int=Field(10,cli=('-m','--max-records'))defexample_runner(opts:MinOptions)->int:return0defcustom_exception_handler(ex)->int:exception_map=dict(ValueError=3,IOError=7)sys.stderr.write(str(ex))exit_code=exception_map.get(ex.__class__,1)returnexit_codeif__name__=='__main__':run_and_exit(MinOptions,example_runner,exception_handler=custom_exception_handler)A general pre-execution hook can be called using theprologue_handler. This function isCallable[[T], None], whereTis an instance of your Pydantic data model.This setup hook will be called before the execution of your main function (e.g.,example_runner).importsysimportloggingdefcustom_prologue_handler(opts)->None:logging.basicConfig(level="DEBUG",stream=sys.stdout)if__name__=='__main__':run_and_exit(MinOptions,example_runner,prolgue_handler=custom_prologue_handler)Similarly, the post execution hook can be called. This function isCallable[[int, float], None]that is theexit codeandprogram runtimein sec as input.frompydantic_cliimportrun_and_exitdefcustom_epilogue_handler(exit_code:int,run_time_sec:float):m="Success"ifexit_codeelse"Failed"msg=f"Completed running ({m}) in{run_time_sec:.2f}sec"print(msg)if__name__=='__main__':run_and_exit(MinOptions,example_runner,epilogue_handler=custom_epilogue_handler)SubParsersDefining a subparser to your commandline tool is enabled by creating a containerSubParserdict and callingrun_sp_and_exitimporttypingasTfrompydanticimportBaseModel,AnyUrl,Fieldfrompydantic_cliimportrun_sp_and_exit,SubParserclassAlphaOptions(BaseModel):input_file:str=Field(...,cli=('-i',))max_records:int=Field(10,cli=('-m','--max-records'))classBetaOptions(BaseModel):url:AnyUrl=Field(...,cli=('-u','--url'))num_retries:int=Field(3,cli=('-n','--num-retries'))defprinter_runner(opts:T.Any):print(f"Mock example running with{opts}")return0defto_runner(sx):defexample_runner(opts)->int:print(f"Mock{sx}example running with{opts}")return0returnexample_runnerdefto_subparser_example():return{'alpha':SubParser(AlphaOptions,to_runner("Alpha"),"Alpha SP Description"),'beta':SubParser(BetaOptions,to_runner("Beta"),"Beta SP Description")}if__name__=="__main__":run_sp_and_exit(to_subparser_example(),description=__doc__,version='0.1.0')Configuration Details and Advanced FeaturesPydantic-cli attempts to stylistically follow Pydantic's approach using a class style configuration. See `DefaultConfig in ``pydantic_cli' for more details.importtypingasTfrompydantic_cliimportCustomOptsTypeclassDefaultConfig:"""Core Default Config "mixin" for CLI configuration."""# value used to generate the CLI format --{key}CLI_JSON_KEY:str="json-config"# Enable JSON config loadingCLI_JSON_ENABLE:bool=False# Set the default ENV var for defining the JSON config pathCLI_JSON_CONFIG_ENV_VAR:str="PCLI_JSON_CONFIG"# Set the default Path for JSON config fileCLI_JSON_CONFIG_PATH:T.Optional[str]=None# If a default path is provided or provided from the commandlineCLI_JSON_VALIDATE_PATH:bool=True# Customize the default prefix that is generated# if a boolean flag is provided. Boolean custom CLI# MUST be provided as Tuple[str, str]CLI_BOOL_PREFIX:T.Tuple[str,str]=("--enable-","--disable-")# Add a flag that will emit the shell completion# this requires 'shtab'# https://github.com/iterative/shtabCLI_SHELL_COMPLETION_ENABLE:bool=FalseCLI_SHELL_COMPLETION_FLAG:str="--emit-completion"AutoComplete leveraging shtabThere is support forzshandbashautocomplete generation usingshtabTheoptionaldependency can be installed as follows.pipinstall"pydantic-cli[shtab]"To enable the emitting of bash/zsh autocomplete files from shtab, setCLI_SHELL_COMPLETION_ENABLE: bool = Truein your data modelConfig.Then use your executable (or.pyfile) emit the autocomplete file to the necessary output directory.For example, usingzshand a script callmy-tool.py,my-tool.py --emit-completion zsh > ~/.zsh/completions/_my-tool.py. By convention/default, the executable name must be prefixed with an underscore.When using autocomplete it should looks similar to this.>./my-tool.py--emit-completionzsh>~/.zsh/completions/_my-tool.py
Completedwritingzshshelloutputtostdout
>./my-tool.py--max--option--
--max_filter_score--(type:intdefault:1.0)--max_length--(type:intdefault:12)--max_records--(type:intdefault:123455)--max_size--(type:intdefault:13)Seeshtabfor more details.Note, that due to the (typically) global zsh completions directory, this can create some friction points with different virtual (or conda) ENVS with the same executable name.General Suggested Testing ModelAt a high level,pydantic_cliis (hopefully) a thin bridge between yourOptionsdefined as a Pydantic model and your
mainrunner(opts: Options)func that has hooks into the startup, shutdown and error handling of the command line tool.
It also supports loading config files defined as JSON. By design,pydantic_cliexplicitly doesn't expose, or leak the argparse instance
because it would add too much surface area and it would enable users' to start mucking with the argparse instance in all kinds of unexpected ways.
The use ofargparseinternally is an hidden implementation detail.Testing can be done by leveraging theto_runnerinterface.It's recommend trying to do the majority of testing via unit tests (independent ofpydantic_cli) with your main function and different instances of your pydantic data model.Once this test coverage is reasonable, it can be useful to add a few smoke tests at the integration level leveragingto_runnerto make sure the tool is functional. Any bugs at this level are probably at thepydantic_clilevel, not your library code.Note, thatto_runner(Opts, my_main)returns aCallable[[List[str]], int]that can be used withargvto return an integer exit code of your program. Theto_runnerlayer will also catch any exceptions.importunittestfrompydanticimportBaseModelfrompydantic_cliimportto_runnerclassOptions(BaseModel):alpha:intdefmain(opts:Options)->int:ifopts.alpha<0:raiseException(f"Got options{opts}. Forced raise for testing.")return0classTestExample(unittest.TestCase):deftest_core(self):# Note, this has nothing to do with pydantic_cli# If possible, this is where the bulk of the testing should beself.assertEqual(0,main(Options(alpha=1)))deftest_example(self):f=to_runner(Options,main)self.assertEqual(0,f(["--alpha","100"]))deftest_expected_error(self):f=to_runner(Options,main)self.assertEqual(1,f(["--alpha","-10"]))For more scrappy, interactive local development, it can be useful to addipdborpdband create a customexception_handler.importsysfrompydanticimportBaseModelfrompydantic_cliimportdefault_exception_handler,run_and_exitclassOptions(BaseModel):alpha:intdefexception_handler(ex:BaseException)->int:exit_code=default_exception_handler(ex)importipdb;ipdb.set_trace()returnexit_codedefmain(opts:Options)->int:ifopts.alpha<0:raiseException(f"Got options{opts}. Forced raise for testing.")return0if__name__=="__main__":run_and_exit(Options,main,exception_handler=exception_handler)Alternatively, wrap your main function to callipdb.importsysfrompydanticimportBaseModelfrompydantic_cliimportrun_and_exitclassOptions(BaseModel):alpha:intdefmain(opts:Options)->int:ifopts.alpha<0:raiseException(f"Got options{opts}. Forced raise for testing.")return0defmain_with_ipd(opts:Options)->int:importipdb;ipdb.set_trace()returnmain(opts)if__name__=="__main__":run_and_exit(Options,main_with_ipd)The core design choice inpydantic_cliis leveraging composable functionsf(g(x))style providing a straight-forward mechanism to plug into.More ExamplesMore examples are provided hereandTesting Examples can be seen here.TheTestHarnessmight provide examples of how to test your CLI tool(s)LimitationsPositional Arguments are not supported(See more info in the next subsection)Using Pydantic BaseSettings to set values fromdotenvor ENV variables isnot supported. Loadingdotenvor similar in Pydantic overlapped and competed too much with the "preset" JSON loading model inpydantic-cli.Pydantic has a perhaps counterintuitive model that sets default values based on the Type signature. ForOptional[T]with NO default assign, a default ofNoneis assigned. This can sometimes yield surprising commandline args generated from the Pydantic data model.Currentlyonly support "simple" types(e.g., floats, ints, strings, boolean) and limited support for fields defined asList[T],Set[T]and simpleEnums. There isno supportfor nested models.Leveragesargparseunderneath the hood (argparse is a bit thorny of an API to build on top of).Why are Positional Arguments not supported?The core features of pydantic-cli are:Define and validate models using Pydantic and use these schemas as an interface to the command lineLeveragemypy(or similar static analyzer) to enable validating/checking typesafe-ness prior to runtimeLoad partial or complete models using JSON (these are essentially, partial or complete config or "preset" files)Positional arguments create friction points when combined with loading model values from a JSON file. More specifically, (required) positional values of the model could be supplied in the JSON and are no longer required at the command line. This can fundamentally change the commandline interface and create ambiguities/bugs.For example:frompydanticimportBaseModelfrompydantic_cliimportDefaultConfigclassMinOptions(BaseModel):classConfig(DefaultConfig):CLI_JSON_ENABLE=Trueinput_file:strinput_hdf:strmax_records:int=100And the vanilla case running from the command line works as expected.my-tool/path/to/file.txt/path/to/file.h5--max_records200However, when using the JSON "preset" feature, there are potential problems where the positional arguments of the tool are shifting around depending on what fields have been defined in the JSON preset.For example, running with thispreset.json, theinput_filepositional argument is no longer required.{"input_file":"/system/config.txt","max_records":12345}Vanilla case works as expected.my-toolfile.txt/path/to/file.h5--json-config./preset.jsonHowever, this also works as well.my-tool/path/to/file.h5--json-config./preset.jsonIn my experience,the changing of the semantic meaning of the command line tool's positional arguments depending on the contents of thepreset.jsoncreated issues and bugs.The simplest fix is to remove the positional arguments in favor of-ior similar which removed the issue.frompydanticimportBaseModel,Fieldfrompydantic_cliimportrun_and_exit,to_runner,DefaultConfigclassMinOptions(BaseModel):classConfig(DefaultConfig):CLI_JSON_ENABLE=Trueinput_file:str=Field(...,cli=('-i',))input_hdf:str=Field(...,cli=('-d','--hdf'))max_records:int=Field(100,cli=('-m','--max-records'))Running with thepreset.jsondefined above, works as expected.my-tool--hdf/path/to/file.h5--json-config./preset.jsonAs well as overriding the-i.my-tool-ifile.txt--hdf/path/to/file.h5--json-config./preset.jsonOrmy-tool--hdf/path/to/file.h5-ifile.txt--json-config./preset.jsonThis consistency was the motivation for removing positional argument support in earlier versions ofpydantic-cli.Other Related ToolsOther tools that leverage type annotations to create CLI tools.cytoPydantic based model leveraging Pydantic's settings sources. Supports nested values. Optional TOML support. (Leverages: click, pydantic)typerTyper is a library for building CLI applications that users will love using and developers will love creating. Based on Python 3.6+ type hints. (Leverages: click)glacierBuilding Python CLI using docstrings and typehints (Leverages: click)Typed-SettingsManage typed settings with attrs classes – for server processes as well as click applications (Leverages: attrs, click)clicheBuild a simple command-line interface from your functions. (Leverages: argparse and type annotations/hints)SimpleParsingSimple, Elegant, Typed Argument Parsing with argparse. (Leverages: dataclasses, argparse)reclineThis library helps you quickly implement an interactive command-based application in Python. (Leverages: argparse + type annotations/hints)clippyClippy crawls the abstract syntax tree (AST) of a Python file and generates a simple command-line interface.clizeTurn Python functions into command-line interfaces (Leverages: attrs)placParsing the Command Line the Easy Way.typedparseParser for command-line options based on type hints (Leverages: argparse and type annotations/hints)paiargparseExtension to the python argparser allowing to automatically generate a hierarchical argument list based on dataclasses. (Leverages: argparse + dataclasses)piouA CLI tool to build beautiful command-line interfaces with type validation.pyrallisA framework for simple dataclass-based configurations.ConfigArgParseA drop-in replacement for argparse that allows options to also be set via config files and/or environment variables.spockspock is a framework that helps manage complex parameter configurations during research and development of Python applications. (Leverages: argparse, attrs, and type annotations/hints)StatsGithub Star Growth of pydantic-cli