diff --git a/.gitignore b/.gitignore index c800448..1b67c06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,27 @@ -# Ignore Python-Compiled Files +# Python *.pyc +*.pyo +__pycache__/ +.venv/ +venv/ +*.egg +.eggs/ +build/ +dist/ +jsocket.egg-info/ + +# Testing & Linting +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ +.pylint.d/ +.mypy_cache/ +.ruff_cache/ -# Ignore Eclipse/PyDev Project Files +# IDE & Editors +.idea/ +.vscode/ .project .pydevproject .settings @@ -12,10 +32,12 @@ # Ignore Claude Code Files .claude/ *.sw* -.coverage -.pytest_cache/ -.pylint.d/ -coverage.xml -build/ -dist/ -jsocket.egg-info/ + +# Environment +.env + +# OS +.DS_Store + +# Claude Code +.claude/ diff --git a/Makefile b/Makefile index ba0b4d7..f7b75f1 100644 --- a/Makefile +++ b/Makefile @@ -38,15 +38,15 @@ lint: PYLINTHOME=.pylint.d pylint jsocket tests features/steps --fail-under=9.0 --persistent=n --disable=duplicate-code wheel: - python -m build --wheel + python3 -m build --wheel publish: - python -m build - python -m twine check dist/* - python -m twine upload dist/* + python3 -m build + python3 -m twine check dist/* + python3 -m twine upload dist/* version: - @python -c "import pathlib, re; p = pathlib.Path('jsocket/_version.py'); m = re.search(r'__version__\\s*=\\s*[\\\"\\']([^\\\"\\']+)[\\\"\\']', p.read_text(encoding='utf-8')); print(f\"version: {m.group(1) if m else 'unknown'}\")" + @python3 -c "import pathlib, re; p = pathlib.Path('jsocket/_version.py'); m = re.search(r'__version__\\s*=\\s*[\\\"\\']([^\\\"\\']+)[\\\"\\']', p.read_text(encoding='utf-8')); print(f\"version: {m.group(1) if m else 'unknown'}\")" @sha=$$(git log -1 --format=%h 2>/dev/null); \ if [ -n "$$sha" ]; then \ if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then \ diff --git a/tests/test_integration_failures.py b/tests/test_integration_failures.py index c327016..ce22454 100644 --- a/tests/test_integration_failures.py +++ b/tests/test_integration_failures.py @@ -326,7 +326,7 @@ def test_serverfactory_fast_client_not_blocked_by_slow_client(): idle_thread.start() try: - assert ready_event.wait(timeout=1.0) is True + assert ready_event.wait(timeout=5.0) is True fast_client = _connect_client(port) started = time.monotonic() _roundtrip(fast_client, {"echo": "fast"}) diff --git a/tests/test_unit_coverage.py b/tests/test_unit_coverage.py index ec534f6..d6ace44 100644 --- a/tests/test_unit_coverage.py +++ b/tests/test_unit_coverage.py @@ -11,7 +11,9 @@ import jsocket.tserver as tserver -class FakeSocket: +class FakeSocket: # pylint: disable=missing-function-docstring + """Simple socket stub for unit tests.""" + def __init__(self, fileno_value=1, fileno_exc=None, shutdown_exc=None, close_exc=None): self._fileno_value = fileno_value self._fileno_exc = fileno_exc @@ -43,6 +45,7 @@ def send(self, data): def _make_base_socket(): + """Build a JsonSocket stub with a fake connection.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.socket = object() sock.conn = FakeSocket() @@ -52,12 +55,14 @@ def _make_base_socket(): def test_send_obj_no_socket_is_noop(): + """send_obj should no-op when socket is None.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.socket = None sock.send_obj({"x": 1}) def test_send_obj_rejects_oversize(): + """send_obj should reject payloads exceeding max_message_size.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.socket = object() sock._max_message_size = 1 @@ -66,10 +71,12 @@ def test_send_obj_rejects_oversize(): def test_read_header_invalid_magic_closes(): + """_read_header should raise when magic bytes are invalid.""" sock = _make_base_socket() closed = {"n": 0} def _close(): + """Track close invocations.""" closed["n"] += 1 sock._close_connection = _close @@ -85,11 +92,13 @@ def _close(): def test_read_header_oversize_closes(): + """_read_header should raise when length exceeds max_message_size.""" sock = _make_base_socket() sock._max_message_size = 2 closed = {"n": 0} def _close(): + """Track close invocations.""" closed["n"] += 1 sock._close_connection = _close @@ -105,12 +114,14 @@ def _close(): def test_read_obj_invalid_utf8_closes(): + """read_obj should raise when payload is not UTF-8.""" sock = _make_base_socket() closed = {"n": 0} data = b"\xff" checksum = zlib.crc32(data) & 0xFFFFFFFF def _close(): + """Track close invocations.""" closed["n"] += 1 sock._close_connection = _close @@ -122,12 +133,14 @@ def _close(): def test_read_obj_invalid_json_closes(): + """read_obj should raise when payload is not valid JSON.""" sock = _make_base_socket() closed = {"n": 0} data = b"not-json" checksum = zlib.crc32(data) & 0xFFFFFFFF def _close(): + """Track close invocations.""" closed["n"] += 1 sock._close_connection = _close @@ -139,30 +152,35 @@ def _close(): def test_close_socket_handles_oserrors(): + """_close_socket should swallow shutdown/close errors.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.socket = FakeSocket(shutdown_exc=OSError("boom"), close_exc=OSError("boom")) sock._close_socket() def test_close_socket_handles_fileno_error(): + """_close_socket should swallow fileno errors.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.socket = FakeSocket(fileno_exc=OSError("boom")) sock._close_socket() def test_close_connection_handles_oserrors(): + """_close_connection should swallow shutdown/close errors.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.conn = FakeSocket(shutdown_exc=OSError("boom"), close_exc=OSError("boom")) sock._close_connection() def test_close_connection_handles_fileno_error(): + """_close_connection should swallow fileno errors.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock.conn = FakeSocket(fileno_exc=OSError("boom")) sock._close_connection() def test_properties_and_max_message_size_setters(): + """Property setters should accept None and reject non-positive sizes.""" sock = jsocket_base.JsonSocket.__new__(jsocket_base.JsonSocket) sock._max_message_size = 123 assert sock._get_max_message_size() == 123 @@ -175,6 +193,7 @@ def test_properties_and_max_message_size_setters(): def test_jsonserver_close_connection_handles_errors(): + """JsonServer._close_connection should swallow shutdown/close errors.""" server = jsocket_base.JsonServer.__new__(jsocket_base.JsonServer) server.socket = object() server.conn = FakeSocket(shutdown_exc=OSError("boom"), close_exc=OSError("boom")) @@ -182,6 +201,7 @@ def test_jsonserver_close_connection_handles_errors(): def test_jsonserver_close_connection_handles_fileno_error(): + """JsonServer._close_connection should swallow fileno errors.""" server = jsocket_base.JsonServer.__new__(jsocket_base.JsonServer) server.socket = object() server.conn = FakeSocket(fileno_exc=OSError("boom")) @@ -189,6 +209,7 @@ def test_jsonserver_close_connection_handles_fileno_error(): def test_jsonserver_connected_handles_fileno_error(): + """JsonServer.connected should tolerate fileno errors.""" server = jsocket_base.JsonServer.__new__(jsocket_base.JsonServer) server.socket = object() server.conn = FakeSocket(fileno_exc=OSError("boom")) @@ -196,6 +217,7 @@ def test_jsonserver_connected_handles_fileno_error(): def test_response_summary_and_format_client_id(): + """Helper formatting should cover dict/list/str and IPv6 cases.""" assert tserver._response_summary({"a": 1}) == "type=dict keys=1" assert tserver._response_summary([1, 2]) == "type=list items=2" assert tserver._response_summary("x") == "type=str" @@ -203,7 +225,9 @@ def test_response_summary_and_format_client_id(): assert tserver._format_client_id(None) == "unknown" -class _BaseServer(tserver.ThreadedServer): +class _BaseServer(tserver.ThreadedServer): # pylint: disable=missing-function-docstring + """ThreadedServer stub for base-method coverage.""" + def __init__(self): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = False @@ -217,7 +241,9 @@ def _process_message(self, obj): return super()._process_message(obj) -class _AcceptErrorServer(tserver.ThreadedServer): +class _AcceptErrorServer(tserver.ThreadedServer): # pylint: disable=missing-function-docstring + """ThreadedServer stub that raises on accept.""" + def __init__(self, alive): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = alive @@ -235,7 +261,9 @@ def _process_message(self, obj): # pragma: no cover - not used return None -class _RunServer(tserver.ThreadedServer): +class _RunServer(tserver.ThreadedServer): # pylint: disable=missing-function-docstring + """ThreadedServer stub that forces close error.""" + def __init__(self): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = False @@ -254,12 +282,16 @@ def _process_message(self, obj): # pragma: no cover - not used return None -class _PeernameRaises: +class _PeernameRaises: # pylint: disable=missing-function-docstring + """Object that raises on getpeername().""" + def getpeername(self): raise OSError("boom") -class _Worker(tserver.ServerFactoryThread): +class _Worker(tserver.ServerFactoryThread): # pylint: disable=missing-function-docstring + """Worker stub that sends a response once.""" + def __init__(self): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = True @@ -284,7 +316,9 @@ def _close_socket(self): return None -class _FactoryBase(tserver.ServerFactoryThread): +class _FactoryBase(tserver.ServerFactoryThread): # pylint: disable=missing-function-docstring + """Worker stub that calls base _process_message.""" + def __init__(self): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = False @@ -293,7 +327,9 @@ def _process_message(self, obj): return super()._process_message(obj) -class _FactoryWorker(tserver.ServerFactoryThread): +class _FactoryWorker(tserver.ServerFactoryThread): # pylint: disable=missing-function-docstring + """Worker stub without processing logic.""" + def __init__(self): # pylint: disable=super-init-not-called threading.Thread.__init__(self) self._is_alive = False @@ -302,7 +338,9 @@ def _process_message(self, obj): # pragma: no cover - not used return None -class _StoppingFactory(tserver.ServerFactory): +class _StoppingFactory(tserver.ServerFactory): # pylint: disable=missing-function-docstring + """ServerFactory stub that stops immediately.""" + def __init__(self): # pylint: disable=super-init-not-called self._is_alive = True self._thread_type = _FactoryWorker @@ -329,7 +367,9 @@ def close(self): return None -class _StopAllRaises(tserver.ServerFactory): +class _StopAllRaises(tserver.ServerFactory): # pylint: disable=missing-function-docstring + """ServerFactory stub that raises in stop_all.""" + def __init__(self): # pylint: disable=super-init-not-called self._is_alive = True self._address = "127.0.0.1" @@ -339,12 +379,16 @@ def stop_all(self): raise RuntimeError("boom") -class _DeadThread: +class _DeadThread: # pylint: disable=missing-function-docstring + """Thread stub that is not alive.""" + def is_alive(self): return False -class _AliveThread: +class _AliveThread: # pylint: disable=missing-function-docstring + """Thread stub that is alive without a client id.""" + def __init__(self): self._client_started_at = None self._client_id = None @@ -354,7 +398,9 @@ def is_alive(self): return True -class _ThreadWithName: +class _ThreadWithName: # pylint: disable=missing-function-docstring + """Thread stub that is alive with a name.""" + def __init__(self): self._client_started_at = None self._client_id = None @@ -364,7 +410,9 @@ def is_alive(self): return True -class _ThreadLock: +class _ThreadLock: # pylint: disable=missing-function-docstring + """Context manager stub for thread locks.""" + def __enter__(self): return self @@ -372,25 +420,30 @@ def __exit__(self, exc_type, exc, tb): return False -class _StatsFactory(tserver.ServerFactory): +class _StatsFactory(tserver.ServerFactory): # pylint: disable=missing-function-docstring + """ServerFactory stub with dead threads.""" + def __init__(self): # pylint: disable=super-init-not-called self._threads = [_DeadThread()] self._threads_lock = _ThreadLock() -class _StatsFactoryAlive(tserver.ServerFactory): +class _StatsFactoryAlive(tserver.ServerFactory): # pylint: disable=missing-function-docstring + """ServerFactory stub with live threads.""" + def __init__(self): # pylint: disable=super-init-not-called self._threads = [_AliveThread(), _ThreadWithName()] self._threads_lock = _ThreadLock() - def test_base_process_message_returns_none(): + """Base _process_message should return None.""" srv = _BaseServer() assert srv._process_message({"x": 1}) is None def test_record_client_start_handles_getpeername_error(): + """_record_client_start should handle getpeername errors.""" srv = _BaseServer() srv.conn = _PeernameRaises() srv._record_client_start() @@ -398,6 +451,7 @@ def test_record_client_start_handles_getpeername_error(): def test_accept_client_error_branches(): + """_accept_client should handle accept exceptions.""" srv_alive = _AcceptErrorServer(alive=True) assert srv_alive._accept_client() is False assert srv_alive._is_alive is True @@ -408,18 +462,21 @@ def test_accept_client_error_branches(): def test_run_sets_alive_and_handles_close_error(): + """run() should tolerate close() raising OSError.""" srv = _RunServer() srv.run() assert srv._is_alive is False def test_serverfactorythread_swap_socket_handles_getpeername_error(): + """swap_socket should handle getpeername errors.""" worker = _FactoryWorker() worker.swap_socket(_PeernameRaises()) assert worker._client_id == "unknown" def test_serverfactorythread_run_sends_response_and_closes(): + """run() should send a response and close on error.""" worker = _Worker() worker.run() assert worker.sent is True @@ -427,12 +484,16 @@ def test_serverfactorythread_run_sends_response_and_closes(): def test_serverfactorythread_base_process_message_returns_none(): + """Base worker _process_message should return None.""" worker = _FactoryBase() assert worker._process_message({"x": 1}) is None def test_serverfactory_init_type_error(monkeypatch): + """ServerFactory should reject non-worker classes.""" + def fake_init(self, address, port): # pylint: disable=unused-argument + """Stub ThreadedServer initializer for isolation.""" self.address = address self.port = port self.conn = None @@ -445,14 +506,17 @@ def fake_init(self, address, port): # pylint: disable=unused-argument monkeypatch.setattr(tserver.ThreadedServer, "__init__", fake_init, raising=True) class NotAWorker: - pass + """Dummy class that is not a ServerFactoryThread.""" with pytest.raises(TypeError): tserver.ServerFactory(NotAWorker, address="127.0.0.1", port=0) def test_serverfactory_process_message_returns_none(monkeypatch): + """ServerFactory._process_message should return None.""" + def fake_init(self, address, port): # pylint: disable=unused-argument + """Stub ThreadedServer initializer for isolation.""" self.address = address self.port = port self.conn = None @@ -469,6 +533,7 @@ def fake_init(self, address, port): # pylint: disable=unused-argument def test_serverfactory_run_closes_accepted_conn_when_stopping(): + """ServerFactory should close accepted connections when stopping.""" factory = _StoppingFactory() factory.run() assert factory.accepted.shutdown_called is True @@ -476,12 +541,14 @@ def test_serverfactory_run_closes_accepted_conn_when_stopping(): def test_serverfactory_stop_swallows_exception(): + """ServerFactory.stop should swallow stop_all errors.""" factory = _StopAllRaises() factory.stop() assert factory._is_alive is False def test_serverfactory_get_client_stats_skips_dead_threads(): + """get_client_stats should ignore dead threads.""" factory = _StatsFactory() stats = factory.get_client_stats() assert stats["connected_clients"] == 0 @@ -489,6 +556,7 @@ def test_serverfactory_get_client_stats_skips_dead_threads(): def test_serverfactory_get_client_stats_active_threads(): + """get_client_stats should report active threads.""" factory = _StatsFactoryAlive() stats = factory.get_client_stats() assert stats["connected_clients"] == 2