diff --git a/.flake8 b/.flake8 index 0045200..8579996 100644 --- a/.flake8 +++ b/.flake8 @@ -10,7 +10,7 @@ max-line-length = 88 # W504: line break after binary operator # E203: Whitespace before ':' # W291/W293: trailing/blank-line whitespace -ignore = E501, W503, W504, E203, W291, W293 +ignore = W503, W504, E203 # A list of files and directories to exclude from linting. exclude = diff --git a/generate_version_file.py b/generate_version_file.py index a99e7cd..d61deeb 100644 --- a/generate_version_file.py +++ b/generate_version_file.py @@ -15,11 +15,9 @@ import sys -try: - # Try to use the built-in tomllib (Python 3.11+) +if sys.version_info >= (3, 11): import tomllib -except ImportError: - # If not available, fall back to tomli (for Python <3.11) +else: try: import tomli as tomllib # type: ignore[import] except ImportError: diff --git a/pyproject.toml b/pyproject.toml index b54f11b..2b1d632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,11 +54,11 @@ where = ["src"] [tool.black] line-length = 88 -target-version = ['py310', 'py311', 'py312'] +target-version = ['py310'] [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q --color=yes --cov=src/codecat --cov-report=term-missing" +addopts = "-ra -q --color=yes --cov-report=term-missing" testpaths = ["tests"] python_files = "test_*.py" python_classes = "Test*" @@ -70,7 +70,7 @@ filterwarnings = ["ignore::DeprecationWarning"] include = ["src", "tests"] exclude = ["**/__pycache__", ".venv", ".venv312", "dist", "build"] typeCheckingMode = "basic" -pythonVersion = "3.11" +pythonVersion = "3.10" pythonPlatform = "All" reportMissingImports = "warning" diff --git a/src/codecat/__main__.py b/src/codecat/__main__.py index 3797c7e..dc6bfa0 100644 --- a/src/codecat/__main__.py +++ b/src/codecat/__main__.py @@ -25,7 +25,8 @@ def main(): it's assumed to be a double-click. A user-friendly message is shown, and the window is paused to prevent it from closing immediately. """ - # This check is the most reliable way to detect a double-click on a frozen executable. + # This check is the most reliable way to detect + # a double-click on a frozen executable. # It runs before Typer, preventing Typer's default error message from appearing. if len(sys.argv) == 1 and getattr(sys, "frozen", False) and os.name == "nt": from rich.console import Console @@ -40,7 +41,7 @@ def main(): "Example Usage:\n" " [cyan]codecat --help[/cyan]\n\n" "For more information, visit the GitHub README:\n" - " [cyan][link=https://github.com/exonymos/codecat]https://github.com/exonymos/codecat[/link][/cyan]", + " [cyan][link=https://github.com/exonymos/codecat]https://github.com/exonymos/codecat[/link][/cyan]", # noqa: E501 title="Usage Error", border_style="red", padding=(1, 2), diff --git a/src/codecat/cli_app.py b/src/codecat/cli_app.py index 318cae6..09305e8 100644 --- a/src/codecat/cli_app.py +++ b/src/codecat/cli_app.py @@ -38,7 +38,7 @@ # Initialize the Typer CLI app with custom help and settings. app = typer.Typer( name="codecat", - help="🐾 [bold]Codecat CLI[/bold] — A powerful tool to aggregate source code and text into a single Markdown file.", + help="🐾 [bold]Codecat CLI[/bold] — A powerful tool to aggregate source code and text into a single Markdown file.", # noqa: E501 add_completion=False, rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]}, @@ -70,7 +70,7 @@ def version_callback(value: bool): typer.Option( "--config", "-c", - help=f"Path to a custom config file. If not provided, looks for [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan] in the project path.", + help=f"Path to a custom config file. If not provided, looks for [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan] in the project path.", # noqa: E501 resolve_path=True, file_okay=True, dir_okay=False, @@ -83,7 +83,7 @@ def version_callback(value: bool): typer.Option( "--include", "-i", - help='Glob pattern for files to include. [bold]Use multiple times for multiple patterns[/bold] (e.g., -i "*.py" -i "*.js").', + help='Glob pattern for files to include. [bold]Use multiple times for multiple patterns[/bold] (e.g., -i "*.py" -i "*.js").', # noqa: E501 ), ] @@ -92,7 +92,7 @@ def version_callback(value: bool): typer.Option( "--exclude", "-e", - help='Glob pattern to exclude files or directories. [bold]Use multiple times for multiple patterns[/bold] (e.g., -e "dist/*").', + help='Glob pattern to exclude files or directories. [bold]Use multiple times for multiple patterns[/bold] (e.g., -e "dist/*").', # noqa: E501 ), ] @@ -146,7 +146,8 @@ def _log_initial_info( """Prints the initial startup panel and configuration if verbose is enabled.""" console.print( Panel( - f"🐾 [bold]Codecat v{__version__}[/bold] | Processing: [cyan]'{project_path.resolve()}'[/cyan]", + f"🐾 [bold]Codecat v{__version__}[/bold] | " + f"Processing: [cyan]'{project_path.resolve()}'[/cyan]", border_style="blue", ) ) @@ -178,7 +179,8 @@ def _scan_project_files( if not files_to_scan: console.print( - "\n[yellow]No files found to aggregate based on the current configuration.[/yellow]" + "\n[yellow]No files found to aggregate based on the " + "current configuration.[/yellow]" ) raise typer.Exit() @@ -249,12 +251,14 @@ def _write_markdown_output( output_file_path.write_text(markdown_content, encoding="utf-8") if not silent: console.print( - f"\n[bold green]✔ Success![/bold green] Aggregated {num_files} files into:" + f"\n[bold green]✔ Success![/bold green] " + f"Aggregated {num_files} files into:" ) console.print(f"[cyan]{output_file_path.resolve()}[/cyan]") except IOError as e: console.print( - f"\n[bold red]Error writing to output file '{output_file_path.resolve()}': {e}[/bold red]" + f"\n[bold red]Error writing to output file " + f"'{output_file_path.resolve()}': {e}[/bold red]" ) raise typer.Exit(code=1) @@ -294,7 +298,7 @@ def run( typer.Option( "--output-file", "-o", - help="Name for the output Markdown file. [bold]Overrides config setting.[/bold]", + help="Name for the output Markdown file. [bold]Overrides config setting.[/bold]", # noqa: E501 ), ] = None, include_patterns_override: IncludePatterns = None, @@ -312,14 +316,14 @@ def run( typer.Option( "--silent", "-s", - help="Suppress all informational output. Only critical errors will be shown.", + help="Suppress all informational output. Only critical errors will be shown.", # noqa: E501 ), ] = False, dry_run: Annotated[ bool, typer.Option( "--dry-run", - help="Scan and process files, but [bold]do not write the output file.[/bold] Useful for previews.", + help="Scan and process files, but [bold]do not write the output file.[/bold] Useful for previews.", # noqa: E501 ), ] = False, no_header: Annotated[ @@ -333,7 +337,7 @@ def run( Optional[int], typer.Option( "--max-workers", - help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", + help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", # noqa: E501 ), ] = None, ): @@ -341,8 +345,9 @@ def run( Scans a project, aggregates files, and compiles them into a single Markdown file. """ is_verbose = verbose and not silent - # NOTE: Checking for pytest in sys.modules avoids the Rich Status spinner conflicting - # with the test runner's output capture. This is a deliberate practical trade-off; + # NOTE: Checking for pytest in sys.modules avoids the Rich Status spinner + # conflicting with the test runner's output capture. This is a deliberate + # practical trade-off; is_testing = "pytest" in sys.modules show_ui = not is_verbose and not silent and not is_testing @@ -368,7 +373,8 @@ def run( if dry_run: console.print( - "\n[bold yellow]--dry-run enabled. No output file will be written.[/bold yellow]" + "\n[bold yellow]--dry-run enabled. " + "No output file will be written.[/bold yellow]" ) raise typer.Exit() @@ -392,7 +398,7 @@ def stats( Optional[int], typer.Option( "--max-workers", - help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", + help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", # noqa: E501 ), ] = None, ): @@ -414,7 +420,8 @@ def stats( console.print( Panel( - f"📊 [bold]Codecat Stats[/bold] | Analyzing: [cyan]'{project_path.resolve()}'[/cyan]", + f"📊 [bold]Codecat Stats[/bold] | " + f"Analyzing: [cyan]'{project_path.resolve()}'[/cyan]", border_style="blue", ) ) @@ -474,7 +481,7 @@ def generate_config( typer.Option( "--output-dir", "-o", - help="Directory to generate the config file in. Defaults to the current directory.", + help="Directory to generate the config file in. Defaults to the current directory.", # noqa: E501 resolve_path=True, file_okay=False, dir_okay=True, @@ -485,7 +492,7 @@ def generate_config( str, typer.Option( "--config-file-name", - help=f"Name of the config file. Defaults to [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan].", + help=f"Name of the config file. Defaults to [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan].", # noqa: E501 ), ] = DEFAULT_CONFIG_FILENAME, ): @@ -498,12 +505,14 @@ def generate_config( console.print(f"Created directory: [cyan]{output_dir.resolve()}[/cyan]") except Exception as e: console.print( - f"[bold red]Error:[/bold red] Could not create directory '{output_dir.resolve()}'. {e}" + f"[bold red]Error:[/bold red] Could not create directory " + f"'{output_dir.resolve()}'. {e}" ) raise typer.Exit(code=1) elif not output_dir.is_dir(): console.print( - f"[bold red]Error:[/bold red] Output path '{output_dir.resolve()}' exists but is not a directory." + f"[bold red]Error:[/bold red] Output path " + f"'{output_dir.resolve()}' exists but is not a directory." ) raise typer.Exit(code=1) @@ -511,7 +520,8 @@ def generate_config( if config_file_path.exists(): console.print( - f"[yellow]Config file '{config_file_path.resolve()}' already exists.[/yellow]" + f"[yellow]Config file '{config_file_path.resolve()}' " + "already exists.[/yellow]" ) overwrite = typer.confirm("Do you want to overwrite it?", default=False) if not overwrite: @@ -523,11 +533,13 @@ def generate_config( json.dumps(DEFAULT_CONFIG, indent=4), encoding="utf-8" ) console.print( - f"Successfully generated config file: [green]{config_file_path.resolve()}[/green]" + "Successfully generated config file: " + f"[green]{config_file_path.resolve()}[/green]" ) except IOError as e: console.print( - f"[bold red]Error writing config file '{config_file_path.resolve()}': {e}[/bold red]" + f"[bold red]Error writing config file " + f"'{config_file_path.resolve()}': {e}[/bold red]" ) raise typer.Exit(code=1) @@ -540,7 +552,7 @@ def web( typer.Option( "--port", "-p", - help="The port to bind the web server to. Defaults to 8080. If in use, it will find the next available port.", + help="The port to bind the web server to. Defaults to 8080. If in use, it will find the next available port.", # noqa: E501 ), ] = 8080, ): @@ -554,7 +566,7 @@ def web( border_style="magenta", ) ) - start_web_app(port=port, project_path=project_path) + start_web_app(port=port, project_path=project_path) # pragma: no cover @app.callback() diff --git a/src/codecat/config.py b/src/codecat/config.py index 413e417..8a983cb 100644 --- a/src/codecat/config.py +++ b/src/codecat/config.py @@ -20,9 +20,9 @@ # Default configuration for Codecat. # Keys starting with "_" are treated as comments and ignored by the parser. DEFAULT_CONFIG: dict[str, Any] = { - "_comment_main": "This is the default configuration for Codecat. You can customize it for your project.", + "_comment_main": "This is the default configuration for Codecat. You can customize it for your project.", # noqa: E501 "output_file": DEFAULT_OUTPUT_FILENAME, - "_comment_patterns": "Use glob patterns (like *.py, src/*) to control which files are included or excluded.", + "_comment_patterns": "Use glob patterns (like *.py, src/*) to control which files are included or excluded.", # noqa: E501 "include_patterns": [ "*.py", "*.pyw", @@ -234,7 +234,7 @@ "max_file_size_kb": 1024, "stop_on_error": False, "generate_header": True, - "_comment_languages": "Map file extensions to language hints for Markdown code blocks.", + "_comment_languages": "Map file extensions to language hints for Markdown code blocks.", # noqa: E501 "language_hints": { ".py": "python", ".pyw": "python", diff --git a/src/codecat/file_processor.py b/src/codecat/file_processor.py index 113c2aa..c143356 100644 --- a/src/codecat/file_processor.py +++ b/src/codecat/file_processor.py @@ -109,10 +109,12 @@ def process_file( config: dict[str, Any], ) -> ProcessedFileData: """ - Processes a single file: reads its content, detects if it's binary, or handles errors. + Processes a single file: reads its content, detects if it's binary, + or handles errors. - This function is designed to be a self-contained worker that does not perform - any direct console output. It returns a structured result for the main thread to handle. + This function is designed to be a self-contained worker that does not + perform any direct console output. It returns a structured result for the + main thread to handle. """ stop_on_error = config.get("stop_on_error", False) diff --git a/src/codecat/file_scanner.py b/src/codecat/file_scanner.py index 8c1b53e..4fa79f5 100644 --- a/src/codecat/file_scanner.py +++ b/src/codecat/file_scanner.py @@ -26,7 +26,7 @@ def _is_path_excluded_by_pattern( relative_path_str: str, patterns: List[str], case_sensitive: bool = False ) -> bool: - """Determines if a given relative path string matches any exclusion glob patterns.""" + """Determines if a relative path string matches any exclusion glob patterns.""" path_to_match = relative_path_str if case_sensitive else relative_path_str.lower() for pattern_item in patterns: pattern_to_match = pattern_item if case_sensitive else pattern_item.lower() @@ -46,7 +46,7 @@ def _is_path_excluded_by_pattern( def _is_path_included_by_pattern( relative_path_str: str, patterns: List[str], case_sensitive: bool = False ) -> bool: - """Checks if a given relative path string matches any of the inclusion glob patterns.""" + """Checks if a relative path string matches any of the inclusion glob patterns.""" if not patterns: # If no include patterns, everything is implicitly included return True @@ -106,7 +106,8 @@ def scan_project( status_indicator: Optional[Status] = None, ) -> List[Path]: """ - Scans the project directory using os.walk for efficiency and returns a list of files. + Scans the project directory using os.walk for efficiency and returns + a list of files. """ included_files_set: Set[Path] = set() diff --git a/src/codecat/markdown_generator.py b/src/codecat/markdown_generator.py index 6203db9..267a6f8 100644 --- a/src/codecat/markdown_generator.py +++ b/src/codecat/markdown_generator.py @@ -59,7 +59,8 @@ def generate_markdown( project_path_str = project_root_path.as_posix() main_parts.append(f"# Codecat: Aggregated Code for '{project_root_path.name}'") main_parts.append( - f"Generated from `{len(processed_files)}` files found in `{project_path_str}`.\n" + f"Generated from `{len(processed_files)}` files found " + f"in `{project_path_str}`.\n" ) file_blocks: List[str] = [] @@ -77,12 +78,14 @@ def generate_markdown( block_parts.append(f"{fence}{lang_hint}\n{file_data.content}\n{fence}") elif file_data.status == "binary_file": block_parts.append( - f"`[INFO] Binary file detected at '{relative_path_str}'. Content not included.`" + f"`[INFO] Binary file detected at '{relative_path_str}'. " + "Content not included.`" ) elif file_data.status in ["read_error", "skipped_access_error"]: error_msg = file_data.error_message or "An unknown error occurred." block_parts.append( - f"`[WARNING] Could not process file '{relative_path_str}'. Error: {error_msg}`" + f"`[WARNING] Could not process file '{relative_path_str}'. " + f"Error: {error_msg}`" ) file_blocks.append("\n".join(block_parts)) diff --git a/src/codecat/web_ui.py b/src/codecat/web_ui.py index d5e8e61..05f2554 100644 --- a/src/codecat/web_ui.py +++ b/src/codecat/web_ui.py @@ -748,7 +748,9 @@ def _build_subprocess_cmd( return cmd -def _stream_subprocess(h: _Handler, cmd: list[str], project_path: Path) -> None: +def _stream_subprocess( + h: _Handler, cmd: list[str], project_path: Path +) -> None: # pragma: no cover """ Spawn *cmd* and stream its combined stdout/stderr to the response body. @@ -791,7 +793,7 @@ def _stream_subprocess(h: _Handler, cmd: list[str], project_path: Path) -> None: h.wfile.write( f"\n[SERVER ERROR] Could not run Codecat: {exc}\n".encode("utf-8") ) - except (BrokenPipeError, ConnectionResetError): + except (BrokenPipeError, ConnectionResetError): # pragma: no cover pass @@ -837,7 +839,7 @@ def _handle_post_run(h: _Handler, project_path: Path) -> None: _send_head(h, 200, "text/plain; charset=utf-8", {"Cache-Control": "no-cache"}) try: - _stream_subprocess(h, cmd, project_path) + _stream_subprocess(h, cmd, project_path) # pragma: no cover finally: try: tmp_config_path.unlink(missing_ok=True) @@ -911,7 +913,9 @@ class _ReuseAddrServer(socketserver.ThreadingTCPServer): daemon_threads: bool = True -def start_web_app(port: int = 8080, project_path: Path = Path(".")) -> None: +def start_web_app( + port: int = 8080, project_path: Path = Path(".") +) -> None: # pragma: no cover """ Start the Codecat web UI server and open it in the default browser. diff --git a/tests/test_cli_app.py b/tests/test_cli_app.py index 2203ffc..26444ff 100644 --- a/tests/test_cli_app.py +++ b/tests/test_cli_app.py @@ -15,6 +15,7 @@ from codecat import __version__ from codecat.cli_app import app from codecat.constants import DEFAULT_CONFIG_FILENAME +from codecat.file_processor import ProcessedFileData # Use a test runner with a fixed terminal size for predictable UI output. runner = CliRunner(env={"TERM": "xterm-256color", "COLUMNS": "130"}) @@ -29,7 +30,8 @@ def test_version_flag_works_correctly(strip_ansi_codes): def test_generate_config_creates_file(tmp_path: Path, strip_ansi_codes): - """Ensures `generate-config` creates a default config file in the target directory.""" + """Ensures `generate-config` creates a default config file in the target + directory.""" result = runner.invoke(app, ["generate-config", "--output-dir", str(tmp_path)]) assert result.exit_code == 0 assert "Successfully generated config file" in strip_ansi_codes(result.stderr) @@ -39,7 +41,8 @@ def test_generate_config_creates_file(tmp_path: Path, strip_ansi_codes): def test_generate_config_aborts_if_user_says_no_to_overwrite( tmp_path: Path, strip_ansi_codes ): - """Ensures `generate-config` aborts if the file exists and the user declines to overwrite.""" + """Ensures `generate-config` aborts if the file exists and the user + declines to overwrite.""" config_file = tmp_path / DEFAULT_CONFIG_FILENAME config_file.write_text("original content") @@ -160,3 +163,38 @@ def test_run_command_with_no_matching_files(tmp_path: Path, strip_ansi_codes): assert result.exit_code == 0 clean_output = strip_ansi_codes(result.stderr) assert "No files found" in clean_output + + +def test_run_verbose_shows_error_status(tmp_path: Path, mocker, strip_ansi_codes): + """Covers the ✖ Error verbose branch in _process_files_parallel.""" + (tmp_path / "bad.py").write_text("pass") + mocker.patch( + "codecat.cli_app.process_file", + return_value=ProcessedFileData( + path=tmp_path / "bad.py", + relative_path=Path("bad.py"), + status="read_error", + error_message="boom", + ), + ) + result = runner.invoke(app, ["run", str(tmp_path), "--verbose"]) + assert "✖ Error" in strip_ansi_codes(result.stderr) + + +def test_scan_error_with_stop_on_error(tmp_path: Path, mocker, strip_ansi_codes): + """Covers the stop_on_error branch in _scan_project_files.""" + mocker.patch("codecat.cli_app.scan_project", side_effect=RuntimeError("disk fail")) + mocker.patch( + "codecat.cli_app.load_config", + return_value=({"stop_on_error": True, "verbose": False}, None, None), + ) + result = runner.invoke(app, ["run", str(tmp_path)]) + assert result.exit_code != 0 + + +def test_stats_with_no_text_files(tmp_path: Path, strip_ansi_codes): + """Covers the stats command when only binary files are present.""" + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n") + result = runner.invoke(app, ["stats", str(tmp_path), "--include", "*.png"]) + assert result.exit_code == 0 + assert "File Type Statistics" in strip_ansi_codes(result.stderr) diff --git a/tests/test_file_scanner.py b/tests/test_file_scanner.py index 1ce21e7..fb7ff32 100644 --- a/tests/test_file_scanner.py +++ b/tests/test_file_scanner.py @@ -116,7 +116,8 @@ def test_include_patterns_filter_correctly(tmp_path: Path): def test_empty_include_patterns_includes_all_non_excluded_files(tmp_path: Path): - """Ensures that an empty `include_patterns` list includes all files not otherwise excluded.""" + """Ensures that an empty `include_patterns` list includes all files + not otherwise excluded.""" structure = { "file.py": "", "file.txt": "", @@ -193,7 +194,8 @@ def test_max_file_size_limit(tmp_path: Path): def test_exclude_pattern_overrides_include_pattern(tmp_path: Path): - """Ensures that an `exclude_patterns` rule takes precedence over an `include_patterns` rule.""" + """Ensures that an `exclude_patterns` rule takes precedence over an + `include_patterns` rule.""" structure = { "feature.py": "", "feature_test.py": "", @@ -209,7 +211,8 @@ def test_exclude_pattern_overrides_include_pattern(tmp_path: Path): def test_scanning_a_subdirectory(tmp_path: Path): - """Ensures scanning a specific subdirectory works correctly while applying root-level rules.""" + """Ensures scanning a specific subdirectory works correctly while applying + root-level rules.""" structure = { "file_in_root.txt": "root content", "target_dir": { @@ -238,7 +241,8 @@ def test_scanning_a_subdirectory(tmp_path: Path): def test_verbose_output_for_skipped_items(tmp_path: Path, caplog, strip_ansi_codes): - """Ensures that verbose mode correctly logs the reasons for skipping files and dirs.""" + """Ensures that verbose mode correctly logs the reasons for skipping + files and dirs.""" structure = { "large_file.txt": "a" * 2048, "explicitly_excluded.txt": "content", diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index 37e24e1..a2c58ec 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -399,8 +399,19 @@ def test_post_config_invalid_json_returns_400(self, server): def test_post_config_body_too_large_returns_400(self, server): host, port, _ = server - big = json.dumps({"outputFile": "x" * (65 * 1024)}).encode() - status, body = _post(host, port, "/api/config", big) + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request( + "POST", + "/api/config", + body=b"{}", + headers={ + "Content-Type": "application/json", + "Content-Length": str(65 * 1024 + 1), + }, + ) + resp = conn.getresponse() + status = resp.status + body = resp.read() assert status == 400 data = json.loads(body) assert "64 KB" in data["error"] @@ -424,3 +435,30 @@ def test_post_run_body_too_large_returns_400(self, server): assert status == 400 data = json.loads(body) assert "64 KB" in data["error"] + + def test_get_config_corrupt_json(self, server): + """Covers the JSONDecodeError path in _handle_get_config.""" + host, port, project_path = server + (project_path / ".codecat_config.json").write_text( + "not valid json", encoding="utf-8" + ) + status, body = _get(host, port, "/api/config") + assert status == 200 + data = json.loads(body) + assert data["success"] is False + + def test_post_run_invalid_project_path(self, tmp_path: Path): + """Covers the is_dir() check in _handle_post_run.""" + non_dir = tmp_path / "not_a_dir.txt" + non_dir.write_text("x") + port = _free_port() + httpd = _ReuseAddrServer(("127.0.0.1", port), _make_handler(non_dir)) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + status, body = _post("127.0.0.1", port, "/api/run", b"{}") + assert status == 400 + assert b"not a valid directory" in body + finally: + httpd.shutdown() + httpd.server_close()