pyramid_traversal_api 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ARTEFACTS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyramid_traversal_api
3
+ Version: 0.1.0
4
+ Summary: Tools for writing REST APIs in Pyramid using the traversal API
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Framework :: Pyramid
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Dist: marshmallow
13
+ Requires-Dist: pyramid
14
+ Requires-Dist: ruff ; extra == 'dev'
15
+ Requires-Dist: sphinx ; extra == 'dev'
16
+ Requires-Dist: pytest ; extra == 'dev'
17
+ Requires-Dist: pytest-cov ; extra == 'dev'
18
+ Requires-Dist: pytest-mock ; extra == 'dev'
19
+ Requires-Dist: webtest>=3.0.7 ; extra == 'dev'
20
+ Requires-Dist: sqlalchemy ; extra == 'sqla'
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Provides-Extra: sqla
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pyramid-traversal-api
27
+
28
+ **NOTE** This library is currently WIP. Expect breakages, even between minor versions, while on major version 0.
29
+
30
+ A set of helpers that makes it easier to write traversal-based REST APIs for [pyramid](https://trypyramid.com/) with modern QoL features like
31
+
32
+ * Request/response validation
33
+ * Automatic OpenAPI
34
+ * Automatic SQLAlchemy requests
35
+ - But possible to write your own support for any backend
36
+ * Built around writing REST APIs
37
+
38
+ ## Design goals
39
+
40
+ * Build tools FOR pyramid, not REPLACING pyramid
41
+ - No new abstraction layers on top of Pyramid, just new building blocks
42
+ * Easy to slap on top of an existing project, allowing gradual migration
43
+ - Start small finish big, like Pyramid
44
+
45
+ ## Standing on the shoulder of giants
46
+
47
+ Thank you to the pylons project for Pyramid. Greetings also go to `Theron Luhn` for [pyramid-marshmallow](https://pypi.org/project/pyramid-marshmallow/), which the OpenAPI functionality of this package is based on.
48
+
49
+ ## Requirements
50
+
51
+ Python 3.9 or later
@@ -0,0 +1,26 @@
1
+ # pyramid-traversal-api
2
+
3
+ **NOTE** This library is currently WIP. Expect breakages, even between minor versions, while on major version 0.
4
+
5
+ A set of helpers that makes it easier to write traversal-based REST APIs for [pyramid](https://trypyramid.com/) with modern QoL features like
6
+
7
+ * Request/response validation
8
+ * Automatic OpenAPI
9
+ * Automatic SQLAlchemy requests
10
+ - But possible to write your own support for any backend
11
+ * Built around writing REST APIs
12
+
13
+ ## Design goals
14
+
15
+ * Build tools FOR pyramid, not REPLACING pyramid
16
+ - No new abstraction layers on top of Pyramid, just new building blocks
17
+ * Easy to slap on top of an existing project, allowing gradual migration
18
+ - Start small finish big, like Pyramid
19
+
20
+ ## Standing on the shoulder of giants
21
+
22
+ Thank you to the pylons project for Pyramid. Greetings also go to `Theron Luhn` for [pyramid-marshmallow](https://pypi.org/project/pyramid-marshmallow/), which the OpenAPI functionality of this package is based on.
23
+
24
+ ## Requirements
25
+
26
+ Python 3.9 or later
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "pyramid_traversal_api"
3
+ version = "0.1.0"
4
+ description = "Tools for writing REST APIs in Pyramid using the traversal API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICEN[CS]E*"]
8
+ requires-python = ">=3.10"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Framework :: Pyramid",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "marshmallow",
18
+ "pyramid",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ sqla = [
23
+ "sqlalchemy"
24
+ ]
25
+
26
+ dev = [
27
+ "ruff",
28
+ "sphinx",
29
+ "pytest",
30
+ "pytest-cov",
31
+ "pytest-mock",
32
+ "webtest>=3.0.7",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.9.18,<0.10.0"]
37
+ build-backend = "uv_build"
@@ -0,0 +1,104 @@
1
+ from marshmallow import Schema
2
+ from pyramid.response import Response
3
+ from pyramid.viewderivers import VIEW
4
+ import pyramid.httpexceptions as exc
5
+
6
+ import logging
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ def includeme(config):
12
+ # Forward some work to pyramid_marshmallow for now
13
+ config.add_view_deriver(view_validator)
14
+ config.add_view_deriver(view_marshaller, under="rendered_view", over=VIEW)
15
+ config.add_view_deriver(view_api_spec)
16
+
17
+
18
+ def process_schema(schema: dict):
19
+ """Normalizes a schema. If it is a dict, convert it to Schema. If not, return as-is."""
20
+ if isinstance(schema, Schema):
21
+ return schema
22
+ elif isinstance(schema, dict):
23
+ return Schema.from_dict(schema)()
24
+ else:
25
+ raise TypeError(f"Schema {schema} is invalid type")
26
+
27
+
28
+ def process_schemas(schemas):
29
+ """Handle a schemas passed in as a view deriver, creating a nonce schema if a
30
+ dictionary.
31
+ """
32
+ if schemas is None:
33
+ return {}
34
+ new_schemas = {}
35
+ for response, schema in schemas.items():
36
+ new_schemas[response] = process_schema(schema)
37
+ return new_schemas
38
+
39
+
40
+ def view_validator(view, info):
41
+ # https://github.com/luhn/pyramid-marshmallow/blob/main/pyramid_marshmallow/__init__.py
42
+ schema = info.options.get("validate")
43
+ if schema is None:
44
+ return view
45
+ schema = process_schema(schema)
46
+
47
+ def wrapped(context, request):
48
+ if request.method == "GET":
49
+ data = dict()
50
+ for k, v in request.GET.items():
51
+ field = schema.fields.get(k)
52
+ if isinstance(field, fields.List):
53
+ data.setdefault(k, []).append(v)
54
+ else:
55
+ data[k] = v
56
+ else:
57
+ data = request.json_body
58
+ request.data = schema.load(data)
59
+ return view(context, request)
60
+
61
+ return wrapped
62
+
63
+
64
+ view_validator.options = ("validate",)
65
+
66
+
67
+ def view_marshaller(view, info):
68
+ """If there is a schema for the response code being returned, we marshal it
69
+ TODO: Maybe ignore the marshalling altogether and only use it for schema?"""
70
+ schemas = process_schemas(info.options.get("marshal_responses"))
71
+ if not any([schema is not None for schema in schemas.values()]):
72
+ return view
73
+
74
+ def wrapped(context, request):
75
+ output = view(context, request)
76
+
77
+ status = str(request.response.status_int)
78
+
79
+ if isinstance(output, Response) or status not in schemas:
80
+ return output
81
+ else:
82
+ dumped = schemas[status].dump(output)
83
+
84
+ errors = schemas[status].validate(dumped)
85
+ if len(errors) != 0:
86
+ log.info(f"Validation errors: {errors}")
87
+ raise exc.HTTPInternalServerError(
88
+ "Unable to generate correct response. This is on us. Sorry"
89
+ )
90
+ return dumped
91
+
92
+ return wrapped
93
+
94
+
95
+ # Ignore type as we are doing some Python dark magic here
96
+ view_marshaller.options = ("marshal_responses",) # type: ignore[attr-defined]
97
+
98
+
99
+ # TODO: consolidate view options
100
+ def view_api_spec(view, info):
101
+ return view
102
+
103
+
104
+ view_api_spec.options = "api_spec" # type: ignore[attr-defined]
@@ -0,0 +1,56 @@
1
+ # Helper for automagically generating CRUD views for a resource
2
+ import marshmallow
3
+ import typing
4
+ import functools
5
+ import logging
6
+
7
+ log = logging.getLogger(__name__)
8
+
9
+
10
+ def crud(
11
+ create: typing.Optional[marshmallow.Schema],
12
+ read: typing.Optional[bool],
13
+ update: typing.Optional[marshmallow.Schema],
14
+ delete: typing.Optional[bool],
15
+ ):
16
+ """Decorator to attach to a resource class. For each kwarg in create, read, update, delete, creates a corresponding view using the schema if a value is provided.
17
+ For `read` and `delete`, you can only enable or disable creation of the view.
18
+
19
+ :param create:
20
+ Not yet implemented
21
+ :param read:
22
+ Not yet implemented
23
+ :param update:
24
+ Not yet implemented
25
+ :param delete:
26
+ Not yet implemented
27
+ """
28
+
29
+ def inner(f):
30
+ @functools.wraps(f)
31
+ def wrapper(*args, **kwds):
32
+ cls = f(*args, **kwds)
33
+
34
+ log.debug(f"Creating crud for {cls}")
35
+
36
+ if create is not None:
37
+ log.debug("Creating create")
38
+ raise NotImplementedError("Not yet implemented")
39
+
40
+ if read is not None:
41
+ log.debug("Creating read")
42
+ raise NotImplementedError("Not yet implemented")
43
+
44
+ if update is not None:
45
+ log.debug("Creating update")
46
+ raise NotImplementedError("Not yet implemented")
47
+
48
+ if delete is not None:
49
+ log.debug("Creating delete")
50
+ raise NotImplementedError("Not yet implemented")
51
+
52
+ return cls
53
+
54
+ return wrapper
55
+
56
+ return inner
@@ -0,0 +1,41 @@
1
+ """Implements a decorator that allows you to warn API users of deprecation using RFC9745"""
2
+
3
+ import functools
4
+ import typing
5
+ import pyramid.httpexceptions as exc
6
+ from email.utils import formatdate
7
+
8
+ from datetime import datetime
9
+ from time import mktime
10
+
11
+
12
+ def deprecation(deprecation_datetime: str, link: typing.Optional[str]):
13
+ """Implements the deprecation HTTP header (RFC9745) for a view. The view will continue to work after the deprecation datetime, but should be considered not wanted. The deprecation header will be injected both before and after deprecation time.
14
+
15
+ ::param date:
16
+ The date (and time) of the endpoint being deprecated in ISO 8601 format.
17
+ ::param link:
18
+ An optional link to information about the deprecation, provided to the user in the Link header
19
+ """
20
+ parsed_deprecation_datetime = datetime.fromisoformat(deprecation_datetime)
21
+ headers = {
22
+ "Deprecation": formatdate(
23
+ timeval=mktime(parsed_deprecation_datetime.timetuple()),
24
+ localtime=False,
25
+ usegmt=True,
26
+ )
27
+ }
28
+ if link is not None:
29
+ headers["Link"] = f'<{link}>;rel="deprecation";type="text/html"'
30
+
31
+ def inner(f):
32
+ @functools.wraps(f)
33
+ def wrapper(context, request):
34
+ for header in headers.keys():
35
+ request.response.headers[header] = headers[header]
36
+
37
+ return f(context, request)
38
+
39
+ return wrapper
40
+
41
+ return inner
@@ -0,0 +1,232 @@
1
+ """Took for traversing a Pyramid Traversal node graph and generating an OpenAPI spec to describe it."""
2
+
3
+ import inspect
4
+
5
+ from pathlib import Path
6
+
7
+ from pyramid.path import DottedNameResolver
8
+
9
+ from .helpers import (
10
+ set_request_body,
11
+ set_query_params,
12
+ set_response_body,
13
+ get_operation_id,
14
+ split_docstring,
15
+ set_tag,
16
+ schema_name_resolver,
17
+ )
18
+
19
+ from apispec import APISpec, utils
20
+
21
+ import logging
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ def ops_from_view(
27
+ spec, introspectable, url_params, response_types, gen_external_docs_callback=None
28
+ ):
29
+ """Generates a dictionary of operations for a given introspectable(view).
30
+
31
+ `url_params` are url params that apply to the URL we are currently at.
32
+ `traverse_resource` deduces any url params for the current URL,
33
+ and also passes them recursively to child resources.
34
+ """
35
+ # Do openapi magic here
36
+ # Taken from pyramid-marshmallow
37
+ operations = {}
38
+ methods = introspectable["request_methods"] or ["GET"]
39
+ if isinstance(methods, str):
40
+ methods = [methods]
41
+ for method in methods:
42
+ method = method.lower()
43
+ operations[method] = introspectable
44
+
45
+ final_ops = dict()
46
+ for method, view in operations.items():
47
+ summary, descr, user_op = split_docstring(view["callable"].__doc__)
48
+ op = {
49
+ "responses": dict(),
50
+ "parameters": url_params.copy(),
51
+ "operationId": get_operation_id(view)
52
+ if len(operations) == 1
53
+ else f"{method}_{get_operation_id(view)}",
54
+ }
55
+ if gen_external_docs_callback is not None:
56
+ op["externalDocs"] = gen_external_docs_callback(view["callable"])
57
+ if summary:
58
+ op["summary"] = summary
59
+ if descr:
60
+ op["description"] = descr
61
+
62
+ if "validate" in view:
63
+ if method == "get":
64
+ set_query_params(spec, op, view)
65
+ else:
66
+ set_request_body(spec, op, view)
67
+ if "marshal_responses" in view:
68
+ set_response_body(spec, op, view)
69
+ set_tag(spec, op, view)
70
+ final_op = utils.deepupdate(op, user_op)
71
+ final_op = utils.deepupdate(final_op, view.get("api_spec", dict()))
72
+
73
+ # We are required to have some response, so make one up.
74
+ if "200" not in final_op["responses"]:
75
+ final_op["responses"]["200"] = {
76
+ "description": "",
77
+ }
78
+
79
+ # Naive auto-detection of resources that represent an SQL table
80
+ if "404" not in final_op["responses"] and len(url_params) > 0:
81
+ final_op["responses"]["404"] = {
82
+ "description": "An object was not found for "
83
+ + " or ".join(map(lambda param: f"`{param['name']}`", url_params))
84
+ }
85
+ if "400" not in final_op["responses"] and "validate" in view:
86
+ final_op["responses"]["400"] = {"description": "Request validation failed"}
87
+
88
+ # Any response types generated by framework are also added
89
+ for key, value in response_types.items():
90
+ log.debug(f"Checking if {key} in responses")
91
+ if key not in final_op["responses"]:
92
+ log.debug(f"Adding response {key}")
93
+ final_op["responses"][key] = value
94
+
95
+ log.debug(f"FINAL OP: {final_op}")
96
+ final_ops[method] = final_op
97
+
98
+ return final_ops
99
+
100
+
101
+ def populate_url_params(param):
102
+ """Takes a param definition as defined on a resource,
103
+ and adds necessary fields to make it an OpenAPI URL parameter
104
+ """
105
+ return {**param, "in": "path", "required": True}
106
+
107
+
108
+ def traverse_resource(
109
+ spec,
110
+ introspector,
111
+ context,
112
+ path="",
113
+ url_params=[],
114
+ response_types={},
115
+ gen_external_docs_callback=None,
116
+ ):
117
+ log.info(
118
+ f"Traversing {context} at path {path} inherited response types {response_types}"
119
+ )
120
+ # Iterate all views of this resource
121
+ for item in introspector.get_category("views", context):
122
+ introspectable = item["introspectable"]
123
+ if introspectable["context"] == context:
124
+ view_path = f"{path}/{introspectable['name']}"
125
+
126
+ ops = ops_from_view(
127
+ spec,
128
+ introspectable,
129
+ url_params,
130
+ response_types,
131
+ gen_external_docs_callback,
132
+ )
133
+ spec.path(view_path, operations=ops)
134
+ else:
135
+ # log.info(item["introspectable"]["context"])
136
+ pass
137
+
138
+ # Then, check child resources
139
+ new_children = getattr(context, "static_children", None)
140
+
141
+ dynamic_child = context.get_dynamic_openapi_info()
142
+ if dynamic_child:
143
+ log.info("Path has dynamic children")
144
+ new_url_params = url_params + list(
145
+ map(populate_url_params, dynamic_child["params"])
146
+ )
147
+ # We need to determine some sane name for the url param
148
+ # So we pick table name + primary key name
149
+ traverse_resource(
150
+ spec,
151
+ introspector,
152
+ dynamic_child["resource"],
153
+ f"{path}/"
154
+ + "/".join(
155
+ map(lambda param: "{" + param["name"] + "}", dynamic_child["params"])
156
+ ),
157
+ new_url_params,
158
+ response_types,
159
+ gen_external_docs_callback,
160
+ )
161
+
162
+ if new_children:
163
+ log.info("Path has children")
164
+
165
+ # Supports both dynamic and static children at the same time
166
+ for k, v in new_children.items():
167
+ traverse_resource(
168
+ spec,
169
+ introspector,
170
+ v,
171
+ f"{path}/{k}",
172
+ url_params,
173
+ response_types,
174
+ gen_external_docs_callback,
175
+ )
176
+
177
+ log.info("Path has no children")
178
+
179
+
180
+ def generate_apispec(
181
+ name,
182
+ version,
183
+ registry,
184
+ resource,
185
+ path="",
186
+ global_response_types={},
187
+ gen_external_docs_callback=None,
188
+ ) -> dict:
189
+ """Returns an APISpec object from traversing the tree with a given resource as root.
190
+ `path` specifies an optional URL prefix for every endpoint
191
+
192
+ :param name:
193
+ The name of the API that is documented
194
+ :param version:
195
+ The version of the API that is documented
196
+ :param registry:
197
+ An instance of a pyramid registry
198
+ :param resource:
199
+ The traversal context to use as root
200
+ :param path:
201
+ An optional path prefix
202
+ :param global_response_types:
203
+ Any HTTP response codes that may be returned due to how the server is configured (f.ex extra validation or security)
204
+ :param gen_external_docs_callback:
205
+ An optional callback that takes a view and returns a dict containing an external documentation object. See https://swagger.io/specification/#external-documentation-object
206
+ """
207
+
208
+ name_resolver = DottedNameResolver()
209
+ MarshmallowPlugin = name_resolver.maybe_resolve(
210
+ registry.settings.get(
211
+ "openapi.plugin", "apispec.ext.marshmallow.MarshmallowPlugin"
212
+ )
213
+ )
214
+
215
+ marshmallow_plugin = MarshmallowPlugin(schema_name_resolver=schema_name_resolver)
216
+
217
+ spec = APISpec(
218
+ name,
219
+ version,
220
+ openapi_version="3.0.0",
221
+ plugins=[marshmallow_plugin],
222
+ )
223
+
224
+ traverse_resource(
225
+ spec,
226
+ registry.introspector,
227
+ resource,
228
+ path,
229
+ response_types=global_response_types,
230
+ gen_external_docs_callback=gen_external_docs_callback,
231
+ )
232
+ return spec.to_dict()
@@ -0,0 +1,111 @@
1
+ """Helpers for OpenAPI generation
2
+ Mostly copied verbatim or slightly modified from https://github.com/luhn/pyramid-marshmallow/blob/main/pyramid_marshmallow
3
+ """
4
+
5
+ from marshmallow import Schema
6
+ from apispec import utils as apispec_utils
7
+ from apispec.ext.marshmallow.common import (
8
+ resolve_schema_cls,
9
+ resolve_schema_instance,
10
+ )
11
+
12
+
13
+ def _schema(schema):
14
+ if isinstance(schema, dict):
15
+ return Schema.from_dict(schema)
16
+ else:
17
+ return schema
18
+
19
+
20
+ def schema_name_resolver(schema):
21
+ cls = resolve_schema_cls(schema)
22
+ instance = resolve_schema_instance(schema)
23
+ name = cls.__name__
24
+ if not cls.opts.register:
25
+ # Unregistered schemas are put inline.
26
+ return False
27
+ if instance.only or instance.exclude:
28
+ # If schema includes only select fields, treat it as nonce
29
+ return False
30
+ if name.endswith("Schema"):
31
+ name = name[:-6] or name
32
+ if instance.partial:
33
+ name = "Partial" + name
34
+ return name
35
+
36
+
37
+ def set_request_body(spec, op, view):
38
+ op["requestBody"] = {
39
+ "content": {
40
+ "application/json": {
41
+ "schema": _schema(view["validate"]),
42
+ },
43
+ },
44
+ }
45
+
46
+
47
+ def set_query_params(spec, op, view):
48
+ op["parameters"].append(
49
+ {
50
+ "in": "query",
51
+ "schema": _schema(view["validate"]),
52
+ }
53
+ )
54
+
55
+
56
+ def set_tag(spec, op, view):
57
+ context = view["context"]
58
+ if not context:
59
+ return
60
+ tag = getattr(context, "__tag__", None)
61
+ if not tag:
62
+ return
63
+ if isinstance(tag, dict):
64
+ # Cheating and using the private variable spec._tags
65
+ if not any(x["name"] == tag["name"] for x in spec._tags):
66
+ spec.tag(tag)
67
+ tag_name = tag["name"]
68
+ else:
69
+ tag_name = tag
70
+ op.setdefault("tags", []).append(tag_name)
71
+
72
+
73
+ def split_docstring(docstring):
74
+ """
75
+ Split a docstring in half, delineated with a "---". The first half is
76
+ returned verbatim, the second half is parsed as YAML.
77
+
78
+ """
79
+ split_lines = apispec_utils.trim_docstring(docstring).split("\n")
80
+
81
+ # Cut YAML from rest of docstring
82
+ for index, line in enumerate(split_lines):
83
+ line = line.strip()
84
+ if line.startswith("---"):
85
+ cut_from = index
86
+ break
87
+ else:
88
+ cut_from = len(split_lines)
89
+
90
+ summary = split_lines[0].strip() or None
91
+ docs = "\n".join(split_lines[1:cut_from]).strip() or None
92
+ yaml_string = "\n".join(split_lines[cut_from:])
93
+ if yaml_string:
94
+ parsed = yaml.safe_load(yaml_string)
95
+ else:
96
+ parsed = dict()
97
+ return summary, docs, parsed
98
+
99
+
100
+ def set_response_body(spec, op, view):
101
+ for response_code, schema in view["marshal_responses"].items():
102
+ op["responses"][response_code] = {
103
+ "description": "",
104
+ "content": {
105
+ "application/json": {"schema": schema},
106
+ },
107
+ }
108
+
109
+
110
+ def get_operation_id(view):
111
+ return view["callable"].__name__
@@ -0,0 +1,89 @@
1
+ import time
2
+ import logging
3
+
4
+ from typing import Type
5
+
6
+ log = logging.getLogger(__name__)
7
+
8
+
9
+ class Resource:
10
+ # Static children are child nodes that can be reached by a static name without needing any backend lookup.
11
+ static_children: dict[str, Type["Resource"]] = {}
12
+
13
+ # Store known views (in class namespace, or "class static", so it is remembered)
14
+ # Key is the view name, and the value is a list of request methods
15
+ _known_views: dict[str, list[str]] = {}
16
+
17
+ def __init__(self, request, *args, **kwargs):
18
+ self.request = request
19
+ self.variables = kwargs or {}
20
+
21
+ def __getitem__(self, key: str) -> "Resource":
22
+ if self._determine_is_reserved_keyname(key):
23
+ raise KeyError("A view exists by this name, so stopping traversal")
24
+
25
+ if key not in self.static_children:
26
+ raise KeyError("no item")
27
+ item = self.static_children[key]
28
+
29
+ return self._mkchild(key, item)
30
+
31
+ def __getattr__(self, key):
32
+ if key in self.variables:
33
+ return self.variables[key]
34
+
35
+ # Attribute doesn't exist, provide some debug info for the developer
36
+ available_keys = ", ".join(self.variables.keys())
37
+ raise AttributeError(
38
+ f'Attribute "{key}" not found in {type(self).__name__} (class {self.__class__.__name__}). Available: {available_keys}'
39
+ )
40
+
41
+ def _mkchild(self, key: str, resource, *args, **kwargs):
42
+ """Helper that sets necessary fields for Pyramid traversal"""
43
+ child = resource(self.request, **{**self.variables, **kwargs})
44
+ child.__parent__ = self
45
+ child.__name__ = key
46
+
47
+ return child
48
+
49
+ @classmethod
50
+ def get_dynamic_openapi_info(cls):
51
+ """Returns information about a child resoure and the url parameters required for it.
52
+ Implement it if your resource does some kind of lookup for its children"""
53
+ return None
54
+
55
+ def _determine_is_reserved_keyname(self, key):
56
+ """Uses pyramid introspection (https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/introspector.html)
57
+ to determine if there is a view registered to this resource with the given key name. If so, we probably don't want to look it up in SQL,
58
+ so this function is a helper for used in __getitem__ implementations.
59
+ """
60
+ log.debug(f"Checking if {key} is reserved")
61
+ if key in self._known_views.keys() and (
62
+ self._known_views[key] is None
63
+ or self.request.method in self._known_views[key]
64
+ ):
65
+ # Note that we cannot store if a key is NOT a view as it would lead to a DOS vulnerability
66
+ log.debug(f"{self.request.method} {key} is known to be a view")
67
+ return True
68
+ # Performance tests on my local machine shows this function costs ~0.04ms. Debug logging is orders of magnitude more expensive.
69
+ # In the future we could try wrapping all the resource classes in factories that can perform the introspection at config-time?
70
+ t = time.perf_counter_ns()
71
+
72
+ introspector = self.request.registry.introspector
73
+ view_intr = introspector.get_category("views")
74
+ for view in view_intr:
75
+ context = view["introspectable"]["context"]
76
+ if context == self.__class__:
77
+ name = view["introspectable"]["name"]
78
+ request_methods = view["introspectable"]["request_methods"]
79
+ if name == key and (
80
+ request_methods is None or self.request.method in request_methods
81
+ ):
82
+ t_e = time.perf_counter_ns()
83
+ # We want to be aware of traversal time for now.
84
+ log.debug(
85
+ f"Stopping traversal, done in {(t_e - t) / 1000 / 1000}ms"
86
+ )
87
+ self._known_views[key] = request_methods
88
+ return True
89
+ return False
@@ -0,0 +1,43 @@
1
+ from pyramid_traversal_api.resource import Resource
2
+
3
+
4
+ class ResourceCollectionValidatorMeta(type):
5
+ def __new__(cls, clsname, bases, attrs):
6
+ if clsname != "ResourceCollection":
7
+ if "child_resource_class" not in attrs:
8
+ raise ValueError(
9
+ f"child_resource_class must be set for ResourceClass when creating {clsname}"
10
+ )
11
+
12
+ return super().__new__(cls, clsname, bases, attrs)
13
+
14
+
15
+ class ResourceCollection(Resource, metaclass=ResourceCollectionValidatorMeta):
16
+ """A node that wraps a collection of a resource, usually fetched from a database or similar.
17
+ Override the query_collection function with your own query logic"""
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+
22
+ def __getitem__(self, key):
23
+ if self._determine_is_reserved_keyname(key):
24
+ raise KeyError("A view exists by this name, so stopping traversal")
25
+
26
+ instance = self.query_item(key)
27
+
28
+ if not instance:
29
+ raise KeyError("No object found")
30
+
31
+ resource = self._mkchild(
32
+ key, self.child_resource_class, **{self.__name__: instance}
33
+ )
34
+
35
+ return resource
36
+
37
+ def query_item(self, key):
38
+ """Function intended to be overloaded with your own query logic"""
39
+ raise NotImplementedError("Remember to implement query_item")
40
+
41
+ @classmethod
42
+ def get_dynamic_openapi_info(cls):
43
+ raise NotImplementedError("get_dynamic_openapi_info needs to be implemented")
@@ -0,0 +1,119 @@
1
+ from sqlalchemy.sql.schema import Column
2
+ from sqlalchemy.orm.decl_api import DeclarativeBase
3
+ from pyramid_traversal_api.resource.collection import (
4
+ ResourceCollection,
5
+ ResourceCollectionValidatorMeta,
6
+ )
7
+ from pyramid_traversal_api.resource import Resource
8
+ from sqlalchemy import select
9
+ import sqlalchemy
10
+ import pyramid.httpexceptions as exc
11
+
12
+ from collections.abc import Callable
13
+ from typing import Optional, Type, Any
14
+
15
+ from uuid import UUID
16
+
17
+
18
+ # Key normalizers automatically translate the key (which is a string) to whatever type is required for the column type
19
+ def uuid_normalizer(self, key):
20
+ # Due to SQLite, if the colum type is UUID, we have to convert the key to uuid first
21
+ try:
22
+ return UUID(key)
23
+ except ValueError:
24
+ raise exc.HTTPBadRequest("Bad UUID")
25
+
26
+
27
+ def noop_normalizer(self, key):
28
+ return key
29
+
30
+
31
+ class SqlaCollectionValidatorMeta(ResourceCollectionValidatorMeta):
32
+ """Validates a ResourceSqlaCollection and performs other setup tasks at class creation time, so no runtime validation is necessary"""
33
+
34
+ def __new__(cls, clsname, bases, attrs):
35
+ if clsname != "ResourceSqlaCollection":
36
+ if "sql_class" not in attrs:
37
+ raise ValueError(
38
+ f"sql_class must be set for a ResourceSqlaCollection when creating {clsname}"
39
+ )
40
+ if "child_resource_class" not in attrs:
41
+ raise ValueError(
42
+ f"child_resource_class must be set for a ResourceSqlaCollection when creating {clsname}"
43
+ )
44
+
45
+ # Optional
46
+ if "query_field" not in attrs:
47
+ attrs["query_field"] = cls._determine_sql_field(
48
+ clsname, attrs["sql_class"]
49
+ )
50
+
51
+ # Some key types may need to be normalized before querying, determine that now
52
+ match type(attrs["query_field"].type):
53
+ case sqlalchemy.sql.sqltypes.Uuid:
54
+ attrs["key_normalizer"] = uuid_normalizer
55
+ case _:
56
+ attrs["key_normalizer"] = noop_normalizer
57
+
58
+ return super().__new__(cls, clsname, bases, attrs)
59
+
60
+ @staticmethod
61
+ def _determine_sql_field(clsname, sql_class):
62
+ """This function introspects the SQLAlchemy entity and finds the primary key."""
63
+ if len(sql_class.__table__.primary_key.columns) != 1:
64
+ raise NotImplementedError(
65
+ f"Error validating {clsname} - SqlChildResource does not support sql models with multiple primary keys - sorry!"
66
+ )
67
+ return sql_class.__table__.primary_key.columns[0]
68
+
69
+
70
+ class ResourceSqlaCollection(ResourceCollection, metaclass=SqlaCollectionValidatorMeta):
71
+ """Helper resource class for cases where the child node is an instance of an SQLAlchemy enity.
72
+ By default it will auto-detect the column to query by looking for the primary key. You can also explicitly specify the query column using `query_field` if needed.
73
+ """
74
+
75
+ # By ignoring typing here, we force any downstream class to implement these
76
+ # These need to be set for any inheriting class to work properly
77
+ sql_class: Type[DeclarativeBase] = None # type: ignore
78
+ child_resource_class: Type[Resource] = None # type: ignore
79
+ # Optional, overrides the field to be queried
80
+ query_field: Optional[Type[Column]] = None
81
+
82
+ # Auto-populated by the metaclass
83
+ key_normalizer: Callable[[str], Any]
84
+
85
+ def __init__(self, *args, **kwargs):
86
+ super().__init__(*args, **kwargs)
87
+
88
+ @classmethod
89
+ def get_dynamic_openapi_info(cls):
90
+ return {
91
+ "resource": cls.child_resource_class,
92
+ "params": [
93
+ {
94
+ "name": cls.sql_class.__table__.name + "_" + cls.query_field.name,
95
+ "description": f"Primary key of {cls.sql_class.__table__.name}",
96
+ "schema": {
97
+ # TODO: autodetect a better type based on the primary key field type?
98
+ "type": "string",
99
+ },
100
+ }
101
+ ],
102
+ }
103
+
104
+ def _query_object(self, key):
105
+ """Performs the SQLAlchemy query to fetch the object. By default uses the provided query field, or the one autodetected by the constructor.
106
+ Override this if you have special requirements for how the query is made
107
+
108
+ The database session is assumed to be reachable on request and be named dbsession.
109
+ TODO: make this configurable"""
110
+
111
+ normalized_key = self.key_normalizer(key)
112
+
113
+ return self.request.dbsession.scalars(
114
+ select(self.sql_class).where(self.query_field == normalized_key)
115
+ ).first()
116
+
117
+ def query_item(self, key):
118
+ """Queries SQLAlchemy for the specified key"""
119
+ return self._query_object(key)
@@ -0,0 +1,47 @@
1
+ """Implements a decorator that allows you to warn of and enforce using RFC8594"""
2
+
3
+ import functools
4
+ import typing
5
+ import pyramid.httpexceptions as exc
6
+ from email.utils import formatdate
7
+
8
+ from datetime import datetime
9
+ from time import mktime
10
+
11
+
12
+ def sunset(sunset_datetime: str, link: typing.Optional[str]):
13
+ """Implements the sunset HTTP header (RFC8594) for a view. The view will automatically stop working entirely once the date is passed.
14
+ This decorator can be used for automatically enforcing grade periods for deprecated views.
15
+
16
+ ::param date:
17
+ The date (and time) of the endpoint being sunset in ISO 8601 format. The endpoint will automatically stop working after this date.
18
+ ::param link:
19
+ An optional link to information about the sunsetting, provided to the suer in the Link header.
20
+ """
21
+ parsed_sunset_datetime = datetime.fromisoformat(sunset_datetime)
22
+ headers = {
23
+ "Sunset": formatdate(
24
+ timeval=mktime(parsed_sunset_datetime.timetuple()),
25
+ localtime=False,
26
+ usegmt=True,
27
+ )
28
+ }
29
+ if link is not None:
30
+ headers["Link"] = f'<{link}>;rel="sunset";type="text/html"'
31
+
32
+ def inner(f):
33
+ @functools.wraps(f)
34
+ def wrapper(context, request):
35
+ if datetime.now() > parsed_sunset_datetime:
36
+ raise exc.HTTPBadRequest(
37
+ "This endpoint has been sunset", headers=headers
38
+ )
39
+ else:
40
+ for header in headers.keys():
41
+ request.response.headers[header] = headers[header]
42
+
43
+ return f(context, request)
44
+
45
+ return wrapper
46
+
47
+ return inner