llama-github 0.4.1__tar.gz → 0.4.2__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.
Files changed (49) hide show
  1. {llama_github-0.4.1 → llama_github-0.4.2}/CHANGELOG.md +12 -0
  2. {llama_github-0.4.1/llama_github.egg-info → llama_github-0.4.2}/PKG-INFO +5 -4
  3. {llama_github-0.4.1 → llama_github-0.4.2}/README.md +3 -2
  4. llama_github-0.4.2/llama_github/data_retrieval/__init__.py +19 -0
  5. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/data_retrieval/github_entities.py +332 -1
  6. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/github_integration/github_auth_manager.py +98 -0
  7. llama_github-0.4.2/llama_github/version.py +1 -0
  8. {llama_github-0.4.1 → llama_github-0.4.2/llama_github.egg-info}/PKG-INFO +5 -4
  9. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github.egg-info/requires.txt +1 -1
  10. {llama_github-0.4.1 → llama_github-0.4.2}/pyproject.toml +2 -2
  11. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_data_retrieval.py +194 -1
  12. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_distribution_contents.py +3 -3
  13. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_github_auth_manager.py +61 -0
  14. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_minimal_imports.py +4 -2
  15. llama_github-0.4.1/llama_github/rag_processing/__init__.py +0 -0
  16. llama_github-0.4.1/llama_github/version.py +0 -1
  17. {llama_github-0.4.1 → llama_github-0.4.2}/LICENSE +0 -0
  18. {llama_github-0.4.1 → llama_github-0.4.2}/MANIFEST.in +0 -0
  19. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/__init__.py +0 -0
  20. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/config/__init__.py +0 -0
  21. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/config/config.json +0 -0
  22. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/config/config.py +0 -0
  23. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/data_retrieval/github_api.py +0 -0
  24. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/github_integration/__init__.py +0 -0
  25. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/github_rag.py +0 -0
  26. {llama_github-0.4.1/llama_github/data_retrieval → llama_github-0.4.2/llama_github/llm_integration}/__init__.py +0 -0
  27. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/llm_integration/initial_load.py +0 -0
  28. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/llm_integration/llm_handler.py +0 -0
  29. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/logger.py +0 -0
  30. {llama_github-0.4.1/llama_github/llm_integration → llama_github-0.4.2/llama_github/rag_processing}/__init__.py +0 -0
  31. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/rag_processing/rag_processor.py +0 -0
  32. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github/utils.py +0 -0
  33. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github.egg-info/SOURCES.txt +0 -0
  34. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github.egg-info/dependency_links.txt +0 -0
  35. {llama_github-0.4.1 → llama_github-0.4.2}/llama_github.egg-info/top_level.txt +0 -0
  36. {llama_github-0.4.1 → llama_github-0.4.2}/pytest.ini +0 -0
  37. {llama_github-0.4.1 → llama_github-0.4.2}/scripts/__init__.py +0 -0
  38. {llama_github-0.4.1 → llama_github-0.4.2}/scripts/verify_distribution_contents.py +0 -0
  39. {llama_github-0.4.1 → llama_github-0.4.2}/setup.cfg +0 -0
  40. {llama_github-0.4.1 → llama_github-0.4.2}/setup.py +0 -0
  41. {llama_github-0.4.1 → llama_github-0.4.2}/tests/__init__.py +0 -0
  42. {llama_github-0.4.1 → llama_github-0.4.2}/tests/conftest.py +0 -0
  43. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_github_rag.py +0 -0
  44. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_initial_load.py +0 -0
  45. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_llm_handler.py +0 -0
  46. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_logger.py +0 -0
  47. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_logging_privacy.py +0 -0
  48. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_rag_processor.py +0 -0
  49. {llama_github-0.4.1 → llama_github-0.4.2}/tests/test_utils.py +0 -0
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.2] - 2026-07-15
9
+
10
+ ### Added
11
+ - Added a typed, fixed-cap repository text-read API with distinct success, policy, missing, oversize, binary, directory, and transport outcomes
12
+ - Added explicit `dependency_lock` and `ci_config` opt-ins without changing the legacy `get_file_content()` exclusion or cache contract
13
+
14
+ ### Security
15
+ - Enforced the 2 MiB product cap both from GitHub size metadata and while streaming raw bytes
16
+ - Kept binary, generated, minified, and sensitive paths outside the new opt-in surface
17
+ - Raised the tested `setuptools` and `transformers` lower bounds to versions that clear the current dependency-audit security gate
18
+
8
19
  ## [0.4.1] - 2026-07-12
9
20
 
10
21
  ### Added
@@ -181,6 +192,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
181
192
  - Integration with LLM for processing and generating responses
182
193
 
183
194
  [0.1.4]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.3...v0.1.4
195
+ [0.4.2]: https://github.com/JetXu-LLM/llama-github/compare/v0.4.1...v0.4.2
184
196
  [0.4.1]: https://github.com/JetXu-LLM/llama-github/compare/v0.4.0...v0.4.1
185
197
  [0.1.3]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.2...v0.1.3
186
198
  [0.1.2]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.1...v0.1.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llama-github
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: GitHub retrieval and context generation utilities for coding assistants and AI developer workflows.
5
5
  Author-email: Jet Xu <Voldemort.xu@foxmail.com>
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Requires-Dist: pydantic<3,>=2.10
31
31
  Requires-Dist: python-dateutil<3,>=2.9
32
32
  Requires-Dist: requests<3,>=2.33
33
33
  Requires-Dist: tokenizers<1,>=0.22.1
34
- Requires-Dist: transformers<6,>=5.3
34
+ Requires-Dist: transformers<6,>=5.5
35
35
  Requires-Dist: urllib3<3,>=2.7
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: build<2,>=1.2; extra == "dev"
@@ -61,13 +61,14 @@ pip install llama-github
61
61
 
