Skip to content

Commit ff5fda2

Browse files
authored
Merge branch 'main' into pre-commit-ci-update-config
2 parents faa2d4f + 1ba0b52 commit ff5fda2

File tree

10 files changed

+215
-58
lines changed

10 files changed

+215
-58
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request:
77
branches: ["main"]
88

9+
env:
10+
UV_FROZEN: 1
11+
912
jobs:
1013
lint:
1114
name: Lint

.github/workflows/release.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,19 @@ jobs:
5757
- name: Commit and push changes
5858
if: ${{ steps.release-version.outputs.release-version != steps.current-version.outputs.current-version }}
5959
run: |
60-
git add .
61-
git commit -m "🚀 Release $RELEASE_VERSION"
60+
git commit -a -m "🚀 Release $RELEASE_VERSION" || exit 0
6261
git tag -f $RELEASE_VERSION
6362
git push origin $RELEASE_VERSION --force
6463
git push origin HEAD:main
64+
env:
65+
RELEASE_VERSION: ${{ steps.release-version.outputs.release-version }}
66+
6567

6668
publish-docs:
6769
runs-on: ubuntu-latest
6870
needs: [bump-version]
71+
env:
72+
UV_FROZEN: 1
6973
steps:
7074
- uses: actions/checkout@v4
7175
with:
@@ -91,6 +95,8 @@ jobs:
9195
publish-pypi:
9296
needs: [bump-version]
9397
runs-on: ubuntu-latest
98+
env:
99+
UV_FROZEN: 1
94100
steps:
95101
- name: Checkout
96102
uses: actions/checkout@v4

.pre-commit-config.yaml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,29 @@ repos:
5050
types: [python]
5151
require_serial: true
5252

53-
- id: pytest
54-
name: pytest
53+
- id: pytest-unit
54+
name: pytest-unit
5555
description: "Run 'pytest' for unit testing"
56-
entry: uv run pytest --cov-fail-under=90
56+
entry: uv run pytest -m "not integration"
5757
language: system
5858
pass_filenames: false
5959

60+
- id: pytest-integration
61+
name: pytest-integration
62+
description: "Run 'pytest' for integration testing"
63+
entry: uv run pytest -m "integration" --cov-append
64+
language: system
65+
pass_filenames: false
66+
67+
- id: coverage-report
68+
name: coverage-report
69+
description: "Generate coverage report"
70+
entry: uv run coverage report --fail-under=100
71+
language: system
72+
pass_filenames: false
73+
74+
6075
ci:
6176
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
6277
autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate
63-
skip: [uv-lock, mypy, pytest]
78+
skip: [uv-lock, mypy, pytest-unit, pytest-integration, coverage-report]

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"python.terminal.activateEnvironment": true,
2424
"python.testing.pytestEnabled": true,
2525
"python.testing.unittestEnabled": false,
26-
"python.testing.pytestArgs": ["--no-cov", "--color=yes"],
26+
"python.testing.pytestArgs": ["--color=yes"],
2727
"python.analysis.inlayHints.pytestParameters": true,
2828

2929
// Python editor settings

grelmicro/sync/postgres.py

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,40 +16,52 @@
1616

1717

1818
class _PostgresSettings(BaseSettings):
19+
"""PostgreSQL settings from the environment variables."""
20+
1921
POSTGRES_HOST: str | None = None
2022
POSTGRES_PORT: int = 5432
2123
POSTGRES_DB: str | None = None
2224
POSTGRES_USER: str | None = None
2325
POSTGRES_PASSWORD: str | None = None
2426
POSTGRES_URL: PostgresDsn | None = None
2527

