Skip to content

Commit 1fce96e

Browse files
authored
Merge pull request #4479 from mathesar-foundation/0.2.4
Release 0.2.4
2 parents 4122c06 + 348c142 commit 1fce96e

File tree

26 files changed

+668
-71
lines changed

26 files changed

+668
-71
lines changed

.github/workflows/test-and-lint-code.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,57 @@ jobs:
100100
- name: echo
101101
run: echo "${{ steps.changed_files.outputs.any_changed }}"
102102

103+
cache-images:
104+
runs-on: ubuntu-latest
105+
needs: [python_tests_required, all_be_tests_required]
106+
if: needs.python_tests_required.outputs.tests_should_run == 'true' ||
107+
needs.all_be_tests_required.outputs.tests_should_run == 'true'
108+
steps:
109+
- uses: actions/checkout@v4
110+
- id: docker-cache
111+
uses: actions/cache@v3
112+
with:
113+
path: /tmp/docker-images
114+
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/api_tests/Dockerfile', '**/Dockerfile.caddy', '**/Dockerfile.devdb') }}
115+
- name: Verify image SHAs
116+
id: verifysha
117+
if: steps.docker-cache.outputs.cache-hit == 'true'
118+
run: |
119+
touch /tmp/live-images-sha.txt
120+
121+
for version in 3.9 3.10 3.11 3.12 3.13; do
122+
echo "$(skopeo inspect docker://python:$version-bookworm | jq -r '.Digest') python:$version-bookworm" >> /tmp/live-images-sha.txt
123+
done
124+
125+
for version in 13 14 15 16 17; do
126+
echo "$(skopeo inspect docker://postgres:$version | jq -r '.Digest') postgres:$version" >> /tmp/live-images-sha.txt
127+
done
128+
129+
if diff <(sort -k2 /tmp/live-images-sha.txt) <(sort -k2 /tmp/docker-images/cached-images-sha.txt); then
130+
echo "needs-update=false" >> $GITHUB_OUTPUT;
131+
echo "exit code $?";
132+
else
133+
echo "needs-update=true" >> $GITHUB_OUTPUT;
134+
echo "exit code $?";
135+
fi
136+
- name: echo
137+
run: echo "${{ steps.verifysha.outputs.needs-update }}"
138+
- name: Pull and save python images on cache misses
139+
if: steps.docker-cache.outputs.cache-hit != 'true' || steps.verifysha.outputs.needs-update == 'true'
140+
run: |
141+
mkdir -p /tmp/docker-images && cd /tmp/docker-images
142+
for version in 3.9 3.10 3.11 3.12 3.13; do
143+
docker pull python:$version-bookworm && docker save python:$version-bookworm -o python-$version-bookworm.tar
144+
done
145+
146+
for version in 13 14 15 16 17; do
147+
docker pull postgres:$version && docker save postgres:$version -o postgres-$version.tar
148+
done
149+
150+
docker images --digests --format '{{.Digest}} {{.Repository}}:{{.Tag}}' > cached-images-sha.txt
151+
152+
cat cached-images-sha.txt
153+
103154
################################################################################
104155
## BACK END TEST/LINT RUNNERS ##
105156
## ##
@@ -131,6 +182,15 @@ jobs:
131182
# container to run tests successfully
132183
- name: Fix permissions
133184
run: sudo chown -R 1000:1000 .
185+
- id: docker-cache
186+
uses: actions/cache@v3
187+
with:
188+
path: /tmp/docker-images
189+
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/api_tests/Dockerfile', '**/Dockerfile.caddy', '**/Dockerfile.devdb') }}
190+
- name: Load docker image
191+
run: |
192+
docker load -i /tmp/docker-images/python-${{ matrix.py-version }}.tar
193+
docker load -i /tmp/docker-images/postgres-${{ matrix.pg-version }}.tar
134194
- name: Build the stack
135195
run: docker compose -f docker-compose.dev.yml up --build -d test-service
136196
env:
@@ -156,6 +216,13 @@ jobs:
156216
# container to run tests successfully
157217
- name: Fix permissions
158218
run: sudo chown -R 1000:1000 .
219+
- id: docker-cache
220+
uses: actions/cache@v3
221+
with:
222+
path: /tmp/docker-images
223+
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/api_tests/Dockerfile', '**/Dockerfile.caddy', '**/Dockerfile.devdb') }}
224+
- name: Load docker image
225+
run: docker load -i /tmp/docker-images/postgres-${{ matrix.pg-version }}.tar
159226
- name: Build the test DB
160227
run: docker compose -f docker-compose.dev.yml up --build -d dev-db
161228
env:
@@ -182,6 +249,15 @@ jobs:
182249
# container to run tests successfully
183250
- name: Fix permissions
184251
run: sudo chown -R 1000:1000 .
252+
- id: docker-cache
253+
uses: actions/cache@v3
254+
with:
255+
path: /tmp/docker-images
256+
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/api_tests/Dockerfile', '**/Dockerfile.caddy', '**/Dockerfile.devdb') }}
257+
- name: Load docker image
258+
run: |
259+
docker load -i /tmp/docker-images/python-${{ matrix.py-version }}.tar
260+
docker load -i /tmp/docker-images/postgres-${{ matrix.pg-version }}.tar
185261
- name: Run tests
186262
run: sh run_api_tests.sh
187263
env:
@@ -393,6 +469,15 @@ jobs:
393469
# container to run tests successfully
394470
- name: Fix permissions
395471
run: sudo chown -R 1000:1000 .
472+
- id: docker-cache
473+
uses: actions/cache@v3
474+
with:
475+
path: /tmp/docker-images
476+
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/api_tests/Dockerfile', '**/Dockerfile.caddy', '**/Dockerfile.devdb') }}
477+
- name: Load docker image
478+
run: | # load default versions for building mathesar in prod and dev mode
479+
docker load -i /tmp/docker-images/python-3.13-bookworm.tar
480+
docker load -i /tmp/docker-images/postgres-17.tar
396481
- name: Build the stack
397482
run: docker compose -f docker-compose.dev.yml up --build -d test-service
398483
env:

