python3-commons 0.12.7__py3-none-any.whl → 0.13.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.
Potentially problematic release.
This version of python3-commons might be problematic. Click here for more details.
- python3_commons/auth.py +8 -48
- python3_commons/db/models/__init__.py +0 -7
- python3_commons/db/models/auth.py +2 -26
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/METADATA +1 -5
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/RECORD +9 -11
- python3_commons/db/models/rbac.py +0 -91
- python3_commons/permissions.py +0 -46
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/WHEEL +0 -0
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/licenses/AUTHORS.rst +0 -0
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/licenses/LICENSE +0 -0
- {python3_commons-0.12.7.dist-info → python3_commons-0.13.1.dist-info}/top_level.txt +0 -0
python3_commons/auth.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from collections.abc import
|
|
2
|
+
from collections.abc import Sequence
|
|
3
3
|
from http import HTTPStatus
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import TypeVar
|
|
5
5
|
|
|
6
6
|
import aiohttp
|
|
7
7
|
import msgspec
|
|
8
|
-
from fastapi import Depends, HTTPException
|
|
9
|
-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
10
|
-
from jose import JWTError, jwt
|
|
11
8
|
|
|
12
9
|
from python3_commons.conf import oidc_settings
|
|
13
10
|
|
|
@@ -23,7 +20,6 @@ class TokenData(msgspec.Struct):
|
|
|
23
20
|
|
|
24
21
|
T = TypeVar('T', bound=TokenData)
|
|
25
22
|
OIDC_CONFIG_URL = f'{oidc_settings.authority_url}/.well-known/openid-configuration'
|
|
26
|
-
bearer_security = HTTPBearer(auto_error=oidc_settings.enabled)
|
|
27
23
|
|
|
28
24
|
|
|
29
25
|
async def fetch_openid_config() -> dict:
|
|
@@ -32,9 +28,9 @@ async def fetch_openid_config() -> dict:
|
|
|
32
28
|
"""
|
|
33
29
|
async with aiohttp.ClientSession() as session, session.get(OIDC_CONFIG_URL) as response:
|
|
34
30
|
if response.status != HTTPStatus.OK:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
31
|
+
msg = 'Failed to fetch OpenID configuration'
|
|
32
|
+
|
|
33
|
+
raise RuntimeError(msg)
|
|
38
34
|
|
|
39
35
|
return await response.json()
|
|
40
36
|
|
|
@@ -45,44 +41,8 @@ async def fetch_jwks(jwks_uri: str) -> dict:
|
|
|
45
41
|
"""
|
|
46
42
|
async with aiohttp.ClientSession() as session, session.get(jwks_uri) as response:
|
|
47
43
|
if response.status != HTTPStatus.OK:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return await response.json()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def get_token_verifier[T](
|
|
54
|
-
token_cls: type[T],
|
|
55
|
-
jwks: MutableMapping,
|
|
56
|
-
) -> Callable[[HTTPAuthorizationCredentials], Coroutine[Any, Any, T | None]]:
|
|
57
|
-
async def get_verified_token(
|
|
58
|
-
authorization: Annotated[HTTPAuthorizationCredentials, Depends(bearer_security)],
|
|
59
|
-
) -> T | None:
|
|
60
|
-
"""
|
|
61
|
-
Verify the JWT access token using OIDC authority JWKS.
|
|
62
|
-
"""
|
|
63
|
-
if not oidc_settings.enabled:
|
|
64
|
-
return None
|
|
44
|
+
msg = 'Failed to fetch JWKS'
|
|
65
45
|
|
|
66
|
-
|
|
46
|
+
raise RuntimeError(msg)
|
|
67
47
|
|
|
68
|
-
|
|
69
|
-
if not jwks:
|
|
70
|
-
openid_config = await fetch_openid_config()
|
|
71
|
-
_jwks = await fetch_jwks(openid_config['jwks_uri'])
|
|
72
|
-
jwks.clear()
|
|
73
|
-
jwks.update(_jwks)
|
|
74
|
-
|
|
75
|
-
if oidc_settings.client_id:
|
|
76
|
-
payload = jwt.decode(token, jwks, algorithms=['RS256'], audience=oidc_settings.client_id)
|
|
77
|
-
else:
|
|
78
|
-
payload = jwt.decode(token, jwks, algorithms=['RS256'])
|
|
79
|
-
|
|
80
|
-
token_data = token_cls(**payload)
|
|
81
|
-
except jwt.ExpiredSignatureError as e:
|
|
82
|
-
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail='Token has expired') from e
|
|
83
|
-
except JWTError as e:
|
|
84
|
-
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=f'Token is invalid: {e!s}') from e
|
|
85
|
-
|
|
86
|
-
return token_data
|
|
87
|
-
|
|
88
|
-
return get_verified_token
|
|
48
|
+
return await response.json()
|
|
@@ -1,8 +1 @@
|
|
|
1
|
-
from python3_commons.db.models.auth import ApiKey as ApiKey
|
|
2
|
-
from python3_commons.db.models.auth import User as User
|
|
3
1
|
from python3_commons.db.models.auth import UserGroup as UserGroup
|
|
4
|
-
from python3_commons.db.models.rbac import RBACApiKeyRole as RBACApiKeyRole
|
|
5
|
-
from python3_commons.db.models.rbac import RBACPermission as RBACPermission
|
|
6
|
-
from python3_commons.db.models.rbac import RBACRole as RBACRole
|
|
7
|
-
from python3_commons.db.models.rbac import RBACRolePermission as RBACRolePermission
|
|
8
|
-
from python3_commons.db.models.rbac import RBACUserRole as RBACUserRole
|
|
@@ -1,35 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
|
|
4
|
-
from fastapi_users_db_sqlalchemy import GUID, SQLAlchemyBaseUserTableUUID
|
|
5
|
-
from sqlalchemy import BIGINT, DateTime, ForeignKey, String
|
|
1
|
+
from sqlalchemy import String
|
|
6
2
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
3
|
|
|
8
4
|
from python3_commons.db import Base
|
|
9
|
-
from python3_commons.db.models.common import BaseDBModel
|
|
5
|
+
from python3_commons.db.models.common import BaseDBModel
|
|
10
6
|
|
|
11
7
|
|
|
12
8
|
class UserGroup(BaseDBModel, Base):
|
|
13
9
|
__tablename__ = 'user_groups'
|
|
14
10
|
|
|
15
11
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class User(SQLAlchemyBaseUserTableUUID, Base):
|
|
19
|
-
__tablename__ = 'users'
|
|
20
|
-
|
|
21
|
-
username: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
|
22
|
-
group_id: Mapped[int | None] = mapped_column(BIGINT, ForeignKey('user_groups.id'))
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class ApiKey(BaseDBUUIDModel, Base):
|
|
26
|
-
__tablename__ = 'api_keys'
|
|
27
|
-
|
|
28
|
-
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
29
|
-
GUID,
|
|
30
|
-
ForeignKey('users.id', name='fk_api_key_user', ondelete='RESTRICT'),
|
|
31
|
-
index=True,
|
|
32
|
-
)
|
|
33
|
-
partner_name: Mapped[str] = mapped_column(String, unique=True)
|
|
34
|
-
key: Mapped[str] = mapped_column(String, unique=True)
|
|
35
|
-
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python3-commons
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.1
|
|
4
4
|
Summary: Re-usable Python3 code
|
|
5
5
|
Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0
|
|
@@ -15,14 +15,10 @@ License-File: AUTHORS.rst
|
|
|
15
15
|
Requires-Dist: aiobotocore~=2.25.0
|
|
16
16
|
Requires-Dist: aiohttp[speedups]<3.15.0,>=3.12.0
|
|
17
17
|
Requires-Dist: asyncpg~=0.30.0
|
|
18
|
-
Requires-Dist: fastapi-users-db-sqlalchemy~=7.0.0
|
|
19
|
-
Requires-Dist: fastapi-users[sqlalchemy]~=15.0.1
|
|
20
18
|
Requires-Dist: lxml~=6.0.2
|
|
21
19
|
Requires-Dist: msgpack~=1.1.2
|
|
22
20
|
Requires-Dist: msgspec~=0.19.0
|
|
23
|
-
Requires-Dist: pydantic[email]~=2.12.3
|
|
24
21
|
Requires-Dist: pydantic-settings~=2.11.0
|
|
25
|
-
Requires-Dist: python-jose==3.5.0
|
|
26
22
|
Requires-Dist: SQLAlchemy[asyncio]~=2.0.44
|
|
27
23
|
Requires-Dist: valkey[libvalkey]~=6.1.1
|
|
28
24
|
Requires-Dist: zeep~=4.3.2
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
python3_commons/__init__.py,sha256=0KgaYU46H_IMKn-BuasoRN3C4Hi45KlkHHoPbU9cwiA,189
|
|
2
2
|
python3_commons/api_client.py,sha256=0PE8iYW5zq7n89veC6afSkwj_fzD_nldBc5wdXyg5jo,5266
|
|
3
3
|
python3_commons/audit.py,sha256=vPwGR0s7RoDpxhK1Q2N44T1pdannxmSgpqcA5hn5bPE,6078
|
|
4
|
-
python3_commons/auth.py,sha256=
|
|
4
|
+
python3_commons/auth.py,sha256=DJVOTFtJ8TuyMXNoMK3xtXsHx50FBdvOvo2BuQVjLJY,1239
|
|
5
5
|
python3_commons/cache.py,sha256=-3CvGo1FH5H8N_gUClEcvWgz5VWU5YlgQa48PXfdmMY,7728
|
|
6
6
|
python3_commons/conf.py,sha256=77nqTBL5bAk8iOEdtdHWjYx8wLKtHBj6TCylSo3BWQk,2392
|
|
7
7
|
python3_commons/fs.py,sha256=dn8ZcwsQf9xcAEg6neoxLN6IzJbWpprfm8wV8S55BL0,337
|
|
8
8
|
python3_commons/helpers.py,sha256=RpbHU04MXQ-b2GC0RZlmkcpu33KCU4Mq8-qa2hbNbgA,4027
|
|
9
9
|
python3_commons/object_storage.py,sha256=dhTCHz_SbttB9e4wkzp3jxs-fASFa2L-F-PdkG5bedI,6584
|
|
10
|
-
python3_commons/permissions.py,sha256=n6q4mSlBkiIeQMNFydQoXCkyu-TovAEzYK_Cvg2rU6g,1527
|
|
11
10
|
python3_commons/db/__init__.py,sha256=gizEDoNNWMWyaLSpb01qXmyViKPaz1XRrk_fOZ9AveU,2918
|
|
12
11
|
python3_commons/db/helpers.py,sha256=n56yYCE0fvzvU7nL1936NfZhbaQmvfumzRsGimBlNV4,1776
|
|
13
|
-
python3_commons/db/models/__init__.py,sha256=
|
|
14
|
-
python3_commons/db/models/auth.py,sha256=
|
|
12
|
+
python3_commons/db/models/__init__.py,sha256=EpTt9C7vimMDSB7DBuFDM0xaPSyWR443zetTQuB9_aA,66
|
|
13
|
+
python3_commons/db/models/auth.py,sha256=3RndBuvARojVXO7RdvJ37A0OUvHTD39gpZ3MnKXGS-c,308
|
|
15
14
|
python3_commons/db/models/common.py,sha256=nRLQVi7Y0SsXo3qMIwQX6GuDO9kHnlma4O_mYXQVtHQ,1512
|
|
16
|
-
python3_commons/db/models/rbac.py,sha256=BIB7nJXQkCes0XA-fg-oCHP6YU0_rXIm29O73j4pNUg,3160
|
|
17
15
|
python3_commons/log/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
16
|
python3_commons/log/filters.py,sha256=fuyjXZAUm-i2MNrxvFYag8F8Rr27x8W8MdV3ke6miSs,175
|
|
19
17
|
python3_commons/log/formatters.py,sha256=p2AtZD4Axp3Em0e9gWzW8U_yOR5entD7xn7Edvc-IuM,719
|
|
@@ -22,9 +20,9 @@ python3_commons/serializers/common.py,sha256=VkA7C6wODvHk0QBXVX_x2JieDstihx3U__U
|
|
|
22
20
|
python3_commons/serializers/json.py,sha256=UPkC3ps13x2C_NxwVV-K7Ewp4VjkVHSSUkJVw5k7Wiw,712
|
|
23
21
|
python3_commons/serializers/msgpack.py,sha256=mKQwfjPHh3BiXYCZLAhBsEhys8DxKHMZJIq-gDgqGDM,1451
|
|
24
22
|
python3_commons/serializers/msgspec.py,sha256=CAqlaZnDh-jiT6B_TsxXF9AojROMcV3bBd0Kp8ZFAzY,2952
|
|
25
|
-
python3_commons-0.
|
|
26
|
-
python3_commons-0.
|
|
27
|
-
python3_commons-0.
|
|
28
|
-
python3_commons-0.
|
|
29
|
-
python3_commons-0.
|
|
30
|
-
python3_commons-0.
|
|
23
|
+
python3_commons-0.13.1.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
|
|
24
|
+
python3_commons-0.13.1.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
|
|
25
|
+
python3_commons-0.13.1.dist-info/METADATA,sha256=NHWuqEYyItPHSI4KUW636Uk575RLjcfw1FS4SXM7dBc,977
|
|
26
|
+
python3_commons-0.13.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
27
|
+
python3_commons-0.13.1.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
|
|
28
|
+
python3_commons-0.13.1.dist-info/RECORD,,
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import uuid
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
|
|
4
|
-
from fastapi_users_db_sqlalchemy import GUID
|
|
5
|
-
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, PrimaryKeyConstraint, String
|
|
6
|
-
from sqlalchemy.dialects.postgresql import UUID
|
|
7
|
-
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
-
|
|
9
|
-
from python3_commons.db import Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class RBACRole(Base):
|
|
13
|
-
__tablename__ = 'rbac_roles'
|
|
14
|
-
|
|
15
|
-
uid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True)
|
|
16
|
-
name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class RBACPermission(Base):
|
|
20
|
-
__tablename__ = 'rbac_permissions'
|
|
21
|
-
|
|
22
|
-
uid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True)
|
|
23
|
-
name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
24
|
-
|
|
25
|
-
__table_args__ = (CheckConstraint("name ~ '^[a-z0-9_.]+$'", name='check_rbac_permissions_name'),)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class RBACRolePermission(Base):
|
|
29
|
-
__tablename__ = 'rbac_role_permissions'
|
|
30
|
-
|
|
31
|
-
role_uid: Mapped[uuid.UUID | None] = mapped_column(
|
|
32
|
-
UUID,
|
|
33
|
-
ForeignKey('rbac_roles.uid', name='fk_rbac_role_permissions_role', ondelete='CASCADE'),
|
|
34
|
-
index=True,
|
|
35
|
-
)
|
|
36
|
-
permission_uid: Mapped[uuid.UUID | None] = mapped_column(
|
|
37
|
-
UUID,
|
|
38
|
-
ForeignKey('rbac_permissions.uid', name='fk_rbac_role_permissions_permission', ondelete='CASCADE'),
|
|
39
|
-
index=True,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
__table_args__ = (PrimaryKeyConstraint('role_uid', 'permission_uid', name='pk_rbac_role_permissions'),)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class RBACUserRole(Base):
|
|
46
|
-
__tablename__ = 'rbac_user_roles'
|
|
47
|
-
|
|
48
|
-
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
49
|
-
GUID,
|
|
50
|
-
ForeignKey('users.id', name='fk_rbac_user_roles_user', ondelete='CASCADE'),
|
|
51
|
-
index=True,
|
|
52
|
-
)
|
|
53
|
-
role_uid: Mapped[uuid.UUID | None] = mapped_column(
|
|
54
|
-
UUID,
|
|
55
|
-
ForeignKey('rbac_roles.uid', name='fk_rbac_user_roles_role', ondelete='CASCADE'),
|
|
56
|
-
index=True,
|
|
57
|
-
)
|
|
58
|
-
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
59
|
-
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
60
|
-
|
|
61
|
-
__table_args__ = (PrimaryKeyConstraint('user_id', 'role_uid', name='pk_rbac_user_roles'),)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class RBACApiKeyRole(Base):
|
|
65
|
-
__tablename__ = 'rbac_api_key_roles'
|
|
66
|
-
|
|
67
|
-
api_key_uid: Mapped[uuid.UUID | None] = mapped_column(
|
|
68
|
-
UUID,
|
|
69
|
-
ForeignKey('api_keys.uid', name='fk_rbac_api_key_roles_user', ondelete='CASCADE'),
|
|
70
|
-
index=True,
|
|
71
|
-
)
|
|
72
|
-
role_uid: Mapped[uuid.UUID | None] = mapped_column(
|
|
73
|
-
UUID,
|
|
74
|
-
ForeignKey('rbac_roles.uid', name='fk_rbac_api_key_roles_role', ondelete='CASCADE'),
|
|
75
|
-
index=True,
|
|
76
|
-
)
|
|
77
|
-
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
78
|
-
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
79
|
-
|
|
80
|
-
__table_args__ = (PrimaryKeyConstraint('api_key_uid', 'role_uid', name='pk_rbac_api_key_roles'),)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# class RBACRoleRelation(Base):
|
|
84
|
-
# __tablename__ = 'rbac_role_relations'
|
|
85
|
-
#
|
|
86
|
-
# parent_uid: Mapped[uuid.UUID] = mapped_column(UUID)
|
|
87
|
-
# child_uid: Mapped[uuid.UUID] = mapped_column(UUID)
|
|
88
|
-
#
|
|
89
|
-
# __table_args__ = (
|
|
90
|
-
# PrimaryKeyConstraint('parent_uid', 'child_uid', name='pk_rbac_role_relations'),
|
|
91
|
-
# )
|
python3_commons/permissions.py
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from uuid import UUID
|
|
3
|
-
|
|
4
|
-
import sqlalchemy as sa
|
|
5
|
-
from sqlalchemy import and_, exists, func
|
|
6
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
-
|
|
8
|
-
from python3_commons.db.models import RBACApiKeyRole, RBACPermission, RBACRolePermission, RBACUserRole
|
|
9
|
-
|
|
10
|
-
logger = logging.getLogger(__name__)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
async def has_api_key_permission(session: AsyncSession, api_key_uid: UUID, permission: str) -> bool:
|
|
14
|
-
query = sa.select(
|
|
15
|
-
exists().where(
|
|
16
|
-
and_(
|
|
17
|
-
RBACApiKeyRole.api_key_uid == api_key_uid,
|
|
18
|
-
(RBACApiKeyRole.expires_at.is_(None) | (RBACApiKeyRole.expires_at > func.now())),
|
|
19
|
-
RBACApiKeyRole.role_uid == RBACRolePermission.role_uid,
|
|
20
|
-
RBACRolePermission.permission_uid == RBACPermission.uid,
|
|
21
|
-
RBACPermission.name == permission,
|
|
22
|
-
)
|
|
23
|
-
)
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
cursor = await session.execute(query)
|
|
27
|
-
|
|
28
|
-
return bool(cursor.scalar())
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
async def has_user_permission(session: AsyncSession, user_id: UUID, permission: str) -> bool:
|
|
32
|
-
query = sa.select(
|
|
33
|
-
exists().where(
|
|
34
|
-
and_(
|
|
35
|
-
RBACUserRole.user_id == user_id,
|
|
36
|
-
(RBACUserRole.expires_at.is_(None) | (RBACUserRole.expires_at > func.now())),
|
|
37
|
-
RBACUserRole.role_uid == RBACRolePermission.role_uid,
|
|
38
|
-
RBACRolePermission.permission_uid == RBACPermission.uid,
|
|
39
|
-
RBACPermission.name == permission,
|
|
40
|
-
)
|
|
41
|
-
)
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
cursor = await session.execute(query)
|
|
45
|
-
|
|
46
|
-
return bool(cursor.scalar())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|