fetchkit-agents 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.
fetchkit/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """
2
+ fetchkit — a YAML-configured data fetching library for agentic applications.
3
+
4
+ Fetch posts and comments from sources (Hacker News, RSS/Atom, arXiv, GitHub,
5
+ Lobsters) into a single canonical ``Post`` model, with deduplication, sorting,
6
+ and a typed config. Designed as a data-collection layer for agentic / LLM
7
+ applications.
8
+
9
+ Quick start::
10
+
11
+ from fetchkit import load_config, collect_all
12
+
13
+ config = load_config("config.yaml")
14
+ result = collect_all(config)
15
+ for post in result.posts:
16
+ print(post.title, post.url)
17
+
18
+ See the README for the full configuration reference and the guide to adding
19
+ fetchers.
20
+ """
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ from fetchkit.collector import collect_all
25
+ from fetchkit.config_loader import load_config, ConfigError
26
+ from fetchkit.fetchers.base import Fetcher, FetcherResult
27
+ from fetchkit.fetchers.registry import register_fetcher, get_fetcher
28
+ from fetchkit.http import (
29
+ HttpClient,
30
+ RateLimiter,
31
+ get_default_client,
32
+ set_default_client,
33
+ use_client,
34
+ )
35
+ from fetchkit.schemas.post import Post, Comment, Source
36
+ from fetchkit.schemas.fetcher import (
37
+ SortOrder,
38
+ PostFetchConfig,
39
+ CommentFetchConfig,
40
+ FetcherBase,
41
+ HackerNewsFetchConfig,
42
+ RSSFeedDescriptor,
43
+ RSSFetchConfig,
44
+ ArxivFetchConfig,
45
+ GitHubFetchConfig,
46
+ LobstersFetchConfig,
47
+ FetcherConfig,
48
+ )
49
+ from fetchkit.schemas.collector import CollectorResult
50
+ from fetchkit.schemas.config import FetchKitConfig, HttpConfig
51
+ from fetchkit.utils.time import resolve_window, parse_duration
52
+
53
+ __all__ = [
54
+ "__version__",
55
+ # Orchestration
56
+ "collect_all",
57
+ "load_config",
58
+ "ConfigError",
59
+ # Fetcher protocol & registry
60
+ "Fetcher",
61
+ "FetcherResult",
62
+ "register_fetcher",
63
+ "get_fetcher",
64
+ # HTTP
65
+ "HttpClient",
66
+ "RateLimiter",
67
+ "get_default_client",
68
+ "set_default_client",
69
+ "use_client",
70
+ # Schemas
71
+ "Post",
72
+ "Comment",
73
+ "Source",
74
+ "SortOrder",
75
+ "PostFetchConfig",
76
+ "CommentFetchConfig",
77
+ "FetcherBase",
78
+ "HackerNewsFetchConfig",
79
+ "RSSFeedDescriptor",
80
+ "RSSFetchConfig",
81
+ "ArxivFetchConfig",
82
+ "GitHubFetchConfig",
83
+ "LobstersFetchConfig",
84
+ "FetcherConfig",
85
+ "CollectorResult",
86
+ "FetchKitConfig",
87
+ "HttpConfig",
88
+ # Time helpers
89
+ "resolve_window",
90
+ "parse_duration",
91
+ ]
fetchkit/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m fetchkit`` as an alias for the ``fetchkit`` CLI."""
2
+
3
+ import sys
4
+
5
+ from fetchkit.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
fetchkit/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """Command-line interface for fetchkit.
2
+
3
+ Designed so an agent (or a shell script) can fetch news deterministically from a
4
+ YAML spec and parse structured JSON from stdout::
5
+
6
+ fetchkit run config.yaml # JSON: {"posts": [...], "errors": [...]}
7
+ fetchkit run config.yaml -o out.json # write JSON to a file instead of stdout
8
+ fetchkit run config.yaml --fail-on-error # exit 1 if any source failed
9
+ fetchkit validate config.yaml # check a config without fetching
10
+
11
+ stdout carries only JSON (for ``run``) so it is safe to pipe into ``jq`` or parse
12
+ programmatically. Diagnostics and ``--verbose`` logging go to stderr.
13
+
14
+ Exit codes::
15
+
16
+ 0 success
17
+ 1 fetch completed but a source failed (only with --fail-on-error)
18
+ 2 configuration error (missing file, invalid YAML, validation failure)
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import logging
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, Optional, Sequence
27
+
28
+ from fetchkit import __version__
29
+ from fetchkit.collector import collect_all
30
+ from fetchkit.config_loader import ConfigError, load_config
31
+ from fetchkit.utils.time import resolve_window
32
+
33
+
34
+ def _emit_json(payload: Any, indent: Optional[int], output: Optional[str]) -> None:
35
+ """Write a JSON document to ``output`` (a file path) or stdout.
36
+
37
+ When writing to a file, stdout stays empty so the file is the sole result
38
+ artifact; a one-line confirmation is logged to stderr.
39
+ """
40
+ text = json.dumps(payload, indent=indent, ensure_ascii=False)
41
+ if output is None:
42
+ sys.stdout.write(text + "\n")
43
+ return
44
+ Path(output).expanduser().write_text(text + "\n", encoding="utf-8")
45
+ print(f"Wrote {payload['count']} post(s) to {output}", file=sys.stderr)
46
+
47
+
48
+ def _cmd_run(args: argparse.Namespace) -> int:
49
+ """Load a config, collect posts, and emit a JSON result document on stdout."""
50
+ try:
51
+ config = load_config(args.config)
52
+ except ConfigError as exc:
53
+ print(str(exc), file=sys.stderr)
54
+ return 2
55
+
56
+ # The window can be set two ways: in the YAML config itself (a `window:` key,
57
+ # mutually exclusive with start_time/end_time), or here on the command line.
58
+ # --window is a runtime override: it replaces whatever bounds the config
59
+ # resolved to, so you can reuse one config across different time ranges.
60
+ if args.window:
61
+ try:
62
+ config.start_time, config.end_time = resolve_window(args.window)
63
+ except ValueError as exc:
64
+ print(f"Invalid --window: {exc}", file=sys.stderr)
65
+ return 2
66
+
67
+ # Keep stdout pure JSON: progress/verbosity is logged to stderr, never printed.
68
+ result = collect_all(config)
69
+
70
+ payload = {
71
+ "count": len(result.posts),
72
+ "posts": [post.model_dump(mode="json") for post in result.posts],
73
+ "errors": [
74
+ {"source": source, "error": str(err)} for source, err in result.errors
75
+ ],
76
+ }
77
+ indent = None if args.compact else args.indent
78
+ _emit_json(payload, indent, args.output)
79
+
80
+ if args.fail_on_error and result.has_errors:
81
+ return 1
82
+ return 0
83
+
84
+
85
+ def _cmd_validate(args: argparse.Namespace) -> int:
86
+ """Load and validate a config without fetching; report the result."""
87
+ try:
88
+ config = load_config(args.config)
89
+ except ConfigError as exc:
90
+ print(str(exc), file=sys.stderr)
91
+ return 2
92
+
93
+ enabled = sum(1 for f in config.fetchers if f.enabled)
94
+ print(
95
+ f"OK: {args.config} is valid "
96
+ f"({len(config.fetchers)} fetcher(s), {enabled} enabled).",
97
+ file=sys.stderr,
98
+ )
99
+ return 0
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ """Construct the top-level argument parser with ``run`` and ``validate``."""
104
+ parser = argparse.ArgumentParser(
105
+ prog="fetchkit",
106
+ description="Fetch news/posts deterministically from a YAML spec.",
107
+ )
108
+ parser.add_argument(
109
+ "--version", action="version", version=f"fetchkit {__version__}"
110
+ )
111
+ parser.add_argument(
112
+ "-v", "--verbose", action="store_true",
113
+ help="Enable INFO-level logging to stderr.",
114
+ )
115
+
116
+ # Define the two subcommands (`fetchkit run ...` / `fetchkit validate ...`).
117
+ # `dest="command"` records which one was chosen; `required=True` makes picking
118
+ # a subcommand mandatory (running bare `fetchkit` errors with usage help).
119
+ sub = parser.add_subparsers(dest="command", required=True)
120
+
121
+ run = sub.add_parser("run", help="Fetch posts and print JSON to stdout.")
122
+ run.add_argument("config", help="Path to the YAML config file.")
123
+ run.add_argument(
124
+ "-o", "--output", default=None, metavar="PATH",
125
+ help="Write JSON to this file instead of stdout.",
126
+ )
127
+ run.add_argument(
128
+ "--window", default=None, metavar="SPEC",
129
+ help="Override the config time window, e.g. 'last 6 hours', 'yesterday', '7d'.",
130
+ )
131
+ run.add_argument(
132
+ "--indent", type=int, default=2,
133
+ help="JSON indentation for pretty output (default: 2).",
134
+ )
135
+ run.add_argument(
136
+ "--compact", action="store_true",
137
+ help="Emit single-line JSON (overrides --indent).",
138
+ )
139
+ run.add_argument(
140
+ "--fail-on-error", action="store_true",
141
+ help="Exit 1 if any source reported an error.",
142
+ )
143
+ run.set_defaults(func=_cmd_run)
144
+
145
+ validate = sub.add_parser("validate", help="Validate a config without fetching.")
146
+ validate.add_argument("config", help="Path to the YAML config file.")
147
+ validate.set_defaults(func=_cmd_validate)
148
+
149
+ return parser
150
+
151
+
152
+ def main(argv: Optional[Sequence[str]] = None) -> int:
153
+ """CLI entry point. Returns a process exit code."""
154
+ parser = build_parser()
155
+ args = parser.parse_args(argv)
156
+
157
+ if args.verbose:
158
+ logging.basicConfig(
159
+ level=logging.INFO,
160
+ stream=sys.stderr,
161
+ format="%(levelname)s %(name)s: %(message)s",
162
+ )
163
+
164
+ exit_code: int = args.func(args)
165
+ return exit_code
166
+
167
+
168
+ if __name__ == "__main__": # pragma: no cover
169
+ sys.exit(main())
fetchkit/collector.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ Unified fetcher orchestrator.
3
+
4
+ Orchestrates fetching from multiple data sources (Hacker News, RSS, etc.)
5
+ using a single top-level configuration. Preserves three core invariants:
6
+
7
+ 1. Per-fetcher time windows inherit the global window when omitted.
8
+ 2. Posts are de-duplicated by ``(source, id)`` (first occurrence wins).
9
+ 3. Posts are sorted descending by ``(created_at or UTC_MIN, id)`` for determinism.
10
+ """
11
+
12
+ import logging
13
+ from contextlib import nullcontext
14
+ from typing import Optional
15
+
16
+ from fetchkit.schemas.post import Post
17
+ from fetchkit.schemas.config import FetchKitConfig
18
+ from fetchkit.fetchers.registry import get_fetcher
19
+ from fetchkit.utils.time import UTC_MIN
20
+ from fetchkit.schemas.collector import CollectorResult
21
+ from fetchkit.http import HttpClient, use_client
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def collect_all(
27
+ config: FetchKitConfig,
28
+ verbose: bool = False,
29
+ http_client: Optional[HttpClient] = None,
30
+ ) -> CollectorResult:
31
+ """
32
+ Collect posts from all enabled data sources.
33
+
34
+ Args:
35
+ config: Top-level FetchKitConfig with global time window and fetcher settings.
36
+ verbose: When True, print per-source progress to stdout (off by default for
37
+ library use).
38
+ http_client: Optional shared HTTP client to install for this run. When
39
+ provided, it becomes the active default client used by fetchers for the
40
+ duration of the call. When None and ``config.http`` is set, a client is
41
+ built from it; otherwise the built-in default client is used.
42
+
43
+ Returns:
44
+ CollectorResult containing posts and any errors that occurred.
45
+ Callers should check result.has_errors to detect partial failures.
46
+ """
47
+ all_posts: list[Post] = []
48
+ errors: list[tuple[str, Exception]] = []
49
+
50
+ # Resolve the HTTP client for this run: the explicit override, else one built
51
+ # from config.http, else None (fall back to the ambient default). When we do
52
+ # have a run-specific client, install it thread-locally for the duration of
53
+ # the run via use_client() so concurrent collect_all() calls never clobber
54
+ # each other or a caller's set_default_client().
55
+ run_client: Optional[HttpClient] = http_client
56
+ if run_client is None and config.http is not None:
57
+ run_client = HttpClient(config.http)
58
+ client_ctx = use_client(run_client) if run_client is not None else nullcontext()
59
+
60
+ def _log(msg: str) -> None:
61
+ if verbose:
62
+ print(msg)
63
+
64
+ def _record_error(source: str, err: Exception) -> None:
65
+ """Track a source failure and emit matching log/console diagnostics."""
66
+ errors.append((source, err))
67
+ logger.error("Failed to fetch from %s: %s", source, err)
68
+ _log(f"Error: Failed to fetch from {source}: {err}")
69
+
70
+ with client_ctx:
71
+ for fetcher_config in config.fetchers:
72
+ if not fetcher_config.enabled:
73
+ continue
74
+
75
+ source_name = fetcher_config.type
76
+
77
+ try:
78
+ # Inherit global time window if not set
79
+ f_config = fetcher_config.model_copy()
80
+ if f_config.start_time is None:
81
+ f_config.start_time = config.start_time
82
+ if f_config.end_time is None:
83
+ f_config.end_time = config.end_time
84
+
85
+ fetcher = get_fetcher(f_config.type)
86
+ result = fetcher(f_config)
87
+
88
+ msg_name = f_config.name if f_config.name else f_config.type
89
+ _log(f"Fetched {len(result.posts)} posts from {msg_name}")
90
+
91
+ all_posts.extend(result.posts)
92
+
93
+ for err in result.errors:
94
+ _record_error(source_name, err)
95
+
96
+ except Exception as e:
97
+ _record_error(source_name, e)
98
+
99
+ # Dedup by (source, id) for maximum safety
100
+ seen_keys = set()
101
+ unique_posts = []
102
+
103
+ for post in all_posts:
104
+ key = (post.source, post.id)
105
+ if key not in seen_keys:
106
+ seen_keys.add(key)
107
+ unique_posts.append(post)
108
+
109
+ # Sort by created_at DESC, then by source-specific ID for determinism
110
+ sorted_posts = sorted(
111
+ unique_posts,
112
+ key=lambda x: (x.created_at or UTC_MIN, x.id),
113
+ reverse=True,
114
+ )
115
+
116
+ return CollectorResult(posts=sorted_posts, errors=errors)
@@ -0,0 +1,59 @@
1
+ """Load and validate YAML fetcher configuration into typed fetchkit models."""
2
+
3
+ import yaml
4
+ from pathlib import Path
5
+ from pydantic import ValidationError
6
+ from fetchkit.schemas.config import FetchKitConfig
7
+
8
+
9
+ class ConfigError(Exception):
10
+ """Base exception for configuration loading errors."""
11
+ pass
12
+
13
+
14
+ def load_config(path: str) -> FetchKitConfig:
15
+ """
16
+ Load a FetchKitConfig from a YAML file.
17
+
18
+ The YAML schema is fetchers-only::
19
+
20
+ start_time: "2026-01-31T00:00:00Z"
21
+ end_time: "2026-02-01T00:00:00Z"
22
+ fetchers:
23
+ - type: hackernews
24
+ posts: { max_items: 50, order: new }
25
+ - type: rss
26
+ feeds:
27
+ - url: "https://example.com/rss"
28
+ http: # optional
29
+ timeout: 15
30
+ max_retries: 5
31
+
32
+ Raises:
33
+ ConfigError: If the file is missing, malformed YAML, or fails validation.
34
+ """
35
+ config_path = Path(path).expanduser()
36
+ if not config_path.exists():
37
+ raise ConfigError(f"Config file not found: {path}")
38
+
39
+ try:
40
+ raw_config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
41
+ except yaml.YAMLError as e:
42
+ raise ConfigError(f"Error parsing YAML in {path}: {e}")
43
+ except Exception as e:
44
+ raise ConfigError(f"Error reading config file {path}: {e}")
45
+
46
+ if raw_config is None:
47
+ raw_config = {}
48
+
49
+ try:
50
+ return FetchKitConfig.model_validate(raw_config)
51
+ except ValidationError as e:
52
+ # Format Pydantic errors for better CLI readability
53
+ error_messages = []
54
+ for error in e.errors():
55
+ loc = ".".join(str(part) for part in error["loc"])
56
+ msg = error["msg"]
57
+ error_messages.append(f" - {loc}: {msg}")
58
+ detail = "\n".join(error_messages)
59
+ raise ConfigError(f"Config validation failed for {path}:\n{detail}") from e
@@ -0,0 +1,11 @@
1
+ """Fetcher package exports and builtin fetcher registration side effects."""
2
+
3
+ from .registry import get_fetcher, register_fetcher
4
+ # Import built-in fetchers to ensure they register themselves on import.
5
+ from . import hackernews
6
+ from . import rss
7
+ from . import arxiv
8
+ from . import github
9
+ from . import lobsters
10
+
11
+ __all__ = ["get_fetcher", "register_fetcher"]
@@ -0,0 +1,133 @@
1
+ """
2
+ arXiv fetcher.
3
+
4
+ Fetches papers from the arXiv export API (https://export.arxiv.org/api/query),
5
+ which returns Atom XML — parsed with feedparser, already a fetchkit dependency.
6
+ Multi-valued detail (all authors, categories, DOI, PDF link) is preserved in
7
+ ``Post.metadata``.
8
+ """
9
+
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Optional
13
+
14
+ import feedparser
15
+
16
+ from fetchkit.http import get_default_client
17
+ from fetchkit.schemas.post import Post, Source
18
+ from fetchkit.schemas.fetcher import ArxivFetchConfig, FetcherConfig
19
+ from fetchkit.fetchers.base import FetcherResult
20
+ from fetchkit.fetchers.registry import register_fetcher
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ ARXIV_API_URL = "https://export.arxiv.org/api/query"
25
+ SOURCE_NAME = Source.ARXIV
26
+ DEFAULT_TIMEOUT_S = 30 # arXiv can be slow
27
+
28
+
29
+ def _build_search_query(config: ArxivFetchConfig) -> str:
30
+ """Combine categories and free-text query into an arXiv ``search_query``."""
31
+ clauses: list[str] = []
32
+ if config.categories:
33
+ cats = " OR ".join(f"cat:{c}" for c in config.categories)
34
+ clauses.append(f"({cats})")
35
+ if config.query:
36
+ clauses.append(f"all:{config.query}")
37
+ return " AND ".join(clauses) if clauses else "all:*"
38
+
39
+
40
+ def _parse_published(entry: Any) -> Optional[datetime]:
41
+ """Extract the published date (UTC) from a feedparser entry."""
42
+ for attr in ("published_parsed", "updated_parsed"):
43
+ st = getattr(entry, attr, None)
44
+ if st:
45
+ return datetime(st[0], st[1], st[2], st[3], st[4], st[5], tzinfo=timezone.utc)
46
+ return None
47
+
48
+
49
+ def _pdf_link(entry: Any) -> Optional[str]:
50
+ """Return the PDF link for an entry, if present."""
51
+ for link in getattr(entry, "links", []) or []:
52
+ if getattr(link, "type", None) == "application/pdf" or getattr(link, "title", None) == "pdf":
53
+ return str(getattr(link, "href", "")) or None
54
+ return None
55
+
56
+
57
+ def _normalize_entry(entry: Any) -> Optional[Post]:
58
+ """Convert a feedparser arXiv entry into a canonical Post."""
59
+ created_at = _parse_published(entry)
60
+ abs_url = str(getattr(entry, "id", "")) or None # arXiv abs page
61
+ if not abs_url:
62
+ return None
63
+
64
+ # arXiv id is the trailing path segment of the abs URL (e.g. 2401.12345v1).
65
+ arxiv_id = abs_url.rstrip("/").rsplit("/", 1)[-1]
66
+
67
+ authors = [str(a.get("name")) for a in getattr(entry, "authors", []) if a.get("name")]
68
+ categories = [str(t.get("term")) for t in getattr(entry, "tags", []) if t.get("term")]
69
+
70
+ metadata: dict[str, Any] = {}
71
+ if authors:
72
+ metadata["authors"] = authors
73
+ if categories:
74
+ metadata["categories"] = categories
75
+ if getattr(entry, "arxiv_doi", None):
76
+ metadata["doi"] = str(entry.arxiv_doi)
77
+ pdf = _pdf_link(entry)
78
+ if pdf:
79
+ metadata["pdf_url"] = pdf
80
+ if getattr(entry, "arxiv_primary_category", None):
81
+ primary = entry.arxiv_primary_category.get("term") if isinstance(entry.arxiv_primary_category, dict) else None
82
+ if primary:
83
+ metadata["primary_category"] = str(primary)
84
+
85
+ return Post(
86
+ id=arxiv_id,
87
+ source=SOURCE_NAME,
88
+ title=getattr(entry, "title", None),
89
+ text=getattr(entry, "summary", None),
90
+ url=pdf or abs_url,
91
+ author=", ".join(authors) if authors else None,
92
+ created_at=created_at,
93
+ source_url=abs_url,
94
+ metadata=metadata,
95
+ )
96
+
97
+
98
+ def fetch_posts(config: ArxivFetchConfig) -> list[Post]:
99
+ """Fetch papers from arXiv matching the configured categories/query."""
100
+ params = {
101
+ "search_query": _build_search_query(config),
102
+ "start": 0,
103
+ "max_results": config.max_items,
104
+ "sortBy": "submittedDate",
105
+ "sortOrder": "descending",
106
+ }
107
+ client = get_default_client()
108
+ response = client.get(ARXIV_API_URL, params=params, timeout=DEFAULT_TIMEOUT_S)
109
+ response.raise_for_status()
110
+
111
+ feed = feedparser.parse(response.content)
112
+ posts: list[Post] = []
113
+ for entry in feed.entries:
114
+ post = _normalize_entry(entry)
115
+ if post is None:
116
+ continue
117
+ # Window filtering (datetimes are UTC-normalized by the schema).
118
+ if config.start_time is not None and config.end_time is not None:
119
+ if post.created_at is None or not (config.start_time <= post.created_at <= config.end_time):
120
+ continue
121
+ posts.append(post)
122
+ return posts
123
+
124
+
125
+ @register_fetcher("arxiv")
126
+ def fetch(config: FetcherConfig) -> FetcherResult:
127
+ """Fetcher protocol implementation for arXiv."""
128
+ if not isinstance(config, ArxivFetchConfig):
129
+ raise ValueError(f"Invalid config type for arxiv fetcher: {type(config)}")
130
+ try:
131
+ return FetcherResult(posts=fetch_posts(config), errors=[])
132
+ except Exception as e:
133
+ return FetcherResult(posts=[], errors=[e])
@@ -0,0 +1,31 @@
1
+ """
2
+ Base protocol for data fetchers.
3
+ """
4
+ from typing import Protocol, runtime_checkable
5
+ from dataclasses import dataclass, field
6
+ from fetchkit.schemas.post import Post
7
+ from fetchkit.schemas.fetcher import FetcherConfig
8
+
9
+
10
+ @dataclass
11
+ class FetcherResult:
12
+ """Result from a fetch operation."""
13
+ posts: list[Post] = field(default_factory=list)
14
+ errors: list[Exception] = field(default_factory=list)
15
+
16
+
17
+ @runtime_checkable
18
+ class Fetcher(Protocol):
19
+ """Protocol that all fetchers must implement."""
20
+
21
+ def __call__(self, config: FetcherConfig) -> FetcherResult:
22
+ """
23
+ Execute the fetch operation.
24
+
25
+ Args:
26
+ config: The configuration for this specific fetcher instance.
27
+
28
+ Returns:
29
+ FetcherResult containing fetched posts and any errors.
30
+ """
31
+ ...