From d87bf78b03175eba880f62232a9dc9520e000415 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:58:17 -0600 Subject: [PATCH 1/3] feat: add GitHub Actions workflow for testing and linting Python code --- .github/workflows/test.yml | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..dda8bee90 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Test & Lint Python + +permissions: + contents: read + +on: + push: + branches: ["main"] + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Run ruff check + run: uvx ruff check src/ + + - name: Run ruff format check + run: uvx ruff format --check src/ + + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra test + + - name: Run tests + run: uv run pytest From 998b779b58b690fcb086eb8d9f2a3896e47c867c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:11:39 -0600 Subject: [PATCH 2/3] fix: resolve ruff lint errors in specify_cli - Remove extraneous f-string prefixes (F541) - Split multi-statement lines (E701, E702) - Remove unused variable assignments (F841) - Remove ruff format check from CI workflow (format-only PR to follow) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 3 --- src/specify_cli/__init__.py | 54 ++++++++++++++++++++----------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dda8bee90..60e3114c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,9 +26,6 @@ jobs: - name: Run ruff check run: uvx ruff check src/ - - name: Run ruff format check - run: uvx ruff format --check src/ - pytest: runs-on: ubuntu-latest strategy: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 52e2290a3..379493321 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -670,7 +670,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri except ValueError as je: raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") except Exception as e: - console.print(f"[red]Error fetching release information[/red]") + console.print("[red]Error fetching release information[/red]") console.print(Panel(str(e), title="Fetch Error", border_style="red")) raise typer.Exit(1) @@ -700,7 +700,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri zip_path = download_dir / filename if verbose: - console.print(f"[cyan]Downloading template...[/cyan]") + console.print("[cyan]Downloading template...[/cyan]") try: with client.stream( @@ -739,7 +739,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) except Exception as e: - console.print(f"[red]Error downloading template[/red]") + console.print("[red]Error downloading template[/red]") detail = str(e) if zip_path.exists(): zip_path.unlink() @@ -823,7 +823,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Found nested directory structure[/cyan]") + console.print("[cyan]Found nested directory structure[/cyan]") for item in source_dir.iterdir(): dest_path = project_path / item.name @@ -848,7 +848,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") shutil.copy2(item, dest_path) if verbose and not tracker: - console.print(f"[cyan]Template files merged into current directory[/cyan]") + console.print("[cyan]Template files merged into current directory[/cyan]") else: zip_ref.extractall(project_path) @@ -874,7 +874,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Flattened nested directory structure[/cyan]") + console.print("[cyan]Flattened nested directory structure[/cyan]") except Exception as e: if tracker: @@ -924,13 +924,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = continue except Exception: continue - st = script.stat(); mode = st.st_mode + st = script.stat() + mode = st.st_mode if mode & 0o111: continue new_mode = mode - if mode & 0o400: new_mode |= 0o100 - if mode & 0o040: new_mode |= 0o010 - if mode & 0o004: new_mode |= 0o001 + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 if not (new_mode & 0o100): new_mode |= 0o100 os.chmod(script, new_mode) @@ -976,7 +980,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") else: - console.print(f"[cyan]Initialized constitution from template[/cyan]") + console.print("[cyan]Initialized constitution from template[/cyan]") except Exception as e: if tracker: tracker.add("constitution", "Constitution setup") @@ -1510,9 +1514,9 @@ def init( enhancement_lines = [ "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", "", - f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", - f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", + "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", + "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" ] enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) console.print() @@ -1545,10 +1549,10 @@ def check(): # Check VS Code variants (not in agent config) tracker.add("code", "Visual Studio Code") - code_ok = check_tool("code", tracker=tracker) + check_tool("code", tracker=tracker) tracker.add("code-insiders", "Visual Studio Code Insiders") - code_insiders_ok = check_tool("code-insiders", tracker=tracker) + check_tool("code-insiders", tracker=tracker) console.print(tracker.render()) @@ -1814,14 +1818,14 @@ def extension_add( if zip_path.exists(): zip_path.unlink() - console.print(f"\n[green]✓[/green] Extension installed successfully!") + console.print("\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") console.print(f" {manifest.description}") - console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") + console.print("\n[bold cyan]Provided commands:[/bold cyan]") for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") - console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{manifest.id}/") except ValidationError as e: @@ -1871,11 +1875,11 @@ def extension_remove( # Confirm removal if not force: - console.print(f"\n[yellow]⚠ This will remove:[/yellow]") + console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") console.print(f" • Extension directory: .specify/extensions/{extension}/") if not keep_config: - console.print(f" • Config files (will be backed up)") + console.print(" • Config files (will be backed up)") console.print() confirm = typer.confirm("Continue?") @@ -1894,7 +1898,7 @@ def extension_remove( console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") console.print(f"\nTo reinstall: specify extension add {extension}") else: - console.print(f"[red]Error:[/red] Failed to remove extension") + console.print("[red]Error:[/red] Failed to remove extension") raise typer.Exit(1) @@ -2169,8 +2173,8 @@ def extension_update( # TODO: Implement download and reinstall from URL # For now, just show message console.print( - f"[yellow]Note:[/yellow] Automatic update not yet implemented. " - f"Please update manually:" + "[yellow]Note:[/yellow] Automatic update not yet implemented. " + "Please update manually:" ) console.print(f" specify extension remove {ext_id} --keep-config") console.print(f" specify extension add {ext_id}") @@ -2270,7 +2274,7 @@ def extension_disable( hook_executor.save_project_config(config) console.print(f"[green]✓[/green] Extension '{extension}' disabled") - console.print(f"\nCommands will no longer be available. Hooks will not execute.") + console.print("\nCommands will no longer be available. Hooks will not execute.") console.print(f"To re-enable: specify extension enable {extension}") From 18bb65d641925b49b94d518139c5718a79d19be4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:15:01 -0600 Subject: [PATCH 3/3] fix: strip ANSI codes in ai-skills help text test The Rich/Typer CLI injects ANSI escape codes into option names in --help output, causing plain string matching to fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_ai_skills.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 1b1b71e3a..b86b4a470 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -10,6 +10,7 @@ - CLI validation: --ai-skills requires --ai """ +import re import pytest import tempfile import shutil @@ -626,5 +627,6 @@ def test_ai_skills_flag_appears_in_help(self): runner = CliRunner() result = runner.invoke(app, ["init", "--help"]) - assert "--ai-skills" in result.output - assert "agent skills" in result.output.lower() + plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + assert "--ai-skills" in plain + assert "agent skills" in plain.lower()