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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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