diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef49b4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install ruff + run: pip install ruff + - name: Lint + run: ruff check src tests + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package with dev extras + run: pip install -e ".[dev]" + - name: Run tests + run: pytest -q + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + pip install build twine + python -m build + twine check dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..934e5cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +# Publishes to PyPI when a version tag (e.g. v1.0.0) is pushed. +# +# Uses PyPI Trusted Publishing (OIDC) — no API token or secret is stored in +# the repo. One-time setup on PyPI: create the "kql-cli" project's trusted +# publisher pointing at this repo, workflow "release.yml", environment "pypi". +# See: https://docs.pypi.org/trusted-publishers/ + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build + run: | + pip install build twine + python -m build + twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for Trusted Publishing (OIDC) + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 21e9abf..fd876e9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,10 @@ htmlcov/ # Build *.whl -# IBA-specific queries live in the private knowledge-base repo, not here +# IBA-specific queries live in the private knowledge-base repo, not here. +# Anchored to the repo root so it never matches the bundled package data +# in src/kq/queries/. KQL snippets.md *.snippets.md -queries/ +/queries/ +/.kq/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6aa4891 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-07-07 + +First public release on PyPI. + +### Added +- Query Azure Data Explorer (Kusto) from the command line: raw KQL, saved + parameterized queries, and bundled examples. +- Multi-cluster configuration with XDG-compliant config in + `~/.config/kq/config.yaml`. +- Layered query resolution: project-local `.kq/`, user `~/.config/kq/queries/`, + then bundled examples. +- Authentication chain: service principal → Azure CLI → cached device code + (with ~90-day silent refresh). +- Output formats: `table`, `json`, `csv`. +- Query safety levels (`safe` / `caution` / `dangerous`). +- Test suite, `ruff` linting, GitHub Actions CI (Python 3.9–3.13), and a + Trusted-Publishing release workflow. + +### Note +- The tool is distributed on PyPI as **`kql-cli`** (the command remains `kq`), + because the `kq` name on PyPI is taken by an unrelated project. + +[1.0.0]: https://github.com/cptfinch/kq/releases/tag/v1.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..acbcfa9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cptfinch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b46fa1f..c964542 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,24 @@ # kq -KQL CLI - Query Azure Data Explorer from the command line. +[![CI](https://github.com/cptfinch/kq/actions/workflows/ci.yml/badge.svg)](https://github.com/cptfinch/kq/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/kql-cli.svg)](https://pypi.org/project/kql-cli/) +[![Python versions](https://img.shields.io/pypi/pyversions/kql-cli.svg)](https://pypi.org/project/kql-cli/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Like `jq` for JSON, but for Kusto/KQL. +KQL CLI — query Azure Data Explorer (Kusto) from the command line. + +Like `jq` for JSON, but for Kusto/KQL. Run raw KQL, keep a git-versioned library +of parameterized queries, and pipe results straight into your shell. ## Installation ```bash -pip install kq +pip install kql-cli ``` +> The command you run is `kq`. The PyPI **package** is named `kql-cli` because +> `kq` was already taken on PyPI by an unrelated project. + Or from source: ```bash @@ -159,6 +168,34 @@ MyTable | where Category == 'Error' - **Unix-friendly** - Pipes, scripts, automation - **Personal queries** - User queries never overwritten by updates +## Development + +```bash +git clone https://github.com/cptfinch/kq.git +cd kq +python -m venv .venv && . .venv/bin/activate +pip install -e ".[dev]" + +pytest # run tests +ruff check . # lint +python -m build # build sdist + wheel +``` + +CI runs lint + tests across Python 3.9–3.13 on every push and pull request. + +### Releasing + +Releases publish to PyPI automatically via +[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no +tokens stored in the repo). To cut a release: + +1. Bump `__version__` in `src/kq/__init__.py` and update `CHANGELOG.md`. +2. Tag and push: `git tag v1.2.3 && git push origin v1.2.3`. + +The `release.yml` workflow builds the artifacts and publishes them. This +requires a one-time PyPI setup: configure `kql-cli`'s trusted publisher to point +at this repository, workflow `release.yml`, environment `pypi`. + ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index 5708dce..c104278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,20 +3,23 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "kq" -version = "0.1.0" -description = "KQL CLI - Query Azure Data Explorer from the command line" +name = "kql-cli" +dynamic = ["version"] +description = "KQL CLI - query Azure Data Explorer (Kusto) from the command line. Like jq, but for KQL." readme = "README.md" license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.9" authors = [ - { name = "Your Name" } + { name = "cptfinch", email = "cptfinch@gmail.com" }, ] -keywords = ["kql", "kusto", "azure", "data-explorer", "adx", "cli"] +keywords = ["kql", "kusto", "azure", "data-explorer", "adx", "cli", "jq"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", - "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -37,24 +40,41 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", + "ruff>=0.5.0", + "build>=1.0.0", ] [project.scripts] kq = "kq.cli:main" [project.urls] +Homepage = "https://github.com/cptfinch/kq" Documentation = "https://github.com/cptfinch/kq#readme" Source = "https://github.com/cptfinch/kq" Issues = "https://github.com/cptfinch/kq/issues" +Changelog = "https://github.com/cptfinch/kq/blob/main/CHANGELOG.md" + +[tool.hatch.version] +path = "src/kq/__init__.py" [tool.hatch.build.targets.wheel] packages = ["src/kq"] -[tool.hatch.build.targets.wheel.force-include] -"src/kq/queries" = "kq/queries" - [tool.hatch.build.targets.sdist] include = [ "/src", "/README.md", + "/LICENSE", + "/CHANGELOG.md", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/src/kq/__init__.py b/src/kq/__init__.py index 51ccb97..da03bfe 100644 --- a/src/kq/__init__.py +++ b/src/kq/__init__.py @@ -11,4 +11,4 @@ kq "PLC | take 5" # Run raw KQL """ -__version__ = "0.1.0" +__version__ = "1.0.0" diff --git a/src/kq/auth.py b/src/kq/auth.py index 2104143..0c7ec0b 100644 --- a/src/kq/auth.py +++ b/src/kq/auth.py @@ -9,9 +9,9 @@ # Suppress noisy azure-identity credential chain warnings logging.getLogger("azure.identity").setLevel(logging.ERROR) -from azure.identity import ( - AzureCliCredential, +from azure.identity import ( # noqa: E402 (imported after logger is quieted) AuthenticationRecord, + AzureCliCredential, ClientSecretCredential, DeviceCodeCredential, TokenCachePersistenceOptions, @@ -135,10 +135,10 @@ def prompt_callback(url, code, expires_on): else: expires = str(token.expires_on) - print(f"\nAuthentication successful!") + print("\nAuthentication successful!") print(f" Expires: {expires}") print(f" Auth record saved to {AUTH_RECORD_PATH}") - print(f" Subsequent queries will authenticate silently") + print(" Subsequent queries will authenticate silently") return True except Exception as e: print(f"Authentication failed: {e}", file=sys.stderr) diff --git a/src/kq/cli.py b/src/kq/cli.py index 0d49b92..f6e51fe 100644 --- a/src/kq/cli.py +++ b/src/kq/cli.py @@ -1,22 +1,28 @@ """kq CLI - Command line interface.""" -import sys import argparse +import sys from rich.console import Console -from rich.table import Table from rich.panel import Panel from rich.syntax import Syntax +from rich.table import Table -from . import __version__ -from . import auth -from . import client -from .config import get_config, CONFIG_FILE, USER_QUERIES_DIR +from . import __version__, auth, client +from .config import CONFIG_FILE, USER_QUERIES_DIR, get_config from .registry import get_registry - console = Console() +NO_CLUSTER_MSG = ( + "No cluster configured. Use --cluster URL " + "or run: kq config set default_cluster URL" +) +NO_DATABASE_MSG = ( + "No database configured. Use --database DB " + "or configure in cluster settings" +) + def get_cluster_and_db(args): """Get cluster URL and database from args or config.""" @@ -41,7 +47,7 @@ def cmd_auth(args): if args.auth_cmd == "login": if not cluster_url: - print("No cluster configured. Use --cluster URL or run: kq config set default_cluster URL") + print(NO_CLUSTER_MSG) return 1 success = auth.login(cluster_url, device_code=True) return 0 if success else 1 @@ -171,7 +177,7 @@ def cmd_run(args): if not query: print(f"Query not found: {args.name}") - print(f"Run 'kq list' to see available queries") + print("Run 'kq list' to see available queries") return 1 # Parse positional params @@ -201,11 +207,11 @@ def cmd_run(args): cluster_url, database = get_cluster_and_db(args) if not cluster_url: - print("No cluster configured. Use --cluster URL or run: kq config set default_cluster URL") + print(NO_CLUSTER_MSG) return 1 if not database: - print("No database configured. Use --database DB or configure in cluster settings") + print(NO_DATABASE_MSG) return 1 try: @@ -229,11 +235,11 @@ def cmd_query(args): cluster_url, database = get_cluster_and_db(args) if not cluster_url: - print("No cluster configured. Use --cluster URL or run: kq config set default_cluster URL") + print(NO_CLUSTER_MSG) return 1 if not database: - print("No database configured. Use --database DB or configure in cluster settings") + print(NO_DATABASE_MSG) return 1 try: diff --git a/src/kq/client.py b/src/kq/client.py index 9ed9578..1064e02 100644 --- a/src/kq/client.py +++ b/src/kq/client.py @@ -1,15 +1,18 @@ """Azure Data Explorer client.""" import io -import json -import os -import sys from contextlib import redirect_stderr from azure.kusto.data import KustoClient, KustoConnectionStringBuilder from .auth import get_credential +# Re-exported for backward compatibility; formatters live in their own, +# Azure-free module so they can be unit-tested in isolation. +from .formatters import format_csv, format_json, format_table + +__all__ = ["connect", "execute", "format_table", "format_json", "format_csv"] + def connect(cluster_url: str): """Connect to ADX cluster.""" @@ -30,63 +33,3 @@ def execute(query: str, cluster_url: str, database: str): return None return response.primary_results[0] - - -def format_table(results, max_rows=50): - """Format results as readable table.""" - if not results: - return "No results." - - headers = [col.column_name for col in results.columns] - lines = [" | ".join(headers)] - lines.append("-" * min(len(lines[0]), 100)) - - for i, row in enumerate(results): - if i >= max_rows: - lines.append(f"... ({results.rows_count - max_rows} more rows)") - break - values = [] - for v in row: - s = str(v) if v is not None else "" - values.append(s[:30] + "..." if len(s) > 30 else s) - lines.append(" | ".join(values)) - - return "\n".join(lines) - - -def format_json(results): - """Format results as JSON array.""" - if not results: - return "[]" - - data = results.to_dict().get("data", []) - - def serialize(obj): - if hasattr(obj, "isoformat"): - return obj.isoformat() - return str(obj) - - return json.dumps(data, indent=2, default=serialize) - - -def format_csv(results): - """Format results as CSV.""" - if not results: - return "" - - lines = [] - headers = [col.column_name for col in results.columns] - lines.append(",".join(f'"{h}"' for h in headers)) - - for row in results: - values = [] - for v in row: - s = str(v) if v is not None else "" - if '"' in s: - s = s.replace('"', '""') - if "," in s or '"' in s or "\n" in s: - s = f'"{s}"' - values.append(s) - lines.append(",".join(values)) - - return "\n".join(lines) diff --git a/src/kq/formatters.py b/src/kq/formatters.py new file mode 100644 index 0000000..dafa757 --- /dev/null +++ b/src/kq/formatters.py @@ -0,0 +1,84 @@ +"""Output formatters for query results. + +These functions are deliberately free of any Azure SDK imports so they can be +unit-tested against a plain object. They expect a result table that quacks like +``azure.kusto.data``'s ``KustoResultTable``: + +- ``.columns`` — a sequence of objects exposing ``.column_name`` +- iteration — yields rows, each an iterable of cell values +- ``.rows_count`` — total number of rows +- ``.to_dict()`` — returns ``{"data": [ {column: value}, ... ]}`` +""" + +import csv +import io +import json + +MAX_TABLE_ROWS = 50 +MAX_CELL_WIDTH = 30 + + +def format_table(results, max_rows: int = MAX_TABLE_ROWS) -> str: + """Format results as a readable, fixed-width table.""" + if not results: + return "No results." + + headers = [col.column_name for col in results.columns] + lines = [" | ".join(headers)] + lines.append("-" * min(len(lines[0]), 100)) + + for i, row in enumerate(results): + if i >= max_rows: + lines.append(f"... ({results.rows_count - max_rows} more rows)") + break + values = [] + for v in row: + s = str(v) if v is not None else "" + values.append(s[:MAX_CELL_WIDTH] + "..." if len(s) > MAX_CELL_WIDTH else s) + lines.append(" | ".join(values)) + + return "\n".join(lines) + + +def format_json(results) -> str: + """Format results as a JSON array.""" + if not results: + return "[]" + + data = results.to_dict().get("data", []) + + def serialize(obj): + if hasattr(obj, "isoformat"): + return obj.isoformat() + return str(obj) + + return json.dumps(data, indent=2, default=serialize) + + +def format_csv(results) -> str: + """Format results as CSV using the stdlib ``csv`` module (RFC 4180). + + Headers are always quoted for stable, greppable output; row cells use + minimal quoting so numbers and plain strings stay bare. The ``csv`` module + handles every quoting edge case (embedded quotes/commas/CR/LF) correctly. + """ + if not results: + return "" + + output = io.StringIO() + headers = [col.column_name for col in results.columns] + csv.writer(output, quoting=csv.QUOTE_ALL, lineterminator="\n").writerow(headers) + + minimal = csv.writer(output, quoting=csv.QUOTE_MINIMAL, lineterminator="\n") + all_quoted = csv.writer(output, quoting=csv.QUOTE_ALL, lineterminator="\n") + for row in results: + cells = [str(v) if v is not None else "" for v in row] + # Python < 3.11 does not quote a bare "\r" under an LF line terminator, + # so force full quoting for such rows. This keeps CSV output RFC 4180 + # correct and identical across all supported Python versions while + # preserving LF line endings. + writer = all_quoted if any("\r" in c for c in cells) else minimal + writer.writerow(cells) + + # Match the previous formatter's contract: no trailing newline. + return output.getvalue().removesuffix("\n") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ab8532b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +"""Shared test fixtures.""" + +import pytest + + +class FakeColumn: + def __init__(self, name): + self.column_name = name + + +class FakeResultTable: + """Minimal stand-in for azure.kusto.data's KustoResultTable. + + Supports the surface the formatters rely on: ``.columns``, iteration over + rows, ``.rows_count`` and ``.to_dict()``. + """ + + def __init__(self, column_names, rows): + self.columns = [FakeColumn(n) for n in column_names] + self._rows = rows + self.rows_count = len(rows) + self._column_names = column_names + + def __iter__(self): + return iter(self._rows) + + def __bool__(self): + return True + + def to_dict(self): + return { + "data": [dict(zip(self._column_names, row)) for row in self._rows], + } + + +@pytest.fixture +def simple_table(): + return FakeResultTable(["Name", "Count"], [["alpha", 1], ["beta", 2]]) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c0d8e2a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,61 @@ +"""Tests for config resolution and query-path precedence.""" + +from kq import config as config_mod +from kq.config import Config + + +def test_default_cluster_direct_url(): + c = Config() + c._config = {"default_cluster": "https://x.kusto.windows.net"} + assert c.default_cluster == "https://x.kusto.windows.net" + + +def test_default_cluster_named_lookup(): + c = Config() + c._config = { + "default_cluster": "prod", + "clusters": {"prod": {"url": "https://prod.kusto.windows.net", "database": "pdb"}}, + } + assert c.default_cluster == "https://prod.kusto.windows.net" + assert c.default_database == "pdb" + + +def test_default_database_standalone(): + c = Config() + c._config = { + "default_cluster": "https://x.kusto.windows.net", + "default_database": "mydb", + } + assert c.default_database == "mydb" + + +def test_add_cluster_mutates_config(tmp_path, monkeypatch): + monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_mod, "CONFIG_FILE", tmp_path / "config.yaml") + c = Config() + c._config = {} + c.add_cluster("dev", "https://dev.kusto.windows.net", database="devdb") + assert c.clusters["dev"] == { + "url": "https://dev.kusto.windows.net", + "database": "devdb", + } + + +def test_query_paths_priority_order(tmp_path, monkeypatch): + local = tmp_path / "proj" / ".kq" + user = tmp_path / "user_queries" + bundled = tmp_path / "bundled" + for d in (local, user, bundled): + d.mkdir(parents=True) + + monkeypatch.chdir(tmp_path / "proj") + monkeypatch.setattr(config_mod, "USER_QUERIES_DIR", user) + monkeypatch.setattr(config_mod, "BUNDLED_QUERIES_DIR", bundled) + + c = Config() + c._config = {} + paths = c.query_paths + # local project queries win, bundled examples are the fallback + assert paths[0] == local + assert paths[-1] == bundled + assert user in paths diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 0000000..fca7034 --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,94 @@ +"""Tests for the Azure-free output formatters.""" + +import json +from datetime import datetime + +from kq.formatters import format_csv, format_json, format_table +from tests.conftest import FakeResultTable + + +def test_table_has_header_and_rows(simple_table): + out = format_table(simple_table) + lines = out.splitlines() + assert lines[0] == "Name | Count" + assert lines[1].startswith("---") + assert "alpha | 1" in out + assert "beta | 2" in out + + +def test_table_none_results(): + assert format_table(None) == "No results." + + +def test_table_truncates_wide_cells(): + wide = "x" * 100 + table = FakeResultTable(["Col"], [[wide]]) + out = format_table(table) + assert "..." in out + # 30 chars kept + ellipsis, never the full 100 + assert wide not in out + + +def test_table_row_limit_notes_remainder(): + rows = [[i] for i in range(60)] + table = FakeResultTable(["N"], rows) + out = format_table(table, max_rows=50) + assert "... (10 more rows)" in out + + +def test_json_roundtrips(simple_table): + out = format_json(simple_table) + data = json.loads(out) + assert data == [{"Name": "alpha", "Count": 1}, {"Name": "beta", "Count": 2}] + + +def test_json_serializes_datetimes(): + table = FakeResultTable(["ts"], [[datetime(2026, 1, 2, 3, 4, 5)]]) + data = json.loads(format_json(table)) + assert data[0]["ts"] == "2026-01-02T03:04:05" + + +def test_json_empty(): + assert format_json(None) == "[]" + + +def test_csv_quotes_headers(simple_table): + out = format_csv(simple_table) + assert out.splitlines()[0] == '"Name","Count"' + + +def test_csv_escapes_commas_and_quotes(): + table = FakeResultTable(["v"], [["a,b"], ['say "hi"'], ["plain"]]) + lines = format_csv(table).splitlines() + assert lines[1] == '"a,b"' # comma forces quoting + assert lines[2] == '"say ""hi"""' # embedded quotes doubled + assert lines[3] == "plain" # nothing special, left bare + + +def test_csv_quotes_embedded_newline(): + # A newline inside a field must stay wrapped in quotes (a single CSV + # record that happens to span two physical lines). + table = FakeResultTable(["v"], [["line1\nline2"]]) + out = format_csv(table) + assert '"line1\nline2"' in out + + +def test_csv_escapes_quotes_in_headers(): + # A quote in a column name must be doubled, not emitted raw. + table = FakeResultTable(['My "Special" Column'], [["x"]]) + assert format_csv(table).splitlines()[0] == '"My ""Special"" Column"' + + +def test_csv_quotes_bare_carriage_return(): + # A lone \r (no \n) must still force quoting per RFC 4180. + table = FakeResultTable(["v"], [["a\rb"]]) + out = format_csv(table) + assert '"a\rb"' in out + + +def test_csv_no_trailing_newline(simple_table): + assert not format_csv(simple_table).endswith("\n") + + +def test_csv_empty(): + assert format_csv(None) == "" diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..7d9f3f4 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,48 @@ +"""Tests for the query registry and parameter rendering.""" + +import pytest + +from kq.registry import Query + + +def make_query(**data): + return Query(category="cat", name="q", data=data) + + +def test_render_substitutes_named_params(): + q = make_query( + query="{table} | take {count}", + parameters=[{"name": "table"}, {"name": "count"}], + ) + assert q.render(table="Events", count="5") == "Events | take 5" + + +def test_render_uses_defaults(): + q = make_query( + query="{table} | take {count}", + parameters=[ + {"name": "table"}, + {"name": "count", "default": "10"}, + ], + ) + assert q.render(table="Events") == "Events | take 10" + + +def test_render_missing_required_raises(): + q = make_query( + query="{table} | take 5", + parameters=[{"name": "table", "required": True}], + ) + with pytest.raises(ValueError, match="Missing required parameter: table"): + q.render() + + +def test_render_strips_whitespace(): + q = make_query(query="\n .show tables\n ") + assert q.render() == ".show tables" + + +def test_full_name_and_defaults(): + q = make_query(description="d", query="x") + assert q.full_name == "cat.q" + assert q.safety == "safe" # default when unspecified