config/database_config.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass, field, replace
3+
from typing import Optional, Mapping, Any, Dict
4+
from psycopg import conninfo
5+
6+
from django.conf import settings
7+
8+
9+
POSTGRES_ENGINE = "django.db.backends.postgresql"
10+
11+
12+
# Refer Django docs: https://docs.djangoproject.com/en/4.2/ref/settings/#databases
13+
# Inspiration: https://github.com/jazzband/dj-database-url/blob/master/dj_database_url/__init__.py
14+
# We do not use dj-database-url directly due to a bug with it's parsing logic when unix sockets
15+
# are involved.
16+
# Following class only contains a subset of the available django configurations.
17+
@dataclass(frozen=True)
18+
class DBConfig(ABC):
19+
dbname: str
20+
engine: str
21+
host: Optional[str] = None
22+
port: Optional[int] = None
23+
role: Optional[str] = None
24+
password: Optional[str] = field(default=None, repr=False)
25+
options: Mapping[str, Any] = field(default_factory=dict)
26+
atomic_requests: Optional[bool] = None
27+
autocommit: Optional[bool] = None
28+
conn_max_age: Optional[int] = 0
29+
conn_health_checks: Optional[bool] = False
30+
disable_server_side_cursors: Optional[bool] = False
31+
time_zone: Optional[str] = None
32+
33+
@classmethod
34+
@abstractmethod
35+
def from_connection_string(cls, url: str) -> "DBConfig":
36+
"""
37+
Parse a database URL into a DBConfig instance.
38+
Must be implemented by subclasses.
39+
"""
40+
raise NotImplementedError
41+
42+
@classmethod
43+
def from_django_dict(cls, cfg: Mapping[str, Any]) -> "DBConfig":
44+
"""
45+
Create DBConfig instance from a Django DATABASES dict
46+
"""
47+
missing = {"ENGINE", "NAME"} - set(cfg.keys())
48+
if missing:
49+
raise ValueError(f"DBConfig.from_dict missing required fields: {missing}")
50+
51+
return cls(
52+
engine=cfg["ENGINE"],
53+
dbname=cfg["NAME"],
54+
host=cfg.get("HOST"),
55+
port=parse_port(cfg.get("PORT")),
56+
role=cfg.get("USER"),
57+
password=cfg.get("PASSWORD"),
58+
options=cfg.get("OPTIONS", {}).copy(),
59+
atomic_requests=cfg.get("ATOMIC_REQUESTS"),
60+
autocommit=cfg.get("AUTOCOMMIT"),
61+
conn_max_age=cfg.get("CONN_MAX_AGE"),
62+
conn_health_checks=cfg.get("CONN_HEALTH_CHECKS"),
63+
disable_server_side_cursors=cfg.get("DISABLE_SERVER_SIDE_CURSORS"),
64+
time_zone=cfg.get("TIME_ZONE"),
65+
)
66+
67+
def to_django_dict(self) -> Dict[str, Any]:
68+
result: Dict[str, Any] = {
69+
"ENGINE": self.engine,
70+
"NAME": self.dbname,
71+
}
72+
if self.host is not None:
73+
result["HOST"] = self.host
74+
if self.port is not None:
75+
result["PORT"] = str(self.port)
76+
if self.role is not None:
77+
result["USER"] = self.role
78+
if self.password is not None:
79+
result["PASSWORD"] = self.password
80+
if self.atomic_requests is not None:
81+
result["ATOMIC_REQUESTS"] = self.atomic_requests
82+
if self.autocommit is not None:
83+
result["AUTOCOMMIT"] = self.autocommit
84+
if self.conn_max_age is not None:
85+
result["CONN_MAX_AGE"] = self.conn_max_age
86+
if self.conn_health_checks is not None:
87+
result["CONN_HEALTH_CHECKS"] = self.conn_health_checks
88+
if self.disable_server_side_cursors is not None:
89+
result["DISABLE_SERVER_SIDE_CURSORS"] = self.disable_server_side_cursors
90+
if self.time_zone is not None:
91+
result["TIME_ZONE"] = self.time_zone
92+
if self.options:
93+
result["OPTIONS"] = dict(self.options)
94+
return result
95+
96+
97+
# Reference: https://docs.djangoproject.com/en/4.2/ref/databases/#postgresql-notes
98+
# Options are merged from values passed to the class, only sslmode is explicitly handled here.
99+
@dataclass(frozen=True)
100+
class PostgresConfig(DBConfig):
101+
engine: str = POSTGRES_ENGINE
102+
sslmode: Optional[str] = None
103+
104+
# Inject sslmode into OPTIONS
105+
# https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION
106+
# TODO: Avoid doing this in the frozen class, find a better way.
107+
def __post_init__(self) -> None:
108+
base_opts = dict(self.options)
109+
if self.sslmode is not None:
110+
base_opts["sslmode"] = self.sslmode
111+
object.__setattr__(self, "options", base_opts)
112+
113+
@classmethod
114+
def from_connection_string(cls, url: str) -> "PostgresConfig":
115+
params = conninfo.conninfo_to_dict(url)
116+
dbname = params.get("dbname")
117+
if not dbname:
118+
raise ValueError("PostgresConfig.from_connection_string: missing database name in URL")
119+
120+
return cls(
121+
dbname=dbname,
122+
host=params.get("host"),
123+
port=parse_port(params.get("port")),
124+
role=params.get("user"),
125+
password=params.get("password"),
126+
sslmode=params.get("sslmode"),
127+
)
128+
129+
@classmethod
130+
def from_django_dict(cls, cfg: Mapping[str, Any]) -> "PostgresConfig":
131+
raw_opts = cfg.get("OPTIONS", {}).copy()
132+
sslmode = raw_opts.pop("sslmode", None)
133+
base_cfg = dict(cfg, OPTIONS=raw_opts)
134+
base = super().from_django_dict(base_cfg)
135+
return replace(base, sslmode=sslmode)
136+
137+
138+
def get_internal_database_config():
139+
conn_info = settings.DATABASES.get("default")
140+
if not conn_info:
141+
raise KeyError("settings.DATABASES['default'] is not defined")
142+
engine = conn_info.get("ENGINE")
143+
if engine == POSTGRES_ENGINE:
144+
return PostgresConfig.from_django_dict(conn_info)
145+
raise NotImplementedError(f"Database engine '{engine}' is not supported")
146+
147+
148+
def parse_port(raw_port):
149+
port = None
150+
if raw_port not in (None, ""):
151+
try:
152+
port = int(raw_port)
153+
except (TypeError, ValueError):
154+
raise ValueError(f"Invalid PORT value: {raw_port!r}")
155+
return port

