contentgrid-extension-helpers 0.0.1__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.
- contentgrid_extension_helpers/__init__.py +0 -0
- contentgrid_extension_helpers/authentication/__init__.py +2 -0
- contentgrid_extension_helpers/authentication/oidc.py +238 -0
- contentgrid_extension_helpers/authentication/user.py +9 -0
- contentgrid_extension_helpers/logging/__init__.py +1 -0
- contentgrid_extension_helpers/logging/json_logging.py +93 -0
- contentgrid_extension_helpers-0.0.1.dist-info/LICENSE +13 -0
- contentgrid_extension_helpers-0.0.1.dist-info/METADATA +32 -0
- contentgrid_extension_helpers-0.0.1.dist-info/RECORD +11 -0
- contentgrid_extension_helpers-0.0.1.dist-info/WHEEL +5 -0
- contentgrid_extension_helpers-0.0.1.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import hyperlink
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
from contentgrid_extension_helpers.authentication.user import ContentGridUser
|
|
7
|
+
import jwt
|
|
8
|
+
import requests
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from fastapi import Depends, HTTPException, status
|
|
11
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
# Load environment variables
|
|
15
|
+
load_dotenv()
|
|
16
|
+
load_dotenv(".env.secret")
|
|
17
|
+
|
|
18
|
+
# Default constants
|
|
19
|
+
DEFAULT_ALGORITHM = "RS256"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_jwks_client_from_config_uri(config_uri: str) -> tuple[dict, jwt.PyJWKClient]:
|
|
23
|
+
"""
|
|
24
|
+
Common helper function to retrieve JWKS client from a configuration URI.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config_uri: The URI to find the OAuth/OIDC configuration.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of OAuth/OIDC configuration and JWKS client.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
config = requests.get(config_uri).json()
|
|
34
|
+
return config, jwt.PyJWKClient(config["jwks_uri"])
|
|
35
|
+
except (requests.RequestException, KeyError) as e:
|
|
36
|
+
logging.exception(f"Error retrieving configuration: {e}")
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
def get_oidc_jwks_client(
|
|
40
|
+
oidc_issuer: str,
|
|
41
|
+
) -> tuple[dict, jwt.PyJWKClient]:
|
|
42
|
+
"""
|
|
43
|
+
Retrieves the JWKS (JSON Web Key Set) client from the OIDC (OpenID Connect) issuer configuration URI.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
oidc_issuer (str): The OIDC issuer URL. The issuer URL is used to retrieve the JWKS URI.
|
|
47
|
+
The url is adjusted to point to the .well-known/openid-configuration path, based on the OpenID Connect specification.
|
|
48
|
+
If not on the default endpoint, use function _get_jwks_client_from_config_uri instead.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Tuple[dict, jwt.PyJWKClient]: A tuple containing the OIDC configuration and the JWKS client.
|
|
52
|
+
"""
|
|
53
|
+
oidc_issuer_url: hyperlink.URL = hyperlink.parse(oidc_issuer)
|
|
54
|
+
assert oidc_issuer_url.absolute
|
|
55
|
+
|
|
56
|
+
oidc_config_uri = oidc_issuer_url.replace(
|
|
57
|
+
path=oidc_issuer_url.path + (".well-known", "openid-configuration")
|
|
58
|
+
).to_iri().to_text()
|
|
59
|
+
|
|
60
|
+
return _get_jwks_client_from_config_uri(oidc_config_uri)
|
|
61
|
+
|
|
62
|
+
def get_oauth_jwks_client(
|
|
63
|
+
oauth_issuer: str,
|
|
64
|
+
oauth_config_uri: Optional[str] = None,
|
|
65
|
+
) -> tuple[dict, jwt.PyJWKClient]:
|
|
66
|
+
"""
|
|
67
|
+
Retrieve JWKS client from a generic OAuth 2.0 issuer. Constructs the configuration URI if not provided.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
oauth_issuer: OAuth issuer used to construct the configuration URI.
|
|
71
|
+
oauth_config_uri: Optional URI to find the OAuth config. If not provided, will be derived from oauth_issuer.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of OAuth configuration and JWKS client.
|
|
75
|
+
"""
|
|
76
|
+
if not oauth_issuer:
|
|
77
|
+
oauth_issuer = os.environ.get("OAUTH_ISSUER", None)
|
|
78
|
+
assert oauth_issuer
|
|
79
|
+
oauth_issuer_url = hyperlink.parse(oauth_issuer)
|
|
80
|
+
assert oauth_issuer_url.absolute
|
|
81
|
+
|
|
82
|
+
if oauth_config_uri is None:
|
|
83
|
+
oauth_config_uri = oauth_issuer_url.replace(
|
|
84
|
+
path=(".well-known", "oauth-authorization-server") + oauth_issuer_url.path
|
|
85
|
+
).to_iri().to_text()
|
|
86
|
+
|
|
87
|
+
return _get_jwks_client_from_config_uri(oauth_config_uri)
|
|
88
|
+
|
|
89
|
+
def decode_and_verify_token(
|
|
90
|
+
access_token: str,
|
|
91
|
+
jwks_client: jwt.PyJWKClient,
|
|
92
|
+
issuer: str,
|
|
93
|
+
algorithms: Optional[list[str]] = None,
|
|
94
|
+
audience: Optional[str] = None,
|
|
95
|
+
verify_exp: bool = True,
|
|
96
|
+
verify_aud: bool = True,
|
|
97
|
+
verify_iss: bool = True,
|
|
98
|
+
verify_nbf: bool = False,
|
|
99
|
+
verify_iat: bool = False,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""
|
|
102
|
+
Decode and verify the access token with flexible configuration.
|
|
103
|
+
"""
|
|
104
|
+
if not issuer:
|
|
105
|
+
issuer = os.environ.get("OIDC_ISSUER", None)
|
|
106
|
+
|
|
107
|
+
if not audience:
|
|
108
|
+
audience = os.environ.get("AUDIENCE", None)
|
|
109
|
+
|
|
110
|
+
if verify_iss and issuer is None:
|
|
111
|
+
raise Exception("Issuer not provided while expecting to verify the issuer.")
|
|
112
|
+
|
|
113
|
+
if verify_aud and audience is None:
|
|
114
|
+
raise Exception("Audience not provided while expecting to verify the audience.")
|
|
115
|
+
|
|
116
|
+
if algorithms is None:
|
|
117
|
+
algorithms = [os.environ.get("ALGORITHM", DEFAULT_ALGORITHM)]
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
|
|
121
|
+
data = jwt.decode(
|
|
122
|
+
access_token,
|
|
123
|
+
signing_key.key,
|
|
124
|
+
algorithms=algorithms,
|
|
125
|
+
issuer=issuer,
|
|
126
|
+
audience=audience,
|
|
127
|
+
options={
|
|
128
|
+
"verify_exp": verify_exp,
|
|
129
|
+
"verify_aud": verify_aud,
|
|
130
|
+
"verify_iss": verify_iss,
|
|
131
|
+
"verify_iat": verify_iat,
|
|
132
|
+
"verify_nbf": verify_nbf
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
data["access_token"] = access_token
|
|
136
|
+
return data
|
|
137
|
+
except jwt.PyJWTError as e:
|
|
138
|
+
logging.exception(f"Token verification failed: {e}")
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
def create_oauth2_scheme(token_url: str = "token") -> OAuth2PasswordBearer:
|
|
142
|
+
"""
|
|
143
|
+
Create OAuth2 password bearer scheme with configurable token URL.
|
|
144
|
+
"""
|
|
145
|
+
return OAuth2PasswordBearer(tokenUrl=token_url)
|
|
146
|
+
|
|
147
|
+
def create_current_user_dependency(
|
|
148
|
+
jwks_client: jwt.PyJWKClient,
|
|
149
|
+
oidc_issuer: Optional[str] = None,
|
|
150
|
+
audience: Optional[str] = None,
|
|
151
|
+
user_model: type[BaseModel] = ContentGridUser,
|
|
152
|
+
token_url: str = "token",
|
|
153
|
+
algorithms: Optional[list[str]] = None,
|
|
154
|
+
verify_exp: bool = True,
|
|
155
|
+
verify_aud: bool = True,
|
|
156
|
+
verify_iss: bool = True,
|
|
157
|
+
verify_nbf: bool = False,
|
|
158
|
+
verify_iat: bool = False,
|
|
159
|
+
):
|
|
160
|
+
"""
|
|
161
|
+
Creates a FastAPI dependency for retrieving and verifying the current user from a JWT.
|
|
162
|
+
|
|
163
|
+
This function sets up an OAuth2PasswordBearer scheme and a dependency that can be used
|
|
164
|
+
in FastAPI endpoints to authenticate users based on JWTs (JSON Web Tokens). It handles
|
|
165
|
+
token decoding, verification, and user data extraction.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
jwks_client: The PyJWKClient instance used to verify the JWT signature. This client
|
|
169
|
+
should be initialized with the correct JWKS (JSON Web Key Set) for your
|
|
170
|
+
authentication provider.
|
|
171
|
+
oidc_issuer: The expected issuer of the JWT (e.g., "https://extensions.eu-west-1.contentgrid.cloud/authentication/external"). This value
|
|
172
|
+
is used to verify the `iss` (issuer) claim in the JWT. If provided, the
|
|
173
|
+
`verify_iss` parameter must be True.
|
|
174
|
+
audience: The expected audience of the JWT (e.g., "contentgrid:extension:extract"). This value is used to
|
|
175
|
+
verify the `aud` (audience) claim in the JWT. If provided, the `verify_aud`
|
|
176
|
+
parameter must be True. This is a crucial security setting.
|
|
177
|
+
user_model: The Pydantic model that represents the user data. Defaults to ContentGridUser.
|
|
178
|
+
This model will be used to deserialize the JWT payload after successful
|
|
179
|
+
verification. It should match the structure of the claims in your JWT.
|
|
180
|
+
token_url: The URL of the token endpoint. This is used by the OAuth2PasswordBearer
|
|
181
|
+
scheme for its OpenAPI documentation and is typically "/token".
|
|
182
|
+
algorithms: A list of allowed algorithms for JWT verification (e.g., ["RS256", "ES256"]).
|
|
183
|
+
Defaults to None, which might use a default algorithm or an algorithm from the environment.
|
|
184
|
+
It's highly recommended to explicitly specify the allowed algorithms for security reasons.
|
|
185
|
+
verify_exp: Whether to verify the 'exp' (expiration time) claim of the JWT. Defaults to True.
|
|
186
|
+
verify_aud: Whether to verify the 'aud' (audience) claim of the JWT. Defaults to True.
|
|
187
|
+
verify_iss: Whether to verify the 'iss' (issuer) claim of the JWT. Defaults to True.
|
|
188
|
+
verify_nbf: Whether to verify the 'nbf' (not before) claim of the JWT. Defaults to False.
|
|
189
|
+
verify_iat: Whether to verify the 'iat' (issued at) claim of the JWT. Defaults to False.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
A callable (dependency) that can be used with FastAPI's `Depends()` to inject
|
|
193
|
+
the authenticated user into an endpoint. The dependency raises an HTTPException
|
|
194
|
+
with a 401 status code if authentication fails.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
HTTPException: If the token is invalid, expired, has an invalid signature, incorrect issuer or audience,
|
|
198
|
+
or if the user data cannot be extracted.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
# Assuming you have a jwks_client and oidc_issuer configured:
|
|
202
|
+
get_current_user = create_current_user_dependency(
|
|
203
|
+
jwks_client=my_jwks_client,
|
|
204
|
+
oidc_issuer="https://extensions.eu-west-1.contentgrid.cloud/authentication/external",
|
|
205
|
+
audience="contentgrid:extension:extract" # Important: Set the audience!
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@app.get("/items/")
|
|
209
|
+
async def read_items(current_user: ContentGridUser = Depends(get_current_user)):
|
|
210
|
+
# current_user is now an instance of ContentGridUser, populated from the JWT
|
|
211
|
+
return {"user": current_user}
|
|
212
|
+
"""
|
|
213
|
+
oauth2_scheme = create_oauth2_scheme(token_url)
|
|
214
|
+
|
|
215
|
+
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> BaseModel:
|
|
216
|
+
try:
|
|
217
|
+
token_data = decode_and_verify_token(
|
|
218
|
+
access_token=token,
|
|
219
|
+
jwks_client=jwks_client,
|
|
220
|
+
audience=audience,
|
|
221
|
+
issuer=oidc_issuer,
|
|
222
|
+
algorithms=algorithms,
|
|
223
|
+
verify_aud=verify_aud,
|
|
224
|
+
verify_exp=verify_exp,
|
|
225
|
+
verify_iss=verify_iss,
|
|
226
|
+
verify_nbf=verify_nbf,
|
|
227
|
+
verify_iat=verify_iat
|
|
228
|
+
)
|
|
229
|
+
return user_model(**token_data)
|
|
230
|
+
except Exception:
|
|
231
|
+
logging.exception("Exception thrown in user verification.")
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
234
|
+
detail="Invalid authentication credentials",
|
|
235
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return get_current_user
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .json_logging import setup_json_logging, XenitJsonFormatter
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import List, Optional, Any, Dict
|
|
6
|
+
|
|
7
|
+
class XenitJsonFormatter(logging.Formatter):
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
component: str,
|
|
11
|
+
additional_keys: dict[str,str] = {},
|
|
12
|
+
fmt: Optional[str] = None,
|
|
13
|
+
datefmt: Optional[str] = None,
|
|
14
|
+
style: str = "%",
|
|
15
|
+
validate: bool = True,
|
|
16
|
+
defaults: Optional[Dict[str, Any]] = None
|
|
17
|
+
):
|
|
18
|
+
super().__init__(fmt, datefmt, style, validate, defaults=defaults)
|
|
19
|
+
self.component = component
|
|
20
|
+
self.additional_keys = additional_keys
|
|
21
|
+
|
|
22
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
23
|
+
log_entry = {
|
|
24
|
+
"timestamp": time.time(),
|
|
25
|
+
"type": "application",
|
|
26
|
+
"component": self.component,
|
|
27
|
+
"time": datetime.datetime.now(datetime.UTC).isoformat(
|
|
28
|
+
sep=" ", timespec="milliseconds"
|
|
29
|
+
),
|
|
30
|
+
"shortMessage": record.getMessage() if record.getMessage() else "",
|
|
31
|
+
"loggerName": record.name,
|
|
32
|
+
"severity": record.levelname,
|
|
33
|
+
"thread": record.threadName,
|
|
34
|
+
"fullMessage": self._get_full_message(record),
|
|
35
|
+
"level": record.levelno,
|
|
36
|
+
"sourceClassName": record.pathname,
|
|
37
|
+
"sourceLineNumber": record.lineno,
|
|
38
|
+
"sourceMethodName": record.funcName,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Add additional keys dynamically
|
|
42
|
+
for additional_key, additional_value in self.additional_keys.items():
|
|
43
|
+
log_entry[additional_key] = getattr(record, additional_value, None)
|
|
44
|
+
|
|
45
|
+
return json.dumps(log_entry, ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
def _get_full_message(self, record: logging.LogRecord) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Safely extract full error message
|
|
50
|
+
"""
|
|
51
|
+
if record.exc_info:
|
|
52
|
+
return self.formatException(record.exc_info)
|
|
53
|
+
return record.exc_text or ""
|
|
54
|
+
|
|
55
|
+
def setup_json_logging(
|
|
56
|
+
component: str = "cg-extension",
|
|
57
|
+
additional_keys: dict[str, str] = [],
|
|
58
|
+
log_level: int = logging.DEBUG
|
|
59
|
+
) -> logging.Logger:
|
|
60
|
+
"""
|
|
61
|
+
Setup JSON logging with a single logger
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
component (str): Name of the component for logging
|
|
65
|
+
additional_keys (dict[str, str]): Additional keys to include in log. The key is the key in the json logline, the value is the value of in the extra field for the logging lib.
|
|
66
|
+
log_level (int): Logging level (default: DEBUG)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
logging.Logger: Configured logger
|
|
70
|
+
"""
|
|
71
|
+
# Get the root logger
|
|
72
|
+
root_logger = logging.getLogger()
|
|
73
|
+
root_logger.setLevel(log_level)
|
|
74
|
+
|
|
75
|
+
# Remove existing handlers to prevent duplicate logs
|
|
76
|
+
for handler in root_logger.handlers[:]:
|
|
77
|
+
root_logger.removeHandler(handler)
|
|
78
|
+
|
|
79
|
+
# Create console handler
|
|
80
|
+
ch = logging.StreamHandler()
|
|
81
|
+
ch.setLevel(log_level)
|
|
82
|
+
|
|
83
|
+
# Create JSON formatter
|
|
84
|
+
formatter = XenitJsonFormatter(
|
|
85
|
+
component=component,
|
|
86
|
+
additional_keys=additional_keys
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Add formatter to handler
|
|
90
|
+
ch.setFormatter(formatter)
|
|
91
|
+
|
|
92
|
+
# Add handler to logger
|
|
93
|
+
root_logger.addHandler(ch)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2024 Xenit Solutions
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: contentgrid-extension-helpers
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Helper functions for contentgrid extensions.
|
|
5
|
+
Author-email: Ranec Belpaire <ranec.belpaire@xenit.eu>
|
|
6
|
+
License: Copyright 2024 Xenit Solutions
|
|
7
|
+
|
|
8
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
9
|
+
you may not use this file except in compliance with the License.
|
|
10
|
+
You may obtain a copy of the License at
|
|
11
|
+
|
|
12
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
|
|
14
|
+
Unless required by applicable law or agreed to in writing, software
|
|
15
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
16
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
17
|
+
See the License for the specific language governing permissions and
|
|
18
|
+
limitations under the License.
|
|
19
|
+
Classifier: Development Status :: 3 - Alpha
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Requires-Python: >=3.5
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: requests<3,>=2.20.0
|
|
26
|
+
Requires-Dist: uri-template<2
|
|
27
|
+
Requires-Dist: fastapi>=0.111
|
|
28
|
+
Requires-Dist: PyJWT>2
|
|
29
|
+
|
|
30
|
+
### ContentGrid-Extension-Helpers
|
|
31
|
+
|
|
32
|
+
ContentGrid extensions helpers is a python library that is used for defining reusable helper functions for python extensions.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
contentgrid_extension_helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
contentgrid_extension_helpers/authentication/__init__.py,sha256=b6Bp8jCg2-T7bcTGid7pCk5PfNYwH-52UFUX2Op8ArQ,146
|
|
3
|
+
contentgrid_extension_helpers/authentication/oidc.py,sha256=14XEmp_WWDVygb3oBKB9S29UlgJV8wnm_1lw36U4xxc,9820
|
|
4
|
+
contentgrid_extension_helpers/authentication/user.py,sha256=pr3DVZKchLxpJXU6k2uUNLquwr9LjWfr_sArdAjUjZU,185
|
|
5
|
+
contentgrid_extension_helpers/logging/__init__.py,sha256=u6edUMrsULUinBNN94yJywZyINz4azhIAXO2LMbmtaY,64
|
|
6
|
+
contentgrid_extension_helpers/logging/json_logging.py,sha256=QRoDHZjtMXM-JGPR6JWqJnCljwSQ912wjsZ38XMA7Tw,3119
|
|
7
|
+
contentgrid_extension_helpers-0.0.1.dist-info/LICENSE,sha256=tk6n-p8lEmzLJg-O4052CkMgfUtt1q2Zoh1QLAyL7S8,555
|
|
8
|
+
contentgrid_extension_helpers-0.0.1.dist-info/METADATA,sha256=eP5YG_IsyyRtfonDnTiwnLlKPuQHZ7JWkB2s4aFaO2k,1347
|
|
9
|
+
contentgrid_extension_helpers-0.0.1.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
|
|
10
|
+
contentgrid_extension_helpers-0.0.1.dist-info/top_level.txt,sha256=yJGGofrNVsl5psVGO0vLFHO1610ob88GtB9zpvS8iIk,30
|
|
11
|
+
contentgrid_extension_helpers-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
contentgrid_extension_helpers
|