rsconnect-python 1.30.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.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Detects R dependencies from a project's renv.lock file.
|
|
2
|
+
|
|
3
|
+
Given a directory that contains an renv.lock lockfile, this module parses it
|
|
4
|
+
into the R version and package metadata needed for the deployment manifest.
|
|
5
|
+
The parse is pure: it reads only renv.lock and never invokes R or inspects
|
|
6
|
+
locally installed R packages. This mirrors how Posit Publisher resolves R
|
|
7
|
+
dependencies for Python content that also uses R (e.g. rpy2 apps).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Optional, Sequence, cast
|
|
15
|
+
|
|
16
|
+
from .exception import RSConnectException
|
|
17
|
+
from .log import logger
|
|
18
|
+
|
|
19
|
+
DEFAULT_R_PACKAGE_FILE = "renv.lock"
|
|
20
|
+
|
|
21
|
+
# Repositories renv always assumes are available, keyed by the names renv writes
|
|
22
|
+
# into each package's "Repository" field.
|
|
23
|
+
_DEFAULT_REPOSITORIES = {
|
|
24
|
+
"CRAN": "https://cloud.r-project.org",
|
|
25
|
+
"RSPM": "https://packagemanager.posit.co/cran/latest",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class REnvironment:
|
|
30
|
+
"""R dependencies resolved from a project's renv.lock file.
|
|
31
|
+
|
|
32
|
+
Captures the R version and the package metadata Connect needs to restore the
|
|
33
|
+
R library when deploying Python content that also depends on R.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
r_version: str,
|
|
39
|
+
packages: dict[str, dict[str, Any]],
|
|
40
|
+
):
|
|
41
|
+
self.r_version = r_version
|
|
42
|
+
self.packages = packages
|
|
43
|
+
self.lockfile = DEFAULT_R_PACKAGE_FILE
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def create(cls, directory: str) -> Optional["REnvironment"]:
|
|
47
|
+
"""Resolve R dependencies from an renv.lock file in a project directory.
|
|
48
|
+
|
|
49
|
+
Returns None when there is no renv.lock to resolve, so callers can treat
|
|
50
|
+
R detection as opt-in based on the presence of the lockfile. The location
|
|
51
|
+
honors RENV_PATHS_LOCKFILE and otherwise defaults to <directory>/renv.lock.
|
|
52
|
+
|
|
53
|
+
:param directory: path to the project directory that may contain renv.lock.
|
|
54
|
+
"""
|
|
55
|
+
lockfile_path = _renv_lockfile_path(directory)
|
|
56
|
+
if not os.path.exists(lockfile_path):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
with open(lockfile_path, encoding="utf-8") as f:
|
|
60
|
+
try:
|
|
61
|
+
parsed = json.load(f)
|
|
62
|
+
except json.JSONDecodeError as err:
|
|
63
|
+
raise RSConnectException(f"{lockfile_path} is not valid JSON: {err}") from err
|
|
64
|
+
|
|
65
|
+
# A compatible lockfile is a JSON object whose "R" section lists the
|
|
66
|
+
# Repositories needed to resolve every package back to a URL. renv < 1.1.0
|
|
67
|
+
# omits Repositories; malformed lockfiles may be a non-object or carry
|
|
68
|
+
# null/non-dict sections. Treat all of these as incompatible rather than
|
|
69
|
+
# letting them surface as an AttributeError.
|
|
70
|
+
incompatible_msg = (
|
|
71
|
+
f"{DEFAULT_R_PACKAGE_FILE} is not compatible: missing Repositories section. "
|
|
72
|
+
"Regenerate the lockfile with renv >= 1.1.0."
|
|
73
|
+
)
|
|
74
|
+
if not isinstance(parsed, dict):
|
|
75
|
+
raise RSConnectException(incompatible_msg)
|
|
76
|
+
lockfile = cast("dict[str, Any]", parsed)
|
|
77
|
+
|
|
78
|
+
r_section = lockfile.get("R")
|
|
79
|
+
if not isinstance(r_section, dict):
|
|
80
|
+
raise RSConnectException(incompatible_msg)
|
|
81
|
+
r_section = cast("dict[str, Any]", r_section)
|
|
82
|
+
if not r_section.get("Repositories"):
|
|
83
|
+
raise RSConnectException(incompatible_msg)
|
|
84
|
+
if "Bioconductor" in lockfile and not isinstance(lockfile["Bioconductor"], dict):
|
|
85
|
+
raise RSConnectException(incompatible_msg)
|
|
86
|
+
|
|
87
|
+
logger.debug(f"Resolving R dependencies from {lockfile_path}")
|
|
88
|
+
return cls(
|
|
89
|
+
r_version=r_section.get("Version", ""),
|
|
90
|
+
packages=_lockfile_to_manifest_packages(lockfile),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _renv_lockfile_path(directory: str) -> str:
|
|
95
|
+
# Mimics renv's renv_paths_lockfile() in R/paths.R: RENV_PATHS_LOCKFILE
|
|
96
|
+
# overrides the location, except a trailing slash means "a directory" so
|
|
97
|
+
# renv.lock is appended. An absolute override is used verbatim; a relative
|
|
98
|
+
# override resolves against the project directory (matching renv).
|
|
99
|
+
# With no override we fall back to renv's default of <project>/renv.lock.
|
|
100
|
+
override = os.environ.get("RENV_PATHS_LOCKFILE")
|
|
101
|
+
if override:
|
|
102
|
+
if override.endswith(("/", "\\")):
|
|
103
|
+
override += DEFAULT_R_PACKAGE_FILE
|
|
104
|
+
if not os.path.isabs(override):
|
|
105
|
+
return os.path.join(directory, override)
|
|
106
|
+
return override
|
|
107
|
+
return os.path.join(directory, DEFAULT_R_PACKAGE_FILE)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _lockfile_to_manifest_packages(lockfile: Any) -> dict[str, dict[str, Any]]:
|
|
111
|
+
repo_name_to_url = _find_all_repositories(lockfile)
|
|
112
|
+
result: dict[str, dict[str, Any]] = {}
|
|
113
|
+
for pkg_name, pkg in lockfile.get("Packages", {}).items():
|
|
114
|
+
source, repository = _resolve_package_source(pkg, repo_name_to_url)
|
|
115
|
+
if not source:
|
|
116
|
+
raise RSConnectException(
|
|
117
|
+
f"Package {pkg_name} has an unresolved source; cannot generate manifest entry. "
|
|
118
|
+
"Use --exclude-renv to deploy without R dependency detection."
|
|
119
|
+
)
|
|
120
|
+
if not repository:
|
|
121
|
+
raise RSConnectException(
|
|
122
|
+
f"Package {pkg_name} has an unresolved repository; cannot generate manifest entry. "
|
|
123
|
+
"Use --exclude-renv to deploy without R dependency detection."
|
|
124
|
+
)
|
|
125
|
+
description = _build_description(
|
|
126
|
+
pkg,
|
|
127
|
+
repository,
|
|
128
|
+
{
|
|
129
|
+
"Package": pkg_name,
|
|
130
|
+
"Version": pkg.get("Version", ""),
|
|
131
|
+
"Type": "Package",
|
|
132
|
+
"Title": pkg.get("Title") or f"{source} R package",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
result[pkg_name] = {"Source": source, "Repository": repository, "description": description}
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_all_repositories(lockfile: Any) -> dict[str, str]:
|
|
140
|
+
repos = dict(_DEFAULT_REPOSITORIES)
|
|
141
|
+
|
|
142
|
+
bioc_version = lockfile.get("Bioconductor", {}).get("Version")
|
|
143
|
+
if bioc_version:
|
|
144
|
+
base = f"https://bioconductor.org/packages/{bioc_version}"
|
|
145
|
+
repos["BioCsoft"] = f"{base}/bioc"
|
|
146
|
+
repos["BioCann"] = f"{base}/data/annotation"
|
|
147
|
+
repos["BioCexp"] = f"{base}/data/experiment"
|
|
148
|
+
repos["BioCworkflows"] = f"{base}/workflows"
|
|
149
|
+
repos["BioCbooks"] = f"{base}/books"
|
|
150
|
+
|
|
151
|
+
for repo in lockfile.get("R", {}).get("Repositories", []):
|
|
152
|
+
repos[repo["Name"]] = repo["URL"].rstrip("/")
|
|
153
|
+
|
|
154
|
+
# Packages installed from a remote repository (e.g. a private RSPM) carry the
|
|
155
|
+
# repository URL on the package itself; register it under its short name.
|
|
156
|
+
for pkg in lockfile.get("Packages", {}).values():
|
|
157
|
+
remote_repos = pkg.get("RemoteRepos")
|
|
158
|
+
repository = pkg.get("Repository")
|
|
159
|
+
if remote_repos and repository and _is_url(remote_repos):
|
|
160
|
+
repos[repository] = remote_repos.rstrip("/")
|
|
161
|
+
|
|
162
|
+
return repos
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_package_source(pkg: Any, repo_name_to_url: dict[str, str]) -> tuple[str, str]:
|
|
166
|
+
repo_identifier = pkg.get("RemoteRepos") or pkg.get("Repository") or ""
|
|
167
|
+
pkg_ref = _remote_pkg_ref_or_derived(pkg)
|
|
168
|
+
remote_type = pkg.get("RemoteType")
|
|
169
|
+
|
|
170
|
+
if not repo_identifier and remote_type:
|
|
171
|
+
# git-hosted package with no standard repository
|
|
172
|
+
return remote_type, (_remote_repo_url(remote_type, pkg_ref) or pkg.get("RemoteUrl") or "")
|
|
173
|
+
|
|
174
|
+
if repo_identifier or pkg.get("Source") == "Bioconductor":
|
|
175
|
+
return _resolve_repo_and_source(repo_name_to_url, repo_identifier, pkg.get("Source"))
|
|
176
|
+
|
|
177
|
+
# No resolution possible here; the caller validates the source/repository are non-empty.
|
|
178
|
+
return pkg.get("Source") or "", pkg.get("Repository") or ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_repo_and_source(repo_name_to_url: dict[str, str], repo_str: str, src: Optional[str]) -> tuple[str, str]:
|
|
182
|
+
if _is_url(repo_str):
|
|
183
|
+
repo_url = repo_str.rstrip("/")
|
|
184
|
+
repo_name = repo_url
|
|
185
|
+
for name, url in repo_name_to_url.items():
|
|
186
|
+
if url == repo_url:
|
|
187
|
+
repo_name = name
|
|
188
|
+
break
|
|
189
|
+
elif repo_str:
|
|
190
|
+
url = repo_name_to_url.get(repo_str)
|
|
191
|
+
if url is None:
|
|
192
|
+
raise RSConnectException(f"repository {repo_str} cannot be resolved to a URL")
|
|
193
|
+
repo_url = url
|
|
194
|
+
repo_name = repo_str
|
|
195
|
+
else:
|
|
196
|
+
# Caller guarantees src == "Bioconductor" once repo_str is empty.
|
|
197
|
+
bioc_url = repo_name_to_url.get("BioCsoft")
|
|
198
|
+
if bioc_url is None:
|
|
199
|
+
raise RSConnectException(
|
|
200
|
+
"Bioconductor package source specified but no Bioconductor repositories are available"
|
|
201
|
+
)
|
|
202
|
+
repo_url = bioc_url
|
|
203
|
+
repo_name = "BioCsoft"
|
|
204
|
+
|
|
205
|
+
is_bioc = src == "Bioconductor" or repo_name.startswith("BioC") or "bioconductor.org/packages/" in repo_url.lower()
|
|
206
|
+
source = "Bioconductor" if is_bioc else repo_name
|
|
207
|
+
return source, repo_url
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_description(pkg: Any, resolved_repo: str, initial: dict[str, Any]) -> dict[str, Any]:
|
|
211
|
+
# The manifest "description" mirrors the package DESCRIPTION. Connect treats it
|
|
212
|
+
# as a plain JSON object, so key order is just deterministic insertion order,
|
|
213
|
+
# not a contract. Writes are first-write-wins: setIf only fills a key the first
|
|
214
|
+
# time a truthy value is seen, so derived values never overwrite explicit ones.
|
|
215
|
+
desc = dict(initial)
|
|
216
|
+
|
|
217
|
+
def set_if(key: str, value: Any) -> None:
|
|
218
|
+
# setdefault keeps first-write-wins; the truthy guard avoids writing null/empty fields.
|
|
219
|
+
if value:
|
|
220
|
+
desc.setdefault(key, value)
|
|
221
|
+
|
|
222
|
+
for key in (
|
|
223
|
+
"Hash",
|
|
224
|
+
"Authors@R",
|
|
225
|
+
"Description",
|
|
226
|
+
"License",
|
|
227
|
+
"Maintainer",
|
|
228
|
+
"VignetteBuilder",
|
|
229
|
+
"RoxygenNote",
|
|
230
|
+
"Encoding",
|
|
231
|
+
"NeedsCompilation",
|
|
232
|
+
"Author",
|
|
233
|
+
"SystemRequirements",
|
|
234
|
+
"RemoteType",
|
|
235
|
+
"RemoteRef",
|
|
236
|
+
"RemoteRepos",
|
|
237
|
+
"RemoteReposName",
|
|
238
|
+
"RemotePkgPlatform",
|
|
239
|
+
"RemoteSha",
|
|
240
|
+
"RemoteHost",
|
|
241
|
+
"RemoteRepo",
|
|
242
|
+
"RemoteUsername",
|
|
243
|
+
"RemoteSubdir",
|
|
244
|
+
):
|
|
245
|
+
set_if(key, pkg.get(key))
|
|
246
|
+
set_if("GithubSubdir", pkg.get("RemoteSubdir"))
|
|
247
|
+
set_if("RemoteUrl", pkg.get("RemoteUrl"))
|
|
248
|
+
|
|
249
|
+
pkg_ref = _remote_pkg_ref_or_derived(pkg)
|
|
250
|
+
if pkg_ref:
|
|
251
|
+
desc["RemotePkgRef"] = pkg_ref
|
|
252
|
+
|
|
253
|
+
if pkg.get("RemoteType") == "github" and pkg.get("RemotePkgRef"):
|
|
254
|
+
set_if("URL", f"https://github.com/{pkg['RemotePkgRef']}")
|
|
255
|
+
set_if("BugReports", f"https://github.com/{pkg['RemotePkgRef']}/issues")
|
|
256
|
+
|
|
257
|
+
set_if("URL", pkg.get("URL"))
|
|
258
|
+
set_if("BugReports", pkg.get("BugReports"))
|
|
259
|
+
set_if("Repository", resolved_repo)
|
|
260
|
+
set_if("Config/testthat/edition", pkg.get("Config/testthat/edition"))
|
|
261
|
+
set_if("Config/Needs/website", pkg.get("Config/Needs/website"))
|
|
262
|
+
set_if("Imports", _join_list(pkg.get("Imports")))
|
|
263
|
+
set_if("Suggests", _join_list(pkg.get("Suggests")))
|
|
264
|
+
set_if("LinkingTo", _join_list(pkg.get("LinkingTo")))
|
|
265
|
+
|
|
266
|
+
if pkg.get("Depends"):
|
|
267
|
+
set_if("Depends", _join_list(pkg.get("Depends")))
|
|
268
|
+
elif pkg.get("Requirements"):
|
|
269
|
+
set_if("Depends", _join_list(pkg.get("Requirements")))
|
|
270
|
+
|
|
271
|
+
return desc
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _remote_pkg_ref_or_derived(pkg: Any) -> str:
|
|
275
|
+
if pkg.get("RemotePkgRef"):
|
|
276
|
+
return pkg["RemotePkgRef"]
|
|
277
|
+
if pkg.get("RemoteUsername") and pkg.get("RemoteRepo"):
|
|
278
|
+
return f"{pkg['RemoteUsername']}/{pkg['RemoteRepo']}"
|
|
279
|
+
return ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _remote_repo_url(remote_type: str, pkg_ref: str) -> str:
|
|
283
|
+
if not pkg_ref:
|
|
284
|
+
return ""
|
|
285
|
+
hosts = {
|
|
286
|
+
"github": "https://github.com/",
|
|
287
|
+
"gitlab": "https://gitlab.com/",
|
|
288
|
+
"bitbucket": "https://bitbucket.org/",
|
|
289
|
+
}
|
|
290
|
+
host = hosts.get(remote_type)
|
|
291
|
+
return f"{host}{pkg_ref}" if host else ""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _join_list(value: Optional[Sequence[Optional[str]]]) -> Optional[str]:
|
|
295
|
+
items = [v for v in value if v is not None] if value else []
|
|
296
|
+
return ", ".join(items) if items else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_url(value: str) -> bool:
|
|
300
|
+
return value.startswith(("http://", "https://", "ftp://"))
|
rsconnect/exception.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RSConnectException(Exception):
|
|
7
|
+
def __init__(self, message: str, cause: Optional[Exception] = None, status: Optional[int] = None):
|
|
8
|
+
super(RSConnectException, self).__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.cause = cause
|
|
11
|
+
self.status = status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeploymentFailedException(RSConnectException):
|
|
15
|
+
pass
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git metadata detection utilities for bundle uploads
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from .log import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_git_command(args: list[str], cwd: str) -> Optional[str]:
|
|
15
|
+
"""
|
|
16
|
+
Run a git command and return its output.
|
|
17
|
+
|
|
18
|
+
:param args: git command arguments
|
|
19
|
+
:param cwd: working directory
|
|
20
|
+
:return: command output or None if command failed
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["git"] + args,
|
|
25
|
+
# dirname() of a bare filename (e.g. "manifest.json") yields "",
|
|
26
|
+
# which subprocess rejects; treat it as the current directory.
|
|
27
|
+
cwd=cwd or ".",
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=5,
|
|
31
|
+
)
|
|
32
|
+
if result.returncode == 0:
|
|
33
|
+
return result.stdout.strip()
|
|
34
|
+
return None
|
|
35
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_git_repo(directory: str) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Check if directory is inside a git repository.
|
|
42
|
+
|
|
43
|
+
:param directory: directory to check
|
|
44
|
+
:return: True if inside a git repo, False otherwise
|
|
45
|
+
"""
|
|
46
|
+
result = _run_git_command(["rev-parse", "--git-dir"], directory)
|
|
47
|
+
return result is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_uncommitted_changes(directory: str) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Check if the git repository has uncommitted changes.
|
|
53
|
+
|
|
54
|
+
:param directory: directory to check
|
|
55
|
+
:return: True if there are uncommitted changes
|
|
56
|
+
"""
|
|
57
|
+
# Check for staged and unstaged changes
|
|
58
|
+
result = _run_git_command(["status", "--porcelain"], directory)
|
|
59
|
+
return bool(result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_git_commit(directory: str) -> Optional[str]:
|
|
63
|
+
"""
|
|
64
|
+
Get the current git commit SHA.
|
|
65
|
+
|
|
66
|
+
:param directory: directory to check
|
|
67
|
+
:return: commit SHA or None
|
|
68
|
+
"""
|
|
69
|
+
return _run_git_command(["rev-parse", "HEAD"], directory)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_git_branch(directory: str) -> Optional[str]:
|
|
73
|
+
"""
|
|
74
|
+
Get the current git branch name or tag.
|
|
75
|
+
|
|
76
|
+
:param directory: directory to check
|
|
77
|
+
:return: branch/tag name or None
|
|
78
|
+
"""
|
|
79
|
+
# First try to get branch name
|
|
80
|
+
branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], directory)
|
|
81
|
+
|
|
82
|
+
# If we're in detached HEAD state, try to get tag
|
|
83
|
+
if branch == "HEAD":
|
|
84
|
+
tag = _run_git_command(["describe", "--exact-match", "--tags"], directory)
|
|
85
|
+
if tag:
|
|
86
|
+
return tag
|
|
87
|
+
|
|
88
|
+
return branch
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_git_remote_url(directory: str, remote: str = "origin") -> Optional[str]:
|
|
92
|
+
"""
|
|
93
|
+
Get the URL of a git remote.
|
|
94
|
+
|
|
95
|
+
:param directory: directory to check
|
|
96
|
+
:param remote: remote name (default: "origin")
|
|
97
|
+
:return: remote URL or None
|
|
98
|
+
"""
|
|
99
|
+
return _run_git_command(["remote", "get-url", remote], directory)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalize_git_url_to_https(url: Optional[str]) -> Optional[str]:
|
|
103
|
+
"""
|
|
104
|
+
Normalize a git URL to HTTPS format.
|
|
105
|
+
|
|
106
|
+
Converts SSH URLs like git@github.com:user/repo.git to
|
|
107
|
+
https://github.com/user/repo.git
|
|
108
|
+
|
|
109
|
+
:param url: git URL to normalize
|
|
110
|
+
:return: normalized HTTPS URL or original if already HTTPS/not recognized
|
|
111
|
+
"""
|
|
112
|
+
if not url:
|
|
113
|
+
return url
|
|
114
|
+
|
|
115
|
+
# Already HTTPS
|
|
116
|
+
if url.startswith("https://"):
|
|
117
|
+
return url
|
|
118
|
+
|
|
119
|
+
# Handle git@ SSH format
|
|
120
|
+
if url.startswith("git@"):
|
|
121
|
+
# git@github.com:user/repo.git -> https://github.com/user/repo.git
|
|
122
|
+
# Remove git@ prefix
|
|
123
|
+
url = url[4:]
|
|
124
|
+
# Replace first : with /
|
|
125
|
+
url = url.replace(":", "/", 1)
|
|
126
|
+
# Add https://
|
|
127
|
+
return f"https://{url}"
|
|
128
|
+
|
|
129
|
+
# Handle ssh:// format
|
|
130
|
+
if url.startswith("ssh://"):
|
|
131
|
+
# ssh://git@github.com/user/repo.git -> https://github.com/user/repo.git
|
|
132
|
+
parsed = urlparse(url)
|
|
133
|
+
if parsed.hostname:
|
|
134
|
+
path = parsed.path
|
|
135
|
+
return f"https://{parsed.hostname}{path}"
|
|
136
|
+
|
|
137
|
+
# Return as-is if we can't normalize
|
|
138
|
+
return url
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def detect_git_metadata(directory: str, remote: str = "origin") -> dict[str, str]:
|
|
142
|
+
"""
|
|
143
|
+
Detect git metadata for the given directory.
|
|
144
|
+
|
|
145
|
+
:param directory: directory to inspect
|
|
146
|
+
:param remote: git remote name to use (default: "origin")
|
|
147
|
+
:return: dictionary with source, source_repo, source_branch, source_commit keys
|
|
148
|
+
"""
|
|
149
|
+
metadata: dict[str, str] = {}
|
|
150
|
+
|
|
151
|
+
if not is_git_repo(directory):
|
|
152
|
+
logger.debug(f"Directory {directory} is not a git repository")
|
|
153
|
+
return metadata
|
|
154
|
+
|
|
155
|
+
# Get commit SHA
|
|
156
|
+
commit = get_git_commit(directory)
|
|
157
|
+
if commit:
|
|
158
|
+
# Check for uncommitted changes
|
|
159
|
+
if has_uncommitted_changes(directory):
|
|
160
|
+
commit = f"{commit}-dirty"
|
|
161
|
+
metadata["source_commit"] = commit
|
|
162
|
+
|
|
163
|
+
# Get branch/tag
|
|
164
|
+
branch = get_git_branch(directory)
|
|
165
|
+
if branch:
|
|
166
|
+
metadata["source_branch"] = branch
|
|
167
|
+
|
|
168
|
+
# Get remote URL and normalize to HTTPS
|
|
169
|
+
remote_url = get_git_remote_url(directory, remote)
|
|
170
|
+
if remote_url:
|
|
171
|
+
normalized_url = normalize_git_url_to_https(remote_url)
|
|
172
|
+
if normalized_url:
|
|
173
|
+
metadata["source_repo"] = normalized_url
|
|
174
|
+
|
|
175
|
+
# Always set source to "git" if we got any metadata
|
|
176
|
+
if metadata:
|
|
177
|
+
metadata["source"] = "git"
|
|
178
|
+
logger.debug(f"Detected git metadata: {metadata}")
|
|
179
|
+
|
|
180
|
+
return metadata
|