This commit introduces a complete structural and architectural refactor of

Analysis-Ready Code (ARC). The project is now fully migrated to a modern
src/-based Python package layout, with proper packaging via pyproject.toml,
a clean Nix flake, and improved CLI entry points.

Major changes:

• Add `src/arc/` package with clean module structure:
  - arc/__init__.py now contains the main() dispatcher and clipboard helpers
  - arc/__main__.py provides a proper `python -m arc` entry point
  - arc/cli.py rewritten with full argparse-based interface
  - arc/code_processor.py modernized and relocated
  - arc/directory_handler.py rewritten with output_stream support
  - arc/tee.py added for multi-stream output (stdout + buffer)

• Remove legacy top-level modules:
  - cli.py
  - directory_handler.py
  - main.py

• Introduce fully PEP-517 compliant pyproject.toml with console script:
  - arc = arc.__main__:main

• Add Nix flake (`flake.nix`) providing:
  - buildPythonApplication package `arc`
  - `nix run .#arc` app
  - development shell with Python + xclip

• Add Makefile overhaul:
  - automatic detection of Nix vs Python installation
  - unified install/uninstall targets
  - Nix wrapper installation into ~/.local/bin
  - improved help text and shell safety

• Add GitHub CI pipelines:
  - ci-python.yml for Python builds + Makefile tests + arc --help
  - ci-nix.yml for Nix builds, flake checks, dev-shell tests, and `nix run .#arc`

• Refactor and extend unit tests:
  - test_arc.py updated for src/ imports
  - new tests: test_cli.py, test_main.py, test_tee.py
  - improved CodeProcessor and DirectoryHandler tests

• Add egg-info metadata for local builds

• Add build/lib/ tree for compatibility with setuptools (generated)

Overall, this commit modernizes ARC into a clean, robust, and fully packaged
Python/Nix hybrid tool, enabling reproducible builds, solid CLI behavior,
testable architecture, and CI automation.

https://chatgpt.com/share/693933a0-e280-800f-9cf0-26036d15be04
This commit is contained in:
2025-12-10 09:47:19 +01:00
parent b55576beb2
commit 039481d3a9
19 changed files with 965 additions and 186 deletions

View File

