sci-etl-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Command-line tool for running sci-etl-core extraction pipelines from a YAML file."""
@@ -0,0 +1,3 @@
1
+ from sci_etl_cli.app import main
2
+
3
+ main()
sci_etl_cli/app.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+ from typing import Any
5
+
6
+ import click
7
+ from sci_etl_core.exceptions import SciEtlError
8
+
9
+ from sci_etl_cli.commands.init import init_command
10
+ from sci_etl_cli.commands.parse import parse_command
11
+ from sci_etl_cli.commands.run import run_command
12
+ from sci_etl_cli.commands.search import search_command
13
+ from sci_etl_cli.commands.status import status_command
14
+ from sci_etl_cli.commands.validate import validate_command
15
+ from sci_etl_cli.errors import to_cli_failure
16
+
17
+ _EXIT_CODES = """\b
18
+ Exit codes:
19
+ 0 success
20
+ 1 run aborted, or a request or parse failed
21
+ 2 invalid command-line usage
22
+ 3 configuration problem
23
+ 4 missing Python package
24
+ 130 interrupted after saving state"""
25
+
26
+
27
+ class SciEtlGroup(click.Group):
28
+ """Command group that reports library errors as one line and an exit code."""
29
+
30
+ def invoke(self, ctx: click.Context) -> Any:
31
+ try:
32
+ return super().invoke(ctx)
33
+ except (SciEtlError, ModuleNotFoundError) as error:
34
+ raise to_cli_failure(error) from error
35
+
36
+
37
+ def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
38
+ if not value or ctx.resilient_parsing:
39
+ return
40
+ click.echo(f"sci-etl {version('sci-etl-cli')} (sci-etl-core {version('sci-etl-core')})")
41
+ ctx.exit()
42
+
43
+
44
+ @click.group(
45
+ cls=SciEtlGroup,
46
+ context_settings={"help_option_names": ["-h", "--help"]},
47
+ epilog=_EXIT_CODES,
48
+ )
49
+ @click.option(
50
+ "--version",
51
+ is_flag=True,
52
+ expose_value=False,
53
+ is_eager=True,
54
+ callback=_print_version,
55
+ help="Show the CLI and library versions and exit.",
56
+ )
57
+ def cli() -> None:
58
+ """Run sci-etl-core extraction pipelines from a YAML config file."""
59
+
60
+
61
+ cli.add_command(init_command)
62
+ cli.add_command(validate_command)
63
+ cli.add_command(search_command)
64
+ cli.add_command(run_command)
65
+ cli.add_command(status_command)
66
+ cli.add_command(parse_command)
67
+
68
+
69
+ def main() -> None:
70
+ cli(prog_name="sci-etl")
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Callable
5
+
6
+ from sci_etl_core.exceptions import ConfigurationError
7
+
8
+ if TYPE_CHECKING:
9
+ import logging
10
+
11
+ import httpx
12
+ from sci_etl_core import (
13
+ AsyncETLPipeline,
14
+ AsyncExporter,
15
+ AsyncExtractor,
16
+ AsyncLLMClient,
17
+ AsyncOpenAICompatibleClient,
18
+ AsyncStateManager,
19
+ PipelineMetadata,
20
+ )
21
+
22
+ from sci_etl_cli.settings import CliConfig
23
+
24
+
25
+ def read_prompt(path: Path) -> str:
26
+ """Return a prompt file's text.
27
+
28
+ Raises:
29
+ ConfigurationError: The file is missing, unreadable, not UTF-8, or blank.
30
+ """
31
+ try:
32
+ text = path.read_text(encoding="utf-8")
33
+ except FileNotFoundError as exc:
34
+ raise ConfigurationError(f"Prompt file not found: {path}") from exc
35
+ except (OSError, UnicodeDecodeError) as exc:
36
+ raise ConfigurationError(f"Prompt file could not be read: {path}: {exc}") from exc
37
+ if not text.strip():
38
+ raise ConfigurationError(f"Prompt file is empty: {path}")
39
+ return text
40
+
41
+
42
+ def build_http_client(config: CliConfig) -> httpx.AsyncClient:
43
+ from sci_etl_core.http_async import build_async_client
44
+
45
+ return build_async_client(timeout=config.http.timeout, user_agent=config.http.user_agent)
46
+
47
+
48
+ def build_llm_client(config: CliConfig) -> AsyncOpenAICompatibleClient:
49
+ from sci_etl_core import AsyncOpenAICompatibleClient
50
+
51
+ return AsyncOpenAICompatibleClient(
52
+ api_key=config.llm.api_key,
53
+ base_url=config.llm.base_url,
54
+ model=config.llm.model,
55
+ default_timeout=config.llm.timeout,
56
+ )
57
+
58
+
59
+ def build_extractor(
60
+ config: CliConfig, http_client: httpx.AsyncClient, log: Callable[[str], None]
61
+ ) -> AsyncExtractor:
62
+ from sci_etl_core import AsyncArxivExtractor
63
+ from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
64
+
65
+ return AsyncArxivExtractor(
66
+ client=http_client,
67
+ pdf_parser=PdfPlumberParser(),
68
+ latex_parser=LatexTarballParser(),
69
+ max_retries=config.http.max_retries,
70
+ backoff_factor=config.http.backoff_factor,
71
+ sleep_before_search=config.pipeline.search_delay,
72
+ logger=log,
73
+ )
74
+
75
+
76
+ def build_state_manager(config: CliConfig) -> AsyncStateManager:
77
+ if config.state.backend == "sqlite":
78
+ from sci_etl_core import AsyncSqliteStateManager
79
+
80
+ return AsyncSqliteStateManager(config.state.database)
81
+ from sci_etl_core import AsyncFileStateManager
82
+
83
+ return AsyncFileStateManager(config.state.processed_ids, config.state.metadata)
84
+
85
+
86
+ def build_exporter(config: CliConfig) -> AsyncExporter:
87
+ from sci_etl_core import AsyncCsvUpsertExporter
88
+ from sci_etl_core.processors import DefaultKeyNormalizer
89
+
90
+ export = config.export
91
+ return AsyncCsvUpsertExporter(
92
+ key_column=export.key_column,
93
+ value_columns=list(export.value_columns),
94
+ normalizer=DefaultKeyNormalizer(),
95
+ numeric_clip=dict(export.numeric_clip),
96
+ escape_formulas=export.escape_formulas,
97
+ )
98
+
99
+
100
+ def build_pipeline(
101
+ config: CliConfig,
102
+ log: logging.Logger,
103
+ http_client: httpx.AsyncClient,
104
+ llm_client: AsyncLLMClient,
105
+ state_manager: AsyncStateManager,
106
+ relevance_prompt: str,
107
+ extraction_prompt: str,
108
+ ) -> AsyncETLPipeline:
109
+ from sci_etl_core import AsyncETLPipeline, AsyncLLMEntityExtractor, AsyncLLMRelevanceFilter
110
+
111
+ from sci_etl_cli.reporting import ListingReporter
112
+
113
+ return AsyncETLPipeline(
114
+ extractor=ListingReporter(build_extractor(config, http_client, log.info), log.info),
115
+ relevance_filter=AsyncLLMRelevanceFilter(llm_client=llm_client, system_prompt=relevance_prompt),
116
+ entity_extractor=AsyncLLMEntityExtractor(
117
+ llm_client=llm_client,
118
+ system_prompt=extraction_prompt,
119
+ result_key=config.prompts.result_key,
120
+ timeout=config.llm.timeout,
121
+ ),
122
+ exporter=build_exporter(config),
123
+ state_manager=state_manager,
124
+ destination=str(config.export.destination),
125
+ max_concurrency=config.pipeline.max_workers,
126
+ logger=log.warning,
127
+ closeables=[http_client, llm_client, state_manager],
128
+ )
129
+
130
+
131
+ async def read_state(config: CliConfig) -> tuple[set[str], PipelineMetadata]:
132
+ """Load processed ids and metadata without creating state that does not exist yet."""
133
+ from sci_etl_core.models import PipelineMetadata
134
+
135
+ if config.state.backend == "sqlite" and not config.state.database.exists():
136
+ return set(), PipelineMetadata()
137
+ manager = build_state_manager(config)
138
+ try:
139
+ return await manager.load_processed_ids(), await manager.load_metadata()
140
+ finally:
141
+ aclose = getattr(manager, "aclose", None)
142
+ if aclose is not None:
143
+ await aclose()
File without changes
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.markup import escape
8
+
9
+ from sci_etl_cli.output import stdout_console
10
+
11
+ _TEMPLATES: tuple[tuple[str, str], ...] = (
12
+ ("config.yaml", "config.yaml"),
13
+ ("relevance.txt", "prompts/relevance.txt"),
14
+ ("extraction.txt", "prompts/extraction.txt"),
15
+ ("env.example", ".env.example"),
16
+ )
17
+
18
+
19
+ @click.command("init", short_help="Create a starter project.")
20
+ @click.argument("directory", required=False, default=".", type=click.Path(file_okay=False, path_type=Path))
21
+ @click.option("--force", is_flag=True, help="Overwrite files that already exist.")
22
+ def init_command(directory: Path, force: bool) -> None:
23
+ """Create a starter project in DIRECTORY (default: the current folder).
24
+
25
+ Writes config.yaml, prompts/relevance.txt, prompts/extraction.txt and
26
+ .env.example. The example collects hot Jupiter measurements from arXiv;
27
+ change the query, prompts and export columns for your own subject.
28
+ """
29
+ targets = [(template, directory / relative) for template, relative in _TEMPLATES]
30
+ existing = [path for _template, path in targets if path.exists()]
31
+ if existing and not force:
32
+ names = ", ".join(str(path) for path in existing)
33
+ raise click.UsageError(f"Refusing to overwrite {names}; pass --force to replace them.")
34
+
35
+ templates = resources.files("sci_etl_cli").joinpath("templates")
36
+ console = stdout_console()
37
+ for template, path in targets:
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text(templates.joinpath(template).read_text(encoding="utf-8"), encoding="utf-8")
40
+ console.print(f"[green]wrote[/] {escape(str(path))}", soft_wrap=True)
41
+ console.print(
42
+ "\nNext: copy .env.example to .env, set LLM_API_KEY, then run "
43
+ f"[bold]sci-etl validate {escape(str(directory / 'config.yaml'))}[/]",
44
+ soft_wrap=True,
45
+ )
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ import click
7
+ from sci_etl_core.parsers.reference_trimmer import trim_after_references
8
+
9
+ from sci_etl_cli.errors import CliFailure, ExitCode
10
+
11
+ if TYPE_CHECKING:
12
+ from sci_etl_core.parsers.base import Parser
13
+
14
+ _FORMATS = ("auto", "pdf", "latex", "html")
15
+ _PDF_MAGIC = b"%PDF"
16
+ _GZIP_MAGIC = b"\x1f\x8b"
17
+ _TAR_MAGIC = b"ustar"
18
+ _TAR_MAGIC_OFFSET = 257
19
+
20
+
21
+ @click.command("parse", short_help="Print the text extracted from a PDF, e-print or HTML file.")
22
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
23
+ @click.option(
24
+ "--format",
25
+ "file_format",
26
+ type=click.Choice(_FORMATS),
27
+ default="auto",
28
+ show_default=True,
29
+ help="How to read FILE. auto recognizes PDFs, arXiv e-prints and HTML.",
30
+ )
31
+ @click.option("--trim-references", is_flag=True, help="Cut the text at the references or acknowledgments.")
32
+ def parse_command(file: Path, file_format: str, trim_references: bool) -> None:
33
+ """Print the text a bundled parser extracts from FILE.
34
+
35
+ Accepts a PDF, an arXiv e-print (a tarball or gzipped TeX file) or an HTML
36
+ page. Use it to check full-text quality without running a pipeline.
37
+ """
38
+ content = file.read_bytes()
39
+ parser = build_parser(detect_format(content) if file_format == "auto" else file_format)
40
+ text = parser.extract_text(content)
41
+ if trim_references:
42
+ text = trim_after_references(text) or text
43
+ if not text.strip():
44
+ raise CliFailure(f"No text could be extracted from {file}", ExitCode.FAILURE)
45
+ click.echo(text)
46
+
47
+
48
+ def detect_format(content: bytes) -> str:
49
+ stripped = content.lstrip()
50
+ if stripped.startswith(_PDF_MAGIC):
51
+ return "pdf"
52
+ tar_magic = content[_TAR_MAGIC_OFFSET : _TAR_MAGIC_OFFSET + len(_TAR_MAGIC)]
53
+ if content.startswith(_GZIP_MAGIC) or tar_magic == _TAR_MAGIC:
54
+ return "latex"
55
+ if stripped.startswith(b"<"):
56
+ return "html"
57
+ raise click.UsageError("Could not tell the file's format; pass --format pdf, latex or html.")
58
+
59
+
60
+ def build_parser(file_format: str) -> Parser:
61
+ if file_format == "pdf":
62
+ from sci_etl_core.parsers.pdf import PdfPlumberParser
63
+
64
+ return PdfPlumberParser()
65
+ if file_format == "latex":
66
+ from sci_etl_core.parsers.latex import LatexTarballParser
67
+
68
+ return LatexTarballParser()
69
+ from sci_etl_core.parsers.html import HtmlTextParser
70
+
71
+ return HtmlTextParser()
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from sci_etl_core.exceptions import PipelineAborted
9
+ from sci_etl_core.signals import ShutdownSignal
10
+
11
+ from sci_etl_cli import assembly, shutdown
12
+ from sci_etl_cli.errors import ExitCode, describe_abort
13
+ from sci_etl_cli.options import config_argument, require_api_key, require_query
14
+ from sci_etl_cli.output import run_logger, stderr_console
15
+ from sci_etl_cli.settings import CliConfig, load_cli_config
16
+
17
+
18
+ @click.command("run")
19
+ @config_argument
20
+ @click.option(
21
+ "--limit",
22
+ type=click.IntRange(min=0),
23
+ help="Stop after this many relevant records. Overrides pipeline.max_records.",
24
+ )
25
+ @click.option(
26
+ "--page-size",
27
+ type=click.IntRange(min=1),
28
+ help="Listing entries per request. Overrides pipeline.page_size.",
29
+ )
30
+ @click.option(
31
+ "--workers",
32
+ type=click.IntRange(min=1),
33
+ help="Records processed at once. Overrides pipeline.max_workers.",
34
+ )
35
+ @click.option(
36
+ "--rescan",
37
+ is_flag=True,
38
+ help="Start at offset 0 to pick up new submissions; processed records are skipped.",
39
+ )
40
+ @click.option(
41
+ "--start-index",
42
+ type=click.IntRange(min=0),
43
+ help="Start at this listing offset instead of the saved one.",
44
+ )
45
+ @click.option(
46
+ "--log-file",
47
+ type=click.Path(dir_okay=False, path_type=Path),
48
+ help="Write the log to this file. Overrides logging.file.",
49
+ )
50
+ def run_command(
51
+ config_path: Path,
52
+ limit: int | None,
53
+ page_size: int | None,
54
+ workers: int | None,
55
+ rescan: bool,
56
+ start_index: int | None,
57
+ log_file: Path | None,
58
+ ) -> None:
59
+ """Run the pipeline for CONFIG, resuming from its saved state.
60
+
61
+ Searches arXiv, asks the LLM which papers are relevant, extracts entities
62
+ from their full text and upserts them into the export CSV. Press Ctrl+C
63
+ once to stop after saving state; the next run retries unfinished records.
64
+ """
65
+ if rescan and start_index is not None:
66
+ raise click.UsageError("Use either --rescan or --start-index, not both.")
67
+ config = apply_overrides(
68
+ load_cli_config(config_path), limit=limit, page_size=page_size, workers=workers, log_file=log_file
69
+ )
70
+ require_query(config.pipeline.search_query)
71
+ require_api_key(config)
72
+ relevance_prompt = assembly.read_prompt(config.prompts.relevance)
73
+ extraction_prompt = assembly.read_prompt(config.prompts.extraction)
74
+
75
+ ctx = click.get_current_context()
76
+ with run_logger(config.logging.level, config.logging.file, stderr_console()) as log:
77
+ try:
78
+ processed = asyncio.run(
79
+ execute(config, log, relevance_prompt, extraction_prompt, 0 if rescan else start_index)
80
+ )
81
+ except PipelineAborted as aborted:
82
+ log.error(f"Run aborted: {describe_abort(aborted)}")
83
+ ctx.exit(ExitCode.FAILURE)
84
+ if processed is None:
85
+ log.warning("Interrupted; state was saved. Run the same command again to continue.")
86
+ ctx.exit(ExitCode.INTERRUPTED)
87
+ log.info(f"Processed {describe_count(processed)} into {config.export.destination}")
88
+
89
+
90
+ def apply_overrides(
91
+ config: CliConfig,
92
+ *,
93
+ limit: int | None,
94
+ page_size: int | None,
95
+ workers: int | None,
96
+ log_file: Path | None,
97
+ ) -> CliConfig:
98
+ requested = {"max_records": limit, "page_size": page_size, "max_workers": workers}
99
+ pipeline = config.pipeline.model_copy(update={name: value for name, value in requested.items() if value is not None})
100
+ logging_config = config.logging if log_file is None else config.logging.model_copy(update={"file": log_file})
101
+ return config.model_copy(update={"pipeline": pipeline, "logging": logging_config})
102
+
103
+
104
+ def describe_count(count: int) -> str:
105
+ return f"{count} relevant record{'' if count == 1 else 's'}"
106
+
107
+
108
+ async def execute(
109
+ config: CliConfig,
110
+ log: logging.Logger,
111
+ relevance_prompt: str,
112
+ extraction_prompt: str,
113
+ start_index: int | None,
114
+ ) -> int | None:
115
+ http_client = assembly.build_http_client(config)
116
+ llm_client = assembly.build_llm_client(config)
117
+ state_manager = assembly.build_state_manager(config)
118
+ pipeline = assembly.build_pipeline(
119
+ config, log, http_client, llm_client, state_manager, relevance_prompt, extraction_prompt
120
+ )
121
+ log.info(
122
+ f"Starting run for {config.pipeline.search_query!r}, up to {describe_count(config.pipeline.max_records)}"
123
+ )
124
+ async with pipeline:
125
+ return await shutdown.run_until_interrupted(
126
+ pipeline.run(
127
+ query=config.pipeline.search_query,
128
+ page_size=config.pipeline.page_size,
129
+ total_limit=config.pipeline.max_records,
130
+ sleep_between=config.pipeline.sleep_between,
131
+ start_index=start_index,
132
+ ),
133
+ ShutdownSignal(logger=log.warning),
134
+ state_manager,
135
+ )
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+
11
+ from sci_etl_cli import assembly
12
+ from sci_etl_cli.options import config_argument, require_query
13
+ from sci_etl_cli.output import discard, print_json, stdout_console
14
+ from sci_etl_cli.settings import CliConfig, load_cli_config
15
+
16
+ _ARXIV_MAX_RESULTS = 2000
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ListedRecord:
21
+ record_id: str
22
+ title: str
23
+ url: str | None
24
+ processed: bool
25
+
26
+
27
+ @click.command("search", short_help="Preview arXiv results without calling the LLM.")
28
+ @config_argument
29
+ @click.option("--query", help="Search this instead of pipeline.search_query.")
30
+ @click.option(
31
+ "--limit",
32
+ type=click.IntRange(1, _ARXIV_MAX_RESULTS),
33
+ default=10,
34
+ show_default=True,
35
+ help="Number of entries to request.",
36
+ )
37
+ @click.option(
38
+ "--start-index",
39
+ type=click.IntRange(min=0),
40
+ default=0,
41
+ show_default=True,
42
+ help="Listing offset to start from.",
43
+ )
44
+ @click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table.")
45
+ def search_command(config_path: Path, query: str | None, limit: int, start_index: int, as_json: bool) -> None:
46
+ """Show one page of arXiv results for CONFIG without calling the LLM.
47
+
48
+ Each entry is marked new or processed according to the saved state, so a
49
+ query can be tuned before spending tokens. Nothing is written.
50
+ """
51
+ config = load_cli_config(config_path)
52
+ search_query = require_query(query or config.pipeline.search_query)
53
+ listed = asyncio.run(fetch_page(config, search_query, limit, start_index))
54
+ if as_json:
55
+ print_json([asdict(record) for record in listed])
56
+ return
57
+ console = stdout_console()
58
+ if not listed:
59
+ console.print(f"No entries at offset {start_index}.")
60
+ return
61
+ table = Table("id", "status", "title")
62
+ for record in listed:
63
+ status = "[yellow]processed[/]" if record.processed else "[green]new[/]"
64
+ table.add_row(record.record_id, status, escape(record.title))
65
+ console.print(table)
66
+
67
+
68
+ async def fetch_page(config: CliConfig, query: str, limit: int, start_index: int) -> list[ListedRecord]:
69
+ processed_ids, _metadata = await assembly.read_state(config)
70
+ http_client = assembly.build_http_client(config)
71
+ try:
72
+ extractor = assembly.build_extractor(config, http_client, discard)
73
+ raw_listing = await extractor.search(query, limit, start_index)
74
+ records, _entries = extractor.parse_listing(raw_listing, set())
75
+ finally:
76
+ await http_client.aclose()
77
+ return [
78
+ ListedRecord(record.record_id, record.title, record.source_url, record.record_id in processed_ids)
79
+ for record in records
80
+ ]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import csv
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+
12
+ from sci_etl_cli import assembly
13
+ from sci_etl_cli.errors import CliFailure, ExitCode
14
+ from sci_etl_cli.options import config_argument
15
+ from sci_etl_cli.output import print_json, stdout_console
16
+ from sci_etl_cli.settings import CliConfig, load_cli_config
17
+
18
+
19
+ @click.command("status", short_help="Show processed records, saved offset and export rows.")
20
+ @config_argument
21
+ @click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table.")
22
+ def status_command(config_path: Path, as_json: bool) -> None:
23
+ """Show CONFIG's progress: processed records, saved offset, last run and export rows.
24
+
25
+ Reads state without creating it, so this is safe before the first run.
26
+ """
27
+ config = load_cli_config(config_path)
28
+ processed_ids, metadata = asyncio.run(assembly.read_state(config))
29
+ report: dict[str, Any] = {
30
+ "config": str(config_path.resolve()),
31
+ "backend": config.state.backend,
32
+ "state": [str(path) for path in state_paths(config)],
33
+ "processed": len(processed_ids),
34
+ "saved_offset": metadata.last_start_index,
35
+ "last_run": metadata.last_run_at,
36
+ "export": str(config.export.destination),
37
+ "export_rows": count_rows(config.export.destination),
38
+ }
39
+ if as_json:
40
+ print_json(report)
41
+ return
42
+
43
+ rows = report["export_rows"]
44
+ table = Table(show_header=False, box=None, pad_edge=False)
45
+ table.add_column(no_wrap=True)
46
+ table.add_column(overflow="fold")
47
+ table.add_row("config", escape(report["config"]))
48
+ table.add_row("backend", report["backend"])
49
+ table.add_row("state", escape("\n".join(report["state"])))
50
+ table.add_row("processed", f"{report['processed']} records")
51
+ table.add_row("saved offset", str(report["saved_offset"]))
52
+ table.add_row("last run", escape(report["last_run"] or "never"))
53
+ table.add_row("export", escape(report["export"]))
54
+ table.add_row("rows", "not created yet" if rows is None else str(rows))
55
+ stdout_console().print(table)
56
+
57
+
58
+ def state_paths(config: CliConfig) -> tuple[Path, ...]:
59
+ if config.state.backend == "sqlite":
60
+ return (config.state.database,)
61
+ return (config.state.processed_ids, config.state.metadata)
62
+
63
+
64
+ def count_rows(path: Path) -> int | None:
65
+ """Count data rows in the export CSV, or return ``None`` when it does not exist yet.
66
+
67
+ Raises:
68
+ CliFailure: The file exists but cannot be read as UTF-8 CSV.
69
+ """
70
+ if not path.is_file():
71
+ return None
72
+ try:
73
+ with path.open("r", encoding="utf-8", newline="") as handle:
74
+ return max(sum(1 for _row in csv.reader(handle)) - 1, 0)
75
+ except (OSError, UnicodeDecodeError, csv.Error) as exc:
76
+ raise CliFailure(f"Export file could not be read: {path}: {exc}", ExitCode.FAILURE) from exc