Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
*.pyc
output.txt
save.json
.claude/
.claude/

# coverage artifacts left behind by test.sh
.coverage
cov.xml
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ pip install -r requirements-dev.txt
### Graphical UI (Default)
Run the game with the standard pygame graphical interface:
```bash
python src/ophidian.py
python3 src/ophidian.py
```

### Text-Based UI
Run the game with a text-based terminal interface:
```bash
python src/ophidian.py --text-ui
python3 src/ophidian.py --text-ui
```

`./run.sh` starts the graphical UI too, after pulling the latest code and running
the tests. It and `./test.sh` pick the interpreter themselves — `python3` first,
then `python`, taking whichever reports 3.8 or newer — so set `PYTHON` to
override that choice:
```bash
PYTHON=.venv/bin/python ./test.sh
```

The text-based UI is perfect for:
Expand Down
2 changes: 1 addition & 1 deletion format.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# /bin/bash
#!/bin/bash
# Usage: ./format.sh

# src/lib holds vendored copies of graphik and py_env_lib and is left alone by
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Runtime dependencies.
# Not needed for the text-based UI (`python src/ophidian.py --text-ui`),
# Not needed for the text-based UI (`python3 src/ophidian.py --text-ui`),
# which imports TextRenderer instead of pygame.
pygame>=2.0.0
26 changes: 23 additions & 3 deletions run.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
# /bin/bash
#!/bin/bash
# Usage: ./run.sh

resolvePython() {
# pick an interpreter that meets the requirement in README.md, since
# `python` is Python 2 on some systems and absent on others. PYTHON can be
# set beforehand to override the choice - a virtualenv interpreter, say.
if [ -n "$PYTHON" ]; then
return
fi
for candidate in python3 python; do
if command -v "$candidate" > /dev/null 2>&1 &&
"$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 8))' \
> /dev/null 2>&1; then
PYTHON="$candidate"
return
fi
done
echo "No Python 3.8 or newer interpreter found. Install one, or set PYTHON to the path of one."
exit 1
}

