Skip to content

Commit

Permalink
support pydantic models
Browse files Browse the repository at this point in the history
  • Loading branch information
Fredrik Borg committed Dec 12, 2022
1 parent c68a02f commit f0854b9
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 19 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ The configuration presedence are (from lowest to highest):

# Config

Arguments are parsed in two phases. First, it will look for the argument --config argument
which can be used to specify an alternative location for the ini file. If not --config argument
is given it will look for an ini file in the following locations (~/.config has presedence):
Arguments are parsed in two phases. First, it will look for the argument `--config`
which can be used to specify an alternative location for the ini file. If not `--config` argument
is given it will look for an ini file in the following locations (`~/.config has presedence`):

- ~/.config/<CONFIG_ID>/<CONFIG_FILE_NAME> (or directory specified by XDG_CONFIG_HOME)
- /etc/<CONFIG_FILE_NAME>
- `~/.config/<CONFIG_ID>/<CONFIG_FILE_NAME>` (or directory specified by `$XDG_CONFIG_HOME`)
- `/etc/<CONFIG_FILE_NAME>`

The ini file can contain a "[DEFAULT]" section that will be used for all configurations.
In addition it can have a section that corresponds with <SECTION_NAME> that for
specific configuration, that will over override config from DEFAULT
The ini file can contain a `[DEFAULT]` section that will be used for all configurations.
In addition it can have a section that corresponds with `<SECTION_NAME>` that for
specific configuration, that will over override config from `[DEFAULT]`

# Environment variables

The configuration step will also look for environment variables in uppercase and
with "-" replaced with "_". For the example below it will lookup the following environment
with `-` replaced with `_`. For the example below it will lookup the following environment
variables:

- $NUMBER
Expand All @@ -33,7 +33,7 @@ variables:

Example:

```
```python
>>> import caep
>>> import argparse
>>> parser = argparse.ArgumentParser("test argparse")
Expand Down
1 change: 1 addition & 0 deletions caep/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .config import handle_args # noqa: F401
from .schema import parse as parse # noqa: F401
from .xdg import get_cache_dir, get_config_dir # noqa: F401
Empty file added caep/py.typed
Empty file.
111 changes: 111 additions & 0 deletions caep/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python


import argparse
from typing import Dict, List, Optional, Type, TypeVar

from pydantic import BaseModel

import caep

# Map of pydantic schema types to python types
TYPE_MAPPING: Dict[str, type] = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
}

# Type of BaseModel Subclasses
BaseModelType = TypeVar("BaseModelType", bound=BaseModel)


class SchemaError(Exception):
pass


class FieldError(Exception):
pass


def parse(
model: Type[BaseModelType],
description: str,
config_id: str,
config_file_name: str,
section_name: str,
alias: bool = False,
opts: Optional[List[str]] = None,
) -> BaseModelType:

"""
TODO - document parameters
"""

parser = argparse.ArgumentParser(description)

# Get all pydantic fields
fields = model.schema(alias).get("properties")

if not fields:
raise SchemaError(f"Unable to get properties from schema {model}")

# Map of all fields that are defined as arrays
arrays = {}

# Loop over all pydantic schema fields
for field, schema in fields.items():
field_type: type

# argparse arguments use dash instead of underscore
field = field.replace("_", "-")

if schema["type"] == "array":
array_type = TYPE_MAPPING.get(schema["items"]["type"])

if not array_type:
raise FieldError(
f"Unsupported pydantic type for array field {field}: {schema}"
)

arrays[field] = (array_type, schema.get("split", " "))

# For arrays (lists), we parse as str in caep and split values by configured
# split value later
field_type = str
else:

if schema["type"] not in TYPE_MAPPING:
raise FieldError(
f"Unsupported pydantic type for field {field}: {schema}"
)

field_type = TYPE_MAPPING[schema["type"]]

parser.add_argument(
f"--{field}",
help=schema.get("description", "No help provided"),
type=field_type,
default=schema.get("default"),
)

args = {}

for field, value in vars(
caep.config.handle_args(
parser, config_id, config_file_name, section_name, opts=opts
)
).items():

if field in arrays:
if not value:
value = []
else:
value_type, split = arrays[field]
value = [value_type(v) for v in value.split(split)]

args[field] = value

return model(**args)
2 changes: 1 addition & 1 deletion tests/data/config_testdata.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
number = 3

