From 94176af8f9203137263d580493835de20fa07d23 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 16:14:31 -0700 Subject: [PATCH 1/3] fix: ensure sys.exit(1) on import failure in __main__.py, call main() unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the redundant `if __name__ == "__main__":` guard — Python only executes __main__.py when running as a module, so the guard was misleading and left a NameError trap if the ImportError path was hit. Adds subprocess-based tests for the `python -m tabcmd` entry point. Closes #375 Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/__main__.py | 16 ++++++++++------ tests/test_main.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 tests/test_main.py diff --git a/tabcmd/__main__.py b/tabcmd/__main__.py index 0acec1d3..3ba9deba 100644 --- a/tabcmd/__main__.py +++ b/tabcmd/__main__.py @@ -3,15 +3,19 @@ try: from tabcmd.tabcmd import main except ImportError as e: - print("Exception thrown running program: `{}`, `{}`".format(e, e.__context__), file=sys.stderr) - + print( + "Exception thrown importing tabcmd: `{}`, `{}`".format(e, e.__context__), + file=sys.stderr, + ) if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): print("Application is running in an executable bundle") print("sys.argv[0] is", sys.argv[0]) print("sys.executable is", sys.executable) else: - print("[Possible cause: Tabcmd needs to be run as a module, try running `python -m tabcmd`]", file=sys.stderr) - + print( + "[Possible cause: Tabcmd needs to be installed, try `pip install tabcmd`]", + file=sys.stderr, + ) + sys.exit(1) -if __name__ == "__main__": - main() +main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..bf5fdda8 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,34 @@ +import subprocess +import sys +import unittest + + +class TestPythonMTabcmd(unittest.TestCase): + def test_python_m_tabcmd_no_args_exits_zero(self): + result = subprocess.run( + [sys.executable, "-m", "tabcmd"], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + + def test_python_m_tabcmd_help_exits_zero(self): + result = subprocess.run( + [sys.executable, "-m", "tabcmd", "--help"], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertIn("tabcmd", result.stdout) + + def test_python_m_tabcmd_help_subcommand_exits_zero(self): + result = subprocess.run( + [sys.executable, "-m", "tabcmd", "help"], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + + +if __name__ == "__main__": + unittest.main() From 4170fd2a50c22259dbce18bc06ba6a975e5f89db Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 17:51:27 -0700 Subject: [PATCH 2/3] fix: restore __name__ guard, fix stderr routing, strengthen tests in __main__.py Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/__main__.py | 10 ++++++---- tests/test_main.py | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/tabcmd/__main__.py b/tabcmd/__main__.py index 3ba9deba..9c609c64 100644 --- a/tabcmd/__main__.py +++ b/tabcmd/__main__.py @@ -8,9 +8,9 @@ file=sys.stderr, ) if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): - print("Application is running in an executable bundle") - print("sys.argv[0] is", sys.argv[0]) - print("sys.executable is", sys.executable) + print("Application is running in an executable bundle", file=sys.stderr) + print("sys.argv[0] is", sys.argv[0], file=sys.stderr) + print("sys.executable is", sys.executable, file=sys.stderr) else: print( "[Possible cause: Tabcmd needs to be installed, try `pip install tabcmd`]", @@ -18,4 +18,6 @@ ) sys.exit(1) -main() + +if __name__ == "__main__": + main() diff --git a/tests/test_main.py b/tests/test_main.py index bf5fdda8..c5f0a46c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,7 @@ +import os import subprocess import sys +import tempfile import unittest @@ -9,6 +11,7 @@ def test_python_m_tabcmd_no_args_exits_zero(self): [sys.executable, "-m", "tabcmd"], capture_output=True, text=True, + timeout=30, ) self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") @@ -17,18 +20,52 @@ def test_python_m_tabcmd_help_exits_zero(self): [sys.executable, "-m", "tabcmd", "--help"], capture_output=True, text=True, + timeout=30, ) self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") - self.assertIn("tabcmd", result.stdout) + self.assertIn("usage:", result.stdout.lower()) def test_python_m_tabcmd_help_subcommand_exits_zero(self): result = subprocess.run( [sys.executable, "-m", "tabcmd", "help"], capture_output=True, text=True, + timeout=30, ) self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + def test_import_error_exits_one(self): + # Build a minimal fake tabcmd package in a temp directory. + # Running python -m tabcmd from that directory makes the fake package + # take precedence ('' is first on sys.path), so tabcmd.tabcmd raises + # ImportError and __main__.py must catch it, print the error, and exit 1. + with tempfile.TemporaryDirectory() as tmpdir: + fake_pkg = os.path.join(tmpdir, "tabcmd") + os.makedirs(fake_pkg, exist_ok=True) + # __init__.py — empty, makes it a package + open(os.path.join(fake_pkg, "__init__.py"), "w").close() + # tabcmd.py — raises ImportError on import + with open(os.path.join(fake_pkg, "tabcmd.py"), "w") as f: + f.write('raise ImportError("test error")\n') + # __main__.py — copy of the real entry-point so -m tabcmd works + real_main = os.path.join(os.path.dirname(__file__), "..", "tabcmd", "__main__.py") + with open(real_main) as src, open(os.path.join(fake_pkg, "__main__.py"), "w") as dst: + dst.write(src.read()) + + # Strip PYTHONPATH so the installed package cannot interfere + env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"} + + result = subprocess.run( + [sys.executable, "-m", "tabcmd"], + capture_output=True, + text=True, + timeout=30, + cwd=tmpdir, + env=env, + ) + self.assertEqual(result.returncode, 1, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertIn("Exception thrown importing tabcmd", result.stderr) + if __name__ == "__main__": unittest.main() From b102fde13f06d02e86b94de31d785f1c6019d8ac Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 18:44:25 -0700 Subject: [PATCH 3/3] fix: broaden except clause, improve error message, harden import-error test - except Exception catches SyntaxError/OSError from broken installs, not just ImportError - e.__context__ only printed when present (was always None for top-level imports) - pip install suggestion now recommends --force-reinstall (tabcmd is already on sys.path) - test_import_error_exits_one: use sys.modules injection instead of CWD/path ordering - success tests: pass PYTHONPATH to ensure tabcmd is importable in subprocess Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/__main__.py | 10 ++++---- tests/test_main.py | 60 ++++++++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/tabcmd/__main__.py b/tabcmd/__main__.py index 9c609c64..624a4133 100644 --- a/tabcmd/__main__.py +++ b/tabcmd/__main__.py @@ -2,18 +2,16 @@ try: from tabcmd.tabcmd import main -except ImportError as e: - print( - "Exception thrown importing tabcmd: `{}`, `{}`".format(e, e.__context__), - file=sys.stderr, - ) +except Exception as e: + ctx = ", caused by: {}".format(e.__context__) if e.__context__ else "" + print("Exception thrown importing tabcmd: {}{}".format(e, ctx), file=sys.stderr) if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): print("Application is running in an executable bundle", file=sys.stderr) print("sys.argv[0] is", sys.argv[0], file=sys.stderr) print("sys.executable is", sys.executable, file=sys.stderr) else: print( - "[Possible cause: Tabcmd needs to be installed, try `pip install tabcmd`]", + "[Possible cause: tabcmd is installed but broken, try `pip install --force-reinstall tabcmd`]", file=sys.stderr, ) sys.exit(1) diff --git a/tests/test_main.py b/tests/test_main.py index c5f0a46c..f2c11d33 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,8 +2,17 @@ import subprocess import sys import tempfile +import textwrap import unittest +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def _repo_env(): + env = dict(os.environ) + env["PYTHONPATH"] = _REPO_ROOT + return env + class TestPythonMTabcmd(unittest.TestCase): def test_python_m_tabcmd_no_args_exits_zero(self): @@ -12,8 +21,9 @@ def test_python_m_tabcmd_no_args_exits_zero(self): capture_output=True, text=True, timeout=30, + env=_repo_env(), ) - self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertEqual(result.returncode, 0, msg="stderr: {}\nstdout: {}".format(result.stderr, result.stdout)) def test_python_m_tabcmd_help_exits_zero(self): result = subprocess.run( @@ -21,8 +31,9 @@ def test_python_m_tabcmd_help_exits_zero(self): capture_output=True, text=True, timeout=30, + env=_repo_env(), ) - self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertEqual(result.returncode, 0, msg="stderr: {}\nstdout: {}".format(result.stderr, result.stdout)) self.assertIn("usage:", result.stdout.lower()) def test_python_m_tabcmd_help_subcommand_exits_zero(self): @@ -31,39 +42,36 @@ def test_python_m_tabcmd_help_subcommand_exits_zero(self): capture_output=True, text=True, timeout=30, + env=_repo_env(), ) - self.assertEqual(result.returncode, 0, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertEqual(result.returncode, 0, msg="stderr: {}\nstdout: {}".format(result.stderr, result.stdout)) def test_import_error_exits_one(self): - # Build a minimal fake tabcmd package in a temp directory. - # Running python -m tabcmd from that directory makes the fake package - # take precedence ('' is first on sys.path), so tabcmd.tabcmd raises - # ImportError and __main__.py must catch it, print the error, and exit 1. + # Inject a broken tabcmd.tabcmd into sys.modules before running __main__, + # so the ImportError handler is exercised without relying on sys.path ordering. + script = textwrap.dedent( + """\ + import sys + class _BrokenTabcmd: + def __getattr__(self, _): + raise ImportError("injected failure") + sys.modules["tabcmd.tabcmd"] = _BrokenTabcmd() + import runpy + runpy.run_module("tabcmd", run_name="__main__") + """ + ) with tempfile.TemporaryDirectory() as tmpdir: - fake_pkg = os.path.join(tmpdir, "tabcmd") - os.makedirs(fake_pkg, exist_ok=True) - # __init__.py — empty, makes it a package - open(os.path.join(fake_pkg, "__init__.py"), "w").close() - # tabcmd.py — raises ImportError on import - with open(os.path.join(fake_pkg, "tabcmd.py"), "w") as f: - f.write('raise ImportError("test error")\n') - # __main__.py — copy of the real entry-point so -m tabcmd works - real_main = os.path.join(os.path.dirname(__file__), "..", "tabcmd", "__main__.py") - with open(real_main) as src, open(os.path.join(fake_pkg, "__main__.py"), "w") as dst: - dst.write(src.read()) - - # Strip PYTHONPATH so the installed package cannot interfere - env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"} - + script_path = os.path.join(tmpdir, "run.py") + with open(script_path, "w") as f: + f.write(script) result = subprocess.run( - [sys.executable, "-m", "tabcmd"], + [sys.executable, script_path], capture_output=True, text=True, timeout=30, - cwd=tmpdir, - env=env, + env=_repo_env(), ) - self.assertEqual(result.returncode, 1, msg=f"stderr: {result.stderr}\nstdout: {result.stdout}") + self.assertEqual(result.returncode, 1, msg="stderr: {}\nstdout: {}".format(result.stderr, result.stdout)) self.assertIn("Exception thrown importing tabcmd", result.stderr)