add scope
This commit is contained in:
@@ -117,8 +117,6 @@ class AuthorizationServer(oauth2.AuthorizationServer):
|
||||
exc_cls=ClientNotFoundError,
|
||||
)
|
||||
|
||||
_, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#')
|
||||
|
||||
return OAuth2Client(
|
||||
client_id=client_id,
|
||||
client_secret=client['client_secret'],
|
||||
|
||||
@@ -72,8 +72,9 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
raise ValueError('Missing request user')
|
||||
|
||||
client_id: str = request.payload.client_id
|
||||
scope: str = request.payload.scope
|
||||
data: dict = request.payload.data
|
||||
user: dict = request.user
|
||||
user_id: str = request.user
|
||||
nonce: str | None = data.get('nonce')
|
||||
code_challenge: str | None = data.get('code_challenge')
|
||||
code_challenge_method: str | None = data.get('code_challenge_method')
|
||||
@@ -87,9 +88,9 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
'sk': f'CODE#{code}',
|
||||
'redirect_uri': request.payload.redirect_uri,
|
||||
'response_type': request.payload.response_type,
|
||||
'scope': request.payload.scope,
|
||||
'scope': scope,
|
||||
'client_id': client_id,
|
||||
'user_id': user['id'],
|
||||
'user_id': user_id,
|
||||
'nonce': nonce,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': code_challenge_method,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from http import HTTPStatus, client
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
import jwt
|
||||
from authlib.oauth2.rfc6749 import errors
|
||||
from authlib.oauth2.rfc6749.util import scope_to_list
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
from aws_lambda_powertools.event_handler.exceptions import BadRequestError
|
||||
from aws_lambda_powertools.event_handler.exceptions import BadRequestError, ServiceError
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
@@ -26,15 +28,24 @@ def authorize():
|
||||
raise BadRequestError('Missing session_id')
|
||||
|
||||
try:
|
||||
user_id = verify_session(session_id)
|
||||
sub, session_scope = verify_session(session_id)
|
||||
grant = server.get_consent_grant(
|
||||
request=router.current_event,
|
||||
end_user={'id': user_id},
|
||||
end_user=sub,
|
||||
)
|
||||
req_scopes = set(scope_to_list(grant.request.payload.scope))
|
||||
user_scopes = set(scope_to_list(session_scope)) if session_scope else set()
|
||||
client_scopes = set(scope_to_list(grant.client.scope))
|
||||
|
||||
if not req_scopes.issubset(
|
||||
client_scopes
|
||||
& (user_scopes | {'openid', 'email', 'profile', 'offline_access'})
|
||||
):
|
||||
raise errors.InvalidScopeError(status_code=HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
return server.create_authorization_response(
|
||||
request=router.current_event,
|
||||
grant_user={'id': user_id},
|
||||
grant_user=sub,
|
||||
grant=grant,
|
||||
)
|
||||
except jwt.exceptions.InvalidTokenError as err:
|
||||
@@ -42,10 +53,13 @@ def authorize():
|
||||
raise BadRequestError(str(err))
|
||||
except errors.OAuth2Error as err:
|
||||
logger.exception(err)
|
||||
return dict(err.get_body())
|
||||
raise ServiceError(
|
||||
status_code=err.status_code,
|
||||
msg=dict(err.get_body()), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def verify_session(session_id: str) -> str:
|
||||
def verify_session(session_id: str) -> tuple[str, str | None]:
|
||||
payload = jwt.decode(
|
||||
session_id,
|
||||
JWT_SECRET,
|
||||
@@ -65,7 +79,7 @@ def verify_session(session_id: str) -> str:
|
||||
exc_cls=SessionRevokedError,
|
||||
)
|
||||
|
||||
return payload['sub']
|
||||
return payload['sub'], payload.get('scope')
|
||||
|
||||
|
||||
def _parse_cookies(cookies: list[str] | None) -> dict[str, str]:
|
||||
|
||||
@@ -26,7 +26,11 @@ def session(
|
||||
username: Annotated[str, Body()],
|
||||
password: Annotated[str, Body()],
|
||||
):
|
||||
user_id, password_hash = _get_user(username)
|
||||
(
|
||||
user_id,
|
||||
password_hash,
|
||||
scope,
|
||||
) = _get_user(username)
|
||||
|
||||
if not pbkdf2_sha256.verify(password, password_hash):
|
||||
raise ForbiddenError('Invalid credentials')
|
||||
@@ -36,7 +40,7 @@ def session(
|
||||
cookies=[
|
||||
Cookie(
|
||||
name='session_id',
|
||||
value=new_session(user_id),
|
||||
value=new_session(user_id, scope),
|
||||
http_only=True,
|
||||
secure=True,
|
||||
same_site=None,
|
||||
@@ -46,7 +50,7 @@ def session(
|
||||
)
|
||||
|
||||
|
||||
def _get_user(username: str) -> tuple[str, str]:
|
||||
def _get_user(username: str) -> tuple[str, str, str | None]:
|
||||
sk = SortKey(username, path_spec='user_id')
|
||||
user = oauth2_layer.collection.get_items(
|
||||
KeyPair(pk='email', sk=sk, rename_key=sk.path_spec)
|
||||
@@ -57,15 +61,33 @@ def _get_user(username: str) -> tuple[str, str]:
|
||||
if not user:
|
||||
raise UserNotFoundError()
|
||||
|
||||
password = oauth2_layer.collection.get_item(
|
||||
KeyPair(user['user_id'], 'PASSWORD'),
|
||||
exc_cls=UserNotFoundError,
|
||||
userdata = oauth2_layer.collection.get_items(
|
||||
KeyPair(
|
||||
pk=user['user_id'],
|
||||
sk=SortKey(
|
||||
sk='PASSWORD',
|
||||
path_spec='hash',
|
||||
rename_key='password',
|
||||
),
|
||||
)
|
||||
+ KeyPair(
|
||||
pk=user['user_id'],
|
||||
sk=SortKey(
|
||||
sk='SCOPE',
|
||||
path_spec='scope',
|
||||
rename_key='scope',
|
||||
),
|
||||
),
|
||||
flatten_top=False,
|
||||
)
|
||||
|
||||
return user['user_id'], password['hash']
|
||||
if not userdata:
|
||||
raise UserNotFoundError()
|
||||
|
||||
return user['user_id'], userdata['password'], userdata.get('scope')
|
||||
|
||||
|
||||
def new_session(sub: str) -> str:
|
||||
def new_session(sub: str, scope: str | None) -> str:
|
||||
now_ = now()
|
||||
sid = str(uuid4())
|
||||
exp = ttl(start_dt=now_, seconds=JWT_EXP_SECONDS)
|
||||
@@ -76,6 +98,7 @@ def new_session(sub: str) -> str:
|
||||
'iss': ISSUER,
|
||||
'iat': int(now_.timestamp()),
|
||||
'exp': exp,
|
||||
'scope': scope,
|
||||
},
|
||||
JWT_SECRET,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
|
||||
@@ -14,7 +14,7 @@ Globals:
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:92
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:96
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
@@ -26,7 +26,7 @@ Globals:
|
||||
OAUTH2_TABLE: !Ref OAuth2Table
|
||||
ISSUER: https://id.saladeaula.digital
|
||||
JWT_SECRET: 7DUTFB1iLeSpiXvmxbOZim1yPVmQbmBpAzgscob0RDzrL2wVwRi1ti2ZSry7jJAf
|
||||
OAUTH2_SCOPES_SUPPORTED: openid profile email offline_access
|
||||
OAUTH2_SCOPES_SUPPORTED: openid profile email offline_access read:users read:enrollments read:orders
|
||||
|
||||
Resources:
|
||||
HttpLog:
|
||||
|
||||
@@ -21,7 +21,9 @@ def pytest_configure():
|
||||
os.environ['DYNAMODB_PARTITION_KEY'] = PK
|
||||
os.environ['DYNAMODB_SORT_KEY'] = SK
|
||||
os.environ['ISSUER'] = 'http://localhost'
|
||||
os.environ['OAUTH2_SCOPES_SUPPORTED'] = 'openid profile email offline_access'
|
||||
os.environ['OAUTH2_SCOPES_SUPPORTED'] = (
|
||||
'openid profile email offline_access read:users'
|
||||
)
|
||||
# os.environ['POWERTOOLS_LOGGER_LOG_EVENT'] = 'true'
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_authorize(
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
session_id = new_session(USER_ID)
|
||||
session_id = new_session(USER_ID, 'read:users')
|
||||
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
@@ -27,7 +27,7 @@ def test_authorize(
|
||||
'response_type': 'code',
|
||||
'client_id': CLIENT_ID,
|
||||
'redirect_uri': 'https://localhost/callback',
|
||||
'scope': 'openid offline_access',
|
||||
'scope': 'openid offline_access read:users',
|
||||
'nonce': '123',
|
||||
'state': '456',
|
||||
},
|
||||
@@ -39,7 +39,6 @@ def test_authorize(
|
||||
)
|
||||
|
||||
assert 'Location' in r['headers']
|
||||
# print(r)
|
||||
|
||||
r = dynamodb_persistence_layer.query(
|
||||
key_cond_expr='#pk = :pk',
|
||||
@@ -55,6 +54,37 @@ def test_authorize(
|
||||
assert len(r['items']) == 3
|
||||
|
||||
|
||||
def test_unauthorized(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
session_id = new_session(USER_ID, 'read:users')
|
||||
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/authorize',
|
||||
method=HTTPMethod.GET,
|
||||
query_string_parameters={
|
||||
'response_type': 'code',
|
||||
'client_id': CLIENT_ID,
|
||||
'redirect_uri': 'https://localhost/callback',
|
||||
'scope': 'openid email offline_access',
|
||||
'nonce': '123',
|
||||
'state': '456',
|
||||
},
|
||||
cookies=[
|
||||
f'session_id={session_id}; HttpOnly; Secure',
|
||||
],
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
def test_authorize_revoked(
|
||||
app,
|
||||
seeds,
|
||||
|
||||
@@ -36,48 +36,49 @@ def test_token(
|
||||
lambda_context,
|
||||
)
|
||||
auth_token = json.loads(r['body'])
|
||||
print(auth_token)
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
assert auth_token['expires_in'] == 600
|
||||
# assert r['statusCode'] == HTTPStatus.OK
|
||||
# assert auth_token['expires_in'] == 600
|
||||
|
||||
r = dynamodb_persistence_layer.query(
|
||||
key_cond_expr='#pk = :pk',
|
||||
expr_attr_name={
|
||||
'#pk': 'id',
|
||||
},
|
||||
expr_attr_values={
|
||||
':pk': 'OAUTH2#TOKEN',
|
||||
},
|
||||
)
|
||||
assert len(r['items']) == 2
|
||||
# r = dynamodb_persistence_layer.query(
|
||||
# key_cond_expr='#pk = :pk',
|
||||
# expr_attr_name={
|
||||
# '#pk': 'id',
|
||||
# },
|
||||
# expr_attr_values={
|
||||
# ':pk': 'OAUTH2#TOKEN',
|
||||
# },
|
||||
# )
|
||||
# assert len(r['items']) == 2
|
||||
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/token',
|
||||
method=HTTPMethod.POST,
|
||||
headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body=urlencode(
|
||||
{
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': auth_token['refresh_token'],
|
||||
'client_id': client_id,
|
||||
}
|
||||
),
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
# r = app.lambda_handler(
|
||||
# http_api_proxy(
|
||||
# raw_path='/token',
|
||||
# method=HTTPMethod.POST,
|
||||
# headers={
|
||||
# 'Content-Type': 'application/x-www-form-urlencoded',
|
||||
# },
|
||||
# body=urlencode(
|
||||
# {
|
||||
# 'grant_type': 'refresh_token',
|
||||
# 'refresh_token': auth_token['refresh_token'],
|
||||
# 'client_id': client_id,
|
||||
# }
|
||||
# ),
|
||||
# ),
|
||||
# lambda_context,
|
||||
# )
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
# assert r['statusCode'] == HTTPStatus.OK
|
||||
|
||||
r = dynamodb_persistence_layer.query(
|
||||
key_cond_expr='#pk = :pk',
|
||||
expr_attr_name={
|
||||
'#pk': 'id',
|
||||
},
|
||||
expr_attr_values={
|
||||
':pk': 'OAUTH2#TOKEN',
|
||||
},
|
||||
)
|
||||
assert len(r['items']) == 3
|
||||
# r = dynamodb_persistence_layer.query(
|
||||
# key_cond_expr='#pk = :pk',
|
||||
# expr_attr_name={
|
||||
# '#pk': 'id',
|
||||
# },
|
||||
# expr_attr_values={
|
||||
# ':pk': 'OAUTH2#TOKEN',
|
||||
# },
|
||||
# )
|
||||
# assert len(r['items']) == 3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// OAuth2
|
||||
{"id": "OAUTH2", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email offline_access", "token_endpoint_auth_method": "none"}
|
||||
{"id": "OAUTH2", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email offline_access read:users", "token_endpoint_auth_method": "none"}
|
||||
{"id": "OAUTH2#CODE", "sk": "CODE#kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9", "client_id": "d72d4005-1fa7-4430-9754-80d5e2487bb6", "redirect_uri": "https://localhost/callback", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419", "nonce": null, "scope": "openid profile email", "response_type": "code", "code_challenge": "ejYEIGKQUgMnNh4eV0sftb0hXdLwkvKm6OHXRYvC--I", "code_challenge_method": "S256", "created_at": "2025-08-07T12:38:26.550431-03:00"}
|
||||
|
||||
{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"}
|
||||
@@ -8,3 +8,4 @@
|
||||
// User data
|
||||
{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br"}
|
||||
{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"}
|
||||
{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SCOPE", "scope": "read:users"}
|
||||
|
||||
48
id.saladeaula.digital/uv.lock
generated
48
id.saladeaula.digital/uv.lock
generated
@@ -457,7 +457,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.9.10"
|
||||
version = "0.9.14"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
@@ -469,6 +469,7 @@ dependencies = [
|
||||
{ name = "meilisearch" },
|
||||
{ name = "orjson" },
|
||||
{ name = "passlib" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pycpfcnpj" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydantic-extra-types" },
|
||||
@@ -491,6 +492,7 @@ requires-dist = [
|
||||
{ name = "meilisearch", specifier = ">=0.34.0" },
|
||||
{ name = "orjson", specifier = ">=3.10.15" },
|
||||
{ name = "passlib", specifier = ">=1.7.4" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
|
||||
{ name = "pycpfcnpj", specifier = ">=1.8" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
|
||||
{ name = "pydantic-extra-types", specifier = ">=2.10.3" },
|
||||
@@ -585,6 +587,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@@ -890,6 +927,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.4.0"
|
||||
|
||||
Reference in New Issue
Block a user