62
62
  Current maintained runtime target: Python `3.10` through `3.14`.
63
63
 
64
- Version `0.4.1` keeps the existing high-level API while allowing retrieval-only
64
+ Version `0.4.2` keeps the existing high-level API while allowing retrieval-only
65
65
  integrations to import GitHub and diff helpers without loading the ML/RAG dependency
66
66
  stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
67
67
  models should provide their own immutable revision. Pull-request retrieval now keeps
68
68
  bounded fetch-health metadata, expands related issues only from PR conversation
69
69
  evidence, and preserves review summaries plus each inline comment without collapsing
70
- them together.
70
+ them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
71
+ lockfile or CI-config reads without weakening the generic file policy.
71
72
 
72
73
  ## Usage
73
74
 
@@ -18,13 +18,14 @@ pip install llama-github
18
18
 
19
19
  Current maintained runtime target: Python `3.10` through `3.14`.
20
20
 
21
- Version `0.4.1` keeps the existing high-level API while allowing retrieval-only
21
+ Version `0.4.2` keeps the existing high-level API while allowing retrieval-only
22
22
  integrations to import GitHub and diff helpers without loading the ML/RAG dependency
23
23
  stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
24
24
  models should provide their own immutable revision. Pull-request retrieval now keeps
25
25
  bounded fetch-health metadata, expands related issues only from PR conversation
26
26
  evidence, and preserves review summaries plus each inline comment without collapsing
27
- them together.
27
+ them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
28
+ lockfile or CI-config reads without weakening the generic file policy.
28
29
 
29
30
  ## Usage
30
31
 
@@ -0,0 +1,19 @@
1
+ """Typed retrieval primitives exposed by llama-github."""
2
+
3
+ from .github_entities import (
4
+ BoundedTextReadOptIn,
5
+ BoundedTextReadOutcome,
6
+ BoundedTextReadResult,
7
+ CIStatusSnapshot,
8
+ Repository,
9
+ RepositoryPool,
10
+ )
11
+
12
+ __all__ = [
13
+ "BoundedTextReadOptIn",
14
+ "BoundedTextReadOutcome",
15
+ "BoundedTextReadResult",
16
+ "CIStatusSnapshot",
17
+ "Repository",
18
+ "RepositoryPool",
19
+ ]
@@ -2,6 +2,7 @@ from github import GithubException
2
2
  from threading import Lock, Event, Thread
3
3
  from datetime import datetime, timezone
4
4
  from dataclasses import dataclass, field
5
+ from enum import Enum
5
6
 
6
7
  from typing import Optional, Dict, Any, List, Callable
7
8
  from llama_github.logger import logger
@@ -18,10 +19,143 @@ import requests
18
19
  from llama_github.config.config import config
19
20
  from llama_github.utils import DiffGenerator, CodeAnalyzer
20
21
 
21
- __all__ = ["CIStatusSnapshot", "Repository", "RepositoryPool"]
22
+ __all__ = [
23
+ "BoundedTextReadOptIn",
24
+ "BoundedTextReadOutcome",
25
+ "BoundedTextReadResult",
26
+ "CIStatusSnapshot",
27
+ "Repository",
28
+ "RepositoryPool",
29
+ ]
22
30
 
23
31
 
24
32
  DEFAULT_RELATED_ISSUE_MAX_HITS = 20