26-
def url(self) -> str:
27-
"""Generate the Postgres URL from the parts."""
28-
if self.POSTGRES_URL:
29-
return self.POSTGRES_URL.unicode_string()
30-
31-
if all(
32-
(
33-
self.POSTGRES_HOST,
34-
self.POSTGRES_DB,
35-
self.POSTGRES_USER,
36-
self.POSTGRES_PASSWORD,
37-
)
38-
):
39-
return MultiHostUrl.build(
40-
scheme="postgresql",
41-
username=self.POSTGRES_USER,
42-
password=self.POSTGRES_PASSWORD,
43-
host=self.POSTGRES_HOST,
44-
port=self.POSTGRES_PORT,
45-
path=self.POSTGRES_DB,
46-
).unicode_string()
47-
48-
msg = (
49-
"Either POSTGRES_URL or all of POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER, and "
50-
"POSTGRES_PASSWORD must be set"
51-
)
52-
raise SyncSettingsValidationError(msg)
28+
29+
def _get_postgres_url() -> str:
30+
"""Get the PostgreSQL URL from the environment variables.
31+
32+
Raises:
33+
SyncSettingsValidationError: If the URL or all of the host, database, user, and password
34+
"""
35+
try:
36+
settings = _PostgresSettings()
37+
except ValidationError as error:
38+
raise SyncSettingsValidationError(error) from None
39+
40+
required_parts = [
41+
settings.POSTGRES_HOST,
42+
settings.POSTGRES_DB,
43+
settings.POSTGRES_USER,
44+
settings.POSTGRES_PASSWORD,
45+
]
46+
47+
if settings.POSTGRES_URL and not any(required_parts):
48+
return settings.POSTGRES_URL.unicode_string()
49+
50+
if all(required_parts) and not settings.POSTGRES_URL:
51+
return MultiHostUrl.build(
52+
scheme="postgresql",
53+
username=settings.POSTGRES_USER,
54+
password=settings.POSTGRES_PASSWORD,
55+
host=settings.POSTGRES_HOST,
56+
port=settings.POSTGRES_PORT,
57+
path=settings.POSTGRES_DB,
58+
).unicode_string()
59+
60+
msg = (
61+
"Either POSTGRES_URL or all of POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER, and "
62+
"POSTGRES_PASSWORD must be set"
63+
)
64+
raise SyncSettingsValidationError(msg)
5365

5466

