Skip to content

Commit 147dc53

Browse files
committed
Add initial project structure with CLI, project tools, etc
1 parent ae7efba commit 147dc53

File tree

7 files changed

+347
-0
lines changed

7 files changed

+347
-0
lines changed

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
python-dotenv==1.1.1
2+
pytest==8.4.1
3+
pytest-cov==6.2.1
4+
pdoc==15.0.4

stat_log_db/pyproject.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "stat-log-db"
7+
version = "0.0.1"
8+
description = ""
9+
readme = "README.md"
10+
requires-python = "==3.12.10"
11+
dependencies = [
12+
"python-dotenv==1.1.1"
13+
]
14+
15+
[project.optional-dependencies]
16+
dev = [
17+
"pytest==8.4.1",
18+
"pytest-cov==6.2.1",
19+
"pdoc==15.0.4"
20+
]
21+
22+
[project.scripts]
23+
sldb = "stat_log_db.cli:main"
24+
25+
[tool.setuptools.packages.find]
26+
where = ["src"]
27+
28+
[tool.pytest.ini_options]
29+
pythonpath = ["src"]
30+
testpaths = ["tests"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import exceptions
2+
from . import cli

stat_log_db/src/stat_log_db/cli.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
import sys
3+
import argparse
4+
from dotenv import load_dotenv
5+
6+
from .exceptions import raise_type_error_with_signature
7+
8+
load_dotenv()
9+
10+
11+
def create_parser(parser_args: dict, version: str | int="0.0.1") -> argparse.ArgumentParser:
12+
"""Create the main argument parser."""
13+
# Validate parser_args
14+
if not isinstance(parser_args, dict):
15+
raise_type_error_with_signature()
16+
# Default formatter class
17+
if "formatter_class" not in parser_args:
18+
parser_args["formatter_class"] = argparse.RawDescriptionHelpFormatter
19+
try:
20+
parser = argparse.ArgumentParser(**parser_args)
21+
except Exception as e:
22+
raise Exception(f"Failed to create ArgumentParser: {e}")
23+
24+
# Validate version
25+
if not isinstance(version, (str, int)):
26+
raise_type_error_with_signature()
27+
28+
# Add version argument
29+
parser.add_argument(
30+
"--version",
31+
action="version",
32+
version=version if isinstance(version, str) else str(version)
33+
)
34+
35+
return parser
36+
37+
38+
def main():
39+
"""Main CLI entry point."""
40+
41+
parser = create_parser({
42+
"prog": "sldb",
43+
"description": "My CLI tool",
44+
}, "0.0.1")
45+
46+
args = parser.parse_args()
47+
48+
print(f"{args=}")
49+
50+
51+
if __name__ == "__main__":
52+
main()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
def raise_type_error_with_signature():
3+
"""Generate a standard type error message."""
4+
# TODO: f"arg must be of type 'argtype', but {inspect.stack()[0][3]} got {type(arg).__name__}"
5+
message = ""
6+
try:
7+
import inspect
8+
caller_frame = inspect.stack()[1].frame
9+
import types
10+
if isinstance(caller_frame, types.FrameType):
11+
function_name = inspect.getframeinfo(caller_frame).function
12+
signature = inspect.signature(caller_frame.f_globals[function_name])
13+
message = f"TypeError in function '{function_name}'. Signature:\n{function_name}{signature}"
14+
except Exception as e:
15+
raise Exception(f"Failed to generate type error message: {e}")
16+
raise TypeError(message)

tests/test_tools.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import os
2+
import shutil
3+
import subprocess
4+
import sys
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
#region Global Variables
10+
11+
ROOT = Path(__file__).resolve().parent.parent
12+
SCRIPT = ROOT / 'tools.sh'
13+
VENV_TEST = ROOT / '.venv_test'
14+
PACKAGE_NAME = 'stat_log_db'
15+
16+
#endregion
17+
18+
19+
#region testing tools
20+
21+
def _ensure_test_venv():
22+
"""Ensure the test virtual environment is created."""
23+
if not VENV_TEST.exists():
24+
subprocess.run([sys.executable, '-m', 'venv', str(VENV_TEST)], check=True)
25+
26+
def _venv_python():
27+
"""Return path to the virtual environment's python interpreter."""
28+
return VENV_TEST / ('Scripts' if os.name == 'nt' else 'bin') / ('python.exe' if os.name == 'nt' else 'python')
29+
30+
def is_installed(package: str) -> bool:
31+
"""
32+
Check if a package is installed in the test virtual environment using 'pip show'.
33+
Assumes the test venv has been created.
34+
"""
35+
_ensure_test_venv()
36+
python_executable = _venv_python()
37+
result = subprocess.run([str(python_executable), '-m', 'pip', 'show', package], capture_output=True, text=True)
38+
return result.returncode == 0
39+
40+
def run_tools(args, use_test_venv=False):
41+
"""Run tools.sh returning (code, stdout+stderr)."""
42+
env = os.environ.copy()
43+
if use_test_venv:
44+
_ensure_test_venv()
45+
scripts_dir = VENV_TEST / ('Scripts' if os.name == 'nt' else 'bin')
46+
env['PATH'] = str(scripts_dir) + os.pathsep + env.get('PATH', '')
47+
env['VIRTUAL_ENV'] = str(VENV_TEST)
48+
env['PYTHONHOME'] = '' # ensure venv python resolution
49+
bash = r'C:\Program Files\Git\bin\bash.exe' if os.name == 'nt' else 'bash' # TODO: indicate to the user that they need git bash
50+
proc = subprocess.run([bash, str(SCRIPT), *args], capture_output=True, text=True, cwd=ROOT, env=env)
51+
return proc.returncode, proc.stdout + proc.stderr
52+
53+
#endregion
54+
55+
56+
@pytest.fixture() # scope="module"
57+
def test_venv():
58+
"""
59+
Provision an isolated virtual environment used for install/uninstall tests.
60+
The directory is removed after all related tests complete.
61+
"""
62+
_ensure_test_venv()
63+
yield VENV_TEST
64+
# Teardown: remove the virtual environment directory
65+
if VENV_TEST.exists():
66+
shutil.rmtree(VENV_TEST)
67+
68+
69+
def test_help():
70+
code, out = run_tools(['-h'])
71+
assert code == 0
72+
# Read README.md
73+
readme_path = ROOT / 'README.md'
74+
assert readme_path.exists(), f"README not found at {readme_path}"
75+
readme_content = None
76+
with open(readme_path, 'r', encoding='utf-8') as f:
77+
readme_content = f.read().strip()
78+
assert not (readme_content is None), "Unable to read README"
79+
# Compare README content with help output
80+
try:
81+
assert out == readme_content, "Help output does not match README content"
82+
except AssertionError:
83+
assert out.strip() == readme_content.strip(), "Help output does not match README content (leading & trailing whitespace stripped)"
84+
85+
def test_install_dev(test_venv):
86+
code, out = run_tools(['-id'], use_test_venv=True)
87+
assert code == 0
88+
assert 'Installing' in out
89+
assert 'dev' in out
90+
assert is_installed(PACKAGE_NAME), 'Package should be installed after dev install'
91+
92+
def test_install_normal(test_venv):
93+
code, out = run_tools(['-in'], use_test_venv=True)
94+
assert code == 0
95+
assert 'Installing' in out
96+
assert 'dev' not in out
97+
assert is_installed(PACKAGE_NAME), 'Package should be installed after normal install'
98+
99+
def test_install_invalid_arg(test_venv):
100+
code, out = run_tools(['-ix'], use_test_venv=True)
101+
assert code == 1
102+
assert ('Unsupported argument' in out) or ('Invalid install mode' in out)
103+
assert not is_installed(PACKAGE_NAME), 'Package should not be installed after invalid install argument'
104+
105+
def test_uninstall(test_venv):
106+
# Ensure something installed first (dev mode)
107+
icode, iout = run_tools(['-id'], use_test_venv=True)
108+
assert icode == 0
109+
assert is_installed(PACKAGE_NAME), 'Package should be installed (before uninstall)'
110+
ucode, uout = run_tools(['-u'], use_test_venv=True)
111+
assert ucode == 0
112+
assert 'Uninstalling' in uout
113+
assert 'Uninstall complete' in uout
114+
assert not is_installed(PACKAGE_NAME), 'Package should not be installed after uninstall'
115+
116+
def test_install_and_clean_multi_flag(test_venv):
117+
code, out = run_tools(['-id', '-c'], use_test_venv=True)
118+
assert code == 0
119+
assert is_installed(PACKAGE_NAME), 'Package should be installed'
120+
assert 'Installing' in out
121+
assert 'Cleaning up workspace' in out
122+
assert 'Cleanup complete' in out
123+
assert is_installed(PACKAGE_NAME), 'Cleanup should not remove installed package'
124+
125+
def test_test_no_arg():
126+
code, out = run_tools(['-t'])
127+
assert code == 1
128+
try:
129+
assert out == 'Option -t requires an argument'
130+
except AssertionError:
131+
assert out.strip() == 'Option -t requires an argument'
132+
133+
def test_test_invalid_arg():
134+
code, out = run_tools(['-tx'])
135+
assert code == 1
136+
assert ('Unsupported argument' in out) or ('Invalid test mode' in out)
137+
138+
def test_clean():
139+
code, out = run_tools(['-c'])
140+
assert code == 0
141+
assert 'Cleaning up workspace' in out
142+
assert 'Cleanup complete' in out

tools.sh

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#! /bin/bash
2+
3+
# Flags / state
4+
supported_installation_opts="d n"
5+
install=""
6+
uninstall=0
7+
clean=0
8+
supported_test_opts="p t a d"
9+
test=""
10+
11+
while getopts ":i:t:chu" flag; do
12+
case "${flag}" in
13+
i) if [[ " $supported_installation_opts " =~ " $OPTARG " ]]; then
14+
install="$OPTARG"
15+
else
16+
echo "Unsupported argument '$OPTARG' for '-$flag'. Please specify one of: $supported_installation_opts" >&2 && exit 1;
17+
fi
18+
;;
19+
u) uninstall=1;;
20+
c) clean=1;;
21+
t) if [[ " $supported_test_opts " =~ " $OPTARG " ]]; then
22+
test="$OPTARG"
23+
else
24+
echo "Unsupported argument '$OPTARG' for '-$flag'. Please specify one of: $supported_test_opts" >&2 && exit 1;
25+
fi
26+
;;
27+
h) cat README.md && exit 0;;
28+
:)
29+
echo "Option -$OPTARG requires an argument" >&2; exit 1;;
30+
?) echo "Unknown option -$OPTARG" >&2; exit 1;;
31+
esac
32+
done
33+
34+
# Install [-i]
35+
if [ -n "$install" ]; then
36+
case "$install" in
37+
d)
38+
echo "Installing (editable with dev extras)..."
39+
python -m pip install -e ./stat_log_db[dev]
40+
;;
41+
n)
42+
echo "Installing..."
43+
python -m pip install ./stat_log_db
44+
;;
45+
*)
46+
echo "Invalid install mode '$install'. Use one of: $supported_installation_opts" >&2
47+
exit 1
48+
;;
49+
esac
50+
fi
51+
52+
# Run tests [-t]
53+
if [ -n "$test" ]; then
54+
case "$test" in
55+
d)
56+
echo "Running stat_log_db tests..."
57+
pytest ./stat_log_db/tests/
58+
;;
59+
t)
60+
echo "Running tools.sh tests..."
61+
pytest ./tests/test_tools.py
62+
;;
63+
p)
64+
echo "Running project tests..."
65+
pytest ./tests/
66+
;;
67+
a)
68+
echo "Running all tests..."
69+
pytest
70+
;;
71+
*)
72+
echo "Invalid test mode '$test'. Use one of: $supported_test_opts" >&2
73+
exit 1
74+
;;
75+
esac
76+
fi
77+
78+
# Clean artifacts [-c]
79+
if [ $clean -eq 1 ]; then
80+
echo "Cleaning up workspace..."
81+
dirs_to_clean=(
82+
".pytest_cache"
83+
"tests/__pycache__"
84+
"stat_log_db/build"
85+
"stat_log_db/dist"
86+
"stat_log_db/.pytest_cache"
87+
"stat_log_db/tests/__pycache__"
88+
"stat_log_db/src/stat_log_db.egg-info"
89+
"stat_log_db/src/stat_log_db/__pycache__"
90+
"stat_log_db/src/stat_log_db/commands/__pycache__"
91+
)
92+
rm -rf "${dirs_to_clean[@]}"
93+
echo "Cleanup complete."
94+
fi
95+
96+
# Uninstall [-u]
97+
if [ $uninstall -eq 1 ]; then
98+
echo "Uninstalling..."
99+
python -m pip uninstall -y stat_log_db
100+
echo "Uninstall complete."
101+
fi

0 commit comments

Comments
 (0)