33
+ BOUNDED_TEXT_SOURCE_MAX_BYTES = 2 * 1024 * 1024
34
+
35
+
36
+ class BoundedTextReadOutcome(str, Enum):
37
+ """Truthful terminal outcome for one bounded repository text read."""
38
+
39
+ SUCCESS = "success"
40
+ EXCLUDED_BY_POLICY = "excluded_by_policy"
41
+ NOT_FOUND = "not_found"
42
+ OVERSIZE = "oversize"
43
+ BINARY_OR_NON_UTF8 = "binary_or_non_utf8"
44
+ DIRECTORY = "directory"
45
+ ERROR = "error"
46
+
47
+
48
+ class BoundedTextReadOptIn(str, Enum):
49
+ """Narrow high-intent exceptions to the default repository-read policy."""
50
+
51
+ DEPENDENCY_LOCK = "dependency_lock"
52
+ CI_CONFIG = "ci_config"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class BoundedTextReadResult:
57
+ """Text plus bounded, JSON-safe retrieval metadata."""
58
+
59
+ outcome: BoundedTextReadOutcome
60
+ content: Optional[str] = None
61
+ source_size_bytes: Optional[int] = None
62
+ bytes_read: int = 0
63
+ max_bytes: int = BOUNDED_TEXT_SOURCE_MAX_BYTES
64
+ policy_class: Optional[str] = None
65
+ status_code: Optional[int] = None
66
+ error_type: Optional[str] = None
67
+
68
+ def to_meta(self) -> dict:
69
+ """Return metadata only; never include repository text."""
70
+ return {
71
+ "outcome": self.outcome.value,
72
+ "source_size_bytes": self.source_size_bytes,
73
+ "bytes_read": self.bytes_read,
74
+ "max_bytes": self.max_bytes,
75
+ "policy_class": self.policy_class,
76
+ "status_code": self.status_code,
77
+ "error_type": self.error_type,
78
+ }
79
+
80
+
81
+ def _normalized_repo_path(file_path: str) -> str:
82
+ return "/" + str(file_path or "").replace("\\", "/").lstrip("/")
83
+
84
+
85
+ def _bounded_text_policy_class(file_path: str) -> Optional[str]:
86
+ """Classify paths that the bounded API may not read by default."""
87
+ normalized = _normalized_repo_path(file_path)
88
+ lowered = normalized.casefold()
89
+ basename = lowered.rsplit("/", 1)[-1]
90
+
91
+ if (
92
+ basename == ".env"
93
+ or (basename.startswith(".env.") and basename not in {".env.example", ".env.sample"})
94
+ or basename in {"id_rsa", "id_ed25519", "credentials", "secrets.yml", "secrets.yaml"}
95
+ or any(part in lowered for part in ("/.git/", "/.aws/", "/.ssh/"))
96
+ ):
97
+ return "sensitive"
98
+
99
+ if (
100
+ basename.endswith(".lock")
101
+ or basename.endswith(".lock.json")
102
+ or basename in {
103
+ "package-lock.json",
104
+ "npm-shrinkwrap.json",
105
+ "pnpm-lock.yaml",
106
+ "pnpm-lock.yml",
107
+ "yarn.lock",
108
+ "uv.lock",
109
+ }
110
+ ):
111
+ return BoundedTextReadOptIn.DEPENDENCY_LOCK.value
112
+
113
+ if (
114
+ lowered.startswith("/.github/workflows/")
115
+ or lowered.startswith("/.gitlab/ci/")
116
+ or lowered.startswith("/.circleci/")
117
+ or lowered.startswith("/.buildkite/")
118
+ or basename
119
+ in {
120
+ ".gitlab-ci.yml",
121
+ ".gitlab-ci.yaml",
122
+ ".travis.yml",
123
+ ".travis.yaml",
124
+ "azure-pipelines.yml",
125
+ "azure-pipelines.yaml",
126
+ "bitbucket-pipelines.yml",
127
+ "bitbucket-pipelines.yaml",
128
+ "jenkinsfile",
129
+ }
130
+ ):
131
+ return BoundedTextReadOptIn.CI_CONFIG.value
132
+
133
+ if any(
134
+ basename.endswith(suffix)
135
+ for suffix in (
136
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".obj", ".o", ".a",
137
+ ".lib", ".jar", ".war", ".ear", ".class", ".apk", ".wasm",
138
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".bmp", ".webp",
139
+ ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv", ".webm",
140
+ ".ttf", ".otf", ".woff", ".woff2", ".zip", ".rar", ".7z",
141
+ ".tar", ".gz", ".bz2", ".xz", ".pkl", ".pickle", ".npy",
142
+ ".npz", ".h5", ".hdf5", ".pyc", ".pyo", ".min.js", ".min.css",
143
+ ".bundle.js", ".bundle.css", ".map", ".pbix", ".pbit", ".abf",
144
+ )
145
+ ):
146
+ return "binary_generated_or_minified"
147
+
148
+ if any(
149
+ segment in lowered
150
+ for segment in (
151
+ "/node_modules/", "/bower_components/", "/__pycache__/", "/.git/",
152
+ "/coverage/", "/.pytest_cache/", "/.tox/", "/venv/",
153
+ "/.virtualenv/", "/.cache/", "/.dart_tool/", "/.next/",
154
+ "/.nuxt/", "/.ipynb_checkpoints/",
155
+ )
156
+ ):
157
+ return "binary_generated_or_minified"
158
+ return None
25
159
 
26
160
 
27
161
  @dataclass(frozen=True)
@@ -114,6 +248,7 @@ class Repository:
114
248
 
115
249
  self._structure = None # Singleton pattern for repository structure
116
250
  self._file_contents = {} # Singleton pattern for file contents
251
+ self._bounded_text_reads = {}
117
252
  self._issues = {} # Singleton pattern for file contents
118
253
  self._readme = None # Singleton pattern for README content
119
254
  self._prs = {} # Singleton pattern for PR content
@@ -604,6 +739,201 @@ class Repository:
604
739
  self.update_last_read_time()
605
740
  return self._file_contents.get(file_key)
606
741
 