5567
class PostgresSyncBackend(SyncBackend):
@@ -120,11 +132,7 @@ def __init__(
120132
msg = f"Table name '{table_name}' is not a valid identifier"
121133
raise ValueError(msg)
122134

123-
try:
124-
self._url = url or _PostgresSettings().url()
125-
except ValidationError as error:
126-
raise SyncSettingsValidationError(error) from None
127-
135+
self._url = url or _get_postgres_url()
128136
self._table_name = table_name
129137
self._acquire_sql = self._SQL_ACQUIRE_OR_EXTEND.format(
130138
table_name=table_name

grelmicro/sync/redis.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,52 @@
33
from types import TracebackType
44
from typing import Annotated, Self
55

6-
from pydantic import RedisDsn
6+
from pydantic import RedisDsn, ValidationError
7+
from pydantic_core import Url
8+
from pydantic_settings import BaseSettings
79
from redis.asyncio.client import Redis
810
from typing_extensions import Doc
911

1012
from grelmicro.sync._backends import loaded_backends
1113
from grelmicro.sync.abc import SyncBackend
14+
from grelmicro.sync.errors import SyncSettingsValidationError
15+
16+
17+
class _RedisSettings(BaseSettings):
18+
"""Redis settings from the environment variables."""
19+
20+
REDIS_HOST: str | None = None
21+
REDIS_PORT: int = 6379
22+
REDIS_DB: int = 0
23+
REDIS_PASSWORD: str | None = None
24+
REDIS_URL: RedisDsn | None = None
25+
26+
27+
def _get_redis_url() -> str:
28+
"""Get the Redis URL from the environment variables.
29+
30+
Raises:
31+
SyncSettingsValidationError: If the URL or host is not set.
32+
"""
33+
try:
34+
settings = _RedisSettings()
35+
except ValidationError as error:
36+
raise SyncSettingsValidationError(error) from None
37+
38+
if settings.REDIS_URL and not settings.REDIS_HOST:
39+
return settings.REDIS_URL.unicode_string()
40+
41+
if settings.REDIS_HOST and not settings.REDIS_URL:
42+
return Url.build(
43+
scheme="redis",
44+
host=settings.REDIS_HOST,
45+
port=settings.REDIS_PORT,
46+
path=str(settings.REDIS_DB),
47+
password=settings.REDIS_PASSWORD,
48+
).unicode_string()
49+
50+
msg = "Either REDIS_URL or REDIS_HOST must be set"
51+
raise SyncSettingsValidationError(msg)
1252

1353

1454
class RedisSyncBackend(SyncBackend):
@@ -37,8 +77,24 @@ class RedisSyncBackend(SyncBackend):
3777

3878
def __init__(
3979
self,
40-
url: Annotated[RedisDsn | str, Doc("The Redis database URL.")],
80+
url: Annotated[
81+
RedisDsn | str | None,
82+
Doc("""
83+
The Redis URL.
84+
85+
If not provided, the URL will be taken from the environment variables REDIS_URL
86+
or REDIS_HOST, REDIS_PORT, REDIS_DB, and REDIS_PASSWORD.
87+
"""),
88+
] = None,
4189
*,
90+
prefix: Annotated[
91+
str,
92+
Doc("""
93+
The prefix to add on redis keys to avoid conflicts with other keys.
94+
95+
By default no prefix is added.
96+
"""),
97+
] = "",
4298
auto_register: Annotated[
4399
bool,
44100
Doc(
@@ -47,8 +103,9 @@ def __init__(
47103
] = True,
48104
) -> None:
49105
"""Initialize the lock backend."""
50-
self._url = url
51-
self._redis: Redis = Redis.from_url(str(url))
106+
self._url = url or _get_redis_url()
107+
self._redis: Redis = Redis.from_url(str(self._url))
108+
self._prefix = prefix
52109
self._lua_release = self._redis.register_script(self._LUA_RELEASE)
53110
self._lua_acquire = self._redis.register_script(
54111
self._LUA_ACQUIRE_OR_EXTEND
@@ -73,7 +130,7 @@ async def acquire(self, *, name: str, token: str, duration: float) -> bool:
73130
"""Acquire the lock."""
74131
return bool(
75132
await self._lua_acquire(
76-
keys=[name],
133+
keys=[f"{self._prefix}{name}"],
77134
args=[token, int(duration * 1000)],
78135
client=self._redis,
79136
)
@@ -83,16 +140,16 @@ async def release(self, *, name: str, token: str) -> bool:
83140
"""Release the lock."""
84141
return bool(
85142
await self._lua_release(
86-
keys=[name], args=[token], client=self._redis
143+
keys=[f"{self._prefix}{name}"], args=[token], client=self._redis
87144
)
88145
)
89146

90147
async def locked(self, *, name: str) -> bool:
91148
"""Check if the lock is acquired."""
92-
return bool(await self._redis.get(name))
149+
return bool(await self._redis.get(f"{self._prefix}{name}"))
93150

94151
async def owned(self, *, name: str, token: str) -> bool:
95152
"""Check if the lock is owned."""
96153
return bool(
97-
(await self._redis.get(name)) == token.encode()
154+
(await self._redis.get(f"{self._prefix}{name}")) == token.encode()
98155
) # redis returns bytes

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,9 @@ disallow_untyped_defs = false
153153
[tool.pytest.ini_options]
154154
addopts = """
155155
--cov=grelmicro
156-
--cov-report term:skip-covered
157156
--cov-report xml:cov.xml
158157
--strict-config
159158
--strict-markers
160-
-m "not integration"
161159
"""
162160
markers = """
163161
integration: mark a test as an integration test (disabled by default).

tests/sync/test_postgres.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
pytestmark = [pytest.mark.anyio, pytest.mark.timeout(1)]
1010

11-
URL = "postgres://user:password@localhost:5432/db"
11+
URL = "postgresql://test_user:test_password@test_host:1234/test_db"
1212

1313

1414
@pytest.mark.parametrize(
@@ -51,9 +51,7 @@ async def test_sync_backend_out_of_context_errors() -> None:
5151
@pytest.mark.parametrize(
5252
("environs"),
5353
[
54-
{
55-
"POSTGRES_URL": "postgresql://test_user:test_password@test_host:1234/test_db"
56-
},
54+
{"POSTGRES_URL": URL},
5755
{
5856
"POSTGRES_USER": "test_user",
5957
"POSTGRES_PASSWORD": "test_password",
@@ -75,10 +73,7 @@ def test_postgres_env_var_settings(
7573
backend = PostgresSyncBackend()
7674

7775
# Assert
78-
assert (
79-
backend._url
80-
== "postgresql://test_user:test_password@test_host:1234/test_db"
81-
)
76+
assert backend._url == URL
8277

8378

8479
@pytest.mark.parametrize(
@@ -88,6 +83,14 @@ def test_postgres_env_var_settings(
8883
"POSTGRES_URL": "test://test_user:test_password@test_host:1234/test_db"
8984
},
9085
{"POSTGRES_USER": "test_user"},
86+
{
87+
"POSTGRES_URL": URL,
88+
"POSTGRES_USER": "test_user",
89+
"POSTGRES_PASSWORD": "test_password",
90+
"POSTGRES_HOST": "test_host",
91+
"POSTGRES_PORT": "1234",
92+
"POSTGRES_DB": "test_db",
93+
},
9194
],
9295
)
9396
def test_postgres_env_var_settings_validation_error(

0 commit comments

Comments
 (0)