Skip to content

Commit d619635

Browse files
committed
refactor(common, spectral-validator): introduce jentic-openapi-common package with subprocess utility
Add new `jentic-openapi-common` package containing shared utilities for OpenAPI-related operations: - Implement `run_checked` subprocess utility with enhanced error handling. - Add `SubprocessExecutionError` to handle non-zero exit codes. - Include type-checking support with `py.typed`. - Update workspace and CI configurations to include `jentic-openapi-common`. - Replace direct `subprocess.run` calls with `run_checked` in `spectral_validator.py` for improved error handling and result encapsulation. This package provides reusable functionality for subprocess management, aiding other OpenAPI tools in the monorepo.
1 parent cfa5d68 commit d619635

File tree

15 files changed

+366
-12
lines changed

15 files changed

+366
-12
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ jobs:
2525
uv run ruff format --check .
2626
uv run pyright
2727
28+
test_common:
29+
name: Test (jentic-openapi-Common)
30+
runs-on: ubuntu-latest
31+
strategy:
32+
matrix:
33+
python: ["3.11", "3.12"]
34+
steps:
35+
- uses: actions/checkout@v4
36+
- name: Install uv
37+
uses: astral-sh/setup-uv@v4
38+
- name: Set up Python
39+
run: uv python install ${{ matrix.python }}
40+
- name: Install deps (with dev)
41+
run: uv sync
42+
- name: Install common package in editable mode
43+
run: uv pip install -e jentic-openapi-common
44+
- name: Pytest (parser)
45+
run: uv run pytest jentic-openapi-common/tests -q
46+
2847
test_parser:
2948
name: Test (jentic-openapi-parser)
3049
runs-on: ubuntu-latest

.github/workflows/pre-release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ jobs:
3838
- name: Install dependencies
3939
run: uv sync
4040

41+
- name: Install common package in editable mode
42+
run: uv pip install -e jentic-openapi-common
43+
4144
- name: Run tests
4245
run: |
46+
uv run pytest jentic-openapi-common/tests -v
4347
uv run --package jentic-openapi-parser pytest packages/jentic-openapi-parser/tests -v
4448
uv run --package jentic-openapi-transformer pytest packages/jentic-openapi-transformer/tests -v
4549
uv run --package jentic-openapi-validator pytest packages/jentic-openapi-validator/tests -v

.github/workflows/release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ jobs:
5353
- name: Install dependencies
5454
run: uv sync
5555

56+
- name: Install common package in editable mode
57+
run: uv pip install -e jentic-openapi-common
58+
5659
- name: Dry Run Notice
5760
if: env.DRY_RUN == 'true'
5861
run: |
@@ -122,6 +125,7 @@ jobs:
122125
if: steps.check_release.outputs.release_needed == 'true'
123126
run: |
124127
echo "🧪 Running tests..."
128+
uv run pytest jentic-openapi-common/tests -v
125129
uv run --package jentic-openapi-parser pytest packages/jentic-openapi-parser/tests -v
126130
uv run --package jentic-openapi-transformer pytest packages/jentic-openapi-transformer/tests -v
127131
uv run --package jentic-openapi-validator pytest packages/jentic-openapi-validator/tests -v

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ uv sync --all-packages
1313
### run per-package tests (from root)
1414