742
+ def read_text_file_bounded(
743
+ self,
744
+ file_path: str,
745
+ sha: Optional[str] = None,
746
+ *,
747
+ opt_in: Optional[BoundedTextReadOptIn] = None,
748
+ ) -> BoundedTextReadResult:
749
+ """Read one UTF-8 file under a fixed 2 MiB source cap.
750
+
751
+ Dependency lockfiles and CI configuration require an exact, typed
752
+ ``opt_in``. Other binary, generated, minified, or sensitive paths stay
753
+ excluded even when an opt-in is supplied. The legacy
754
+ :meth:`get_file_content` contract and cache are intentionally separate.
755
+ """
756
+ requested_opt_in = None
757
+ if opt_in is not None:
758
+ requested_opt_in = BoundedTextReadOptIn(opt_in).value
759
+
760
+ policy_class = _bounded_text_policy_class(file_path)
761
+ allowed_exception = policy_class in {
762
+ BoundedTextReadOptIn.DEPENDENCY_LOCK.value,
763
+ BoundedTextReadOptIn.CI_CONFIG.value,
764
+ }
765
+ if (
766
+ (allowed_exception and requested_opt_in != policy_class)
767
+ or (policy_class is not None and not allowed_exception)
768
+ or (policy_class is None and requested_opt_in is not None)
769
+ ):
770
+ return BoundedTextReadResult(
771
+ outcome=BoundedTextReadOutcome.EXCLUDED_BY_POLICY,
772
+ policy_class=policy_class or "opt_in_path_mismatch",
773
+ )
774
+
775
+ cache_key = (str(file_path), sha, requested_opt_in)
776
+ cached = self._bounded_text_reads.get(cache_key)
777
+ if cached is not None:
778
+ return cached
779
+
780
+ try:
781
+ repo = self.repo
782
+ if repo is None:
783
+ return BoundedTextReadResult(
784
+ outcome=BoundedTextReadOutcome.ERROR,
785
+ policy_class=policy_class,
786
+ error_type="repository_unavailable",
787
+ )
788
+ file_content = (
789
+ repo.get_contents(file_path, ref=sha)
790
+ if sha is not None
791
+ else repo.get_contents(file_path)
792
+ )
793
+ except GithubException as exc:
794
+ status_code = getattr(exc, "status", None)
795
+ return BoundedTextReadResult(
796
+ outcome=(
797
+ BoundedTextReadOutcome.NOT_FOUND
798
+ if status_code == 404
799
+ else BoundedTextReadOutcome.ERROR
800
+ ),
801
+ policy_class=policy_class,
802
+ status_code=status_code,
803
+ error_type=(
804
+ "http_404"
805
+ if status_code == 404
806
+ else type(exc).__name__
807
+ ),
808
+ )
809
+ except Exception as exc:
810
+ logger.warning(
811
+ "Bounded text read failed operation=get_contents error_type=%s",
812
+ type(exc).__name__,
813
+ )
814
+ return BoundedTextReadResult(
815
+ outcome=BoundedTextReadOutcome.ERROR,
816
+ policy_class=policy_class,
817
+ error_type=type(exc).__name__,
818
+ )
819
+
820
+ try:
821
+ is_directory = (
822
+ isinstance(file_content, list)
823
+ or getattr(file_content, "type", None) == "dir"
824
+ )
825
+ raw_size = getattr(file_content, "size", None)
826
+ except Exception as exc:
827
+ return BoundedTextReadResult(
828
+ outcome=BoundedTextReadOutcome.ERROR,
829
+ policy_class=policy_class,
830
+ error_type=type(exc).__name__,
831
+ )
832
+ if is_directory:
833
+ return BoundedTextReadResult(
834
+ outcome=BoundedTextReadOutcome.DIRECTORY,
835
+ policy_class=policy_class,
836
+ )
837
+
838
+ source_size = (
839
+ raw_size
840
+ if isinstance(raw_size, int) and not isinstance(raw_size, bool) and raw_size >= 0
841
+ else None
842
+ )
843
+ if source_size is not None and source_size > BOUNDED_TEXT_SOURCE_MAX_BYTES:
844
+ return BoundedTextReadResult(
845
+ outcome=BoundedTextReadOutcome.OVERSIZE,
846
+ source_size_bytes=source_size,
847
+ max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
848
+ policy_class=policy_class,
849
+ )
850
+
851
+ try:
852
+ encoding = getattr(file_content, "encoding", None)
853
+ if encoding == "base64":
854
+ encoded = file_content.content
855
+ if not isinstance(encoded, str):
856
+ raise ValueError("base64 content is not text")
857
+ raw_bytes = base64.b64decode(encoded)
858
+ bytes_read = len(raw_bytes)
859
+ status_code = 200
860
+ else:
861
+ raw_url = getattr(file_content, "url", None)
862
+ if not isinstance(raw_url, str) or not raw_url:
863
+ raw_url = getattr(file_content, "download_url", None)
864
+ bounded_request = getattr(self._github, "_request_bounded_bytes", None)
865
+ if not isinstance(raw_url, str) or not raw_url or not callable(bounded_request):
866
+ return BoundedTextReadResult(
867
+ outcome=BoundedTextReadOutcome.ERROR,
868
+ source_size_bytes=source_size,
869
+ policy_class=policy_class,
870
+ error_type="raw_transport_unavailable",
871
+ )
872
+ response = bounded_request(
873
+ raw_url,
874
+ max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
875
+ operation="bounded_text_read",
876
+ )
877
+ if response.oversize:
878
+ declared_size = response.declared_size_bytes
879
+ return BoundedTextReadResult(
880
+ outcome=BoundedTextReadOutcome.OVERSIZE,
881
+ source_size_bytes=source_size or declared_size,
882
+ bytes_read=response.bytes_read,
883
+ policy_class=policy_class,
884
+ status_code=response.status_code,
885
+ )
886
+ if response.error_type is not None or response.data is None:
887
+ return BoundedTextReadResult(
888
+ outcome=(
889
+ BoundedTextReadOutcome.NOT_FOUND
890
+ if response.status_code == 404
891
+ else BoundedTextReadOutcome.ERROR
892
+ ),
893
+ source_size_bytes=source_size,
894
+ bytes_read=response.bytes_read,
895
+ policy_class=policy_class,
896
+ status_code=response.status_code,
897
+ error_type=response.error_type or "empty_raw_response",
898
+ )
899
+ raw_bytes = response.data
900
+ bytes_read = response.bytes_read
901
+ status_code = response.status_code
902
+ if source_size is None:
903
+ source_size = response.declared_size_bytes
904
+
905
+ if len(raw_bytes) > BOUNDED_TEXT_SOURCE_MAX_BYTES:
906
+ return BoundedTextReadResult(
907
+ outcome=BoundedTextReadOutcome.OVERSIZE,
908
+ source_size_bytes=source_size or len(raw_bytes),
909
+ bytes_read=len(raw_bytes),
910
+ policy_class=policy_class,
911
+ status_code=status_code,
912
+ )
913
+ if b"\x00" in raw_bytes:
914
+ raise UnicodeDecodeError("utf-8", raw_bytes, 0, 1, "NUL byte")
915
+ decoded = raw_bytes.decode("utf-8")
916
+ except (UnicodeDecodeError, ValueError, TypeError):
917
+ return BoundedTextReadResult(
918
+ outcome=BoundedTextReadOutcome.BINARY_OR_NON_UTF8,
919
+ source_size_bytes=source_size,
920
+ policy_class=policy_class,
921
+ )
922
+
923
+ result = BoundedTextReadResult(
924
+ outcome=BoundedTextReadOutcome.SUCCESS,
925
+ content=decoded,
926
+ source_size_bytes=source_size or len(raw_bytes),
927
+ bytes_read=bytes_read,
928
+ policy_class=policy_class,
929
+ status_code=status_code,
930
+ )
931
+ with self._file_contents_lock:
932
+ self._bounded_text_reads.setdefault(cache_key, result)
933
+ result = self._bounded_text_reads[cache_key]
934
+ self.update_last_read_time()
935
+ return result
936
+
607
937
 
