recursive yaml loading; readme text; minor refactoring

This commit is contained in:
Daniil Fajnberg 2021-12-29 17:30:34 +01:00
parent 4b1536c19a
commit 987e83c110
3 changed files with 96 additions and 18 deletions

View File

@ -1,3 +1,47 @@
# HTTP forms defined in YAML
This module allows creating simple interfaces to forms/payloads for use in HTTP POST requests by defining them in highly readable and easily maintainable YAML files.
## Form definition
A form is defined by its fields.
A field is defined by an **alias** (for internal use) and the field's **name**, which is the parameter name in the payload sent during a `POST` request, and which typically corresponds to the `name` attribute of a `<select>` or `<input>` HTML tag.
Optionally, a field can have a **default** value, value **options** (as `<select>` tags do), and may be declared **required** (i.e. non-optional).
A form definition in YAML format will consist of the field aliases as top-level keys, and the corresponding fields' definitions as key-value-pairs below them.
## Example
### Definition
```yaml
# definition.yaml
field1:
name: payload_param_1
field2:
name: payload_param_2
default: foo
choice_field:
name: payload_param_3
options:
value1: text for option 1
value2: text for option 2
default: value1
mandatory_field:
name: payload_param_0
required: true
```
### Usage
```
>>> from yamlhttpforms import load_form
>>> form_interface = load_form('definition.yaml')
>>> form_interface.get_payload(field1='abc', field2='bar', mandatory_field='420')
{'payload_param_1': 'abc', 'payload_param_2': 'bar', 'payload_param_3': 'value1', 'payload_param_0': '420'}
>>> form_interface.get_payload(field1='abc', choice_field='baz', mandatory_field='420')
Traceback (most recent call last):
...
ValueError: "baz" is not a valid option for <SelectField: name="payload_param_3", default="value1", options={'value1': 'text for option 1', 'value2': 'text for option 2'}>
```

View File

@ -0,0 +1 @@
from yamlhttpforms.form import Form, yaml_overload, load_form

View File

@ -1,5 +1,5 @@
from pathlib import Path
from typing import Dict, Callable, Union, Optional, TYPE_CHECKING
from typing import Dict, Callable, Union, Optional, Any, TYPE_CHECKING
from yaml import safe_load
if TYPE_CHECKING:
@ -41,7 +41,7 @@ class FormField:
def valid_option(self, option: str) -> bool:
return self.options is None or option in self.options.keys() or option in self.options.values()
def clean_value(self, value: str) -> str:
def clean(self, value: str) -> str:
if self.options is None or value in self.options.keys():
return value
# Try to find an unambiguous match in the visible option texts:
@ -73,7 +73,7 @@ class Form:
url (optional):
Can be set in advance to the url that requests using this form's payload should be made to.
"""
self._fields: Dict[str, FormField] = {alias: FormField(**field_def) for alias, field_def in definition.items()}
self.fields: Dict[str, FormField] = {alias: FormField(**field_def) for alias, field_def in definition.items()}
self.full_payload_always: bool = full_payload
self.url: Optional[str] = url
@ -81,13 +81,9 @@ class Form:
fields = ', '.join(f"'{alias}': {field}" for alias, field in self.fields.items())
return f'Form({fields})'
@property
def fields(self) -> Dict[str, FormField]:
return self._fields.copy()
@property
def required_fields(self) -> Dict[str, FormField]:
return {alias: field for alias, field in self._fields.items() if field.required}
return {alias: field for alias, field in self.fields.items() if field.required}
def get_payload(self, **kwargs: str) -> Dict[str, str]:
"""
@ -115,7 +111,7 @@ class Form:
if value is None:
payload[field.name] = field.default
else:
payload[field.name] = field.clean_value(value)
payload[field.name] = field.clean(value)
elif field.required:
raise ValueError(f"`{key}` is a required field, but no argument was passed.")
elif self.full_payload_always:
@ -167,18 +163,55 @@ class Form:
return post(self.url, data=self.get_payload(**kwargs))
def load_form(*def_paths: PathT, full_payload: bool = True) -> Form:
def yaml_overload(*paths: PathT, dir_sort_key: Callable[[PathT], Any] = None, dir_sort_reverse: bool = False) -> dict:
"""
Loads YAML files from any number of paths, recursively going through any directories.
Args:
paths:
Each argument should be a path to either a YAML file or a directory containing YAML files;
only files with the extension `.yaml` are loaded.
dir_sort_key (optional):
If one of the paths is a directory, its contents are sorted, before recursively passing them into this
function; to apply a specific comparison key for each sub-path, a callable can be used here, which is
passed into the builtin `sorted` function.
dir_sort_reverse (optional):
Same as with the parameter above, this argument is also passed into the `sorted` function.
Returns:
Dictionary comprised of the contents of iteratively loaded YAML files.
NOTE: Since it is updated each time a file is loaded, their load order matters!
"""
output_dict = {}
for path in paths:
path = Path(path)
if path.is_dir():
output_dict.update(yaml_overload(*sorted(path.iterdir(), key=dir_sort_key, reverse=dir_sort_reverse)))
elif path.suffix == '.yaml':
with open(path, 'r') as f:
output_dict.update(safe_load(f))
return output_dict
def load_form(*def_paths: PathT, dir_sort_key: Callable[[PathT], Any] = None, dir_sort_reverse: bool = False,
full_payload: bool = True, url: str = None) -> Form:
"""
Creates a form instance from an arbitrary number of definition files in YAML format.
Args:
def_paths:
Each element must be a path to a readable YAML file containing a valid form definition.
Paths to the YAML files containing the form definitions; see the `paths` parameter in `yaml_overload`
dir_sort_key (optional):
See `yaml_overload`
dir_sort_reverse (optional):
See `yaml_overload`
full_payload (optional):
Passed directly into the Form constructor.
See the `Form` constructor
url (optional):
See the `Form` constructor
"""
definition = {}
for path in def_paths:
with open(path, 'r') as f:
definition.update(safe_load(f))
return Form(definition, full_payload=full_payload)
return Form(
definition=yaml_overload(*def_paths, dir_sort_key=dir_sort_key, dir_sort_reverse=dir_sort_reverse),
full_payload=full_payload,
url=url
)