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.
Files changed (34) hide show
  1. fastlife/adapters/jinjax/renderer.py +44 -15
  2. fastlife/adapters/jinjax/widget_factory/__init__.py +1 -0
  3. fastlife/adapters/jinjax/widget_factory/base.py +38 -0
  4. fastlife/adapters/jinjax/widget_factory/bool_builder.py +43 -0
  5. fastlife/adapters/jinjax/widget_factory/emailstr_builder.py +46 -0
  6. fastlife/adapters/jinjax/widget_factory/enum_builder.py +47 -0
  7. fastlife/adapters/jinjax/widget_factory/factory.py +165 -0
  8. fastlife/adapters/jinjax/widget_factory/literal_builder.py +52 -0
  9. fastlife/adapters/jinjax/widget_factory/model_builder.py +64 -0
  10. fastlife/adapters/jinjax/widget_factory/secretstr_builder.py +47 -0
  11. fastlife/adapters/jinjax/widget_factory/sequence_builder.py +58 -0
  12. fastlife/adapters/jinjax/widget_factory/set_builder.py +80 -0
  13. fastlife/adapters/jinjax/widget_factory/simpletype_builder.py +47 -0
  14. fastlife/adapters/jinjax/widget_factory/union_builder.py +90 -0
  15. fastlife/adapters/jinjax/widgets/hidden.py +2 -0
  16. fastlife/adapters/jinjax/widgets/model.py +2 -0
  17. fastlife/components/Form.jinja +12 -0
  18. fastlife/config/configurator.py +15 -15
  19. fastlife/config/exceptions.py +2 -0
  20. fastlife/config/resources.py +2 -2
  21. fastlife/config/settings.py +2 -0
  22. fastlife/middlewares/reverse_proxy/x_forwarded.py +7 -8
  23. fastlife/services/policy.py +1 -1
  24. fastlife/services/translations.py +12 -6
  25. fastlife/shared_utils/resolver.py +58 -1
  26. fastlife/testing/dom.py +140 -0
  27. fastlife/testing/form.py +204 -0
  28. fastlife/testing/session.py +67 -0
  29. fastlife/testing/testclient.py +4 -387
  30. {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/METADATA +6 -6
  31. {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/RECORD +33 -18
  32. fastlife/adapters/jinjax/widgets/factory.py +0 -525
  33. {fastlifeweb-0.16.4.dist-info → fastlifeweb-0.17.0.dist-info}/LICENSE +0 -0
  34. {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
@@ -1,3 +1,5 @@
1
+ """Hidden fields"""
2
+
1
3
  from typing import Any
2
4
 
3
5
  from .base import Widget
@@ -1,3 +1,5 @@
1
+ """Pydantic models"""
2
+
1
3
  from collections.abc import Sequence
2
4
  from typing import Any
3
5
 
@@ -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 -%}
@@ -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
- package = None
242
- if module.startswith("."):
243
- caller_module = inspect.getmodule(inspect.stack()[1][0])
244
- package = caller_module.__name__ if caller_module else "__main__"
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 add_open_tag(self, tag: OpenApiTag) -> Self:
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
- request = Request(self.registry, request)
542
- resp = handler(request, exc)
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 request.registry.get_renderer(template)(request).render(
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[[Configurator], None],
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.
@@ -1,3 +1,5 @@
1
+ """Customize error pages."""
2
+
1
3
  from collections.abc import Callable
2
4
  from typing import Any
3
5
 
@@ -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.Configurator.include`
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.add_open_tag(
71
+ config.add_openapi_tag(
72
72
  OpenApiTag(
73
73
  name=tag, description=description, externalDocs=external_docs
74
74
  )
@@ -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 hdr in headers:
13
- if hdr[0].lower() == key:
14
- return hdr[1].decode("latin1")
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 hdr in headers:
20
- if hdr[0].lower() == key:
21
- ret = hdr[1].decode("latin1")
19
+ for hkey, val in headers:
20
+ if hkey.lower() == key:
22
21
  try:
23
- return int(ret)
22
+ return int(val.decode("latin1"))
24
23
  except ValueError:
25
- pass
24
+ break
26
25
  return None
27
26
 
28
27
 
@@ -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.Configurator.set_security_policy`
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
- if locale_dir.is_dir(): # Ensure it's a directory (locale)
21
- lc_messages_dir = locale_dir / "LC_MESSAGES"
22
- if lc_messages_dir.exists() and lc_messages_dir.is_dir():
23
- for mo_file in lc_messages_dir.glob("*.mo"): # Find .mo files
24
- yield locale_dir.name, mo_file.name[:-3], mo_file
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
@@ -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)