fastlifeweb 0.16.4__py3-none-any.whl → 0.17.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastlife/adapters/jinjax/renderer.py +44 -15
- fastlife/adapters/jinjax/widget_factory/__init__.py +1 -0
- fastlife/adapters/jinjax/widget_factory/base.py +38 -0
- fastlife/adapters/jinjax/widget_factory/bool_builder.py +43 -0
- fastlife/adapters/jinjax/widget_factory/emailstr_builder.py +46 -0
- fastlife/adapters/jinjax/widget_factory/enum_builder.py +47 -0
- fastlife/adapters/jinjax/widget_factory/factory.py +165 -0
- fastlife/adapters/jinjax/widget_factory/literal_builder.py +52 -0
- fastlife/adapters/jinjax/widget_factory/model_builder.py +64 -0
- fastlife/adapters/jinjax/widget_factory/secretstr_builder.py +47 -0
- fastlife/adapters/jinjax/widget_factory/sequence_builder.py +58 -0
- fastlife/adapters/jinjax/widget_factory/set_builder.py +80 -0
- fastlife/adapters/jinjax/widget_factory/simpletype_builder.py +47 -0
- fastlife/adapters/jinjax/widget_factory/union_builder.py +90 -0
- fastlife/adapters/jinjax/widgets/hidden.py +2 -0
- fastlife/adapters/jinjax/widgets/model.py +2 -0
- fastlife/components/Form.jinja +12 -0
- fastlife/config/configurator.py +15 -15
- fastlife/config/exceptions.py +2 -0
- fastlife/config/resources.py +2 -2
- fastlife/config/settings.py +2 -0
- fastlife/middlewares/reverse_proxy/x_forwarded.py +7 -8
- fastlife/services/policy.py +1 -1
- fastlife/services/translations.py +12 -6
- fastlife/shared_utils/resolver.py +58 -1
- fastlife/testing/dom.py +140 -0
- fastlife/testing/form.py +204 -0
- fastlife/testing/session.py +67 -0
- fastlife/testing/testclient.py +4 -387
- {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/METADATA +6 -6
- {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/RECORD +33 -18
- fastlife/adapters/jinjax/widgets/factory.py +0 -525
- {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/LICENSE +0 -0
- {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
"""Handle Set type."""
|
2
|
+
|
3
|
+
from collections.abc import Mapping
|
4
|
+
from enum import Enum
|
5
|
+
from typing import Any, Literal, get_origin
|
6
|
+
|
7
|
+
from pydantic.fields import FieldInfo
|
8
|
+
|
9
|
+
from fastlife.adapters.jinjax.widget_factory.base import BaseWidgetBuilder
|
10
|
+
from fastlife.adapters.jinjax.widgets.base import Widget
|
11
|
+
from fastlife.adapters.jinjax.widgets.checklist import Checkable, ChecklistWidget
|
12
|
+
|
13
|
+
|
14
|
+
class SetBuilder(BaseWidgetBuilder[set[Any]]):
|
15
|
+
"""Builder for Set."""
|
16
|
+
|
17
|
+
def accept(self, typ: type[Any], origin: type[Any] | None) -> bool:
|
18
|
+
"""True for Set"""
|
19
|
+
return origin is set
|
20
|
+
|
21
|
+
def build(
|
22
|
+
self,
|
23
|
+
*,
|
24
|
+
field_name: str,
|
25
|
+
field_type: type[Any],
|
26
|
+
field: FieldInfo | None,
|
27
|
+
value: set[Any] | None,
|
28
|
+
form_errors: Mapping[str, Any],
|
29
|
+
removable: bool,
|
30
|
+
) -> Widget[Any]:
|
31
|
+
"""Build the widget."""
|
32
|
+
choice_wrapper = field_type.__args__[0]
|
33
|
+
choices = []
|
34
|
+
choice_wrapper_origin = get_origin(choice_wrapper)
|
35
|
+
if choice_wrapper_origin:
|
36
|
+
if choice_wrapper_origin is Literal:
|
37
|
+
litchoice: list[str] = choice_wrapper.__args__ # type: ignore
|
38
|
+
choices = [
|
39
|
+
Checkable(
|
40
|
+
label=c,
|
41
|
+
value=c,
|
42
|
+
checked=c in value if value else False, # type: ignore
|
43
|
+
name=field_name,
|
44
|
+
token=self.factory.token,
|
45
|
+
error=form_errors.get(f"{field_name}-{c}"),
|
46
|
+
)
|
47
|
+
for c in litchoice
|
48
|
+
]
|
49
|
+
|
50
|
+
else:
|
51
|
+
raise NotImplementedError # coverage: ignore
|
52
|
+
elif issubclass(choice_wrapper, Enum):
|
53
|
+
choices = [
|
54
|
+
Checkable(
|
55
|
+
label=e.value,
|
56
|
+
value=e.name,
|
57
|
+
checked=e.name in value if value else False, # type: ignore
|
58
|
+
name=field_name,
|
59
|
+
token=self.factory.token,
|
60
|
+
error=form_errors.get(f"{field_name}-{e.name}"),
|
61
|
+
)
|
62
|
+
for e in choice_wrapper
|
63
|
+
]
|
64
|
+
else:
|
65
|
+
raise NotImplementedError # coverage: ignore
|
66
|
+
|
67
|
+
return ChecklistWidget(
|
68
|
+
field_name,
|
69
|
+
title=field.title if field else "",
|
70
|
+
hint=field.description if field else None,
|
71
|
+
aria_label=(
|
72
|
+
field.json_schema_extra.get("aria_label") # type:ignore
|
73
|
+
if field and field.json_schema_extra
|
74
|
+
else None
|
75
|
+
),
|
76
|
+
token=self.factory.token,
|
77
|
+
value=choices,
|
78
|
+
removable=removable,
|
79
|
+
error=form_errors.get(field_name),
|
80
|
+
)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
"""Handle simple types (str, int, float, ...)."""
|
2
|
+
|
3
|
+
from collections.abc import Mapping
|
4
|
+
from decimal import Decimal
|
5
|
+
from typing import Any
|
6
|
+
from uuid import UUID
|
7
|
+
|
8
|
+
from pydantic.fields import FieldInfo
|
9
|
+
|
10
|
+
from fastlife.adapters.jinjax.widget_factory.base import BaseWidgetBuilder
|
11
|
+
from fastlife.adapters.jinjax.widgets.base import Widget
|
12
|
+
from fastlife.adapters.jinjax.widgets.text import TextWidget
|
13
|
+
|
14
|
+
|
15
|
+
class SimpleTypeBuilder(BaseWidgetBuilder[str | int | str | float | Decimal | UUID]):
|
16
|
+
"""Builder for simple types."""
|
17
|
+
|
18
|
+
def accept(self, typ: type[Any], origin: type[Any] | None) -> bool:
|
19
|
+
"""True for simple types: int, str, float, Decimal, UUID"""
|
20
|
+
return issubclass(typ, int | str | float | Decimal | UUID)
|
21
|
+
|
22
|
+
def build(
|
23
|
+
self,
|
24
|
+
*,
|
25
|
+
field_name: str,
|
26
|
+
field_type: type[Any],
|
27
|
+
field: FieldInfo | None,
|
28
|
+
value: int | str | float | Decimal | UUID | None,
|
29
|
+
form_errors: Mapping[str, Any],
|
30
|
+
removable: bool,
|
31
|
+
) -> Widget[int | str | float | Decimal | UUID]:
|
32
|
+
"""Build the widget."""
|
33
|
+
return TextWidget(
|
34
|
+
field_name,
|
35
|
+
placeholder=str(field.examples[0]) if field and field.examples else None,
|
36
|
+
title=field.title if field else "",
|
37
|
+
hint=field.description if field else None,
|
38
|
+
aria_label=(
|
39
|
+
field.json_schema_extra.get("aria_label") # type:ignore
|
40
|
+
if field and field.json_schema_extra
|
41
|
+
else None
|
42
|
+
),
|
43
|
+
removable=removable,
|
44
|
+
token=self.factory.token,
|
45
|
+
value=str(value) if value else "",
|
46
|
+
error=form_errors.get(field_name),
|
47
|
+
)
|
@@ -0,0 +1,90 @@
|
|
1
|
+
"""Handle Union type."""
|
2
|
+
|
3
|
+
from collections.abc import Mapping
|
4
|
+
from types import NoneType
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
from pydantic import ValidationError
|
8
|
+
from pydantic.fields import FieldInfo
|
9
|
+
|
10
|
+
from fastlife.adapters.jinjax.widget_factory.base import BaseWidgetBuilder
|
11
|
+
from fastlife.adapters.jinjax.widgets.base import Widget
|
12
|
+
from fastlife.adapters.jinjax.widgets.union import UnionWidget
|
13
|
+
from fastlife.shared_utils.infer import is_complex_type, is_union
|
14
|
+
|
15
|
+
|
16
|
+
class UnionBuilder(BaseWidgetBuilder[Any]):
|
17
|
+
"""Builder for Union."""
|
18
|
+
|
19
|
+
def accept(self, typ: type[Any], origin: type[Any] | None) -> bool:
|
20
|
+
"""True for unions Union[A,B], A | B or event Optional[A], A | None"""
|
21
|
+
return is_union(typ)
|
22
|
+
|
23
|
+
def build(
|
24
|
+
self,
|
25
|
+
*,
|
26
|
+
field_name: str,
|
27
|
+
field_type: type[Any],
|
28
|
+
field: FieldInfo | None,
|
29
|
+
value: Any | None,
|
30
|
+
form_errors: Mapping[str, Any],
|
31
|
+
removable: bool,
|
32
|
+
) -> Widget[Any]:
|
33
|
+
"""Build the widget."""
|
34
|
+
types: list[type[Any]] = []
|
35
|
+
# required = True
|
36
|
+
for typ in field_type.__args__: # type: ignore
|
37
|
+
if typ is NoneType:
|
38
|
+
# required = False
|
39
|
+
continue
|
40
|
+
types.append(typ) # type: ignore
|
41
|
+
|
42
|
+
if (
|
43
|
+
not removable
|
44
|
+
and len(types) == 1
|
45
|
+
# if the optional type is a complex type,
|
46
|
+
and not is_complex_type(types[0])
|
47
|
+
):
|
48
|
+
return self.factory.build( # coverage: ignore
|
49
|
+
types[0],
|
50
|
+
name=field_name,
|
51
|
+
field=field,
|
52
|
+
value=value,
|
53
|
+
form_errors=form_errors,
|
54
|
+
removable=False,
|
55
|
+
)
|
56
|
+
child = None
|
57
|
+
if value:
|
58
|
+
for typ in types:
|
59
|
+
try:
|
60
|
+
typ(**value)
|
61
|
+
except ValidationError:
|
62
|
+
pass
|
63
|
+
else:
|
64
|
+
child = self.factory.build(
|
65
|
+
typ,
|
66
|
+
name=field_name,
|
67
|
+
field=field,
|
68
|
+
value=value,
|
69
|
+
form_errors=form_errors,
|
70
|
+
removable=False,
|
71
|
+
)
|
72
|
+
|
73
|
+
widget = UnionWidget(
|
74
|
+
field_name,
|
75
|
+
# we assume those types are BaseModel
|
76
|
+
value=child,
|
77
|
+
children_types=types, # type: ignore
|
78
|
+
title=field.title if field else "",
|
79
|
+
hint=field.description if field else None,
|
80
|
+
aria_label=(
|
81
|
+
field.json_schema_extra.get("aria_label") # type:ignore
|
82
|
+
if field and field.json_schema_extra
|
83
|
+
else None
|
84
|
+
),
|
85
|
+
token=self.factory.token,
|
86
|
+
removable=removable,
|
87
|
+
error=form_errors.get(field_name),
|
88
|
+
)
|
89
|
+
|
90
|
+
return widget
|
fastlife/components/Form.jinja
CHANGED
@@ -22,6 +22,15 @@
|
|
22
22
|
] = None,
|
23
23
|
method: Annotated[Literal["get", "post"] | None, "Http method used"] = None,
|
24
24
|
action: Annotated[str | None, "url where the form will be submitted"] = None,
|
25
|
+
hx_target: Annotated[
|
26
|
+
str | None,
|
27
|
+
"target the element for swapping than the one issuing the AJAX request."
|
28
|
+
] = None,
|
29
|
+
hx_select: Annotated[str | None, "select the content swapped from response of the AJAX request."] = None,
|
30
|
+
hx_swap: Annotated[
|
31
|
+
str | None,
|
32
|
+
"specify how the response will be swapped in relative to the target of an AJAX request."
|
33
|
+
] = None,
|
25
34
|
hx_post: Annotated[
|
26
35
|
str | Literal[True] | None,
|
27
36
|
"url where the form will be submitted using htmx. if ``True``, the current url is used."\
|
@@ -40,6 +49,9 @@
|
|
40
49
|
{%- if hx_post is not none %}
|
41
50
|
hx-post="{% if hx_post is not true %}{{hx_post}}{% endif %}"
|
42
51
|
{%- endif %}
|
52
|
+
{%- if hx_select %} hx-select="{{ hx_select }}" {%- endif %}
|
53
|
+
{%- if hx_swap %} hx-swap="{{ hx_swap }}" {%- endif %}
|
54
|
+
{%- if hx_target %} hx-target="{{ hx_target }}" {%- endif %}
|
43
55
|
{%- endif %}
|
44
56
|
{%- if action is not none %} action="{{action}}" {%- endif %}
|
45
57
|
{%- if method %} method="{{method}}" {%- endif -%}
|
fastlife/config/configurator.py
CHANGED
@@ -11,15 +11,13 @@ The configurator is designed to handle the setup during the configuration
|
|
11
11
|
phase.
|
12
12
|
"""
|
13
13
|
|
14
|
-
import importlib
|
15
|
-
import inspect
|
16
14
|
import logging
|
17
15
|
from collections import defaultdict
|
18
16
|
from collections.abc import Callable, Mapping, Sequence
|
19
17
|
from enum import Enum
|
20
18
|
from pathlib import Path
|
21
19
|
from types import ModuleType
|
22
|
-
from typing import TYPE_CHECKING, Annotated, Any, Generic, Self
|
20
|
+
from typing import TYPE_CHECKING, Annotated, Any, Generic, Self, TypeVar
|
23
21
|
|
24
22
|
import venusian
|
25
23
|
from fastapi import Depends, FastAPI, Response
|
@@ -30,12 +28,12 @@ from fastapi.types import IncEx
|
|
30
28
|
|
31
29
|
from fastlife.config.openapiextra import OpenApiTag
|
32
30
|
from fastlife.middlewares.base import AbstractMiddleware
|
33
|
-
from fastlife.request.request import Request
|
31
|
+
from fastlife.request.request import GenericRequest, Request
|
34
32
|
from fastlife.routing.route import Route
|
35
33
|
from fastlife.routing.router import Router
|
36
34
|
from fastlife.security.csrf import check_csrf
|
37
35
|
from fastlife.services.policy import check_permission
|
38
|
-
from fastlife.shared_utils.resolver import resolve
|
36
|
+
from fastlife.shared_utils.resolver import resolve, resolve_maybe_relative
|
39
37
|
|
40
38
|
from .registry import DefaultRegistry, TRegistry
|
41
39
|
from .settings import Settings
|
@@ -238,12 +236,11 @@ class GenericConfigurator(Generic[TRegistry]):
|
|
238
236
|
:param ignore: ignore submodules
|
239
237
|
"""
|
240
238
|
if isinstance(module, str):
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
239
|
+
try:
|
240
|
+
module = resolve_maybe_relative(module, stack_depth=2)
|
241
|
+
except ModuleNotFoundError as exc:
|
242
|
+
raise ConfigurationError(f"Can't resolve {module}") from exc
|
245
243
|
|
246
|
-
module = importlib.import_module(module, package)
|
247
244
|
old, self._route_prefix = self._route_prefix, route_prefix
|
248
245
|
try:
|
249
246
|
self.scanner.scan( # type: ignore
|
@@ -301,7 +298,7 @@ class GenericConfigurator(Generic[TRegistry]):
|
|
301
298
|
self.api_redoc_url = redoc_url
|
302
299
|
return self
|
303
300
|
|
304
|
-
def
|
301
|
+
def add_openapi_tag(self, tag: OpenApiTag) -> Self:
|
305
302
|
"""Register a tag description in the documentation."""
|
306
303
|
if tag.name in self.tags:
|
307
304
|
raise ConfigurationError(f"Tag {tag.name} can't be registered twice.")
|
@@ -538,8 +535,8 @@ class GenericConfigurator(Generic[TRegistry]):
|
|
538
535
|
# class is wrong.
|
539
536
|
# Until we store a security policy per rooter, we rebuild an
|
540
537
|
# incomplete request here.
|
541
|
-
|
542
|
-
resp = handler(
|
538
|
+
req = GenericRequest[DefaultRegistry](self.registry, request)
|
539
|
+
resp = handler(req, exc)
|
543
540
|
if isinstance(resp, Response):
|
544
541
|
return resp
|
545
542
|
|
@@ -551,7 +548,7 @@ class GenericConfigurator(Generic[TRegistry]):
|
|
551
548
|
"did not return a Response"
|
552
549
|
)
|
553
550
|
|
554
|
-
return
|
551
|
+
return req.registry.get_renderer(template)(req).render(
|
555
552
|
template,
|
556
553
|
params=resp,
|
557
554
|
status_code=status_code,
|
@@ -593,8 +590,11 @@ class Configurator(GenericConfigurator[DefaultRegistry]):
|
|
593
590
|
"""
|
594
591
|
|
595
592
|
|
593
|
+
TConfigurator = TypeVar("TConfigurator", bound=GenericConfigurator[Any])
|
594
|
+
|
595
|
+
|
596
596
|
def configure(
|
597
|
-
wrapped: Callable[[
|
597
|
+
wrapped: Callable[[TConfigurator], None],
|
598
598
|
) -> Callable[[Any], None]:
|
599
599
|
"""
|
600
600
|
Decorator used to attach route in a submodule while using the configurator.include.
|
fastlife/config/exceptions.py
CHANGED
fastlife/config/resources.py
CHANGED
@@ -55,7 +55,7 @@ def resource(
|
|
55
55
|
|
56
56
|
Note that there is no abstract class that declare this method, this is done by
|
57
57
|
introspection while returning the configuration method
|
58
|
-
{meth}`fastlife.config.configurator.
|
58
|
+
{meth}`fastlife.config.configurator.GenericConfigurator.include`
|
59
59
|
"""
|
60
60
|
tag = name
|
61
61
|
|
@@ -68,7 +68,7 @@ def resource(
|
|
68
68
|
|
69
69
|
config: Configurator = getattr(scanner, VENUSIAN_CATEGORY)
|
70
70
|
if description:
|
71
|
-
config.
|
71
|
+
config.add_openapi_tag(
|
72
72
|
OpenApiTag(
|
73
73
|
name=tag, description=description, externalDocs=external_docs
|
74
74
|
)
|
fastlife/config/settings.py
CHANGED
@@ -74,6 +74,8 @@ class Settings(BaseSettings):
|
|
74
74
|
)
|
75
75
|
"""
|
76
76
|
Set global constants accessible in every templates.
|
77
|
+
Defaults to `fastlife.templates.constants:Constants`
|
78
|
+
See {class}`fastlife.templates.constants.Constants`
|
77
79
|
"""
|
78
80
|
|
79
81
|
session_secret_key: str = Field(default="")
|
@@ -9,20 +9,19 @@ log = logging.getLogger(__name__)
|
|
9
9
|
|
10
10
|
|
11
11
|
def get_header(headers: Sequence[tuple[bytes, bytes]], key: bytes) -> str | None:
|
12
|
-
for
|
13
|
-
if
|
14
|
-
return
|
12
|
+
for hkey, val in headers:
|
13
|
+
if hkey.lower() == key:
|
14
|
+
return val.decode("latin1")
|
15
15
|
return None
|
16
16
|
|
17
17
|
|
18
18
|
def get_header_int(headers: Sequence[tuple[bytes, bytes]], key: bytes) -> int | None:
|
19
|
-
for
|
20
|
-
if
|
21
|
-
ret = hdr[1].decode("latin1")
|
19
|
+
for hkey, val in headers:
|
20
|
+
if hkey.lower() == key:
|
22
21
|
try:
|
23
|
-
return int(
|
22
|
+
return int(val.decode("latin1"))
|
24
23
|
except ValueError:
|
25
|
-
|
24
|
+
break
|
26
25
|
return None
|
27
26
|
|
28
27
|
|
fastlife/services/policy.py
CHANGED
@@ -13,7 +13,7 @@ def check_permission(permission_name: str) -> CheckPermissionHook:
|
|
13
13
|
|
14
14
|
Adding a permission on the route requires that a security policy has been
|
15
15
|
added using the method
|
16
|
-
{meth}`fastlife.config.configurator.
|
16
|
+
{meth}`fastlife.config.configurator.GenericConfigurator.set_security_policy`
|
17
17
|
|
18
18
|
:param permission_name: a permission name set in a view to check access.
|
19
19
|
:return: a function that raise http exceptions or any configured exception here.
|
@@ -13,15 +13,21 @@ locale_name = str
|
|
13
13
|
|
14
14
|
|
15
15
|
def find_mo_files(root_path: str) -> Iterator[tuple[str, str, pathlib.Path]]:
|
16
|
+
"""
|
17
|
+
Find .mo files in a locales directory.
|
18
|
+
|
19
|
+
:param root_path: locales directory.
|
20
|
+
:return: a tupple containing locale_name, domain, file.
|
21
|
+
"""
|
16
22
|
root = pathlib.Path(root_path)
|
17
23
|
|
18
|
-
# Walk through the directory structure and match the pattern
|
19
24
|
for locale_dir in root.iterdir():
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
+
lc_messages_dir = locale_dir / "LC_MESSAGES"
|
26
|
+
if not (locale_dir.is_dir() and lc_messages_dir.is_dir()):
|
27
|
+
continue
|
28
|
+
|
29
|
+
for mo_file in lc_messages_dir.glob("*.mo"):
|
30
|
+
yield locale_dir.name, mo_file.stem, mo_file
|
25
31
|
|
26
32
|
|
27
33
|
class Localizer:
|
@@ -1,8 +1,9 @@
|
|
1
1
|
"""Resolution of python objects for dependency injection and more."""
|
2
2
|
|
3
3
|
import importlib.util
|
4
|
+
import inspect
|
4
5
|
from pathlib import Path
|
5
|
-
from types import UnionType
|
6
|
+
from types import ModuleType, UnionType
|
6
7
|
from typing import Any, Union
|
7
8
|
|
8
9
|
|
@@ -51,3 +52,59 @@ def resolve_path(value: str) -> str:
|
|
51
52
|
package_path = spec.origin
|
52
53
|
full_path = Path(package_path).parent / resource_name
|
53
54
|
return str(full_path)
|
55
|
+
|
56
|
+
|
57
|
+
def resolve_package(mod: ModuleType) -> ModuleType:
|
58
|
+
"""
|
59
|
+
Return the
|
60
|
+
[regular package](https://docs.python.org/3/glossary.html#term-regular-package)
|
61
|
+
of a module or itself if it is the ini file of a package.
|
62
|
+
|
63
|
+
"""
|
64
|
+
|
65
|
+
# Compiled package has no __file__ attribute, ModuleType declare it as NoneType
|
66
|
+
if not hasattr(mod, "__file__") or mod.__file__ is None:
|
67
|
+
return mod
|
68
|
+
|
69
|
+
module_path = Path(mod.__file__)
|
70
|
+
if module_path.name == "__init__.py":
|
71
|
+
return mod
|
72
|
+
|
73
|
+
parent_module_name = mod.__name__.rsplit(".", 1)[0]
|
74
|
+
parent_module = importlib.import_module(parent_module_name)
|
75
|
+
return parent_module
|
76
|
+
|
77
|
+
|
78
|
+
def _strip_left_dots(s: str) -> tuple[str, int]:
|
79
|
+
stripped_string = s.lstrip(".")
|
80
|
+
num_stripped_dots = len(s) - len(stripped_string)
|
81
|
+
return stripped_string, num_stripped_dots - 1
|
82
|
+
|
83
|
+
|
84
|
+
def _get_parent(pkg: str, num_parents: int) -> str:
|
85
|
+
if num_parents == 0:
|
86
|
+
return pkg
|
87
|
+
segments = pkg.split(".")
|
88
|
+
return ".".join(segments[:-num_parents])
|
89
|
+
|
90
|
+
|
91
|
+
def resolve_maybe_relative(mod: str, stack_depth: int = 1) -> ModuleType:
|
92
|
+
"""
|
93
|
+
Resolve a module, maybe relative to the stack frame and import it.
|
94
|
+
|
95
|
+
:param mod: the module to import. starts with a dot if it is relative.
|
96
|
+
:param stack_depth: relative to which module in the stack.
|
97
|
+
used to do an api that call it instead of resolve the module directly.
|
98
|
+
:return: the imported module
|
99
|
+
"""
|
100
|
+
if mod.startswith("."):
|
101
|
+
caller_module = inspect.getmodule(inspect.stack()[stack_depth][0])
|
102
|
+
# we could do an assert here but caller_module could really be none ?
|
103
|
+
parent_module = resolve_package(caller_module) # type: ignore
|
104
|
+
package = parent_module.__name__
|
105
|
+
mod, count = _strip_left_dots(mod)
|
106
|
+
package = _get_parent(package, count)
|
107
|
+
mod = f"{package}.{mod}".rstrip(".")
|
108
|
+
|
109
|
+
module = importlib.import_module(mod)
|
110
|
+
return module
|
fastlife/testing/dom.py
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
"""Class utilities to access to the DOM."""
|
2
|
+
|
3
|
+
import re
|
4
|
+
from collections.abc import Iterator, Sequence
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
import bs4
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
from .testclient import WebResponse, WebTestClient # coverage: ignore
|
11
|
+
|
12
|
+
|
13
|
+
class Element:
|
14
|
+
"""Access to a dom element."""
|
15
|
+
|
16
|
+
def __init__(self, client: "WebTestClient", tag: bs4.Tag):
|
17
|
+
self._client = client
|
18
|
+
self._tag = tag
|
19
|
+
|
20
|
+
def click(self) -> "WebResponse":
|
21
|
+
"""Simulate a client to a a link. No javascript exectuted here."""
|
22
|
+
return self._client.get(self._tag.attrs["href"])
|
23
|
+
|
24
|
+
@property
|
25
|
+
def node_name(self) -> str:
|
26
|
+
"""Get the node name of the dom element."""
|
27
|
+
return self._tag.name
|
28
|
+
|
29
|
+
@property
|
30
|
+
def attrs(self) -> dict[str, str]:
|
31
|
+
"""Attributes of the element."""
|
32
|
+
return self._tag.attrs
|
33
|
+
|
34
|
+
@property
|
35
|
+
def text(self) -> str:
|
36
|
+
"""
|
37
|
+
Return the text of the element, with text of childs element.
|
38
|
+
|
39
|
+
Note that the text is stripped for convenience but inner text may contains
|
40
|
+
many spaces not manipulated here.
|
41
|
+
"""
|
42
|
+
return self._tag.text.strip()
|
43
|
+
|
44
|
+
@property
|
45
|
+
def h1(self) -> "Element":
|
46
|
+
"""
|
47
|
+
Return the h1 child element.
|
48
|
+
|
49
|
+
Should be used on the html body element directly.
|
50
|
+
"""
|
51
|
+
nodes = self.by_node_name("h1")
|
52
|
+
assert len(nodes) == 1, f"Should have 1 <h1>, got {len(nodes)} in {self}"
|
53
|
+
return nodes[0]
|
54
|
+
|
55
|
+
@property
|
56
|
+
def h2(self) -> Sequence["Element"]:
|
57
|
+
"""
|
58
|
+
Return the h2 elements.
|
59
|
+
"""
|
60
|
+
return self.by_node_name("h2")
|
61
|
+
|
62
|
+
@property
|
63
|
+
def form(self) -> "Element | None":
|
64
|
+
"""Get the form element of the web page."""
|
65
|
+
return Element(self._client, self._tag.form) if self._tag.form else None
|
66
|
+
|
67
|
+
@property
|
68
|
+
def hx_target(self) -> str | None:
|
69
|
+
"""
|
70
|
+
Return the hx-target of the element.
|
71
|
+
|
72
|
+
It may be set on a parent. It also resolve special case "this" and return the id
|
73
|
+
of the element.
|
74
|
+
"""
|
75
|
+
el: bs4.Tag | None = self._tag
|
76
|
+
while el:
|
77
|
+
if "hx-target" in el.attrs:
|
78
|
+
ret = el.attrs["hx-target"]
|
79
|
+
if ret == "this":
|
80
|
+
ret = el.attrs["id"]
|
81
|
+
return ret
|
82
|
+
el = el.parent
|
83
|
+
return None
|
84
|
+
|
85
|
+
def by_text(self, text: str, *, node_name: str | None = None) -> "Element | None":
|
86
|
+
"""Find the first element that match the text."""
|
87
|
+
nodes = self.iter_all_by_text(text, node_name=node_name)
|
88
|
+
return next(nodes, None)
|
89
|
+
|
90
|
+
def iter_all_by_text(
|
91
|
+
self, text: str, *, node_name: str | None = None
|
92
|
+
) -> "Iterator[Element]":
|
93
|
+
"""Return an iterator of all elements that match the text."""
|
94
|
+
nodes = self._tag.find_all(string=re.compile(rf"\s*{text}\s*"))
|
95
|
+
for node in nodes:
|
96
|
+
if isinstance(node, bs4.NavigableString):
|
97
|
+
node = node.parent
|
98
|
+
|
99
|
+
if node_name:
|
100
|
+
while node is not None:
|
101
|
+
if node.name == node_name:
|
102
|
+
yield Element(self._client, node)
|
103
|
+
node = node.parent
|
104
|
+
elif node:
|
105
|
+
yield Element(self._client, node)
|
106
|
+
return None
|
107
|
+
|
108
|
+
def get_all_by_text(
|
109
|
+
self, text: str, *, node_name: str | None = None
|
110
|
+
) -> "Sequence[Element]":
|
111
|
+
"""Return the list of all elements that match the text."""
|
112
|
+
nodes = self.iter_all_by_text(text, node_name=node_name)
|
113
|
+
return list(nodes)
|
114
|
+
|
115
|
+
def by_label_text(self, text: str) -> "Element | None":
|
116
|
+
"""Return the element which is the target of the label having the given text."""
|
117
|
+
label = self.by_text(text, node_name="label")
|
118
|
+
assert label is not None
|
119
|
+
assert label.attrs.get("for") is not None
|
120
|
+
resp = self._tag.find(id=label.attrs["for"])
|
121
|
+
assert not isinstance(resp, bs4.NavigableString)
|
122
|
+
return Element(self._client, resp) if resp else None
|
123
|
+
|
124
|
+
def by_node_name(
|
125
|
+
self, node_name: str, *, attrs: dict[str, str] | None = None
|
126
|
+
) -> list["Element"]:
|
127
|
+
"""
|
128
|
+
Return the list of elements with the given node_name.
|
129
|
+
|
130
|
+
An optional set of attributes may given and must match if passed.
|
131
|
+
"""
|
132
|
+
return [
|
133
|
+
Element(self._client, e) for e in self._tag.find_all(node_name, attrs or {})
|
134
|
+
]
|
135
|
+
|
136
|
+
def __repr__(self) -> str:
|
137
|
+
return f"<{self.node_name}>"
|
138
|
+
|
139
|
+
def __str__(self) -> str:
|
140
|
+
return str(self._tag)
|