Compare commits

...

11 Commits

4 changed files with 74 additions and 49 deletions

View File

@ -6,11 +6,11 @@ This module allows creating simple interfaces to forms/payloads for use in HTTP
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.
A field is defined by its _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).
Optionally, a field can have an _alias_ (for internal use), a _default_ value, value _options_ (as `<select>` tags do), and may be declared _required_.
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.
A form definition in YAML format will consist of the field names as top-level keys, and either nothing/`null` or the corresponding fields' definitions as key-value-pairs below them.
## Example
@ -18,19 +18,16 @@ A form definition in YAML format will consist of the field aliases as top-level
```yaml
# definition.yaml
field1:
name: payload_param_1
field2:
name: payload_param_2
default: foo
way_too_long_field_name:
alias: short_name
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
alias: special
required: true
```
@ -38,10 +35,11 @@ mandatory_field:
```
>>> 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')
>>> form_interface.get_payload(short_name='abc', foo='bar', special='420')
{'way_too_long_field_name': 'abc', 'foo': 'bar', 'choice_field': 'value1', 'mandatory_field': '420'}
>>> form_interface.get_payload(short_name='abc', choice_field='baz', special='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'}>
ValueError: "baz" is not a valid option for <SelectField: name="choice_field", default="value1", options={'value1': 'text for option 1', 'value2': 'text for option 2'}>
```

View File

@ -1,6 +1,6 @@
[metadata]
name = yamlhttpforms
version = 0.0.3
version = 0.1.2
author = Daniil F.
author_email = mail@placeholder123.to
description = HTTP forms defined in YAML
@ -26,6 +26,7 @@ req =
requests
aio =
aiohttp
webutils-df @ git+https://git.fajnberg.de/daniil/webutils-df.git
html =
beautifulsoup4

View File

@ -2,8 +2,8 @@ from importlib import import_module
from typing import Dict, Callable, Union, Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from aiohttp import ClientSession as AioSession, ClientResponse as AioResponse
from requests import Session as ReqSession, Response as ReqResponse
from aiohttp import ClientSession as AioSession
from requests import Session as ReqSession
from bs4.element import Tag as BS4Tag
from .utils import PathT, yaml_overload
@ -17,20 +17,10 @@ OptionsT = Dict[str, str]
class FormField:
def __init__(self, name: str, default: DefaultInitT = None, options: OptionsT = None, required: bool = False):
self.name: str = name
self._default: Union[str, CallableDefaultT, None]
if isinstance(default, dict):
try:
module, function = default['module'], default['function']
except KeyError:
raise TypeError(f"Default for field '{name}' is invalid. The default must be either a string or a "
f"dictionary with the special keys 'module' and 'function'.")
obj = import_module(module)
for attr in function.split('.'):
obj = getattr(obj, attr)
self._default = obj
else:
self._default = default
self.options: Optional[OptionsT] = options
self.default = default
self.options: Optional[OptionsT] = None
if options is not None:
self.options = {str(k): str(v) for k, v in options.items()}
self.required: bool = required
def __repr__(self) -> str:
@ -51,10 +41,28 @@ class FormField:
def default(self) -> Optional[str]:
return self._default() if callable(self._default) else self._default
@default.setter
def default(self, default: DefaultInitT) -> None:
if isinstance(default, dict):
try:
module, function = default['module'], default['function']
except KeyError:
raise TypeError(f"Default for field '{self.name}' is invalid. The default must be either a string or "
f"`None` or a dictionary with the special keys 'module' and 'function'.")
obj = import_module(module)
for attr in function.split('.'):
obj = getattr(obj, attr)
self._default = obj
elif default is None:
self._default = None
else:
self._default = str(default)
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(self, value: str) -> str:
def clean(self, value: Any) -> str:
value = str(value)
if self.options is None or value in self.options.keys():
return value
# Try to find an unambiguous match in the visible option texts:
@ -167,7 +175,7 @@ class Form:
payload[field.name] = field.default
return payload
async def post_aio(self, _aiohttp_session_obj: 'AioSession' = None, **kwargs: str) -> 'AioResponse':
async def post_aio(self, _aiohttp_session_obj: 'AioSession' = None, **kwargs: str) -> str:
"""
Uses `aiohttp` to perform a POST request to `.url` with the form's payload generated using `kwargs`.
@ -178,20 +186,20 @@ class Form:
Passed directly into `.get_payload`.
Returns:
The `aiohttp.ClientResponse` object from the request.
The response text from the request.
"""
if self.url is None:
raise AttributeError("`url` attribute not set")
from aiohttp import ClientSession, ClientResponse
from webutils import in_async_session
from aiohttp import ClientSession
from webutils_df import in_async_session
@in_async_session
async def post(url: str, data: dict, session: ClientSession = None) -> ClientResponse:
async def post(url: str, data: dict, session: ClientSession = None) -> str:
async with session.post(url, data=data) as response:
return response
return await response.text()
return await post(self.url, self.get_payload(**kwargs), session=_aiohttp_session_obj)
def post_req(self, _requests_session_obj: 'ReqSession' = None, **kwargs: str) -> 'ReqResponse':
def post_req(self, _requests_session_obj: 'ReqSession' = None, **kwargs: str) -> str:
"""
Uses `requests` to perform a POST request to `.url` with the form's payload generated using `kwargs`.
@ -202,14 +210,14 @@ class Form:
Passed directly into `.get_payload`.
Returns:
The `requests.Response` object from the request.
The response text from the request.
"""
if self.url is None:
raise AttributeError("`url` attribute not set")
if _requests_session_obj is not None:
return _requests_session_obj.post(self.url, data=self.get_payload(**kwargs))
return _requests_session_obj.post(self.url, data=self.get_payload(**kwargs)).text
from requests import post
return post(self.url, data=self.get_payload(**kwargs))
return post(self.url, data=self.get_payload(**kwargs)).text
def check_with_html(self, form_tag: 'BS4Tag', check_defaults: bool = True) -> None:
from .html import check_form_interface

