openworkproof 1.0.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.
- openworkproof/__init__.py +1 -0
- openworkproof/acceptance.py +3006 -0
- openworkproof/cli.py +164 -0
- openworkproof/composition.py +832 -0
- openworkproof/evidence.py +8281 -0
- openworkproof/execution_adapter.py +256 -0
- openworkproof/external_acceptor.py +211 -0
- openworkproof/mcp_server.py +2939 -0
- openworkproof/mcp_transport.py +72 -0
- openworkproof/models.py +3380 -0
- openworkproof/policy.py +2494 -0
- openworkproof/predicates.py +333 -0
- openworkproof/repo_pipeline/__init__.py +74 -0
- openworkproof/repo_pipeline/analysis.py +152 -0
- openworkproof/repo_pipeline/errors.py +64 -0
- openworkproof/repo_pipeline/models.py +80 -0
- openworkproof/repo_pipeline/output.py +68 -0
- openworkproof/repo_pipeline/reader.py +47 -0
- openworkproof/repo_pipeline/sources.py +120 -0
- openworkproof/repo_pipeline/traversal.py +253 -0
- openworkproof/repo_tools.py +8382 -0
- openworkproof/runtime_context.py +189 -0
- openworkproof/schema_registry.py +623 -0
- openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
- openworkproof/schemas/v0.1/schema-registry.json +1 -0
- openworkproof/schemas/v0.1/work-order.schema.json +1 -0
- openworkproof/signing.py +421 -0
- openworkproof/state.py +765 -0
- openworkproof/team_network_client.py +419 -0
- openworkproof/trusted_helper.py +269 -0
- openworkproof-1.0.0.dist-info/METADATA +578 -0
- openworkproof-1.0.0.dist-info/RECORD +39 -0
- openworkproof-1.0.0.dist-info/WHEEL +5 -0
- openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
- openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
- openworkproof-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Structured output: render a ``RepositoryAnalysis`` to canonical JSON.
|
|
2
|
+
|
|
3
|
+
The output is a single self-describing object so downstream tools can consume
|
|
4
|
+
it without extra context:
|
|
5
|
+
|
|
6
|
+
.. code-block:: json
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"schema_version": "openworkproof/repo-analysis/0.1",
|
|
10
|
+
"root": "/abs/path",
|
|
11
|
+
"totals": {"files": 3, "bytes": 42},
|
|
12
|
+
"files": [{"path": "a.py", "language": "Python", "size_bytes": 42,
|
|
13
|
+
"sha256": "...", "lines": 2}],
|
|
14
|
+
"results": [{"analyzer": "language_stats", "data": {...}}]
|
|
15
|
+
}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
from .errors import OutputError, get_logger
|
|
23
|
+
from .models import RepositoryAnalysis
|
|
24
|
+
|
|
25
|
+
_logger = get_logger()
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = "openworkproof/repo-analysis/0.1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _file_record(entry) -> dict[str, object]:
|
|
31
|
+
return {
|
|
32
|
+
"path": entry.relative_path,
|
|
33
|
+
"language": entry.language,
|
|
34
|
+
"size_bytes": entry.size_bytes,
|
|
35
|
+
"sha256": entry.sha256,
|
|
36
|
+
"lines": entry.content.count("\n"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_dict(analysis: RepositoryAnalysis) -> dict[str, object]:
|
|
41
|
+
return {
|
|
42
|
+
"schema_version": SCHEMA_VERSION,
|
|
43
|
+
"root": analysis.scan.root,
|
|
44
|
+
"totals": {
|
|
45
|
+
"files": analysis.scan.total_files,
|
|
46
|
+
"bytes": analysis.scan.total_bytes,
|
|
47
|
+
"skipped_directories": len(analysis.scan.skipped),
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
_file_record(entry) for entry in analysis.scan.entries
|
|
51
|
+
],
|
|
52
|
+
"results": [
|
|
53
|
+
{"analyzer": result.analyzer, "data": dict(result.data)}
|
|
54
|
+
for result in analysis.results
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_json(analysis: RepositoryAnalysis, *, indent: int | None = 2) -> str:
|
|
60
|
+
try:
|
|
61
|
+
return json.dumps(
|
|
62
|
+
to_dict(analysis),
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
sort_keys=True,
|
|
65
|
+
indent=indent,
|
|
66
|
+
)
|
|
67
|
+
except (TypeError, ValueError) as error:
|
|
68
|
+
raise OutputError("repository analysis cannot be serialized") from error
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""File reading: decode a text file with size limits and permission guards.
|
|
2
|
+
|
|
3
|
+
The reader is intentionally a small dependency-free seam so traversal and
|
|
4
|
+
analysis stages can be tested with injected readers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import DecodeError, PermissionDeniedError, get_logger
|
|
14
|
+
|
|
15
|
+
MAX_TEXT_BYTES = 1_048_576 # 1 MiB: larger files are skipped by the reader
|
|
16
|
+
_logger = get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _decode_utf8(raw: bytes) -> str:
|
|
20
|
+
try:
|
|
21
|
+
return raw.decode("utf-8")
|
|
22
|
+
except UnicodeDecodeError as error:
|
|
23
|
+
raise DecodeError("file is not valid UTF-8 text") from error
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_text_file(path: Path, *, max_bytes: int = MAX_TEXT_BYTES) -> str:
|
|
27
|
+
"""Read one file as UTF-8 text with a size cap and permission guard."""
|
|
28
|
+
try:
|
|
29
|
+
size = path.stat().st_size
|
|
30
|
+
except OSError as error:
|
|
31
|
+
raise PermissionDeniedError(f"cannot stat file: {path}") from error
|
|
32
|
+
if size > max_bytes:
|
|
33
|
+
_logger.warning("skipping oversized file (%.1f KiB): %s", size / 1024, path)
|
|
34
|
+
raise DecodeError(f"file exceeds the {max_bytes}-byte text limit: {path}")
|
|
35
|
+
try:
|
|
36
|
+
raw = path.read_bytes()
|
|
37
|
+
except OSError as error:
|
|
38
|
+
raise PermissionDeniedError(f"cannot read file: {path}") from error
|
|
39
|
+
return _decode_utf8(raw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_bytes(raw: bytes) -> str:
|
|
43
|
+
return hashlib.sha256(raw).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def file_sha256(path: Path) -> str:
|
|
47
|
+
return sha256_bytes(path.read_bytes())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Input resolution: turn a local path or a remote repository URL into a
|
|
2
|
+
concrete readable repository root.
|
|
3
|
+
|
|
4
|
+
``resolve_repository_source`` accepts:
|
|
5
|
+
|
|
6
|
+
- a local absolute or relative path to an existing directory;
|
|
7
|
+
- a GitHub URL such as ``https://github.com/owner/repo`` or
|
|
8
|
+
``https://github.com/owner/repo/tree/<ref>``.
|
|
9
|
+
|
|
10
|
+
Remote sources are cloned into a caller-managed temporary root when
|
|
11
|
+
``clone_enabled`` is true and the ``git`` executable is available; otherwise a
|
|
12
|
+
``RemoteCloneError`` is raised so callers can degrade gracefully.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
from .errors import (
|
|
26
|
+
PathNotFoundError,
|
|
27
|
+
PermissionDeniedError,
|
|
28
|
+
RemoteCloneError,
|
|
29
|
+
UnsupportedSourceError,
|
|
30
|
+
get_logger,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_GITHUB_URL = re.compile(
|
|
34
|
+
r"^https?://(?:www\.)?github\.com/(?P<owner>[A-Za-z0-9_.-]+)/"
|
|
35
|
+
r"(?P<repo>[A-Za-z0-9_.-]+)(?:/tree/(?P<ref>.+))?$"
|
|
36
|
+
)
|
|
37
|
+
_KNOWN_REMOTE_HOSTS = frozenset({"github.com"})
|
|
38
|
+
_logger = get_logger()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _looks_like_remote_url(value: str) -> bool:
|
|
42
|
+
parsed = urlparse(value)
|
|
43
|
+
return parsed.scheme in {"http", "https"} and parsed.netloc != ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _classify(value: str) -> str:
|
|
47
|
+
if _looks_like_remote_url(value):
|
|
48
|
+
match = _GITHUB_URL.match(value)
|
|
49
|
+
if match is not None:
|
|
50
|
+
return "github"
|
|
51
|
+
host = urlparse(value).netloc
|
|
52
|
+
if host in _KNOWN_REMOTE_HOSTS:
|
|
53
|
+
return "github"
|
|
54
|
+
raise UnsupportedSourceError(f"unsupported remote host: {host}")
|
|
55
|
+
return "local"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_local_path(value: str | Path) -> Path:
|
|
59
|
+
path = Path(value).expanduser()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise PathNotFoundError(f"repository path does not exist: {path}")
|
|
62
|
+
if not path.is_dir():
|
|
63
|
+
raise UnsupportedSourceError(f"repository source is not a directory: {path}")
|
|
64
|
+
if not os.access(path, os.R_OK):
|
|
65
|
+
raise PermissionDeniedError(f"repository path is not readable: {path}")
|
|
66
|
+
return path.resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clone_github(owner: str, repo: str, ref: str | None) -> Path:
|
|
70
|
+
git = shutil.which("git")
|
|
71
|
+
if git is None:
|
|
72
|
+
raise RemoteCloneError("git executable is unavailable")
|
|
73
|
+
root = Path(tempfile.mkdtemp(prefix="owp-repo-"))
|
|
74
|
+
url = f"https://github.com/{owner}/{repo}.git"
|
|
75
|
+
try:
|
|
76
|
+
subprocess.run(
|
|
77
|
+
(git, "clone", "--depth", "1", url, str(root)),
|
|
78
|
+
check=True,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
timeout=300,
|
|
81
|
+
)
|
|
82
|
+
if ref is not None and ref != "HEAD":
|
|
83
|
+
subprocess.run(
|
|
84
|
+
(git, "-C", str(root), "checkout", ref),
|
|
85
|
+
check=True,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
timeout=120,
|
|
88
|
+
)
|
|
89
|
+
except (subprocess.CalledProcessError, OSError) as error:
|
|
90
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
91
|
+
raise RemoteCloneError(f"remote clone failed for {owner}/{repo}") from error
|
|
92
|
+
return root
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_repository_source(
|
|
96
|
+
source: str | Path,
|
|
97
|
+
*,
|
|
98
|
+
clone_enabled: bool = True,
|
|
99
|
+
) -> Path:
|
|
100
|
+
"""Resolve a local path or remote URL to a readable repository root.
|
|
101
|
+
|
|
102
|
+
Returns the local repository root. Remote sources are cloned into a
|
|
103
|
+
temporary directory that the caller must clean up.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(source, Path):
|
|
106
|
+
return resolve_local_path(source)
|
|
107
|
+
if not isinstance(source, str) or not source:
|
|
108
|
+
raise UnsupportedSourceError("repository source must be a path or URL")
|
|
109
|
+
kind = _classify(source)
|
|
110
|
+
if kind == "local":
|
|
111
|
+
_logger.debug("resolved local repository source: %s", source)
|
|
112
|
+
return resolve_local_path(source)
|
|
113
|
+
match = _GITHUB_URL.match(source)
|
|
114
|
+
if match is None:
|
|
115
|
+
raise UnsupportedSourceError(f"unsupported repository URL: {source}")
|
|
116
|
+
if not clone_enabled:
|
|
117
|
+
raise RemoteCloneError("remote clone is disabled for this pipeline")
|
|
118
|
+
owner, repo, ref = match.group("owner"), match.group("repo"), match.group("ref")
|
|
119
|
+
_logger.info("cloning remote repository %s/%s", owner, repo)
|
|
120
|
+
return _clone_github(owner, repo, ref)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Repository traversal: recursively walk a repository, apply directory
|
|
2
|
+
filters, recognise code file types, and read each supported file.
|
|
3
|
+
|
|
4
|
+
The traversal stage is a pure walker over the local repository root. It
|
|
5
|
+
accepts an injected ``read_file`` callable so tests and callers can substitute
|
|
6
|
+
the reader without touching the walk logic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from .errors import ReadError, get_logger
|
|
16
|
+
from .models import RepoFileEntry, RepositoryScan, SkippedDirectory
|
|
17
|
+
from .reader import read_text_file, sha256_bytes
|
|
18
|
+
|
|
19
|
+
_logger = get_logger()
|
|
20
|
+
|
|
21
|
+
MAX_FILES = 5_000
|
|
22
|
+
MAX_DIRECTORY_DEPTH = 32
|
|
23
|
+
|
|
24
|
+
# Directories never scanned: VCS, dependency installs, build output, caches.
|
|
25
|
+
FILTERED_DIRECTORIES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
".git",
|
|
28
|
+
".hg",
|
|
29
|
+
".svn",
|
|
30
|
+
"node_modules",
|
|
31
|
+
"bower_components",
|
|
32
|
+
"__pycache__",
|
|
33
|
+
".venv",
|
|
34
|
+
"venv",
|
|
35
|
+
".tox",
|
|
36
|
+
".mypy_cache",
|
|
37
|
+
".pytest_cache",
|
|
38
|
+
".ruff_cache",
|
|
39
|
+
".idea",
|
|
40
|
+
".vscode",
|
|
41
|
+
"dist",
|
|
42
|
+
"build",
|
|
43
|
+
"out",
|
|
44
|
+
"target",
|
|
45
|
+
".next",
|
|
46
|
+
".nuxt",
|
|
47
|
+
".cache",
|
|
48
|
+
"coverage",
|
|
49
|
+
".terraform",
|
|
50
|
+
"Pods",
|
|
51
|
+
"vendor",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Extension -> language. Unknown text files still load with language None.
|
|
56
|
+
LANGUAGE_BY_EXTENSION: dict[str, str] = {
|
|
57
|
+
".py": "Python",
|
|
58
|
+
".pyi": "Python",
|
|
59
|
+
".js": "JavaScript",
|
|
60
|
+
".mjs": "JavaScript",
|
|
61
|
+
".cjs": "JavaScript",
|
|
62
|
+
".ts": "TypeScript",
|
|
63
|
+
".tsx": "TypeScript",
|
|
64
|
+
".jsx": "JavaScript",
|
|
65
|
+
".go": "Go",
|
|
66
|
+
".rs": "Rust",
|
|
67
|
+
".java": "Java",
|
|
68
|
+
".kt": "Kotlin",
|
|
69
|
+
".swift": "Swift",
|
|
70
|
+
".c": "C",
|
|
71
|
+
".h": "C",
|
|
72
|
+
".cpp": "C++",
|
|
73
|
+
".cc": "C++",
|
|
74
|
+
".hpp": "C++",
|
|
75
|
+
".cs": "C#",
|
|
76
|
+
".rb": "Ruby",
|
|
77
|
+
".php": "PHP",
|
|
78
|
+
".sh": "Shell",
|
|
79
|
+
".bash": "Shell",
|
|
80
|
+
".zsh": "Shell",
|
|
81
|
+
".fish": "Shell",
|
|
82
|
+
".ps1": "PowerShell",
|
|
83
|
+
".sql": "SQL",
|
|
84
|
+
".html": "HTML",
|
|
85
|
+
".htm": "HTML",
|
|
86
|
+
".css": "CSS",
|
|
87
|
+
".scss": "SCSS",
|
|
88
|
+
".less": "Less",
|
|
89
|
+
".json": "JSON",
|
|
90
|
+
".yaml": "YAML",
|
|
91
|
+
".yml": "YAML",
|
|
92
|
+
".toml": "TOML",
|
|
93
|
+
".xml": "XML",
|
|
94
|
+
".md": "Markdown",
|
|
95
|
+
".rst": "RST",
|
|
96
|
+
".tex": "LaTeX",
|
|
97
|
+
".proto": "Protocol Buffers",
|
|
98
|
+
".graphql": "GraphQL",
|
|
99
|
+
".vue": "Vue",
|
|
100
|
+
".svelte": "Svelte",
|
|
101
|
+
".dockerfile": "Dockerfile",
|
|
102
|
+
".tf": "Terraform",
|
|
103
|
+
".lua": "Lua",
|
|
104
|
+
".pl": "Perl",
|
|
105
|
+
".r": "R",
|
|
106
|
+
".m": "Objective-C",
|
|
107
|
+
".dart": "Dart",
|
|
108
|
+
".ex": "Elixir",
|
|
109
|
+
".exs": "Elixir",
|
|
110
|
+
".erl": "Erlang",
|
|
111
|
+
".hs": "Haskell",
|
|
112
|
+
".clj": "Clojure",
|
|
113
|
+
".scala": "Scala",
|
|
114
|
+
".groovy": "Groovy",
|
|
115
|
+
".fs": "F#",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# File names treated as code regardless of extension.
|
|
119
|
+
NAME_LANGUAGES: dict[str, str] = {
|
|
120
|
+
"Dockerfile": "Dockerfile",
|
|
121
|
+
"Makefile": "Makefile",
|
|
122
|
+
"CMakeLists.txt": "CMake",
|
|
123
|
+
"Justfile": "Justfile",
|
|
124
|
+
"Jenkinsfile": "Jenkinsfile",
|
|
125
|
+
"Rakefile": "Ruby",
|
|
126
|
+
"Gemfile": "Ruby",
|
|
127
|
+
"Cargo.toml": "TOML",
|
|
128
|
+
"go.mod": "Go",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def language_for(path: Path) -> str | None:
|
|
133
|
+
name = path.name
|
|
134
|
+
if name in NAME_LANGUAGES:
|
|
135
|
+
return NAME_LANGUAGES[name]
|
|
136
|
+
return LANGUAGE_BY_EXTENSION.get(path.suffix.lower())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _walk_relative(
|
|
140
|
+
top: Path,
|
|
141
|
+
current: Path,
|
|
142
|
+
*,
|
|
143
|
+
depth: int,
|
|
144
|
+
filters: frozenset[str],
|
|
145
|
+
max_files: int,
|
|
146
|
+
max_depth: int,
|
|
147
|
+
) -> tuple[list[RepoFileEntry], list[SkippedDirectory]]:
|
|
148
|
+
entries: list[RepoFileEntry] = []
|
|
149
|
+
skipped: list[SkippedDirectory] = []
|
|
150
|
+
if depth > max_depth:
|
|
151
|
+
_logger.warning("traversal depth limit reached at %s", current)
|
|
152
|
+
return entries, skipped
|
|
153
|
+
try:
|
|
154
|
+
children = sorted(current.iterdir(), key=lambda item: item.name)
|
|
155
|
+
except OSError as error:
|
|
156
|
+
_logger.error("cannot list directory %s: %s", current, error)
|
|
157
|
+
return entries, skipped
|
|
158
|
+
for child in children:
|
|
159
|
+
if child.is_symlink():
|
|
160
|
+
_logger.debug("skipping symlink: %s", child)
|
|
161
|
+
continue
|
|
162
|
+
if child.is_dir():
|
|
163
|
+
if child.name in filters:
|
|
164
|
+
skipped.append(
|
|
165
|
+
SkippedDirectory(
|
|
166
|
+
relative_path=child.relative_to(top).as_posix(),
|
|
167
|
+
rule="filtered-directory",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
_logger.debug("filtering directory: %s", child.name)
|
|
171
|
+
continue
|
|
172
|
+
sub_entries, sub_skipped = _walk_relative(
|
|
173
|
+
top,
|
|
174
|
+
child,
|
|
175
|
+
depth=depth + 1,
|
|
176
|
+
filters=filters,
|
|
177
|
+
max_files=max_files,
|
|
178
|
+
max_depth=max_depth,
|
|
179
|
+
)
|
|
180
|
+
entries.extend(sub_entries)
|
|
181
|
+
skipped.extend(sub_skipped)
|
|
182
|
+
continue
|
|
183
|
+
if not child.is_file():
|
|
184
|
+
continue
|
|
185
|
+
if len(entries) >= max_files:
|
|
186
|
+
_logger.warning("file count limit reached (%d files)", max_files)
|
|
187
|
+
break
|
|
188
|
+
language = language_for(child)
|
|
189
|
+
if language is None:
|
|
190
|
+
_logger.debug("skipping unrecognised file type: %s", child.name)
|
|
191
|
+
continue
|
|
192
|
+
entries.append(
|
|
193
|
+
RepoFileEntry(
|
|
194
|
+
relative_path=child.relative_to(top).as_posix(),
|
|
195
|
+
language=language,
|
|
196
|
+
size_bytes=0,
|
|
197
|
+
content="",
|
|
198
|
+
sha256="",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return entries, skipped
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def scan_repository(
|
|
205
|
+
root: Path,
|
|
206
|
+
*,
|
|
207
|
+
filters: frozenset[str] = FILTERED_DIRECTORIES,
|
|
208
|
+
max_files: int = MAX_FILES,
|
|
209
|
+
max_depth: int = MAX_DIRECTORY_DEPTH,
|
|
210
|
+
read_file: Callable[[Path], str] = read_text_file,
|
|
211
|
+
) -> RepositoryScan:
|
|
212
|
+
"""Walk ``root`` and build a ``RepositoryScan`` of supported text files."""
|
|
213
|
+
placeholder_entries, skipped = _walk_relative(
|
|
214
|
+
root,
|
|
215
|
+
root,
|
|
216
|
+
depth=0,
|
|
217
|
+
filters=filters,
|
|
218
|
+
max_files=max_files,
|
|
219
|
+
max_depth=max_depth,
|
|
220
|
+
)
|
|
221
|
+
entries: list[RepoFileEntry] = []
|
|
222
|
+
total_bytes = 0
|
|
223
|
+
for placeholder in placeholder_entries:
|
|
224
|
+
path = root / placeholder.relative_path
|
|
225
|
+
try:
|
|
226
|
+
content = read_file(path)
|
|
227
|
+
except ReadError as error:
|
|
228
|
+
_logger.warning("skipping unreadable file %s: %s", path, error)
|
|
229
|
+
continue
|
|
230
|
+
raw = content.encode("utf-8")
|
|
231
|
+
total_bytes += len(raw)
|
|
232
|
+
entries.append(
|
|
233
|
+
RepoFileEntry(
|
|
234
|
+
relative_path=placeholder.relative_path,
|
|
235
|
+
language=placeholder.language,
|
|
236
|
+
size_bytes=len(raw),
|
|
237
|
+
content=content,
|
|
238
|
+
sha256=sha256_bytes(raw),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
entries.sort(key=lambda entry: entry.relative_path)
|
|
242
|
+
languages: dict[str, int] = {}
|
|
243
|
+
for entry in entries:
|
|
244
|
+
if entry.language is not None:
|
|
245
|
+
languages[entry.language] = languages.get(entry.language, 0) + 1
|
|
246
|
+
return RepositoryScan(
|
|
247
|
+
root=str(root),
|
|
248
|
+
entries=tuple(entries),
|
|
249
|
+
skipped=tuple(skipped),
|
|
250
|
+
total_files=len(entries),
|
|
251
|
+
total_bytes=total_bytes,
|
|
252
|
+
languages=languages,
|
|
253
|
+
)
|