fastlifeweb 0.16.3__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 (56) hide show
  1. fastlife/adapters/jinjax/renderer.py +49 -25
  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/base.py +6 -4
  16. fastlife/adapters/jinjax/widgets/checklist.py +1 -1
  17. fastlife/adapters/jinjax/widgets/dropdown.py +7 -7
  18. fastlife/adapters/jinjax/widgets/hidden.py +2 -0
  19. fastlife/adapters/jinjax/widgets/model.py +4 -1
  20. fastlife/adapters/jinjax/widgets/sequence.py +3 -2
  21. fastlife/adapters/jinjax/widgets/text.py +9 -10
  22. fastlife/adapters/jinjax/widgets/union.py +9 -7
  23. fastlife/components/Form.jinja +12 -0
  24. fastlife/config/configurator.py +23 -24
  25. fastlife/config/exceptions.py +4 -1
  26. fastlife/config/openapiextra.py +1 -0
  27. fastlife/config/resources.py +26 -27
  28. fastlife/config/settings.py +2 -0
  29. fastlife/config/views.py +3 -1
  30. fastlife/middlewares/reverse_proxy/x_forwarded.py +22 -15
  31. fastlife/middlewares/session/middleware.py +2 -2
  32. fastlife/middlewares/session/serializer.py +6 -5
  33. fastlife/request/form.py +7 -6
  34. fastlife/request/form_data.py +2 -6
  35. fastlife/routing/route.py +3 -1
  36. fastlife/routing/router.py +1 -0
  37. fastlife/security/csrf.py +2 -1
  38. fastlife/security/policy.py +2 -1
  39. fastlife/services/locale_negociator.py +2 -1
  40. fastlife/services/policy.py +3 -2
  41. fastlife/services/templates.py +2 -1
  42. fastlife/services/translations.py +15 -8
  43. fastlife/shared_utils/infer.py +4 -3
  44. fastlife/shared_utils/resolver.py +64 -4
  45. fastlife/templates/binding.py +2 -1
  46. fastlife/testing/__init__.py +1 -0
  47. fastlife/testing/dom.py +140 -0
  48. fastlife/testing/form.py +204 -0
  49. fastlife/testing/session.py +67 -0
  50. fastlife/testing/testclient.py +7 -390
  51. fastlife/views/pydantic_form.py +4 -4
  52. {fastlifeweb-0.16.3.dist-info → fastlifeweb-0.17.0.dist-info}/METADATA +6 -6
  53. {fastlifeweb-0.16.3.dist-info → fastlifeweb-0.17.0.dist-info}/RECORD +55 -40
  54. fastlife/adapters/jinjax/widgets/factory.py +0 -525
  55. {fastlifeweb-0.16.3.dist-info → fastlifeweb-0.17.0.dist-info}/LICENSE +0 -0
  56. {fastlifeweb-0.16.3.dist-info → fastlifeweb-0.17.0.dist-info}/WHEEL +0 -0
