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 # 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 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 from yaml import safe_load
if TYPE_CHECKING: if TYPE_CHECKING:
@ -41,7 +41,7 @@ class FormField:
def valid_option(self, option: str) -> bool: def valid_option(self, option: str) -> bool:
return self.options is None or option in self.options.keys() or option in self.options.values() 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(): if self.options is None or value in self.options.keys():
return value return value
# Try to find an unambiguous match in the visible option texts: # Try to find an unambiguous match in the visible option texts:
@ -73,7 +73,7 @@ class Form:
url (optional): url (optional):
Can be set in advance to the url that requests using this form's payload should be made to. 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.full_payload_always: bool = full_payload
self.url: Optional[str] = url self.url: Optional[str] = url
@ -81,13 +81,9 @@ class Form:
fields = ', '.join(f"'{alias}': {field}" for alias, field in self.fields.items()) fields = ', '.join(f"'{alias}': {field}" for alias, field in self.fields.items())
return f'Form({fields})' return f'Form({fields})'
@property
def fields(self) -> Dict[str, FormField]:
return self._fields.copy()
@property @property
def required_fields(self) -> Dict[str, FormField]: 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]: def get_payload(self, **kwargs: str) -> Dict[str, str]:
""" """
@ -115,7 +111,7 @@ class Form:
if value is None: if value is None:
payload[field.name] = field.default payload[field.name] = field.default
else: else:
payload[field.name] = field.clean_value(value) payload[field.name] = field.clean(value)
elif field.required: elif field.required:
raise ValueError(f"`{key}` is a required field, but no argument was passed.") raise ValueError(f"`{key}` is a required field, but no argument was passed.")
elif self.full_payload_always: elif self.full_payload_always:
@ -167,18 +163,55 @@ class Form:
return post(self.url, data=self.get_payload(**kwargs)) 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. Creates a form instance from an arbitrary number of definition files in YAML format.
Args: Args:
def_paths: 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): full_payload (optional):
Passed directly into the Form constructor. See the `Form` constructor
url (optional):
See the `Form` constructor
""" """
definition = {} return Form(
for path in def_paths: definition=yaml_overload(*def_paths, dir_sort_key=dir_sort_key, dir_sort_reverse=dir_sort_reverse),
with open(path, 'r') as f: full_payload=full_payload,
definition.update(safe_load(f)) url=url
return Form(definition, full_payload=full_payload) )