[test]
bool = True
enabled = True
str-arg = from ini
16 changes: 8 additions & 8 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
def __argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser("test argparse", allow_abbrev=False)
parser.add_argument("--number", type=int, default=1)
parser.add_argument("--bool", action="store_true")
parser.add_argument("--enabled", action="store_true")
parser.add_argument("--str-arg")

return parser


def test_argparse_only() -> None:
"""all arguments from command line, using default for number and bool"""
"""all arguments from command line, using default for number and enabled"""

parser = __argparser()

Expand All @@ -29,7 +29,7 @@ def test_argparse_only() -> None:

assert args.number == 1
assert args.str_arg == "test"
assert not args.bool
assert not args.enabled


def test_argparse_ini() -> None:
Expand All @@ -44,7 +44,7 @@ def test_argparse_ini() -> None:

assert args.number == 3
assert args.str_arg == "from ini"
assert args.bool is True
assert args.enabled is True


def test_argparse_env() -> None:
Expand All @@ -54,7 +54,7 @@ def test_argparse_env() -> None:
env = {
"STR_ARG": "from env",
"NUMBER": 4,
"BOOL": "yes", # accepts both yes and true
"ENABLED": "yes", # accepts both yes and true
}

for key, value in env.items():
Expand All @@ -64,7 +64,7 @@ def test_argparse_env() -> None:

assert args.number == 4
assert args.str_arg == "from env"
assert args.bool is True
assert args.enabled is True

# Remove from environment variables
for key in env:
Expand All @@ -74,7 +74,7 @@ def test_argparse_env() -> None:
def test_argparse_env_ini() -> None:
"""
--number from enviorment
--bool from ini
--enabled from ini
--str-arg from cmdline
"""
Expand All @@ -95,7 +95,7 @@ def test_argparse_env_ini() -> None:

assert args.number == 4
assert args.str_arg == "cmdline"
assert args.bool is True
assert args.enabled is True

# Remove from environment variables
for key in env:
Expand Down
114 changes: 114 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
""" test config """
import os
from typing import List, Optional

from pydantic import BaseModel, Field

import caep

INI_TEST_FILE = os.path.join(os.path.dirname(__file__), "data/config_testdata.ini")


class Arguments(BaseModel):

# Fields that are not specified as `Field` with description will not have
# any help text

str_arg: str = Field(description="Required String Argument")
number: int = Field(default=1, description="Integer with default value")
enabled: bool = Field(default=False, description="Boolean with default value")

# List fields will be separated by space as default
intlist: List[int] = Field(description="Space separated list of ints")

# Can optionally use "split" argument to use another value to split based on
strlist: List[str] = Field(description="Comma separated list of strings", split=",")


def parse_args(
commandline: Optional[List[str]] = None,
description: str = "Program description",
config_id: str = "config_id",
config_filename: str = "config_filename",
section_name: str = "test",
) -> Arguments:
return caep.parse(
Arguments,
description,
config_id,
config_filename,
section_name,
opts=commandline,
)


def test_schema_commandline() -> None:
"""all arguments from command line, using default for number and bool"""
commandline = "--str-arg test".split()

config = parse_args(commandline)

assert config.number == 1
assert config.str_arg == "test"
assert not config.enabled


def test_schema_ini() -> None:
"""all arguments from ini file"""
commandline = f"--config {INI_TEST_FILE}".split()

config = parse_args(commandline, section_name="test")

assert config.number == 3
assert config.str_arg == "from ini"
assert config.enabled is True


def test_argparse_env() -> None:
"""all arguments from env"""

env = {
"STR_ARG": "from env",
"NUMBER": 4,
"ENABLED": "yes", # accepts both yes and true
}

for key, value in env.items():
os.environ[key] = str(value)

config = parse_args(section_name="test")

assert config.number == 4
assert config.str_arg == "from env"
assert config.enabled is True

# Remove from environment variables
for key in env:
del os.environ[key]


def test_argparse_env_ini() -> None:
"""
--number from environment
--bool from ini
--str-arg from cmdline
"""
env = {
"NUMBER": 4,
}

for key, value in env.items():
os.environ[key] = str(value)

commandline = f"--config {INI_TEST_FILE} --str-arg cmdline".split()

config = parse_args(commandline, section_name="test")

assert config.number == 4
assert config.str_arg == "cmdline"
assert config.enabled is True

# Remove from environment variables
for key in env:
del os.environ[key]

0 comments on commit f0854b9

Please sign in to comment.