getLatest() {
# get the latest version of the code
echo "Pulling latest version of code from GitHub"
Expand Down Expand Up @@ -41,17 +60,18 @@ printVersion() {
runTests() {
# run tests
echo "Running tests"
python -m pytest
"$PYTHON" -m pytest
echo ""
}

startProgram() {
# start program
echo "Starting program"
python src/ophidian.py > output.txt
"$PYTHON" src/ophidian.py > output.txt
}

# main
resolvePython
getLatest
printBranchStatus
printVersion
Expand Down
25 changes: 23 additions & 2 deletions test.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# /bin/bash
#!/bin/bash
# Usage: ./test.sh

resolvePython() {
# pick an interpreter that meets the requirement in README.md, since
# `python` is Python 2 on some systems and absent on others. PYTHON can be
# set beforehand to override the choice - a virtualenv interpreter, say.
if [ -n "$PYTHON" ]; then
return
fi
for candidate in python3 python; do
if command -v "$candidate" > /dev/null 2>&1 &&
"$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 8))' \
> /dev/null 2>&1; then
PYTHON="$candidate"
return
fi
done
echo "No Python 3.8 or newer interpreter found. Install one, or set PYTHON to the path of one."
exit 1
}

resolvePython

# generate coverage file named "cov.xml"
python -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml
"$PYTHON" -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml
203 changes: 203 additions & 0 deletions tests/test_shell_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""The top-level shell scripts, as a fresh clone finds them (issue #135).

Each script documents itself as `./<name>.sh`, which only works when the
file carries a real shebang and the executable bit. `test.sh` is also the
project's only verification gate, so the interpreter it reaches for has to
be one that satisfies the requirement README states rather than whatever
`python` happens to mean on the machine.

The interpreter resolver is exercised for real rather than pattern-matched:
its function body is lifted out of the script under test and run against
fake `python3`/`python` executables that report whatever version a case
needs. Running the scripts whole is not an option here - `test.sh` runs the
suite, and this file is in it.
"""

import os
import re
import shutil
import subprocess
import sys
import tempfile

import pytest

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# resolved up front, since the resolver cases hand bash a PATH holding
# nothing but their own fake interpreters. Absent bash there is nothing to
# say about a bash script, so those cases skip rather than error out.
BASH = shutil.which("bash")
requiresBash = pytest.mark.skipif(BASH is None, reason="bash is not installed")

SCRIPTS = ["test.sh", "run.sh", "format.sh"]
PYTHON_CALLING_SCRIPTS = ["test.sh", "run.sh"]


def _read(name):
with open(os.path.join(REPO_ROOT, name)) as handle:
return handle.read()


def _uncommentedLines(name):
return [
line
for line in _read(name).splitlines()
if line.strip() and not line.strip().startswith("#")
]


def _documentedMinimumVersion():
documented = re.search(r"Python (\d+)\.(\d+) or newer", _read("README.md"))
assert documented is not None, "README no longer states a minimum Python version"
return tuple(int(part) for part in documented.groups())


def _writeExecutable(path, body):
with open(path, "w") as handle:
handle.write(body)
os.chmod(path, 0o755)


def _fakeInterpreter(binDirectory, name, version):
"""An interpreter that answers the resolver's probe as `version` would."""
_writeExecutable(
os.path.join(binDirectory, name),
'#!/bin/bash\nexec "%s" -c "import sys\nsys.version_info = %r\n$2"\n'
% (sys.executable, version),
)


def _runResolver(tmp_path, script, versions, override=None):
"""Run `script`'s resolver alone, seeing only the given fake interpreters.

Returns the completed process; stdout is the interpreter it settled on.
"""
resolver = re.search(r"^resolvePython\(\) \{$.*?^\}$", _read(script), re.M | re.S)
assert resolver is not None, "%s no longer defines resolvePython" % script

# a fresh directory per call, so a test may resolve more than once
# without an earlier call's fake interpreters still being on PATH.
caseDirectory = tempfile.mkdtemp(dir=str(tmp_path))
binDirectory = os.path.join(caseDirectory, "bin")
os.mkdir(binDirectory)
for name, version in versions.items():
_fakeInterpreter(binDirectory, name, version)

harness = os.path.join(caseDirectory, "resolver.sh")
_writeExecutable(harness, '%s\nresolvePython\necho "$PYTHON"\n' % resolver.group())

environment = {"PATH": binDirectory}
if override is not None:
environment["PYTHON"] = override
return subprocess.run(
[BASH, harness], capture_output=True, text=True, env=environment
)


def test_every_script_opens_with_a_real_shebang():
for name in SCRIPTS:
assert _read(name).startswith("#!/bin/bash\n"), name


def test_every_script_is_executable():
for name in SCRIPTS:
assert os.access(os.path.join(REPO_ROOT, name), os.X_OK), name


@requiresBash
def test_every_script_is_valid_bash():
for name in SCRIPTS:
checked = subprocess.run(
[BASH, "-n", os.path.join(REPO_ROOT, name)],
capture_output=True,
text=True,
)
assert checked.returncode == 0, (name, checked.stderr)


def test_python_is_only_ever_run_through_the_resolved_interpreter():
# the resolver names python3/python as candidates; every line that
# actually runs something has to go through what it settled on.
for name in PYTHON_CALLING_SCRIPTS:
for line in _uncommentedLines(name):
if not re.search(r"-m pytest|src/ophidian\.py", line):
continue
assert line.strip().startswith('"$PYTHON" '), (name, line)


def test_the_python_calling_scripts_resolve_before_running_anything():
# a function definition takes effect without running, so the statement
# that has to come first is the top-level call to resolvePython.
for name in PYTHON_CALLING_SCRIPTS:
statements = [
line
for line in _uncommentedLines(name)
if not line.startswith((" ", "\t")) and line not in ("}",)
if not line.endswith("{")
]
assert statements[0] == "resolvePython", (name, statements)


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_the_newest_qualifying_candidate_is_preferred(script, tmp_path):
resolved = _runResolver(
tmp_path, script, {"python3": (3, 10, 0), "python": (3, 12, 0)}
)

assert resolved.returncode == 0, resolved.stderr
assert resolved.stdout.strip() == "python3"


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_a_python_2_fallback_is_taken_only_when_it_qualifies(script, tmp_path):
resolved = _runResolver(tmp_path, script, {"python": (2, 7, 18)})

assert resolved.returncode == 1
assert "No Python 3.8 or newer interpreter found." in resolved.stdout


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_python_is_used_when_python3_is_absent(script, tmp_path):
resolved = _runResolver(tmp_path, script, {"python": (3, 11, 0)})

assert resolved.returncode == 0, resolved.stderr
assert resolved.stdout.strip() == "python"


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_the_documented_minimum_version_is_the_one_enforced(script, tmp_path):
minimum = _documentedMinimumVersion()
justBelow = (minimum[0], minimum[1] - 1, 0)

rejected = _runResolver(tmp_path, script, {"python3": justBelow})
accepted = _runResolver(tmp_path, script, {"python3": minimum + (0,)})

assert rejected.returncode == 1, rejected.stdout
assert accepted.returncode == 0, accepted.stderr
assert accepted.stdout.strip() == "python3"


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_a_preset_python_is_honored_without_probing(script, tmp_path):
resolved = _runResolver(
tmp_path, script, {"python3": (3, 10, 0)}, override="/opt/venv/bin/python"
)

assert resolved.returncode == 0, resolved.stderr
assert resolved.stdout.strip() == "/opt/venv/bin/python"


@requiresBash
@pytest.mark.parametrize("script", PYTHON_CALLING_SCRIPTS)
def test_no_candidate_at_all_fails_with_an_actionable_message(script, tmp_path):
resolved = _runResolver(tmp_path, script, {})

assert resolved.returncode == 1
assert "No Python 3.8 or newer interpreter found." in resolved.stdout
assert "set PYTHON" in resolved.stdout