@@ -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)
@@ -0,0 +1,204 @@
1
+ """Class utilities to access to the web form."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from multidict import MultiDict
6
+
7
+ if TYPE_CHECKING:
8
+ from .dom import Element # coverage: ignore
9
+ from .testclient import WebResponse, WebTestClient # coverage: ignore
10
+
11
+
12
+ class WebForm:
13
+ """
14
+ Handle html form.
15
+
16
+ Form are filled out and submit with methods and try to avoid invalid
17
+ usage, such as selecting an option that don't exists is not possible here.
18
+ Again, no javascript is executed here, but htmx attribute `hx-post` and `hx-target`
19
+ are read while submiting to simulate it.
20
+ """
21
+
22
+ def __init__(self, client: "WebTestClient", origin: str, form: "Element"):
23
+ self._client = client
24
+ self._form = form
25
+ self._origin = origin
26
+ self._formfields: dict[str, Element] = {}
27
+ self._formdata: MultiDict[str] = MultiDict()
28
+ inputs = self._form.by_node_name("input")
29
+ for input in inputs:
30
+ self._formfields[input.attrs["name"]] = input
31
+ if input.attrs.get("type") == "checkbox" and "checked" not in input.attrs:
32
+ continue
33
+ self._formdata.add(input.attrs["name"], input.attrs.get("value", ""))
34
+
35
+ selects = self._form.by_node_name("select")
36
+ for select in selects:
37
+ fieldname = select.attrs["name"]
38
+ self._formfields[fieldname] = select
39
+ options = select.by_node_name("option")
40
+ if "multiple" in select.attrs:
41
+ for option in options:
42
+ if "selected" in option.attrs:
43
+ self._formdata.add(
44
+ fieldname, option.attrs.get("value", option.text)
45
+ )
46
+ else:
47
+ if options:
48
+ self._formdata[fieldname] = options[0].attrs.get(
49
+ "value", options[0].text
50
+ )
51
+ for option in options:
52
+ if "selected" in option.attrs:
53
+ self._formdata[fieldname] = option.attrs.get(
54
+ "value", option.text
55
+ )
56
+ break
57
+
58
+ # field textearea...
59
+
60
+ def set(self, fieldname: str, value: str) -> Any:
61
+ """
62
+ Set a value to an input field.
63
+
64
+ It works for checkbox and radio as well.
65
+ Checkbox may contains many values.
66
+ Options of select can't be set with this method, the select method must
67
+ be used instead.
68
+ """
69
+ if fieldname not in self._formfields:
70
+ raise ValueError(f'"{fieldname}" does not exists')
71
+ if self._formfields[fieldname].node_name == "select":
72
+ raise ValueError(f'"{fieldname}" is a <select>, use select() instead')
73
+
74
+ if self._formfields[fieldname].attrs.get("type") == "checkbox":
75
+ self._formdata.add(fieldname, value)
76
+ return
77
+
78
+ if self._formfields[fieldname].attrs.get("type") == "radio":
79
+ radio = self._form.by_node_name(
80
+ "input", attrs={"type": "radio", "value": value}
81
+ )
82
+ if not radio:
83
+ raise ValueError(
84
+ f'radio "{fieldname}" does not contains {value} option'
85
+ )
86
+
87
+ self._formdata[fieldname] = value
88
+
89
+ def unset(self, fieldname: str, value: str) -> Any:
90
+ """Unset an element. Only works with checkbox."""
91
+ if fieldname not in self._formfields:
92
+ raise ValueError(f'"{fieldname}" does not exists')
93
+ if self._formfields[fieldname].node_name != "input":
94
+ raise ValueError(f'"{fieldname}" is not a checkbox')
95
+ if self._formfields[fieldname].attrs.get("type") != "checkbox":
96
+ raise ValueError(f'"{fieldname}" is not a checkbox')
97
+ values = self._formdata.popall(fieldname)
98
+ if value not in values:
99
+ raise ValueError(f'"{value}" not in "{fieldname}"')
100
+ for val in values:
101
+ if val != value:
102
+ self._formdata[fieldname] = val
103
+
104
+ def select(self, fieldname: str, value: str) -> Any:
105
+ """
106
+ Select an option, if multiple, value is added, otherwise, value is replaced.
107
+ """
108
+ if fieldname not in self._formfields:
109
+ raise ValueError(f'"{fieldname}" does not exists')
110
+ field = self._formfields[fieldname]
111
+ if field.node_name != "select":
112
+ raise ValueError(f"{fieldname} is a {field!r}, " "use set() instead")
113
+
114
+ for option in field.by_node_name("option"):
115
+ if option.text == value.strip():
116
+ if "multiple" in field.attrs:
117
+ self._formdata.add(fieldname, value)
118
+ else:
119
+ self._formdata[fieldname] = option.attrs.get("value", option.text)
120
+ break
121
+ else:
122
+ raise ValueError(f'No option {value} in <select name="{fieldname}">')
123
+
124
+ def unselect(self, fieldname: str, value: str) -> Any:
125
+ """
126
+ Unselect an option if multiple, otherwise an exception is raised.
127
+ """
128
+ if fieldname not in self._formfields:
129
+ raise ValueError(f'"{fieldname}" does not exists')
130
+ field = self._formfields[fieldname]
131
+
132
+ if field.node_name != "select":
133
+ raise ValueError(
134
+ f"{fieldname} is a {self._formfields[fieldname]!r}, "
135
+ "use unset() for checkbox instead"
136
+ )
137
+ if "multiple" not in field.attrs:
138
+ raise ValueError("only <select multiple> support unselect")
139
+
140
+ for option in self._formfields[fieldname].by_node_name("option"):
141
+ if option.text == value.strip():
142
+ values = self._formdata.popall(fieldname)
143
+ if value not in values:
144
+ raise ValueError(f'"{value}" not selected in "{fieldname}"')
145
+ for val in values:
146
+ if val != value:
147
+ self._formdata[fieldname] = val
148
+ break
149
+ else:
150
+ raise ValueError(f'No option {value} in <select name="{fieldname}">')
151
+
152
+ def button(self, text: str, position: int = 0) -> "WebForm":
153
+ """
154
+ Simmulate a click on a button using the text of the button,
155
+
156
+ and eventually a position. The button return the form and the submit()
157
+ should be called directly.
158
+
159
+ This is used in order to inject the value of the button in the form, usually
160
+ done while many actions are available on a form.
161
+
162
+ ::
163
+
164
+ form.button("Go").submit()
165
+
166
+ """
167
+ buttons = self._form.get_all_by_text(text, node_name="button")
168
+ if position >= len(buttons):
169
+ pos = ""
170
+ if position > 0:
171
+ pos = f" at position {position}"
172
+ raise ValueError(f'Button "{text}" not found{pos}')
173
+ button = buttons[position]
174
+ if "name" in button.attrs:
175
+ self._formdata[button.attrs["name"]] = button.attrs.get("value", "")
176
+ return self
177
+
178
+ def submit(self, follow_redirects: bool = True) -> "WebResponse":
179
+ """
180
+ Submit the form as it has been previously filled out.
181
+ """
182
+ headers: dict[str, str] = {}
183
+ target = (
184
+ self._form.attrs.get("hx-post")
185
+ or self._form.attrs.get("post")
186
+ or self._origin
187
+ )
188
+ if "hx-post" in self._form.attrs:
189
+ if hx_target := self._form.hx_target:
190
+ headers["HX-Target"] = hx_target
191
+
192
+ return self._client.post(
193
+ target,
194
+ data=self._formdata,
195
+ headers=headers,
196
+ follow_redirects=follow_redirects,
197
+ )
198
+
199
+ def __contains__(self, key: str) -> bool:
200
+ """Test if a field exists in the form."""
201
+ return key in self._formdata
202
+
203
+ def __repr__(self) -> str:
204
+ return repr(self._formdata)
@@ -0,0 +1,67 @@
1
+ """Session store for the webtest client."""
2
+
3
+ import time
4
+ from collections.abc import Mapping
5
+ from http.cookiejar import Cookie
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+
10
+ if TYPE_CHECKING:
11
+ from .testclient import WebTestClient # coverage: ignore
12
+
13
+
14
+ CookieTypes = httpx._types.CookieTypes # type: ignore
15
+ Cookies = httpx._models.Cookies # type: ignore
16
+
17
+
18
+ class Session(dict[str, Any]):
19
+ """Manipulate the session of the WebTestClient browser."""
20
+
21
+ def __init__(self, client: "WebTestClient"):
22
+ self.client = client
23
+ self.srlz = client.session_serializer
24
+ self.settings = self.client.settings
25
+ data: Mapping[str, Any]
26
+ cookie_name = self.settings.session_cookie_name
27
+ self.has_session = cookie_name in self.client.cookies
28
+ if self.has_session:
29
+ data, error = self.srlz.deserialize(
30
+ self.client.cookies[cookie_name].encode("utf-8")
31
+ )
32
+ if error:
33
+ self.has_session = False
34
+ else:
35
+ data = {}
36
+ super().__init__(data)
37
+
38
+ def __setitem__(self, __key: Any, __value: Any) -> None:
39
+ """Initialize a value in the session of the client in order to test."""
40
+ super().__setitem__(__key, __value)
41
+ settings = self.settings
42
+ data = self.serialize()
43
+ self.client.cookies.jar.set_cookie(
44
+ Cookie(
45
+ version=0,
46
+ name=settings.session_cookie_name,
47
+ value=data,
48
+ port=None,
49
+ port_specified=False,
50
+ domain=f".{settings.session_cookie_domain}",
51
+ domain_specified=True,
52
+ domain_initial_dot=True,
53
+ path="/",
54
+ path_specified=True,
55
+ secure=False,
56
+ expires=int(time.time() + settings.session_duration.total_seconds()),
57
+ discard=False,
58
+ comment=None,
59
+ comment_url=None,
60
+ rest={"HttpOnly": None, "SameSite": "lax"}, # type: ignore
61
+ rfc2109=False,
62
+ )
63
+ )
64
+
65
+ def serialize(self) -> str:
66
+ """Serialize the session"""
67
+ return self.srlz.serialize(self).decode("utf-8")