608
938
  def get_issue_content(self, number, issue=None) -> str:
609
939
  """
@@ -1338,6 +1668,7 @@ class Repository:
1338
1668
  self._structure = None # Reset the repository structure cache
1339
1669
  with self._file_contents_lock:
1340
1670
  self._file_contents = {} # Reset the file contents cache
1671
+ self._bounded_text_reads = {}
1341
1672
  with self._issue_lock:
1342
1673
  self._issues = {} # Reset the issue contents cache
1343
1674
  with self._readme_lock:
@@ -54,6 +54,18 @@ class _JSONResponse:
54
54
  error_type: Optional[str] = None
55
55
 
56
56
 
57
+ @dataclass(frozen=True)
58
+ class _BoundedBytesResponse:
59
+ """Private transport result for a size-capped raw GitHub response."""
60
+
61
+ data: Optional[bytes] = None
62
+ bytes_read: int = 0
63
+ declared_size_bytes: Optional[int] = None
64
+ status_code: Optional[int] = None
65
+ error_type: Optional[str] = None
66
+ oversize: bool = False
67
+
68
+
57
69
  class GitHubAuthManager:
58
70
  """Manage personal-token and GitHub-App authentication for `ExtendedGithub` clients."""
59
71
 
@@ -243,6 +255,92 @@ class ExtendedGithub(Github):
243
255
  """Compatibility helper returning decoded JSON or ``None`` on failure."""
244
256
  return self._request_json_response(url, params).data
245
257
 
258
+ def _request_bounded_bytes(
259
+ self,
260
+ url: str,
261
+ *,
262
+ max_bytes: int,
263
+ operation: str = "github_raw_get",
264
+ ) -> _BoundedBytesResponse:
265
+ """Download at most ``max_bytes`` while retaining typed failure metadata."""
266
+ if max_bytes <= 0:
267
+ raise ValueError("max_bytes must be positive")
268
+
269
+ self._ensure_fresh_token()
270
+ session = self._build_session()
271
+ headers = {
272
+ "Accept": "application/vnd.github.raw+json",
273
+ "X-GitHub-Api-Version": "2022-11-28",
274
+ }
275
+ if self._access_token:
276
+ headers["Authorization"] = f"Bearer {self._access_token}"
277
+
278
+ try:
279
+ response = session.get(
280
+ url,
281
+ headers=headers,
282
+ timeout=(self._connect_timeout, self._read_timeout),
283
+ stream=True,
284
+ )
285
+ response.raise_for_status()
286
+
287
+ declared_size = None
288
+ content_length = response.headers.get("Content-Length")
289
+ if content_length is not None:
290
+ try:
291
+ parsed_length = int(content_length)
292
+ if parsed_length >= 0:
293
+ declared_size = parsed_length
294
+ except (TypeError, ValueError):
295
+ declared_size = None
296
+ if declared_size is not None and declared_size > max_bytes:
297
+ return _BoundedBytesResponse(
298
+ declared_size_bytes=declared_size,
299
+ status_code=response.status_code,
300
+ oversize=True,
301
+ )
302
+
303
+ chunks = []
304
+ bytes_read = 0
305
+ for chunk in response.iter_content(chunk_size=64 * 1024):
306
+ if not chunk:
307
+ continue
308
+ bytes_read += len(chunk)
309
+ if bytes_read > max_bytes:
310
+ return _BoundedBytesResponse(
311
+ bytes_read=bytes_read,
312
+ declared_size_bytes=declared_size,
313
+ status_code=response.status_code,
314
+ oversize=True,
315
+ )
316
+ chunks.append(chunk)
317
+ return _BoundedBytesResponse(
318
+ data=b"".join(chunks),
319
+ bytes_read=bytes_read,
320
+ declared_size_bytes=declared_size,
321
+ status_code=response.status_code,
322
+ )
323
+ except HTTPError as exc:
324
+ status_code = getattr(exc.response, "status_code", None)
325
+ logger.warning(
326
+ "GitHub request failed operation=%s error_type=http_error status_code=%s",
327
+ operation,
328
+ status_code,
329
+ )
330
+ return _BoundedBytesResponse(
331
+ status_code=status_code,
332
+ error_type=f"http_{status_code}" if status_code else "http_error",
333
+ )
334
+ except RequestException as exc:
335
+ logger.warning(
336
+ "GitHub request failed operation=%s error_type=%s",
337
+ operation,
338
+ type(exc).__name__,
339
+ )
340
+ return _BoundedBytesResponse(error_type=type(exc).__name__)
341
+ finally:
342
+ session.close()
343
+
246
344
  def _request_paginated_list(
247
345
  self,
248
346
  url: str,
@@ -0,0 +1 @@
1
+ __version__ = "0.4.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llama-github
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: GitHub retrieval and context generation utilities for coding assistants and AI developer workflows.
5
5
  Author-email: Jet Xu <Voldemort.xu@foxmail.com>
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Requires-Dist: pydantic<3,>=2.10
31
31
  Requires-Dist: python-dateutil<3,>=2.9
32
32
  Requires-Dist: requests<3,>=2.33
33
33
  Requires-Dist: tokenizers<1,>=0.22.1
34
- Requires-Dist: transformers<6,>=5.3
34
+ Requires-Dist: transformers<6,>=5.5
35
35
  Requires-Dist: urllib3<3,>=2.7
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: build<2,>=1.2; extra == "dev"
@@ -61,13 +61,14 @@ pip install llama-github
61
61
 
62
62
  Current maintained runtime target: Python `3.10` through `3.14`.
63
63
 
64
- Version `0.4.1` keeps the existing high-level API while allowing retrieval-only
64
+ Version `0.4.2` keeps the existing high-level API while allowing retrieval-only
65
65
  integrations to import GitHub and diff helpers without loading the ML/RAG dependency
66
66
  stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
67
67
  models should provide their own immutable revision. Pull-request retrieval now keeps
68
68
  bounded fetch-health metadata, expands related issues only from PR conversation
69
69
  evidence, and preserves review summaries plus each inline comment without collapsing
70
- them together.
70
+ them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
71
+ lockfile or CI-config reads without weakening the generic file policy.
71
72
 
72
73
  ## Usage
73
74
 
@@ -12,7 +12,7 @@ pydantic<3,>=2.10
12
12
  python-dateutil<3,>=2.9
13
13
  requests<3,>=2.33
14
14
  tokenizers<1,>=0.22.1
15
- transformers<6,>=5.3
15
+ transformers<6,>=5.5
16
16
  urllib3<3,>=2.7
17
17
 
18
18
  [dev]
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=77.0.3", "wheel"]
2
+ requires = ["setuptools>=83.0.0", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
@@ -36,7 +36,7 @@ dependencies = [
36
36
  "python-dateutil>=2.9,<3",
37
37
  "requests>=2.33,<3",
38
38
  "tokenizers>=0.22.1,<1",
39
- "transformers>=5.3,<6",
39
+ "transformers>=5.5,<6",
40
40
  "urllib3>=2.7,<3",
41
41
  ]
42
42
 
@@ -1,11 +1,19 @@
1
+ import base64
1
2
  from datetime import datetime, timezone
3
+ from types import SimpleNamespace
2
4
  from unittest.mock import MagicMock, call, patch
3
5
 
4
6
  import pytest
5
7
  from github import GithubException
6
8
 
7
9
  from llama_github.data_retrieval.github_api import GitHubAPIHandler
8
- from llama_github.data_retrieval.github_entities import Repository, RepositoryPool
10
+ from llama_github.data_retrieval.github_entities import (
11
+ BOUNDED_TEXT_SOURCE_MAX_BYTES,
12
+ BoundedTextReadOptIn,
13
+ BoundedTextReadOutcome,
14
+ Repository,
15
+ RepositoryPool,
16
+ )
9
17
  from llama_github.github_integration.github_auth_manager import (
10
18
  RetrievalOutcome,
11
19
  RetrievalResult,
@@ -48,6 +56,191 @@ class TestRepository:
48
56
 
49
57
  assert content == 'print("hello")'
50
58
 
59
+ def test_bounded_text_read_preserves_legacy_lockfile_exclusion(
60
+ self, mock_github_instance, mock_repo_object
61
+ ):
62
+ mock_github_instance.get_repo.return_value = mock_repo_object
63
+ repo = Repository("owner/test-repo", mock_github_instance)
64
+
65
+ assert repo.get_file_content("package-lock.json") is None
66
+ result = repo.read_text_file_bounded("package-lock.json")
67
+
68
+ assert result.outcome is BoundedTextReadOutcome.EXCLUDED_BY_POLICY
69
+ assert result.policy_class == "dependency_lock"
70
+ mock_repo_object.get_contents.assert_not_called()
71
+
72
+ @pytest.mark.parametrize(
73
+ ("path", "opt_in", "expected_policy"),
74
+ [
75
+ ("uv.lock", BoundedTextReadOptIn.DEPENDENCY_LOCK, "dependency_lock"),
76
+ (
77
+ ".github/workflows/test.yml",
78
+ BoundedTextReadOptIn.CI_CONFIG,
79
+ "ci_config",
80
+ ),
81
+ ],
82
+ )
83
+ def test_bounded_text_read_allows_only_exact_high_intent_opt_in(
84
+ self,
85
+ mock_github_instance,
86
+ mock_repo_object,
87
+ path,
88
+ opt_in,
89
+ expected_policy,
90
+ ):
91
+ mock_github_instance.get_repo.return_value = mock_repo_object
92
+ content = b"version = 1\n"
93
+ file_obj = SimpleNamespace(
94
+ type="file",
95
+ size=len(content),
96
+ encoding="base64",
97
+ content=base64.b64encode(content).decode("ascii"),
98
+ )
99
+ mock_repo_object.get_contents.return_value = file_obj
100
+ repo = Repository("owner/test-repo", mock_github_instance)
101
+
102
+ result = repo.read_text_file_bounded(path, sha="head", opt_in=opt_in)
103
+
104
+ assert result.outcome is BoundedTextReadOutcome.SUCCESS
105
+ assert result.content == content.decode("utf-8")
106
+ assert result.policy_class == expected_policy
107
+ assert result.source_size_bytes == len(content)
108
+ assert result.bytes_read == len(content)
109
+ assert "content" not in result.to_meta()
110
+ mock_repo_object.get_contents.assert_called_once_with(path, ref="head")
111
+
112
+ @pytest.mark.parametrize(
113
+ ("path", "opt_in"),
114
+ [
115
+ ("uv.lock", BoundedTextReadOptIn.CI_CONFIG),
116
+ ("src/main.py", BoundedTextReadOptIn.CI_CONFIG),
117
+ (".env", None),
118
+ ("dist/app.min.js", None),
119
+ ],
120
+ )
121
+ def test_bounded_text_read_rejects_mismatched_or_non_text_policy(
122
+ self, mock_github_instance, mock_repo_object, path, opt_in
123
+ ):
124
+ mock_github_instance.get_repo.return_value = mock_repo_object
125
+ repo = Repository("owner/test-repo", mock_github_instance)
126
+
127
+ result = repo.read_text_file_bounded(path, opt_in=opt_in)
128
+
129
+ assert result.outcome is BoundedTextReadOutcome.EXCLUDED_BY_POLICY
130
+ mock_repo_object.get_contents.assert_not_called()
131
+
132
+ def test_bounded_text_read_rejects_oversize_before_content_access(
133
+ self, mock_github_instance, mock_repo_object
134
+ ):
135
+ mock_github_instance.get_repo.return_value = mock_repo_object
136
+ file_obj = MagicMock()
137
+ file_obj.type = "file"
138
+ file_obj.size = BOUNDED_TEXT_SOURCE_MAX_BYTES + 1
139
+ mock_repo_object.get_contents.return_value = file_obj
140
+ repo = Repository("owner/test-repo", mock_github_instance)
141
+
142
+ result = repo.read_text_file_bounded("src/large.txt")
143
+
144
+ assert result.outcome is BoundedTextReadOutcome.OVERSIZE
145
+ assert result.source_size_bytes == BOUNDED_TEXT_SOURCE_MAX_BYTES + 1
146
+ assert file_obj.content.call_count == 0
147
+
148
+ @pytest.mark.parametrize(
149
+ ("payload_size", "expected"),
150
+ [
151
+ (BOUNDED_TEXT_SOURCE_MAX_BYTES, BoundedTextReadOutcome.SUCCESS),
152
+ (BOUNDED_TEXT_SOURCE_MAX_BYTES + 1, BoundedTextReadOutcome.OVERSIZE),
153
+ ],
154
+ )
155
+ def test_bounded_text_read_enforces_local_cap_after_decode(
156
+ self, mock_github_instance, mock_repo_object, payload_size, expected
157
+ ):
158
+ mock_github_instance.get_repo.return_value = mock_repo_object
159
+ payload = b"a" * payload_size
160
+ mock_repo_object.get_contents.return_value = SimpleNamespace(
161
+ type="file",
162
+ size=BOUNDED_TEXT_SOURCE_MAX_BYTES,
163
+ encoding="base64",
164
+ content=base64.b64encode(payload).decode("ascii"),
165
+ )
166
+ repo = Repository("owner/test-repo", mock_github_instance)
167
+
168
+ result = repo.read_text_file_bounded("src/large.txt")
169
+
170
+ assert result.outcome is expected
171
+
172
+ @pytest.mark.parametrize("payload", [b"\xff\xfe", b"text\x00tail"])
173
+ def test_bounded_text_read_rejects_binary_or_non_utf8(
174
+ self, mock_github_instance, mock_repo_object, payload
175
+ ):
176
+ mock_github_instance.get_repo.return_value = mock_repo_object
177
+ mock_repo_object.get_contents.return_value = SimpleNamespace(
178
+ type="file",
179
+ size=len(payload),
180
+ encoding="base64",
181
+ content=base64.b64encode(payload).decode("ascii"),
182
+ )
183
+ repo = Repository("owner/test-repo", mock_github_instance)
184
+
185
+ result = repo.read_text_file_bounded("src/data.txt")
186
+
187
+ assert result.outcome is BoundedTextReadOutcome.BINARY_OR_NON_UTF8
188
+
189
+ def test_bounded_text_read_distinguishes_directory_not_found_and_error(
190
+ self, mock_github_instance, mock_repo_object
191
+ ):
192
+ mock_github_instance.get_repo.return_value = mock_repo_object
193
+ repo = Repository("owner/test-repo", mock_github_instance)
194
+
195
+ mock_repo_object.get_contents.return_value = []
196
+ assert (
197
+ repo.read_text_file_bounded("src").outcome
198
+ is BoundedTextReadOutcome.DIRECTORY
199
+ )
200
+
201
+ mock_repo_object.get_contents.side_effect = GithubException(404, {"message": "missing"})
202
+ assert (
203
+ repo.read_text_file_bounded("missing.txt").outcome
204
+ is BoundedTextReadOutcome.NOT_FOUND
205
+ )
206
+
207
+ mock_repo_object.get_contents.side_effect = GithubException(503, {"message": "down"})
208
+ assert (
209
+ repo.read_text_file_bounded("unavailable.txt").outcome
210
+ is BoundedTextReadOutcome.ERROR
211
+ )
212
+
213
+ def test_bounded_text_read_maps_raw_transport_outcomes(
214
+ self, mock_github_instance, mock_repo_object
215
+ ):
216
+ mock_github_instance.get_repo.return_value = mock_repo_object
217
+ mock_repo_object.get_contents.return_value = SimpleNamespace(
218
+ type="file",
219
+ size=None,
220
+ encoding="none",
221
+ url="https://api.github.test/contents/src/config.txt",
222
+ download_url=None,
223
+ )
224
+ mock_github_instance._request_bounded_bytes.return_value = SimpleNamespace(
225
+ data=b"tail = true\n",
226
+ bytes_read=12,
227
+ declared_size_bytes=12,
228
+ status_code=200,
229
+ error_type=None,
230
+ oversize=False,
231
+ )
232
+ repo = Repository("owner/test-repo", mock_github_instance)
233
+
234
+ result = repo.read_text_file_bounded("src/config.txt")
235
+
236
+ assert result.outcome is BoundedTextReadOutcome.SUCCESS
237
+ assert result.content == "tail = true\n"
238
+ mock_github_instance._request_bounded_bytes.assert_called_once_with(
239
+ "https://api.github.test/contents/src/config.txt",
240
+ max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
241
+ operation="bounded_text_read",
242
+ )
243
+
51
244
  def test_get_pr_content_includes_current_head_sha(
52
245
  self, mock_github_instance, mock_repo_object
53
246
  ):
@@ -16,20 +16,20 @@ def _write_wheel(path: Path, *, extra_files=()) -> None:
16
16
  with zipfile.ZipFile(path, "w") as archive:
17
17
  for name in sorted(expected_package_files(ROOT)):
18
18
  archive.writestr(name, b"")
19
- archive.writestr("llama_github-0.4.1.dist-info/METADATA", b"")
19
+ archive.writestr("llama_github-0.4.2.dist-info/METADATA", b"")
20
20
  for name in extra_files:
21
21
  archive.writestr(name, b"")
22
22
 
23
23
 
24
24
  def test_wheel_content_verifier_accepts_exact_source_package(tmp_path):
25
- wheel = tmp_path / "llama_github-0.4.1-py3-none-any.whl"
25
+ wheel = tmp_path / "llama_github-0.4.2-py3-none-any.whl"
26
26
  _write_wheel(wheel)
27
27
 
28
28
  verify_wheel(wheel, source_root=ROOT)
29
29
 
30
30
 
31
31
  def test_wheel_content_verifier_rejects_stale_duplicate_module(tmp_path):
32
- wheel = tmp_path / "llama_github-0.4.1-py3-none-any.whl"
32
+ wheel = tmp_path / "llama_github-0.4.2-py3-none-any.whl"
33
33
  _write_wheel(
34
34
  wheel,
35
35
  extra_files=("llama_github/data_retrieval/github_entities 2.py",),
@@ -148,6 +148,67 @@ class TestExtendedGithub:
148
148
  assert session.get.call_args.kwargs["timeout"] == (2.5, 9.0)
149
149
  session.close.assert_called_once()
150
150
 
151
+ def test_bounded_raw_request_rejects_declared_oversize_before_stream(self):
152
+ gh = ExtendedGithub("token")
153
+ response = MagicMock()
154
+ response.status_code = 200
155
+ response.headers = {"Content-Length": "11"}
156
+ session = MagicMock()
157
+ session.get.return_value = response
158
+ gh._build_session = MagicMock(return_value=session)
159
+
160
+ result = gh._request_bounded_bytes(
161
+ "https://api.github.com/example",
162
+ max_bytes=10,
163
+ )
164
+
165
+ assert result.oversize is True
166
+ assert result.declared_size_bytes == 11
167
+ response.iter_content.assert_not_called()
168
+ assert session.get.call_args.kwargs["stream"] is True
169
+ session.close.assert_called_once()
170
+
171
+ def test_bounded_raw_request_rejects_stream_that_crosses_cap(self):
172
+ gh = ExtendedGithub("token")
173
+ response = MagicMock()
174
+ response.status_code = 200
175
+ response.headers = {}
176
+ response.iter_content.return_value = [b"123456", b"78901"]
177
+ session = MagicMock()
178
+ session.get.return_value = response
179
+ gh._build_session = MagicMock(return_value=session)
180
+
181
+ result = gh._request_bounded_bytes(
182
+ "https://api.github.com/example",
183
+ max_bytes=10,
184
+ )
185
+
186
+ assert result.oversize is True
187
+ assert result.bytes_read == 11
188
+ assert result.data is None
189
+ session.close.assert_called_once()
190
+
191
+ def test_bounded_raw_request_returns_exact_cap(self):
192
+ gh = ExtendedGithub("token")
193
+ response = MagicMock()
194
+ response.status_code = 200
195
+ response.headers = {"Content-Length": "10"}
196
+ response.iter_content.return_value = [b"12345", b"67890"]
197
+ session = MagicMock()
198
+ session.get.return_value = response
199
+ gh._build_session = MagicMock(return_value=session)
200
+
201
+ result = gh._request_bounded_bytes(
202
+ "https://api.github.com/example",
203
+ max_bytes=10,
204
+ )
205
+
206
+ assert result.oversize is False
207
+ assert result.data == b"1234567890"
208
+ assert result.bytes_read == 10
209
+ assert result.declared_size_bytes == 10
210
+ session.close.assert_called_once()
211
+
151
212
  def test_refresh_token_updates_requester(self):
152
213
  gh = ExtendedGithub("token-1")
153
214
  gh.refresh_token("token-2")
@@ -12,7 +12,7 @@ def test_retrieval_imports_do_not_load_heavy_rag_dependencies():
12
12
  assert "llama_github.github_rag" not in sys.modules
13
13
 
14
14
  from llama_github.data_retrieval.github_api import GitHubAPIHandler
15
- from llama_github.data_retrieval.github_entities import CIStatusSnapshot, Repository, RepositoryPool
15
+ from llama_github.data_retrieval.github_entities import BoundedTextReadResult, CIStatusSnapshot, Repository, RepositoryPool
16
16
  from llama_github.github_integration.github_auth_manager import GitHubAuthManager
17
17
  from llama_github.utils import AsyncHTTPClient, DiffGenerator
18
18
 
@@ -20,7 +20,9 @@ def test_retrieval_imports_do_not_load_heavy_rag_dependencies():
20
20
  loaded = forbidden.intersection(sys.modules)
21
21
  assert not loaded, sorted(loaded)
22
22
  assert callable(Repository.get_ci_status_with_status)
23
- assert llama_github.__version__ == "0.4.1"
23
+ assert callable(Repository.read_text_file_bounded)
24
+ assert BoundedTextReadResult.__name__ == "BoundedTextReadResult"
25
+ assert llama_github.__version__ == "0.4.2"
24
26
  """
25
27
  )
26
28
 
@@ -1 +0,0 @@
1
- __version__ = "0.4.1"
File without changes
File without changes
File without changes
File without changes
File without changes