@@ -6,13 +6,14 @@ import tempfile
import unittest
from contextlib import redirect_stdout
# Ensure project root is on sys.path when running via discover
# Ensure src/ is on sys.path when running via discover
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
SRC_ROOT = os.path.join(PROJECT_ROOT, "src")
if SRC_ROOT not in sys.path:
sys.path.insert(0, SRC_ROOT)
from code_processor import CodeProcessor
from directory_handler import DirectoryHandler
from arc.code_processor import CodeProcessor
from arc.directory_handler import DirectoryHandler
class TestCodeProcessor(unittest.TestCase):
@@ -35,7 +36,7 @@ def f():
self.assertNotIn("# a comment", out)
# tolerate whitespace normalization from tokenize.untokenize
self.assertRegex(out, r'y\s*=\s*"string with # not a comment"')
self.assertIn('triple quoted but not a docstring', out)
self.assertIn("triple quoted but not a docstring", out)
def test_cstyle_comment_stripping(self):
src = '''\
@@ -170,8 +171,12 @@ class TestDirectoryHandler(unittest.TestCase):
with open(p, "w") as f:
f.write("# comment only\nx=1\n")
buf = io.StringIO()
with redirect_stdout(buf):
DirectoryHandler.print_file_content(p, no_comments=True, compress=False)
DirectoryHandler.print_file_content(
p,
no_comments=True,
compress=False,
output_stream=buf,
)
out = buf.getvalue()
self.assertIn("<< START:", out)
# be whitespace-tolerant (tokenize may insert spaces)
@@ -179,8 +184,12 @@ class TestDirectoryHandler(unittest.TestCase):
self.assertNotIn("# comment only", out)
buf = io.StringIO()
with redirect_stdout(buf):
DirectoryHandler.print_file_content(p, no_comments=True, compress=True)
DirectoryHandler.print_file_content(
p,
no_comments=True,
compress=True,
output_stream=buf,
)
out = buf.getvalue()
self.assertIn("COMPRESSED CODE:", out)
self.assertIn("<< END >>", out)

60
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,60 @@
# tests/unit/test_cli.py
import os
import sys
import unittest
from unittest.mock import patch
# Ensure src/ is on sys.path when running via discover
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
SRC_ROOT = os.path.join(PROJECT_ROOT, "src")
if SRC_ROOT not in sys.path:
sys.path.insert(0, SRC_ROOT)
from arc.cli import parse_arguments # noqa: E402
class TestCliParseArguments(unittest.TestCase):
def test_basic_paths_and_defaults(self):
with patch.object(sys, "argv", ["arc", "foo", "bar"]):
args = parse_arguments()
self.assertEqual(args.paths, ["foo", "bar"])
self.assertEqual(args.file_types, [])
self.assertEqual(args.ignore_file_strings, [])
self.assertFalse(args.clipboard)
self.assertFalse(args.quiet)
# show_hidden default is False → ignore_hidden should be True
self.assertFalse(args.show_hidden)
self.assertTrue(args.ignore_hidden)
def test_clipboard_and_quiet_short_flags(self):
with patch.object(sys, "argv", ["arc", ".", "-x", "-q"]):
args = parse_arguments()
self.assertTrue(args.clipboard)
self.assertTrue(args.quiet)
def test_ignore_file_strings_short_and_long(self):
# Test only the short form -I collecting multiple values
with patch.object(
sys,
"argv",
["arc", ".", "-I", "build", "dist", "node_modules"],
):
args = parse_arguments()
self.assertEqual(
args.ignore_file_strings,
["build", "dist", "node_modules"],
)
def test_show_hidden_switches_ignore_hidden_off(self):
with patch.object(sys, "argv", ["arc", ".", "--show-hidden"]):
args = parse_arguments()
self.assertTrue(args.show_hidden)
self.assertFalse(args.ignore_hidden)
if __name__ == "__main__":
unittest.main()

145
tests/unit/test_main.py Normal file
View File

@@ -0,0 +1,145 @@
# tests/unit/test_main.py
import io
import os
import sys
import tempfile
import types
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
# Ensure src/ is on sys.path when running via discover
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
SRC_ROOT = os.path.join(PROJECT_ROOT, "src")
if SRC_ROOT not in sys.path:
sys.path.insert(0, SRC_ROOT)
import arc # noqa: E402
class TestArcMain(unittest.TestCase):
def _make_args(
self,
path,
clipboard=False,
quiet=False,
file_types=None,
ignore_file_strings=None,
ignore_hidden=True,
verbose=False,
no_comments=False,
compress=False,
path_contains=None,
content_contains=None,
no_gitignore=False,
scan_binary_files=False,
):
return types.SimpleNamespace(
paths=[path],
clipboard=clipboard,
quiet=quiet,
file_types=file_types or [],
ignore_file_strings=ignore_file_strings or [],
ignore_hidden=ignore_hidden,
show_hidden=not ignore_hidden,
verbose=verbose,
no_comments=no_comments,
compress=compress,
path_contains=path_contains or [],
content_contains=content_contains or [],
no_gitignore=no_gitignore,
scan_binary_files=scan_binary_files,
)
@patch("arc.subprocess.run")
@patch("arc.DirectoryHandler.handle_directory")
@patch("arc.parse_arguments")
def test_main_clipboard_calls_xclip_and_uses_tee(
self, mock_parse_arguments, mock_handle_directory, mock_run
):
# create a temporary directory as scan target
with tempfile.TemporaryDirectory() as tmpdir:
args = self._make_args(path=tmpdir, clipboard=True, quiet=False)
mock_parse_arguments.return_value = args
def fake_handle_directory(path, **kwargs):
out = kwargs["output_stream"]
# should be a Tee instance
self.assertEqual(out.__class__.__name__, "Tee")
out.write("FROM ARC\n")
mock_handle_directory.side_effect = fake_handle_directory
buf = io.StringIO()
with redirect_stdout(buf):
arc.main()
# stdout should contain the text once (via Tee -> sys.stdout)
stdout_value = buf.getvalue()
self.assertIn("FROM ARC", stdout_value)
# xclip should have been called with the same text in input
mock_run.assert_called_once()
called_args, called_kwargs = mock_run.call_args
self.assertEqual(called_args[0], ["xclip", "-selection", "clipboard"])
self.assertIn("FROM ARC", called_kwargs.get("input", ""))
@patch("arc.subprocess.run")
@patch("arc.DirectoryHandler.handle_directory")
@patch("arc.parse_arguments")
def test_main_clipboard_quiet_only_clipboard_no_stdout(
self, mock_parse_arguments, mock_handle_directory, mock_run
):
with tempfile.TemporaryDirectory() as tmpdir:
args = self._make_args(path=tmpdir, clipboard=True, quiet=True)
mock_parse_arguments.return_value = args
def fake_handle_directory(path, **kwargs):
out = kwargs["output_stream"]
# quiet + clipboard → output_stream is a buffer (StringIO)
self.assertIsInstance(out, io.StringIO)
out.write("SILENT CONTENT\n")
mock_handle_directory.side_effect = fake_handle_directory
buf = io.StringIO()
# stdout should stay empty
with redirect_stdout(buf):
arc.main()
stdout_value = buf.getvalue()
self.assertEqual(stdout_value, "")
mock_run.assert_called_once()
called_args, called_kwargs = mock_run.call_args
self.assertEqual(called_args[0], ["xclip", "-selection", "clipboard"])
self.assertIn("SILENT CONTENT", called_kwargs.get("input", ""))
@patch("arc.DirectoryHandler.handle_directory")
@patch("arc.parse_arguments")
def test_main_quiet_without_clipboard_uses_nullwriter(
self, mock_parse_arguments, mock_handle_directory
):
with tempfile.TemporaryDirectory() as tmpdir:
args = self._make_args(path=tmpdir, clipboard=False, quiet=True)
mock_parse_arguments.return_value = args
def fake_handle_directory(path, **kwargs):
out = kwargs["output_stream"]
# quiet without clipboard → internal NullWriter class
self.assertEqual(out.__class__.__name__, "NullWriter")
# writing should not raise
out.write("SHOULD NOT APPEAR ANYWHERE\n")
mock_handle_directory.side_effect = fake_handle_directory
buf = io.StringIO()
with redirect_stdout(buf):
arc.main()
# Nothing should be printed to stdout
self.assertEqual(buf.getvalue(), "")
if __name__ == "__main__":
unittest.main()

54
tests/unit/test_tee.py Normal file
View File

@@ -0,0 +1,54 @@
# tests/unit/test_tee.py
import io
import os
import sys
import unittest
# Ensure src/ is on sys.path when running via discover
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
SRC_ROOT = os.path.join(PROJECT_ROOT, "src")
if SRC_ROOT not in sys.path:
sys.path.insert(0, SRC_ROOT)
from arc.tee import Tee # noqa: E402
class TestTee(unittest.TestCase):
def test_write_writes_to_all_streams(self):
buf1 = io.StringIO()
buf2 = io.StringIO()
tee = Tee(buf1, buf2)
tee.write("hello")
tee.write(" world")
self.assertEqual(buf1.getvalue(), "hello world")
self.assertEqual(buf2.getvalue(), "hello world")
def test_flush_flushes_all_streams(self):
class DummyStream:
def __init__(self):
self.flushed = False
self.data = ""
def write(self, s):
self.data += s
def flush(self):
self.flushed = True
s1 = DummyStream()
s2 = DummyStream()
tee = Tee(s1, s2)
tee.write("x")
tee.flush()
self.assertTrue(s1.flushed)
self.assertTrue(s2.flushed)
self.assertEqual(s1.data, "x")
self.assertEqual(s2.data, "x")
if __name__ == "__main__":
unittest.main()