View File

@ -1,4 +1,6 @@
import sys
from typing import Dict, TYPE_CHECKING
from bs4.element import Tag
if TYPE_CHECKING:
@ -8,6 +10,8 @@ if TYPE_CHECKING:
INPUT, SELECT, OPTION = 'input', 'select', 'option'
NAME, VALUE, SELECTED = 'name', 'value', 'selected'
NON_PRINTABLE_TO_NONE = {code: None for code in range(sys.maxunicode + 1) if not chr(code).isprintable()}
class WrongInterface(Exception):
pass
@ -29,6 +33,10 @@ class UnknownField(WrongInterface):
pass
def printable_only(string: str) -> str:
return string.translate(NON_PRINTABLE_TO_NONE)
def check_select_field_options(field_tag: Tag, field_interface: 'FormField', check_defaults: bool = True) -> None:
"""
Compares the `options` and `default` attributes of a `'FormField'` object with the options of its HTML counterpart.
@ -45,13 +53,23 @@ def check_select_field_options(field_tag: Tag, field_interface: 'FormField', che
`FieldInterfaceWrong`
if the `default` is not equal to the value of the <option> tag which has the `selected` attribute
"""
options = {tag[VALUE]: tag.get_text(strip=True) for tag in field_tag.find_all(OPTION)}
if options != field_interface.options:
raise SelectOptionsWrong(f"Wrong options in {field_interface}")
html_options = {(tag[VALUE], printable_only(tag.get_text(strip=True))) for tag in field_tag.find_all(OPTION)}
interface_options = set(field_interface.options.items())
missing_in_interface = html_options - interface_options
not_in_html = interface_options - html_options
s = ""
if missing_in_interface:
s += "\nThe following <options> HTML tags were not found in the interface:\n"
s += "\n".join(str(tup) for tup in missing_in_interface)
if not_in_html:
s += "\nThe following options were defined, but are not present in the HTML:\n"
s += "\n".join(str(tup) for tup in not_in_html)
if s:
raise SelectOptionsWrong(f"Wrong options in field '{field_interface.name}'." + s)
if check_defaults:
default = field_tag.find(lambda tag: tag.name == OPTION and tag.has_attr(SELECTED))[VALUE]
if default != field_interface.default:
raise FieldInterfaceWrong(f"Default option '{default}' missing for {field_interface}")
default_option = field_tag.find(lambda tag: tag.name == OPTION and tag.has_attr(SELECTED))
if default_option is not None and default_option[VALUE] != field_interface.default:
raise FieldInterfaceWrong(f"Default option '{default_option[VALUE]}' missing for {field_interface}")
def check_field_interface(field_tag: Tag, field_interface: 'FormField', check_defaults: bool = True) -> None:
@ -99,7 +117,7 @@ def check_form_interface(form_tag: Tag, form_interface: 'Form', check_defaults:
"""
field_tags: Dict[str, Tag] = {tag[NAME]: tag for tag in form_tag.find_all(INPUT) + form_tag.find_all(SELECT)}
for field in form_interface.fields.values():
tag = field_tags.pop(field.name)
tag = field_tags.pop(field.name, None)
if tag is None:
raise UnknownField(f"The defined field does not exist in the form: {field}")
check_field_interface(tag, field, check_defaults=check_defaults)