Skip to content

Commit b6cb333

Browse files
committed
improve error handling + add logs in python sdk
1 parent 7094673 commit b6cb333

File tree

5 files changed

+157
-88
lines changed

5 files changed

+157
-88
lines changed

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,15 +458,26 @@ Most desktop Linux distributions and macOS have these libraries pre-installed. Y
458458

459459
### PostgreSQL Cannot Run as Root
460460

461-
PostgreSQL refuses to run as root for security reasons. If you see permission errors in Docker or Linux:
461+
PostgreSQL refuses to run as root for security reasons. If you see this error:
462+
463+
```
464+
initdb: error: cannot be run as root
465+
```
466+
467+
You need to run pg0 as a non-root user:
462468

463469
```bash
464-
# Create a non-root user
470+
# Create a non-root user and run pg0
465471
useradd -m pguser
466472
su - pguser -c "pg0 start"
467473
```
468474

469-
See the [Docker](#docker) section for complete examples.
475+
**Note:** This means pg0 won't work in environments that only allow root access, such as:
476+
- Google Colab (runs as root)
477+
- Some CI environments
478+
- Restricted containers
479+
480+
See the [Docker](#docker) section for complete examples of running pg0 as a non-root user.
470481

471482
### Port Already in Use
472483

sdk/python/pg0/__init__.py

Lines changed: 56 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@
2929

3030
__version__ = "0.1.0"
3131

32-
# GitHub repo for pg0 releases
33-
PG0_REPO = "vectorize-io/pg0"
34-
INSTALL_SCRIPT_URL = f"https://raw.githubusercontent.com/{PG0_REPO}/main/install.sh"
35-
3632

3733
class Pg0Error(Exception):
3834
"""Base exception for pg0 errors."""
@@ -102,72 +98,8 @@ def _get_install_dir() -> Path:
10298
return Path.home() / ".local" / "bin"
10399

104100

105-
def install(force: bool = False) -> Path:
106-
"""
107-
Install the pg0 binary using the official install script.
108-
109-
Note: If the package was installed from a platform-specific wheel,
110-
the binary is already bundled and this function returns immediately.
111-
112-
Args:
113-
force: Force reinstall even if already installed
114-
115-
Returns:
116-
Path to the installed binary
117-
"""
118-
# Check for bundled binary first (from platform-specific wheel)
119-
bundled = _get_bundled_binary()
120-
if bundled and not force:
121-
return bundled
122-
123-
install_dir = _get_install_dir()
124-
binary_name = "pg0.exe" if sys.platform == "win32" else "pg0"
125-
binary_path = install_dir / binary_name
126-
127-
# Check if already installed externally
128-
if binary_path.exists() and not force:
129-
return binary_path
130-
131-
# Use the official install script which handles:
132-
# - Platform detection (including old glibc fallback to musl)
133-
# - Intel Mac Rosetta handling
134-
# - Proper binary naming
135-
if sys.platform == "win32":
136-
# Windows: download directly since bash isn't available
137-
raise Pg0NotFoundError(
138-
"Auto-install not supported on Windows. "
139-
"Please download pg0 manually from https://github.com/vectorize-io/pg0/releases"
140-
)
141-
142-
print("Installing pg0 using official install script...")
143-
try:
144-
result = subprocess.run(
145-
["bash", "-c", f"curl -fsSL {INSTALL_SCRIPT_URL} | bash"],
146-
capture_output=True,
147-
text=True,
148-
timeout=120,
149-
)
150-
if result.returncode != 0:
151-
raise Pg0NotFoundError(f"Install script failed: {result.stderr}")
152-
153-
# Verify installation
154-
if binary_path.exists():
155-
return binary_path
156-
157-
# Check if installed to a different location
158-
path = shutil.which("pg0")
159-
if path:
160-
return Path(path)
161-
162-
raise Pg0NotFoundError("Install script succeeded but pg0 binary not found")
163-
except subprocess.TimeoutExpired:
164-
raise Pg0NotFoundError("Install script timed out")
165-
except FileNotFoundError:
166-
raise Pg0NotFoundError("bash not found - please install pg0 manually")
167-
168-
169101
def _find_pg0() -> str:
170-
"""Find the pg0 binary, installing if necessary."""
102+
"""Find the pg0 binary or raise an error if not found."""
171103
# Check for bundled binary first (from platform-specific wheel)
172104
bundled = _get_bundled_binary()
173105
if bundled:
@@ -178,17 +110,20 @@ def _find_pg0() -> str:
178110
if path:
179111
return path
180112

181-
# Check our install location
113+
# Check common install location
182114
install_dir = _get_install_dir()
183115
binary_name = "pg0.exe" if sys.platform == "win32" else "pg0"
184116
binary_path = install_dir / binary_name
185117

186118
if binary_path.exists():
187119
return str(binary_path)
188120

189-
# Auto-install as fallback
190-
installed_path = install()
191-
return str(installed_path)
121+
# No binary found
122+
raise Pg0NotFoundError(
123+
"pg0 binary not found. Install it with:\n"
124+
" curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash\n"
125+
"Or download from: https://github.com/vectorize-io/pg0/releases"
126+
)
192127

193128

194129
def _run_pg0(*args: str, check: bool = True) -> subprocess.CompletedProcess:
@@ -219,21 +154,21 @@ class Pg0:
219154
220155
Args:
221156
name: Instance name (allows multiple instances)
222-
port: Port to listen on
157+
port: Port to listen on (None = auto-select available port)
223158
username: Database username
224159
password: Database password
225160
database: Database name
226161
data_dir: Custom data directory
227162
config: Dict of PostgreSQL configuration options
228163
229164
Example:
230-
# Simple usage
165+
# Simple usage (auto-selects available port)
231166
pg = Pg0()
232167
pg.start()
233168
print(pg.uri)
234169
pg.stop()
235170
236-
# Context manager
171+
# Context manager with specific port
237172
with Pg0(port=5433, database="myapp") as pg:
238173
print(pg.uri)
239174
@@ -244,7 +179,7 @@ class Pg0:
244179
def __init__(
245180
self,
246181
name: str = "default",
247-
port: int = 5432,
182+
port: Optional[int] = None,
248183
username: str = "postgres",
249184
password: str = "postgres",
250185
database: str = "postgres",
@@ -273,12 +208,14 @@ def start(self) -> InstanceInfo:
273208
args = [
274209
"start",
275210
"--name", self.name,
276-
"--port", str(self.port),
277211
"--username", self.username,
278212
"--password", self.password,
279213
"--database", self.database,
280214
]
281215

216+
if self.port is not None:
217+
args.extend(["--port", str(self.port)])
218+
282219
if self.data_dir:
283220
args.extend(["--data-dir", self.data_dir])
284221

@@ -364,6 +301,25 @@ def execute(self, sql: str) -> str:
364301
result = self.psql("-c", sql)
365302
return result.stdout
366303

304+
def logs(self, lines: Optional[int] = None) -> str:
305+
"""
306+
Get PostgreSQL logs for this instance.
307+
308+
Args:
309+
lines: Number of lines to return (None = all logs)
310+
311+
Returns:
312+
Log content as string
313+
314+
Example:
315+
print(pg.logs(50)) # Last 50 lines
316+
"""
317+
args = ["logs", "--name", self.name]
318+
if lines is not None:
319+
args.extend(["-n", str(lines)])
320+
result = _run_pg0(*args, check=False)
321+
return result.stdout
322+
367323
def __enter__(self) -> "Pg0":
368324
"""Context manager entry - starts PostgreSQL."""
369325
self.start()
@@ -407,7 +363,7 @@ def list_extensions() -> list[str]:
407363

408364
def start(
409365
name: str = "default",
410-
port: int = 5432,
366+
port: Optional[int] = None,
411367
username: str = "postgres",
412368
password: str = "postgres",
413369
database: str = "postgres",
@@ -418,7 +374,7 @@ def start(
418374
419375
Args:
420376
name: Instance name
421-
port: Port to listen on
377+
port: Port to listen on (None = auto-select available port)
422378
username: Database username
423379
password: Database password
424380
database: Database name
@@ -428,7 +384,7 @@ def start(
428384
InstanceInfo with connection details
429385
430386
Example:
431-
info = pg0.start(port=5433, shared_buffers="512MB")
387+
info = pg0.start(shared_buffers="512MB") # auto-selects port
432388
print(info.uri)
433389
"""
434390
pg = Pg0(
@@ -486,6 +442,24 @@ def info(name: str = "default") -> InstanceInfo:
486442
return InstanceInfo.from_dict(data)
487443

488444

445+
def logs(name: str = "default", lines: Optional[int] = None) -> str:
446+
"""
447+
Get PostgreSQL logs for an instance (convenience function).
448+
449+
Args:
450+
name: Instance name
451+
lines: Number of lines to return (None = all logs)
452+
453+
Returns:
454+
Log content as string
455+
"""
456+
args = ["logs", "--name", name]
457+
if lines is not None:
458+
args.extend(["-n", str(lines)])
459+
result = _run_pg0(*args, check=False)
460+
return result.stdout
461+
462+
489463
# Keep PostgreSQL as alias for backwards compatibility
490464
PostgreSQL = Pg0
491465

@@ -498,12 +472,12 @@ def info(name: str = "default") -> InstanceInfo:
498472
"Pg0NotFoundError",
499473
"Pg0NotRunningError",
500474
"Pg0AlreadyRunningError",
501-
"install",
502475
"list_instances",
503476
"list_extensions",
504477
"start",
505478
"stop",
506479
"drop",
507480
"info",
481+
"logs",
508482
"_get_bundled_binary", # for testing
509483
]

sdk/python/tests/test_pg0.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
import pg0
5-
from pg0 import Pg0, InstanceInfo, Pg0AlreadyRunningError
5+
from pg0 import Pg0, InstanceInfo, Pg0AlreadyRunningError, Pg0Error
66

77

88
# Use a unique port to avoid conflicts
@@ -127,6 +127,26 @@ def test_info_when_not_running(self, clean_instance):
127127
assert info.running is False
128128
assert info.uri is None
129129

130+
def test_port_conflict_error(self, clean_instance):
131+
"""Test that starting two instances on the same port gives a readable error."""
132+
pg1 = Pg0(name=TEST_NAME, port=TEST_PORT)
133+
pg2 = Pg0(name=f"{TEST_NAME}-2", port=TEST_PORT)
134+
135+
pg1.start()
136+
137+
try:
138+
with pytest.raises(Pg0Error) as exc_info:
139+
pg2.start()
140+
141+
# Verify the error message mentions the port conflict
142+
error_message = str(exc_info.value).lower()
143+
assert "port" in error_message or "address" in error_message or "in use" in error_message, \
144+
f"Error message should mention port conflict, got: {exc_info.value}"
145+
finally:
146+
pg1.stop()
147+
pg2.stop()
148+
pg0.drop(f"{TEST_NAME}-2")
149+
130150

131151
class TestConvenienceFunctions:
132152
"""Tests for module-level convenience functions."""
@@ -156,6 +176,29 @@ def test_list_instances(self, clean_instance):
156176
finally:
157177
pg0.stop(TEST_NAME)
158178

179+
def test_logs(self, clean_instance):
180+
"""Test getting logs."""
181+
pg = Pg0(name=TEST_NAME, port=TEST_PORT)
182+
pg.start()
183+
184+
try:
185+
# Run a query to generate some log activity
186+
pg.execute("SELECT 1;")
187+
188+
# Get logs via instance method
189+
logs = pg.logs()
190+
assert isinstance(logs, str)
191+
192+
# Get logs with line limit
193+
logs_limited = pg.logs(lines=10)
194+
assert isinstance(logs_limited, str)
195+
196+
# Get logs via module function
197+
logs_module = pg0.logs(TEST_NAME)
198+
assert isinstance(logs_module, str)
199+
finally:
200+
pg.stop()
201+
159202

160203
class TestInstanceInfo:
161204
"""Tests for InstanceInfo dataclass."""

sdk/python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)