1515
```bash
16+
uv run pytest jentic-openapi-common/tests -q
1617
uv run --package jentic-openapi-parser pytest packages/jentic-openapi-parser/tests -q
1718
uv run --package jentic-openapi-transformer pytest packages/jentic-openapi-transformer/tests -q
1819
uv run --package jentic-openapi-validator pytest packages/jentic-openapi-validator/tests -q

jentic-openapi-common/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# jentic-openapi-common
2+
3+
Common code for OpenAPI not specific to any package.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "jentic-openapi-common"
3+
version = "0.1.0"
4+
description = "Jentic OpenAPI Common"
5+
readme = "README.md"
6+
authors = [{ name = "Jentic", email = "hello@jentic.com" }]
7+
license = { text = "Apache-2.0" }
8+
requires-python = ">=3.11"
9+
dependencies = []
10+
11+
[project.urls]
12+
Homepage = "https://github.com/jentic/jentic-openapi-tools"
13+
14+
[build-system]
15+
requires = ["hatchling"]
16+
build-backend = "hatchling.build"
17+
18+
[tool.hatch.build.targets.wheel]
19+
packages = ["src/jentic_openapi_common"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .subprocess import run_checked, SubprocessExecutionResult, SubprocessExecutionError
2+
3+
__all__ = ["run_checked", "SubprocessExecutionResult", "SubprocessExecutionError"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# marker file for type checkers
2+
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import subprocess
2+
from typing import Sequence, Optional
3+
4+
5+
class SubprocessExecutionResult:
6+
"""Returned by a subprocess."""
7+
8+
def __init__(
9+
self,
10+
returncode: int,
11+
stdout: str = "",
12+
stderr: str = "",
13+
):
14+
self.returncode = returncode
15+
self.stdout = stdout
16+
self.stderr = stderr
17+
18+
19+
class SubprocessExecutionError(RuntimeError):
20+
"""Raised when a subprocess exits with non-zero return code."""
21+
22+
def __init__(
23+
self,
24+
cmd: Sequence[str],
25+
returncode: int,
26+
stdout: Optional[str] = None,
27+
stderr: Optional[str] = None,
28+
):
29+
self.cmd = list(cmd)
30+
self.returncode = returncode
31+
self.stdout = stdout
32+
self.stderr = stderr
33+
message = (
34+
f"Command {cmd!r} failed with exit code {returncode}\n"
35+
f"--- stdout ---\n{stdout or ''}\n"
36+
f"--- stderr ---\n{stderr or ''}"
37+
)
38+
super().__init__(message)
39+
40+
41+
def run_checked(
42+
cmd: Sequence[str],
43+
*,
44+
fail_on_error: bool = False,
45+
timeout: Optional[float] = None,
46+
encoding: str = "utf-8",
47+
errors: str = "strict",
48+
) -> SubprocessExecutionResult:
49+
"""
50+
Run a subprocess command and return (stdout, stderr) as text.
51+
Raises SubprocessExecutionError if the command fails.
52+
53+
Parameters
54+
----------
55+
cmd : sequence of str
56+
The command and its arguments.
57+
timeout : float | None
58+
Seconds before timing out.
59+
encoding : str
60+
Passed to subprocess.run so stdout/stderr are decoded as text.
61+
errors : str
62+
Error handler for text decoding.
63+
64+
Returns
65+
-------
66+
(stdout, stderr, returncode) : SubprocessExecutionResult
67+
"""
68+
try:
69+
completed = subprocess.run(
70+
cmd,
71+
check=False,
72+
capture_output=True,
73+
text=True,
74+
encoding=encoding, # ensure CompletedProcess has str stdout/stderr
75+
errors=errors,
76+
timeout=timeout,
77+
)
78+
except subprocess.TimeoutExpired as e:
79+
# e.stdout / e.stderr are bytes|None even with text=True — normalize to str
80+
stdout = (
81+
e.stdout.decode(encoding, errors)
82+
if isinstance(e.stdout, (bytes, bytearray))
83+
else (e.stdout or "")
84+
)
85+
stderr = (
86+
e.stderr.decode(encoding, errors)
87+
if isinstance(e.stderr, (bytes, bytearray))
88+
else (e.stderr or "")
89+
)
90+
raise SubprocessExecutionError(cmd, -1, stdout, stderr) from e
91+
except OSError as e: # e.g., executable not found, permission denied
92+
raise SubprocessExecutionError(cmd, -1, None, str(e)) from e
93+
94+
if completed.returncode != 0 and fail_on_error:
95+
raise SubprocessExecutionError(
96+
cmd,
97+
completed.returncode,
98+
completed.stdout or "",
99+
completed.stderr or "",
100+
)
101+
102+
# At this point CompletedProcess stdout/stderr are str due to text=True + encoding
103+
return SubprocessExecutionResult(
104+
completed.returncode, completed.stdout or "", completed.stderr or ""
105+
)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import sys
2+
import unittest
3+
import platform
4+
5+
from jentic_openapi_common.subprocess import (
6+
run_checked,
7+
SubprocessExecutionResult,
8+
SubprocessExecutionError,
9+
)
10+
11+
12+
class TestSubprocessModule(unittest.TestCase):
13+
"""Test cases for the custom subprocess module."""
14+
15+
def setUp(self):
16+
"""Set up test fixtures."""
17+
self.is_windows = platform.system() == "Windows"
18+
self.is_unix = platform.system() in ("Linux", "Darwin")
19+
20+
@unittest.skipUnless(
21+
platform.system() in ("Linux", "Darwin", "Windows"), "Requires a supported operating system"
22+
)
23+
def test_successful_command(self):
24+
"""Test running a successful command."""
25+
if self.is_windows:
26+
cmd = ["cmd", "/c", "echo", "hello"]
27+
else:
28+
cmd = ["echo", "hello"]
29+
30+
result = run_checked(cmd)
31+
32+
self.assertIsInstance(result, SubprocessExecutionResult)
33+
self.assertEqual(result.returncode, 0)
34+
self.assertIn("hello", result.stdout)
35+
self.assertEqual(result.stderr, "")
36+
37+
@unittest.skipUnless(platform.system() in ("Linux", "Darwin"), "Requires Unix-like system")
38+
def test_unix_ls_command(self):
39+
"""Test ls command on Unix systems."""
40+
result = run_checked(["ls", "/"])
41+
42+
self.assertEqual(result.returncode, 0)
43+
self.assertIsInstance(result.stdout, str)
44+
self.assertTrue(len(result.stdout) > 0)
45+
46+
@unittest.skipUnless(platform.system() == "Windows", "Requires Windows system")
47+
def test_windows_dir_command(self):
48+
"""Test dir command on Windows systems."""
49+
result = run_checked(["cmd", "/c", "dir", "C:\\"])
50+
51+
self.assertEqual(result.returncode, 0)
52+
self.assertIsInstance(result.stdout, str)
53+
self.assertTrue(len(result.stdout) > 0)
54+
55+
@unittest.skipUnless(
56+
platform.system() in ("Linux", "Darwin", "Windows"), "Requires a supported operating system"
57+
)
58+
def test_python_version_command(self):
59+
"""Test running python --version command."""
60+
# Use sys.executable to ensure we're using the right Python
61+
result = run_checked([sys.executable, "--version"])
62+
63+
self.assertEqual(result.returncode, 0)
64+
self.assertIn("Python", result.stdout)
65+
66+
def test_nonexistent_command(self):
67+
"""Test running a command that doesn't exist."""
68+
with self.assertRaises(SubprocessExecutionError) as cm:
69+
run_checked(["nonexistent_command_12345"])
70+
71+
self.assertEqual(cm.exception.returncode, -1)
72+
self.assertEqual(cm.exception.cmd, ["nonexistent_command_12345"])
73+
74+
@unittest.skipUnless(
75+
platform.system() in ("Linux", "Darwin", "Windows"), "Requires a supported operating system"
76+
)
77+
def test_command_with_non_zero_exit_code(self):
78+
"""Test command that exits with non-zero code."""
79+
if self.is_windows:
80+
cmd = ["cmd", "/c", "exit", "1"]
81+
else:
82+
cmd = ["sh", "-c", "exit 1"]
83+
84+
# Without fail_on_error, should not raise exception
85+
result = run_checked(cmd)
86+
self.assertEqual(result.returncode, 1)
87+
88+
# With fail_on_error=True, should raise exception
89+
with self.assertRaises(SubprocessExecutionError) as cm:
90+
run_checked(cmd, fail_on_error=True)
91+
92+
self.assertEqual(cm.exception.returncode, 1)
93+
self.assertEqual(cm.exception.cmd, cmd)
94+
95+
@unittest.skipUnless(
96+
platform.system() in ("Linux", "Darwin"), "Requires Unix-like system for stderr test"
97+
)
98+
def test_command_with_stderr(self):
99+
"""Test command that produces stderr output."""
100+
# Use a command that writes to stderr
101+
result = run_checked(["sh", "-c", "echo 'error message' >&2"])
102+
103+
self.assertEqual(result.returncode, 0)
104+
self.assertEqual(result.stdout, "")
105+
self.assertIn("error message", result.stderr)
106+
107+
def test_encoding_parameter(self):
108+
"""Test custom encoding parameter."""
109+
if self.is_windows:
110+
cmd = ["cmd", "/c", "echo", "hello"]
111+
else:
112+
cmd = ["echo", "hello"]
113+
114+
result = run_checked(cmd, encoding="utf-8")
115+
116+
self.assertEqual(result.returncode, 0)
117+
self.assertIn("hello", result.stdout)
118+
self.assertIsInstance(result.stdout, str)
119+
120+
def test_subprocess_execution_result_initialization(self):
121+
"""Test SubprocessExecutionResult initialization."""
122+
result = SubprocessExecutionResult(0, "stdout", "stderr")
123+
124+
self.assertEqual(result.returncode, 0)
125+
self.assertEqual(result.stdout, "stdout")
126+
self.assertEqual(result.stderr, "stderr")
127+
128+
# Test with defaults
129+
result_defaults = SubprocessExecutionResult(1)
130+
self.assertEqual(result_defaults.returncode, 1)
131+
self.assertEqual(result_defaults.stdout, "")
132+
self.assertEqual(result_defaults.stderr, "")
133+
134+
def test_subprocess_execution_error_initialization(self):
135+
"""Test SubprocessExecutionError initialization."""
136+
cmd = ["test", "command"]
137+
error = SubprocessExecutionError(cmd, 1, "out", "err")
138+
139+
self.assertEqual(error.cmd, ["test", "command"])
140+
self.assertEqual(error.returncode, 1)
141+
self.assertEqual(error.stdout, "out")
142+
self.assertEqual(error.stderr, "err")
143+
144+
# Test message formatting
145+
error_msg = str(error)
146+
self.assertIn("['test', 'command']", error_msg)
147+
self.assertIn("exit code 1", error_msg)
148+
self.assertIn("out", error_msg)
149+
self.assertIn("err", error_msg)
150+
151+
@unittest.skipUnless(platform.system() in ("Linux", "Darwin"), "Requires Unix-like system")
152+
def test_empty_command_output(self):
153+
"""Test command that produces no output."""
154+
result = run_checked(["true"]) # Unix command that does nothing and succeeds
155+
156+
self.assertEqual(result.returncode, 0)
157+
self.assertEqual(result.stdout, "")
158+
self.assertEqual(result.stderr, "")
159+
160+
@unittest.skipUnless(
161+
platform.system() in ("Linux", "Darwin", "Windows"), "Requires a supported operating system"
162+
)
163+
def test_command_with_arguments(self):
164+
"""Test command with multiple arguments."""
165+
if self.is_windows:
166+
cmd = ["cmd", "/c", "echo", "hello", "world"]
167+
else:
168+
cmd = ["echo", "hello", "world"]
169+
170+
result = run_checked(cmd)
171+
172+
self.assertEqual(result.returncode, 0)
173+
self.assertIn("hello", result.stdout)
174+
self.assertIn("world", result.stdout)
175+
176+
177+
if __name__ == "__main__":
178+
unittest.main()

0 commit comments

Comments
 (0)