config/settings/common_settings.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import os
1414
from pathlib import Path
1515

16-
from dj_database_url import parse as db_url
16+
from config.database_config import PostgresConfig, parse_port
1717

1818

1919
# We use a 'tuple' with pipes as delimiters as decople naively splits the global
@@ -113,7 +113,7 @@ def pipe_delim(pipe_string):
113113
# MATHESAR_DATABASES should be of the form '({db_name}|{db_url}), ({db_name}|{db_url})'
114114
# See pipe_delim above for why we use pipes as delimiters
115115
DATABASES = {
116-
db_key: db_url(url_string)
116+
db_key: PostgresConfig.from_connection_string(url_string).to_django_dict()
117117
for db_key, url_string in [pipe_delim(i) for i in os.environ.get('MATHESAR_DATABASES', default='').split(',') if i != '']
118118
}
119119

@@ -124,15 +124,14 @@ def pipe_delim(pipe_string):
124124
POSTGRES_PORT = os.environ.get('POSTGRES_PORT', default=None)
125125

126126
# POSTGRES_DB, POSTGRES_USER, and POSTGRES_HOST are required env variables for forming a pg connection string for the django database
127-
# We expect the environment variables to be url-encoded, we do not do additional encoding here
128127
if POSTGRES_DB and POSTGRES_USER and POSTGRES_HOST:
129-
DATABASES['default'] = db_url(
130-
f"postgres://{POSTGRES_USER}"
131-
f"{':' + POSTGRES_PASSWORD if POSTGRES_PASSWORD else ''}"
132-
f"@{POSTGRES_HOST}"
133-
f"{':' + POSTGRES_PORT if POSTGRES_PORT else ''}"
134-
f"/{POSTGRES_DB}"
135-
)
128+
DATABASES['default'] = PostgresConfig(
129+
dbname=POSTGRES_DB,
130+
host=POSTGRES_HOST,
131+
port=parse_port(POSTGRES_PORT),
132+
role=POSTGRES_USER,
133+
password=POSTGRES_PASSWORD,
134+
).to_django_dict()
136135

137136
for db_key, db_dict in DATABASES.items():
138137
# Engine should be '.postgresql' or '.postgresql_psycopg2' for all db(s)

0 commit comments

Comments
 (0)