cadwyn 5.4.6__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.
File without changes
@@ -0,0 +1,136 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>OpenAPI Contracts</title>
8
+ <meta name="robots" content="noindex">
9
+
10
+ <!-- Favicon -->
11
+ <link rel="shortcut icon" type="image/x-icon"
12
+ href="https://cpwebassets.codepen.io/assets/favicon/favicon-aec34940fbc1a6e787974dcd360f2c6b63348d4b1f4e06c77743096d55480f33.ico">
13
+ <link rel="mask-icon"
14
+ href="https://cpwebassets.codepen.io/assets/favicon/logo-pin-8f3771b1072e3c38bd662872f6b673a722f4b3ca2421637d5596661b4e2132cc.svg"
15
+ color="#111">
16
+ <link rel="canonical" href="https://codepen.io/faaezahmd/pen/dJeRex">
17
+
18
+ <!-- Fonts -->
19
+ <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet">
20
+
21
+ <!-- Reset CSS -->
22
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
23
+
24
+ <!-- Custom Styles -->
25
+ <style>
26
+ body {
27
+ font-family: "Lato", sans-serif;
28
+ background-color: #f4f4f4;
29
+ color: #333;
30
+ margin: 0;
31
+ padding: 0;
32
+ }
33
+
34
+ .container {
35
+ max-width: 800px;
36
+ margin: 50px auto;
37
+ padding: 20px;
38
+ background-color: #fff;
39
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
40
+ border-radius: 5px;
41
+ }
42
+
43
+ h2 {
44
+ font-size: 32px;
45
+ margin: 0 0 20px;
46
+ text-align: center;
47
+ color: #3498db;
48
+ }
49
+
50
+ h2 small {
51
+ display: block;
52
+ font-size: 14px;
53
+ color: #555;
54
+ }
55
+
56
+ .responsive-table li {
57
+ border-radius: 5px;
58
+ padding: 20px;
59
+ display: flex;
60
+ justify-content: space-between;
61
+ margin-bottom: 20px;
62
+ background-color: #ecf0f1;
63
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
64
+ }
65
+
66
+ .responsive-table .table-header {
67
+ background-color: #3498db;
68
+ font-size: 16px;
69
+ text-transform: uppercase;
70
+ letter-spacing: 0.03em;
71
+ color: #fff;
72
+ }
73
+
74
+ .responsive-table .col-1,
75
+ .responsive-table .col-2 {
76
+ flex-basis: 50%;
77
+ text-align: left;
78
+ }
79
+
80
+ .responsive-table .col-1 {
81
+ font-weight: bold;
82
+ }
83
+
84
+ .responsive-table .col-2 a {
85
+ color: black;
86
+ /* Задаем цвет текста черным */
87
+ text-decoration: none;
88
+ font-weight: bold;
89
+ }
90
+
91
+ @media all and (max-width: 767px) {
92
+ .responsive-table .table-header {
93
+ display: none;
94
+ }
95
+
96
+ .responsive-table li {
97
+ display: block;
98
+ }
99
+
100
+ .responsive-table .col {
101
+ flex-basis: 100%;
102
+ display: flex;
103
+ padding: 10px 0;
104
+ }
105
+
106
+ .responsive-table .col:before {
107
+ color: #6C7A89;
108
+ padding-right: 10px;
109
+ content: attr(data-label);
110
+ flex-basis: 50%;
111
+ text-align: right;
112
+ }
113
+ }
114
+
115
+ </style>
116
+ </head>
117
+
118
+ <body>
119
+ <div class="container">
120
+ <h2>OpenAPI Contracts</h2>
121
+ <ul class="responsive-table">
122
+ <li class="table-header">
123
+ <div class="col col-1">Version</div>
124
+ <div class="col col-2">URL</div>
125
+ </li>
126
+ {% for name, url in table.items() %}
127
+ <li class="table-row">
128
+ <div class="col col-1">{{ name }}</div>
129
+ <div class="col col-2"><a href="{{ url }}">{{ url }}</a></div>
130
+ </li>
131
+ {% endfor %}
132
+ </ul>
133
+ </div>
134
+ </body>
135
+
136
+ </html>
@@ -0,0 +1,31 @@
1
+ from .data import (
2
+ RequestInfo,
3
+ ResponseInfo,
4
+ convert_request_to_next_version_for,
5
+ convert_response_to_previous_version_for,
6
+ )
7
+ from .endpoints import endpoint
8
+ from .enums import enum
9
+ from .schemas import schema
10
+ from .versions import (
11
+ HeadVersion,
12
+ Version,
13
+ VersionBundle,
14
+ VersionChange,
15
+ VersionChangeWithSideEffects,
16
+ )
17
+
18
+ __all__ = [
19
+ "HeadVersion",
20
+ "RequestInfo",
21
+ "ResponseInfo",
22
+ "Version",
23
+ "VersionBundle",
24
+ "VersionChange",
25
+ "VersionChangeWithSideEffects",
26
+ "convert_request_to_next_version_for",
27
+ "convert_response_to_previous_version_for",
28
+ "endpoint",
29
+ "enum",
30
+ "schema",
31
+ ]
@@ -0,0 +1,18 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+
4
+ from pydantic import BaseModel
5
+ from typing_extensions import ParamSpec, TypeAlias, TypeVar
6
+
7
+ from cadwyn._utils import DATACLASS_KW_ONLY, DATACLASS_SLOTS
8
+
9
+ VersionedModel = BaseModel
10
+ VersionType: TypeAlias = str
11
+ _P = ParamSpec("_P")
12
+ _R = TypeVar("_R")
13
+ Endpoint: TypeAlias = Callable[_P, _R]
14
+
15
+
16
+ @dataclass(**DATACLASS_SLOTS, **DATACLASS_KW_ONLY)
17
+ class _HiddenAttributeMixin:
18
+ is_hidden_from_changelog: bool
@@ -0,0 +1,249 @@
1
+ import functools
2
+ import inspect
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import ClassVar, Union, cast
6
+
7
+ from fastapi import Request, Response
8
+ from starlette.datastructures import MutableHeaders
9
+ from typing_extensions import Any, ParamSpec, overload
10
+
11
+ from cadwyn._utils import same_definition_as_in
12
+ from cadwyn.structure.endpoints import _validate_that_strings_are_valid_http_methods
13
+
14
+ _P = ParamSpec("_P")
15
+
16
+
17
+ # TODO (https://github.com/zmievsa/cadwyn/issues/49): Add form handling
18
+ class RequestInfo:
19
+ __slots__ = ("_cookies", "_query_params", "_request", "body", "headers")
20
+
21
+ def __init__(self, request: Request, body: Any):
22
+ super().__init__()
23
+ self.body = body
24
+ self.headers = request.headers.mutablecopy()
25
+ self._cookies = request.cookies
26
+ self._query_params = request.query_params._dict
27
+ self._request = request
28
+
29
+ @property
30
+ def cookies(self) -> dict[str, str]:
31
+ return self._cookies
32
+
33
+ @property
34
+ def query_params(self) -> dict[str, str]:
35
+ return self._query_params
36
+
37
+
38
+ # TODO (https://github.com/zmievsa/cadwyn/issues/111): handle _response.media_type and _response.background
39
+ class ResponseInfo:
40
+ __slots__ = ("_response", "body")
41
+
42
+ def __init__(self, response: Response, body: Any):
43
+ super().__init__()
44
+ self.body = body
45
+ self._response = response
46
+
47
+ @property
48
+ def status_code(self) -> int:
49
+ return self._response.status_code
50
+
51
+ @status_code.setter
52
+ def status_code(self, value: int):
53
+ self._response.status_code = value
54
+
55
+ @property
56
+ def headers(self) -> MutableHeaders:
57
+ return self._response.headers
58
+
59
+ @same_definition_as_in(Response.set_cookie)
60
+ def set_cookie(self, *args: Any, **kwargs: Any):
61
+ return self._response.set_cookie(*args, **kwargs)
62
+
63
+ @same_definition_as_in(Response.delete_cookie)
64
+ def delete_cookie(self, *args: Any, **kwargs: Any):
65
+ return self._response.delete_cookie(*args, **kwargs)
66
+
67
+
68
+ @dataclass
69
+ class _AlterDataInstruction:
70
+ transformer: Callable[[Any], None]
71
+ owner: type = field(init=False)
72
+ _payload_arg_name: ClassVar[str]
73
+
74
+ def __post_init__(self):
75
+ signature = inspect.signature(self.transformer)
76
+ if list(signature.parameters) != [self._payload_arg_name]:
77
+ raise ValueError(
78
+ f"Method '{self.transformer.__name__}' must have only 1 parameter: {self._payload_arg_name}",
79
+ )
80
+
81
+ functools.update_wrapper(self, self.transformer)
82
+
83
+ def __set_name__(self, owner: type, name: str):
84
+ self.owner = owner
85
+
86
+ def __call__(self, __request_or_response: Union[RequestInfo, ResponseInfo], /) -> None:
87
+ return self.transformer(__request_or_response)
88
+
89
+
90
+ @dataclass
91
+ class _BaseAlterBySchemaInstruction:
92
+ schemas: tuple[Any, ...]
93
+ check_usage: bool = True
94
+
95
+
96
+ ##########
97
+ # Requests
98
+ ##########
99
+
100
+
101
+ @dataclass
102
+ class _BaseAlterRequestInstruction(_AlterDataInstruction):
103
+ _payload_arg_name = "request"
104
+
105
+
106
+ @dataclass
107
+ class _AlterRequestBySchemaInstruction(_BaseAlterBySchemaInstruction, _BaseAlterRequestInstruction): ...
108
+
109
+
110
+ @dataclass
111
+ class _AlterRequestByPathInstruction(_BaseAlterRequestInstruction):
112
+ path: str
113
+ methods: set[str]
114
+ repr_name = "Request by path converter"
115
+
116
+
117
+ @overload
118
+ def convert_request_to_next_version_for(
119
+ first_schema: type,
120
+ /,
121
+ *additional_schemas: type,
122
+ check_usage: bool = True,
123
+ ) -> "type[staticmethod[_P, None]]": ...
124
+
125
+
126
+ @overload
127
+ def convert_request_to_next_version_for(path: str, methods: list[str], /) -> "type[staticmethod[_P, None]]": ...
128
+
129
+
130
+ def convert_request_to_next_version_for(
131
+ schema_or_path: Union[type, str],
132
+ methods_or_second_schema: Union[list[str], None, type] = None,
133
+ /,
134
+ *additional_schemas: type,
135
+ check_usage: bool = True,
136
+ ) -> "type[staticmethod[_P, None]]":
137
+ _validate_decorator_args(schema_or_path, methods_or_second_schema, additional_schemas)
138
+
139
+ def decorator(transformer: Callable[[RequestInfo], None]) -> Any:
140
+ if isinstance(schema_or_path, str):
141
+ return _AlterRequestByPathInstruction(
142
+ path=schema_or_path,
143
+ methods=set(cast("list", methods_or_second_schema)),
144
+ transformer=transformer,
145
+ )
146
+ else:
147
+ if methods_or_second_schema is None:
148
+ schemas = (schema_or_path,)
149
+ else:
150
+ schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
151
+ return _AlterRequestBySchemaInstruction(
152
+ schemas=schemas,
153
+ transformer=transformer,
154
+ check_usage=check_usage,
155
+ )
156
+
157
+ return decorator # pyright: ignore[reportReturnType]
158
+
159
+
160
+ ###########
161
+ # Responses
162
+ ###########
163
+
164
+
165
+ @dataclass
166
+ class _BaseAlterResponseInstruction(_AlterDataInstruction):
167
+ _payload_arg_name = "response"
168
+ migrate_http_errors: bool
169
+
170
+
171
+ @dataclass
172
+ class _AlterResponseBySchemaInstruction(_BaseAlterBySchemaInstruction, _BaseAlterResponseInstruction): ...
173
+
174
+
175
+ @dataclass
176
+ class _AlterResponseByPathInstruction(_BaseAlterResponseInstruction):
177
+ path: str
178
+ methods: set[str]
179
+ repr_name = "Response by path converter"
180
+
181
+
182
+ @overload
183
+ def convert_response_to_previous_version_for(
184
+ first_schema: type,
185
+ /,
186
+ *schemas: type,
187
+ migrate_http_errors: bool = False,
188
+ check_usage: bool = True,
189
+ ) -> "type[staticmethod[_P, None]]": ...
190
+
191
+
192
+ @overload
193
+ def convert_response_to_previous_version_for(
194
+ path: str,
195
+ methods: list[str],
196
+ /,
197
+ *,
198
+ migrate_http_errors: bool = False,
199
+ ) -> "type[staticmethod[_P, None]]": ...
200
+
201
+
202
+ def convert_response_to_previous_version_for(
203
+ schema_or_path: Union[type, str],
204
+ methods_or_second_schema: Union[list[str], type, None] = None,
205
+ /,
206
+ *additional_schemas: type,
207
+ migrate_http_errors: bool = False,
208
+ check_usage: bool = True,
209
+ ) -> "type[staticmethod[_P, None]]":
210
+ _validate_decorator_args(schema_or_path, methods_or_second_schema, additional_schemas)
211
+
212
+ def decorator(transformer: Callable[[ResponseInfo], None]) -> Any:
213
+ if isinstance(schema_or_path, str):
214
+ # The validation above checks that methods is not None
215
+ return _AlterResponseByPathInstruction(
216
+ path=schema_or_path,
217
+ methods=set(cast("list", methods_or_second_schema)),
218
+ transformer=transformer,
219
+ migrate_http_errors=migrate_http_errors,
220
+ )
221
+ else:
222
+ if methods_or_second_schema is None:
223
+ schemas = (schema_or_path,)
224
+ else:
225
+ schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
226
+ return _AlterResponseBySchemaInstruction(
227
+ schemas=schemas,
228
+ transformer=transformer,
229
+ migrate_http_errors=migrate_http_errors,
230
+ check_usage=check_usage,
231
+ )
232
+
233
+ return decorator # pyright: ignore[reportReturnType]
234
+
235
+
236
+ def _validate_decorator_args(
237
+ schema_or_path: Union[type, str],
238
+ methods_or_second_schema: Union[list[str], type, None],
239
+ additional_schemas: tuple[type, ...],
240
+ ) -> None:
241
+ if isinstance(schema_or_path, str):
242
+ if not isinstance(methods_or_second_schema, list):
243
+ raise TypeError("If path was provided as a first argument, methods must be provided as a second argument")
244
+ _validate_that_strings_are_valid_http_methods(methods_or_second_schema)
245
+ if additional_schemas:
246
+ raise TypeError("If path was provided as a first argument, then additional schemas cannot be added")
247
+
248
+ elif methods_or_second_schema is not None and not isinstance(methods_or_second_schema, type):
249
+ raise TypeError("If schema was provided as a first argument, all other arguments must also be schemas")
@@ -0,0 +1,170 @@
1
+ from collections.abc import Callable, Collection, Sequence
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any, Union
5
+
6
+ from fastapi import Response
7
+ from fastapi.params import Depends
8
+ from fastapi.routing import APIRoute
9
+ from starlette.routing import BaseRoute
10
+
11
+ from cadwyn.exceptions import LintingError
12
+
13
+ from .._utils import DATACLASS_SLOTS, Sentinel
14
+ from .common import _HiddenAttributeMixin
15
+
16
+ HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
17
+
18
+
19
+ @dataclass(**DATACLASS_SLOTS)
20
+ class EndpointAttributesPayload:
21
+ # FastAPI API routes also have "endpoint" and "dependency_overrides_provider" fields.
22
+ # We do not use them because:
23
+ # 1. "endpoint" must not change -- otherwise this versioning is doomed
24
+ # 2. "dependency_overrides_provider" is taken from router's attributes
25
+ # 3. "response_model" must not change for the same reason as endpoint
26
+ # The following for the same reason as endpoint:
27
+ # * response_model_include: SetIntStr | DictIntStrAny
28
+ # * response_model_exclude: SetIntStr | DictIntStrAny
29
+ # * response_model_by_alias: bool
30
+ # * response_model_exclude_unset: bool
31
+ # * response_model_exclude_defaults: bool
32
+ # * response_model_exclude_none: bool
33
+ path: str
34
+ response_model: Any
35
+ status_code: int
36
+ tags: list[Union[str, Enum]]
37
+ # Adding/removing dependencies between versions seems like a bad choice.
38
+ # It makes the system overly complex. Instead, we allow people to
39
+ # overwrite all dependencies of a route at once. Hence you always know exactly
40
+ # which dependencies have been specified, no matter how many migrations you have.
41
+
42
+ dependencies: Sequence[Depends]
43
+ summary: str
44
+ description: str
45
+ response_description: str
46
+ responses: dict[Union[int, str], dict[str, Any]]
47
+ deprecated: bool
48
+ methods: set[str]
49
+ operation_id: str
50
+ include_in_schema: bool
51
+ response_class: type[Response]
52
+ name: str
53
+ callbacks: list[BaseRoute]
54
+ openapi_extra: dict[str, Any]
55
+ generate_unique_id_function: Callable[[APIRoute], str]
56
+
57
+
58
+ @dataclass(**DATACLASS_SLOTS)
59
+ class EndpointHadInstruction(_HiddenAttributeMixin):
60
+ endpoint_path: str
61
+ endpoint_methods: set[str]
62
+ endpoint_func_name: Union[str, None]
63
+ attributes: EndpointAttributesPayload
64
+
65
+
66
+ @dataclass(**DATACLASS_SLOTS)
67
+ class EndpointExistedInstruction(_HiddenAttributeMixin):
68
+ endpoint_path: str
69
+ endpoint_methods: set[str]
70
+ endpoint_func_name: Union[str, None]
71
+
72
+
73
+ @dataclass(**DATACLASS_SLOTS)
74
+ class EndpointDidntExistInstruction(_HiddenAttributeMixin):
75
+ endpoint_path: str
76
+ endpoint_methods: set[str]
77
+ endpoint_func_name: Union[str, None]
78
+
79
+
80
+ @dataclass(**DATACLASS_SLOTS)
81
+ class EndpointInstructionFactory:
82
+ endpoint_path: str
83
+ endpoint_methods: set[str]
84
+ endpoint_func_name: Union[str, None]
85
+
86
+ @property
87
+ def didnt_exist(self) -> EndpointDidntExistInstruction:
88
+ return EndpointDidntExistInstruction(
89
+ is_hidden_from_changelog=False,
90
+ endpoint_path=self.endpoint_path,
91
+ endpoint_methods=self.endpoint_methods,
92
+ endpoint_func_name=self.endpoint_func_name,
93
+ )
94
+
95
+ @property
96
+ def existed(self) -> EndpointExistedInstruction:
97
+ return EndpointExistedInstruction(
98
+ is_hidden_from_changelog=False,
99
+ endpoint_path=self.endpoint_path,
100
+ endpoint_methods=self.endpoint_methods,
101
+ endpoint_func_name=self.endpoint_func_name,
102
+ )
103
+
104
+ def had(
105
+ self,
106
+ *,
107
+ path: str = Sentinel,
108
+ response_model: Any = Sentinel,
109
+ status_code: int = Sentinel,
110
+ tags: list[Union[str, Enum]] = Sentinel,
111
+ dependencies: Sequence[Depends] = Sentinel,
112
+ summary: str = Sentinel,
113
+ description: str = Sentinel,
114
+ response_description: str = Sentinel,
115
+ responses: dict[Union[int, str], dict[str, Any]] = Sentinel,
116
+ deprecated: bool = Sentinel,
117
+ methods: list[str] = Sentinel,
118
+ operation_id: str = Sentinel,
119
+ include_in_schema: bool = Sentinel,
120
+ response_class: type[Response] = Sentinel,
121
+ name: str = Sentinel,
122
+ callbacks: list[BaseRoute] = Sentinel,
123
+ openapi_extra: dict[str, Any] = Sentinel,
124
+ generate_unique_id_function: Callable[[APIRoute], str] = Sentinel,
125
+ ):
126
+ return EndpointHadInstruction(
127
+ is_hidden_from_changelog=False,
128
+ endpoint_path=self.endpoint_path,
129
+ endpoint_methods=self.endpoint_methods,
130
+ endpoint_func_name=self.endpoint_func_name,
131
+ attributes=EndpointAttributesPayload(
132
+ path=path,
133
+ response_model=response_model,
134
+ status_code=status_code,
135
+ tags=tags,
136
+ dependencies=dependencies,
137
+ summary=summary,
138
+ description=description,
139
+ response_description=response_description,
140
+ responses=responses,
141
+ deprecated=deprecated,
142
+ methods=set(methods) if methods is not Sentinel else Sentinel,
143
+ operation_id=operation_id,
144
+ include_in_schema=include_in_schema,
145
+ response_class=response_class,
146
+ name=name,
147
+ callbacks=callbacks,
148
+ openapi_extra=openapi_extra,
149
+ generate_unique_id_function=generate_unique_id_function,
150
+ ),
151
+ )
152
+
153
+
154
+ def endpoint(path: str, methods: list[str], /, *, func_name: Union[str, None] = None) -> EndpointInstructionFactory:
155
+ _validate_that_strings_are_valid_http_methods(methods)
156
+
157
+ return EndpointInstructionFactory(path, set(methods), func_name)
158
+
159
+
160
+ def _validate_that_strings_are_valid_http_methods(methods: Collection[str]):
161
+ invalid_methods = set(methods) - HTTP_METHODS
162
+ if invalid_methods:
163
+ invalid_methods = ", ".join(sorted(invalid_methods))
164
+ raise LintingError(
165
+ f"The following HTTP methods are not valid: {invalid_methods}. "
166
+ "Please use valid HTTP methods such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.",
167
+ )
168
+
169
+
170
+ AlterEndpointSubInstruction = Union[EndpointDidntExistInstruction, EndpointExistedInstruction, EndpointHadInstruction]
@@ -0,0 +1,42 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any, Union
5
+
6
+ from cadwyn._utils import DATACLASS_SLOTS
7
+
8
+ from .common import _HiddenAttributeMixin
9
+
10
+
11
+ @dataclass(**DATACLASS_SLOTS)
12
+ class EnumHadMembersInstruction(_HiddenAttributeMixin):
13
+ enum: type[Enum]
14
+ members: Mapping[str, Any]
15
+
16
+
17
+ @dataclass(**DATACLASS_SLOTS)
18
+ class EnumDidntHaveMembersInstruction(_HiddenAttributeMixin):
19
+ enum: type[Enum]
20
+ members: tuple[str, ...]
21
+
22
+
23
+ @dataclass(**DATACLASS_SLOTS)
24
+ class EnumInstructionFactory:
25
+ enum_class: type[Enum]
26
+
27
+ def had(self, **enum_member_to_value_mapping: Any) -> EnumHadMembersInstruction:
28
+ return EnumHadMembersInstruction(
29
+ is_hidden_from_changelog=False, enum=self.enum_class, members=enum_member_to_value_mapping
30
+ )
31
+
32
+ def didnt_have(self, *enum_members: str) -> EnumDidntHaveMembersInstruction:
33
+ return EnumDidntHaveMembersInstruction(
34
+ is_hidden_from_changelog=False, enum=self.enum_class, members=enum_members
35
+ )
36
+
37
+
38
+ def enum(enum_class: type[Enum], /) -> EnumInstructionFactory:
39
+ return EnumInstructionFactory(enum_class)
40
+
41
+
42
+ AlterEnumSubInstruction = Union[EnumHadMembersInstruction, EnumDidntHaveMembersInstruction]