mcp-methods 0.1.1__tar.gz
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.
- mcp_methods-0.1.1/.coverage +0 -0
- mcp_methods-0.1.1/.github/workflows/publish.yml +126 -0
- mcp_methods-0.1.1/LICENSE +21 -0
- mcp_methods-0.1.1/PKG-INFO +54 -0
- mcp_methods-0.1.1/README.md +28 -0
- mcp_methods-0.1.1/mcp_methods/__init__.py +27 -0
- mcp_methods-0.1.1/mcp_methods/_utils.py +50 -0
- mcp_methods-0.1.1/mcp_methods/files.py +178 -0
- mcp_methods-0.1.1/mcp_methods/git.py +971 -0
- mcp_methods-0.1.1/pyproject.toml +64 -0
- mcp_methods-0.1.1/tests/__init__.py +0 -0
- mcp_methods-0.1.1/tests/test_files.py +165 -0
- mcp_methods-0.1.1/tests/test_git.py +266 -0
- mcp_methods-0.1.1/tests/test_utils.py +68 -0
|
Binary file
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
name: Build and Publish Python Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
paths:
|
|
6
|
+
- 'pyproject.toml'
|
|
7
|
+
- 'mcp_methods/**'
|
|
8
|
+
workflow_dispatch: # Allow manual triggering
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
version:
|
|
12
|
+
name: Extract version
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
outputs:
|
|
15
|
+
version: ${{ steps.get_version.outputs.version }}
|
|
16
|
+
steps:
|
|
17
|
+
- name: Checkout code
|
|
18
|
+
uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Extract version from pyproject.toml
|
|
21
|
+
id: get_version
|
|
22
|
+
run: |
|
|
23
|
+
VERSION=$(grep -m 1 "version" pyproject.toml | cut -d '"' -f 2)
|
|
24
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
25
|
+
echo "Package version: $VERSION"
|
|
26
|
+
|
|
27
|
+
test:
|
|
28
|
+
name: Test on Python ${{ matrix.python-version }}
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
strategy:
|
|
31
|
+
fail-fast: false
|
|
32
|
+
matrix:
|
|
33
|
+
python-version: ['3.10', '3.11', '3.12', '3.13']
|
|
34
|
+
steps:
|
|
35
|
+
- name: Checkout code
|
|
36
|
+
uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
39
|
+
uses: actions/setup-python@v5
|
|
40
|
+
with:
|
|
41
|
+
python-version: ${{ matrix.python-version }}
|
|
42
|
+
|
|
43
|
+
- name: Install dependencies
|
|
44
|
+
run: |
|
|
45
|
+
python -m pip install --upgrade pip
|
|
46
|
+
pip install -e ".[dev]"
|
|
47
|
+
|
|
48
|
+
- name: Run tests
|
|
49
|
+
run: python -m pytest tests/ -v
|
|
50
|
+
|
|
51
|
+
- name: Test import
|
|
52
|
+
run: |
|
|
53
|
+
python -c "from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache; print('Import successful')"
|
|
54
|
+
|
|
55
|
+
build:
|
|
56
|
+
name: Build distribution
|
|
57
|
+
needs: [version, test]
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- name: Checkout code
|
|
61
|
+
uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- name: Set up Python
|
|
64
|
+
uses: actions/setup-python@v5
|
|
65
|
+
with:
|
|
66
|
+
python-version: '3.12'
|
|
67
|
+
|
|
68
|
+
- name: Install build dependencies
|
|
69
|
+
run: |
|
|
70
|
+
python -m pip install --upgrade pip
|
|
71
|
+
pip install build twine
|
|
72
|
+
|
|
73
|
+
- name: Build package
|
|
74
|
+
run: python -m build
|
|
75
|
+
|
|
76
|
+
- name: Check distribution
|
|
77
|
+
run: twine check dist/*
|
|
78
|
+
|
|
79
|
+
- name: List distribution files
|
|
80
|
+
run: ls -lh dist/
|
|
81
|
+
|
|
82
|
+
- name: Upload distribution as artifact
|
|
83
|
+
uses: actions/upload-artifact@v4
|
|
84
|
+
with:
|
|
85
|
+
name: python-package-distributions
|
|
86
|
+
path: dist/
|
|
87
|
+
|
|
88
|
+
publish:
|
|
89
|
+
name: Publish to PyPI
|
|
90
|
+
needs: [version, build]
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
permissions:
|
|
93
|
+
contents: write
|
|
94
|
+
id-token: write # Required for trusted publishing
|
|
95
|
+
steps:
|
|
96
|
+
- name: Download distributions
|
|
97
|
+
uses: actions/download-artifact@v4
|
|
98
|
+
with:
|
|
99
|
+
name: python-package-distributions
|
|
100
|
+
path: dist/
|
|
101
|
+
|
|
102
|
+
- name: Display structure of downloaded files
|
|
103
|
+
run: ls -lh dist/
|
|
104
|
+
|
|
105
|
+
- name: Publish to PyPI
|
|
106
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
107
|
+
with:
|
|
108
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
109
|
+
skip-existing: true
|
|
110
|
+
# Uncomment to test on TestPyPI first
|
|
111
|
+
# repository-url: https://test.pypi.org/legacy/
|
|
112
|
+
|
|
113
|
+
- name: Checkout code for release
|
|
114
|
+
uses: actions/checkout@v4
|
|
115
|
+
|
|
116
|
+
- name: Create GitHub Release
|
|
117
|
+
uses: softprops/action-gh-release@v2
|
|
118
|
+
with:
|
|
119
|
+
files: dist/*
|
|
120
|
+
generate_release_notes: true
|
|
121
|
+
tag_name: v${{ needs.version.outputs.version }}
|
|
122
|
+
name: Release v${{ needs.version.outputs.version }}
|
|
123
|
+
draft: false
|
|
124
|
+
prerelease: false
|
|
125
|
+
env:
|
|
126
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kristian Kollsgard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-methods
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reusable utility methods for MCP servers
|
|
5
|
+
Author: Kristian Kollsgard
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agent,ai,github,mcp,tools
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# mcp-methods
|
|
28
|
+
|
|
29
|
+
Reusable utility methods for MCP servers. Extracts common patterns from MCP server implementations into a shared, pip-installable library.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache
|
|
41
|
+
|
|
42
|
+
# GitHub API
|
|
43
|
+
result = git_api("pydata/xarray", "pulls?state=open")
|
|
44
|
+
|
|
45
|
+
# Fetch issue/PR with smart compaction
|
|
46
|
+
cache = ElementCache()
|
|
47
|
+
result = git_issue("pydata/xarray", 11124, cache=cache)
|
|
48
|
+
|
|
49
|
+
# Search files
|
|
50
|
+
result = grep_files(["/path/to/source"], "pattern", glob="*.py")
|
|
51
|
+
|
|
52
|
+
# Read file with path traversal protection
|
|
53
|
+
result = read_file("src/main.py", ["/path/to/source"])
|
|
54
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# mcp-methods
|
|
2
|
+
|
|
3
|
+
Reusable utility methods for MCP servers. Extracts common patterns from MCP server implementations into a shared, pip-installable library.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache
|
|
15
|
+
|
|
16
|
+
# GitHub API
|
|
17
|
+
result = git_api("pydata/xarray", "pulls?state=open")
|
|
18
|
+
|
|
19
|
+
# Fetch issue/PR with smart compaction
|
|
20
|
+
cache = ElementCache()
|
|
21
|
+
result = git_issue("pydata/xarray", 11124, cache=cache)
|
|
22
|
+
|
|
23
|
+
# Search files
|
|
24
|
+
result = grep_files(["/path/to/source"], "pattern", glob="*.py")
|
|
25
|
+
|
|
26
|
+
# Read file with path traversal protection
|
|
27
|
+
result = read_file("src/main.py", ["/path/to/source"])
|
|
28
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""mcp-methods: Reusable utility methods for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from mcp_methods._utils import load_env, timed
|
|
4
|
+
from mcp_methods.files import grep_files, read_file
|
|
5
|
+
from mcp_methods.git import (
|
|
6
|
+
ElementCache,
|
|
7
|
+
detect_git_repo,
|
|
8
|
+
extract_github_refs,
|
|
9
|
+
git_api,
|
|
10
|
+
git_issue,
|
|
11
|
+
has_git_token,
|
|
12
|
+
validate_repo,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"timed",
|
|
17
|
+
"load_env",
|
|
18
|
+
"git_api",
|
|
19
|
+
"git_issue",
|
|
20
|
+
"ElementCache",
|
|
21
|
+
"detect_git_repo",
|
|
22
|
+
"validate_repo",
|
|
23
|
+
"has_git_token",
|
|
24
|
+
"extract_github_refs",
|
|
25
|
+
"grep_files",
|
|
26
|
+
"read_file",
|
|
27
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared utilities for MCP method libraries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def timed(func: Callable) -> Callable:
|
|
13
|
+
"""Decorator that appends timing info to string return values.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
@timed
|
|
18
|
+
def my_tool(...) -> str:
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@functools.wraps(func)
|
|
23
|
+
def wrapper(*args, **kwargs):
|
|
24
|
+
t0 = time.perf_counter()
|
|
25
|
+
result = func(*args, **kwargs)
|
|
26
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
27
|
+
return result + f"\n\n⏱ {ms:.0f}ms"
|
|
28
|
+
|
|
29
|
+
return wrapper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_env(env_file: str | Path) -> None:
|
|
33
|
+
"""Load key=value pairs from a .env file into ``os.environ``.
|
|
34
|
+
|
|
35
|
+
- Blank lines and lines starting with ``#`` are skipped.
|
|
36
|
+
- Values may be optionally quoted with single or double quotes.
|
|
37
|
+
- Existing environment variables are **not** overwritten.
|
|
38
|
+
"""
|
|
39
|
+
path = Path(env_file)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return
|
|
42
|
+
for line in path.read_text().splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
45
|
+
continue
|
|
46
|
+
key, _, val = line.partition("=")
|
|
47
|
+
key = key.strip()
|
|
48
|
+
val = val.strip().strip("'\"")
|
|
49
|
+
if key and key not in os.environ:
|
|
50
|
+
os.environ[key] = val
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""File search and reading methods for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Constants
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
DEFAULT_SKIP_DIRS: set[str] = {
|
|
14
|
+
".git",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
".tox",
|
|
18
|
+
".mypy_cache",
|
|
19
|
+
".pytest_cache",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
".eggs",
|
|
23
|
+
"venv",
|
|
24
|
+
".venv",
|
|
25
|
+
"target",
|
|
26
|
+
".cargo",
|
|
27
|
+
".ruff_cache",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Public API
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def grep_files(
|
|
36
|
+
source_dirs: list[str | Path],
|
|
37
|
+
pattern: str,
|
|
38
|
+
*,
|
|
39
|
+
glob: str = "*",
|
|
40
|
+
case_insensitive: bool = False,
|
|
41
|
+
max_results: int = 50,
|
|
42
|
+
skip_dirs: set[str] | None = None,
|
|
43
|
+
relative_to: str | Path | None = None,
|
|
44
|
+
transform: Callable[[str], str] | None = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Search for a text pattern across files in *source_dirs*.
|
|
47
|
+
|
|
48
|
+
*source_dirs*: directories to search (recursively).
|
|
49
|
+
*pattern*: plain text or regex pattern to search for.
|
|
50
|
+
*glob*: file glob to filter (default ``"*"`` for all files).
|
|
51
|
+
*case_insensitive*: set True for case-insensitive matching.
|
|
52
|
+
*max_results*: maximum number of matching lines to return.
|
|
53
|
+
*skip_dirs*: directory names to skip (defaults to :data:`DEFAULT_SKIP_DIRS`).
|
|
54
|
+
*relative_to*: if set, display paths relative to this directory.
|
|
55
|
+
*transform*: optional function to transform file content before searching
|
|
56
|
+
(e.g. HTML-to-text converter).
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
60
|
+
regex = re.compile(pattern, flags)
|
|
61
|
+
except re.error as e:
|
|
62
|
+
return f"Invalid regex pattern: {e}"
|
|
63
|
+
|
|
64
|
+
if skip_dirs is None:
|
|
65
|
+
skip_dirs = DEFAULT_SKIP_DIRS
|
|
66
|
+
|
|
67
|
+
rel_base = Path(relative_to) if relative_to else None
|
|
68
|
+
|
|
69
|
+
matches: list[str] = []
|
|
70
|
+
for source_dir in source_dirs:
|
|
71
|
+
source_dir = Path(source_dir)
|
|
72
|
+
if not source_dir.is_dir():
|
|
73
|
+
continue
|
|
74
|
+
for path in sorted(source_dir.rglob(glob)):
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
continue
|
|
77
|
+
if any(part in skip_dirs for part in path.parts):
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
if rel_base:
|
|
81
|
+
rel = str(path.relative_to(rel_base))
|
|
82
|
+
else:
|
|
83
|
+
rel = str(path.relative_to(source_dir))
|
|
84
|
+
text = path.read_text(encoding="utf-8")
|
|
85
|
+
if transform:
|
|
86
|
+
text = transform(text)
|
|
87
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
88
|
+
if regex.search(line):
|
|
89
|
+
matches.append(f" {rel}:{i} {line.rstrip()}")
|
|
90
|
+
if len(matches) >= max_results:
|
|
91
|
+
break
|
|
92
|
+
except (UnicodeDecodeError, PermissionError):
|
|
93
|
+
continue
|
|
94
|
+
if len(matches) >= max_results:
|
|
95
|
+
break
|
|
96
|
+
if len(matches) >= max_results:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if not matches:
|
|
100
|
+
return f"No matches for '{pattern}' in {glob} files."
|
|
101
|
+
header = f"Found {len(matches)} match(es) for '{pattern}'"
|
|
102
|
+
if len(matches) >= max_results:
|
|
103
|
+
header += f" (capped at {max_results})"
|
|
104
|
+
return header + ":\n" + "\n".join(matches)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_file(
|
|
108
|
+
file_path: str,
|
|
109
|
+
allowed_dirs: list[str | Path],
|
|
110
|
+
*,
|
|
111
|
+
start_line: int | None = None,
|
|
112
|
+
end_line: int | None = None,
|
|
113
|
+
max_chars: int | None = None,
|
|
114
|
+
transform: Callable[[str], str] | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Read a file with path-traversal protection.
|
|
117
|
+
|
|
118
|
+
*file_path*: relative path resolved against each directory in *allowed_dirs*.
|
|
119
|
+
*allowed_dirs*: directories in which the file must reside (path traversal guard).
|
|
120
|
+
*start_line*: first line to include (1-indexed).
|
|
121
|
+
*end_line*: last line to include (1-indexed, inclusive).
|
|
122
|
+
*max_chars*: truncate the result to this many characters.
|
|
123
|
+
*transform*: optional function to transform file content before returning
|
|
124
|
+
(e.g. HTML-to-text converter).
|
|
125
|
+
"""
|
|
126
|
+
# Resolve file against allowed directories
|
|
127
|
+
resolved: Path | None = None
|
|
128
|
+
base_dir: Path | None = None
|
|
129
|
+
for d in allowed_dirs:
|
|
130
|
+
d = Path(d)
|
|
131
|
+
candidate = (d / file_path).resolve()
|
|
132
|
+
if candidate.is_relative_to(d.resolve()) and candidate.exists():
|
|
133
|
+
resolved = candidate
|
|
134
|
+
base_dir = d
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if resolved is None:
|
|
138
|
+
# Check if it's an absolute path inside an allowed dir
|
|
139
|
+
abs_path = Path(file_path).resolve()
|
|
140
|
+
for d in allowed_dirs:
|
|
141
|
+
d = Path(d)
|
|
142
|
+
if abs_path.is_relative_to(d.resolve()) and abs_path.exists():
|
|
143
|
+
resolved = abs_path
|
|
144
|
+
base_dir = d
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
if resolved is None:
|
|
148
|
+
return f"Error: file not found or access denied: {file_path}"
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
raw = resolved.read_text(encoding="utf-8")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return f"Error reading file: {e}"
|
|
154
|
+
|
|
155
|
+
# Apply transform (e.g. HTML to text)
|
|
156
|
+
if transform:
|
|
157
|
+
raw = transform(raw)
|
|
158
|
+
|
|
159
|
+
all_lines = raw.splitlines()
|
|
160
|
+
total = len(all_lines)
|
|
161
|
+
|
|
162
|
+
if start_line is not None or end_line is not None:
|
|
163
|
+
s = max(1, start_line or 1)
|
|
164
|
+
e = min(total, end_line or total)
|
|
165
|
+
selected = all_lines[s - 1 : e]
|
|
166
|
+
numbered = [f"{i:>5} {line}" for i, line in enumerate(selected, start=s)]
|
|
167
|
+
header = f"{file_path}:{s}-{e} ({e - s + 1} of {total} lines)"
|
|
168
|
+
else:
|
|
169
|
+
selected = all_lines
|
|
170
|
+
numbered = [f"{i:>5} {line}" for i, line in enumerate(selected, start=1)]
|
|
171
|
+
header = f"{file_path} ({total} lines)"
|
|
172
|
+
|
|
173
|
+
text = header + "\n" + "\n".join(numbered)
|
|
174
|
+
|
|
175
|
+
if max_chars and len(text) > max_chars:
|
|
176
|
+
text = text[:max_chars] + f"\n\n[... truncated at {max_chars} chars — {len(raw)} total]"
|
|
177
|
+
|
|
178
|
+
return text
|