gOdoo-cli 0.0.2__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.
@@ -0,0 +1,95 @@
1
+ import concurrent.futures
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Dict
5
+
6
+ from git import Commit
7
+
8
+ from ..helpers.repospec import update_yml, yaml_roundtrip_loader
9
+ from .git_repo import git_ensure_repo
10
+ from .git_url import GitUrl
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def git_ensure_addon_repos(
16
+ root_folder: Path,
17
+ git_yml_path: Path,
18
+ generate_yml_compare_comments: bool = False,
19
+ download_archive: bool = False,
20
+ ):
21
+ """
22
+ Clone repos specified in Yml.
23
+
24
+ Parameters
25
+ ----------
26
+ root_folder : Path
27
+ clone target folder
28
+ git_yml_path : Path
29
+ yml describing what to clone
30
+ generate_yml_compare_comments : bool, optional
31
+ wether to add three dot compare on remote to repo urls
32
+ download_archive : bool, optional
33
+ wether to download as .zip (fast but no history), by default False
34
+ """
35
+ yaml = yaml_roundtrip_loader()
36
+ git_repos = yaml.load(git_yml_path.resolve())
37
+ if _git_clone_addon_repos(root_folder=root_folder, git_repos=git_repos, download_archive=download_archive):
38
+ update_yml(git_repos, generate_yml_compare_comments)
39
+ LOGGER.info("Updating Git Thirdparty Repo Commit hashes")
40
+ yaml.dump(git_repos, git_yml_path)
41
+
42
+ return git_repos
43
+
44
+
45
+ def _git_clone_addon_repos(
46
+ root_folder: Path,
47
+ git_repos: Dict[str, Dict[str, str]],
48
+ download_archive: bool = False,
49
+ ) -> Dict[str, Commit]:
50
+ """
51
+ Clones Git repos specified in dict into Root folder.
52
+ Ensures repo names are prefixed and uses 8 threads to clone.
53
+
54
+ Parameters
55
+ ----------
56
+ root_folder : Path
57
+ clone target folder
58
+ git_repos : Dict[str, Dict[str, str]]
59
+ dict of {parent_folder_name:{"url":clone_url,"branch":clone_branch,"commit":specific_commit_to_clone}}
60
+ branch and commit are optional
61
+ branch defaults to odoo branch from spec file
62
+ download_archive : bool, optional
63
+ wether to download as .zip (fast but no history), by default False
64
+ Returns
65
+ -------
66
+ Dict[str:Commit]
67
+ dict of {git_src_url:HeadCommit}
68
+ """
69
+ default_branch = git_repos["odoo"].get("branch")
70
+ LOGGER.info("Cloning Thirdparty Addons source.")
71
+ with concurrent.futures.ThreadPoolExecutor(8) as executor:
72
+ futures = []
73
+ thirdparty_repos = git_repos.get("thirdparty")
74
+ if not thirdparty_repos:
75
+ LOGGER.info("No Thirdparty Key in Repospec. Skipping...")
76
+ return
77
+ for prefix, repos in thirdparty_repos.items():
78
+ for repo in repos:
79
+ repo_url = GitUrl(repo["url"])
80
+ name = f"{prefix}_{repo_url.name}"
81
+ futures.append(
82
+ executor.submit(
83
+ git_ensure_repo,
84
+ target_folder=Path(root_folder / name),
85
+ repo_src=repo_url.url,
86
+ branch=repo.get("branch", default_branch),
87
+ commit=repo.get("commit"),
88
+ zip_mode=download_archive,
89
+ filter="blob:none",
90
+ single_branch=True,
91
+ )
92
+ )
93
+ clone_results = [f.result() for f in futures]
94
+ clone_results = {r[0]: r[1] for r in clone_results if r}
95
+ return clone_results
@@ -0,0 +1,157 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from git import InvalidGitRepositoryError, Repo
5
+
6
+ from .zip_download import git_download_zip
7
+
8
+ LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ def git_pull_checkout_reset(
12
+ repo: Repo, branch: str = "master", commit: str = "", pull: str = "", reset_hard: bool = True
13
+ ):
14
+ """
15
+ Ensure git Repo is on specific branch and commit.
16
+ Will unshallow if neccessary
17
+
18
+ Parameters
19
+ ----------
20
+ repo : Repo
21
+ Git Repo
22
+ branch : str, optional
23
+ branch to checkout, by default "master"
24
+ commit : str, optional
25
+ specific commitSHA to checkout, by default ""
26
+ pull : str, optional
27
+ specific target to pull. Usually used in conjunction with branch.
28
+ reset_hard : bool, optional
29
+ will call reset --hard after checkout to ensure clean git repo, by default True
30
+ """
31
+
32
+ try:
33
+ if pull:
34
+ LOGGER.debug("Pulling Repo: %s, %s", repo.git_dir, pull)
35
+ repo.remotes[0].pull(pull)
36
+ if commit:
37
+ if str(repo.head.commit) != str(commit):
38
+ LOGGER.debug("Checking out %s to Commit: %s", repo.git_dir, commit)
39
+ repo.git.checkout(commit)
40
+ return
41
+ if branch:
42
+ LOGGER.debug("Checking Out repo %s to Branch: %s", repo.git_dir, branch)
43
+ repo.git.checkout(branch)
44
+ if reset_hard:
45
+ repo.git.reset("--hard")
46
+ except Exception as e:
47
+ raise Exception(
48
+ str(f"Error while ensuring Git repo: {str(repo)}, branch='{branch}', commit='{commit}', fetch='{pull}'")
49
+ ) from e
50
+
51
+
52
+ def git_ensure_ref(
53
+ target_folder: Path,
54
+ repo_src: str,
55
+ branch: str = "master",
56
+ commit: str = "",
57
+ pull: str = "",
58
+ **kwargs,
59
+ ):
60
+ """
61
+ Clone a git Repo and ensure its HEAD is set to Branch and commit.
62
+
63
+
64
+ Parameters
65
+ ----------
66
+ target_folder : Path
67
+ "target folder name"
68
+ repo_src : str
69
+ "repo source url"
70
+ commit : str, optional
71
+ commit on which to set the head
72
+ pull : str, optional
73
+ specific target to pull. Usually used in conjunction with branch.
74
+ branch : str, optional
75
+ branch on which to set head, by default 'master'
76
+ **kwargs
77
+ get passed to git clone
78
+ """
79
+ LOGGER.info("Ensuring Repo '%s' --> '%s'", repo_src, target_folder)
80
+ target_folder.mkdir(exist_ok=True, parents=True)
81
+ try:
82
+ repo = Repo(target_folder)
83
+ current = repo.head.commit
84
+
85
+ if not pull and commit and str(commit) != str(current):
86
+ pull = str(commit)
87
+
88
+ if not pull and branch and not commit:
89
+ pull = str(branch)
90
+ git_pull_checkout_reset(repo=repo, branch=branch, commit=commit, pull=pull)
91
+
92
+ if current == repo.head.commit:
93
+ LOGGER.info("Repo Commit matches. Skipping: '%s' --> '%s'", repo_src, target_folder)
94
+ else:
95
+ LOGGER.info("Pulled Repo from: '%s'. Head is now at: %s", repo_src, repo.head.commit)
96
+ except InvalidGitRepositoryError:
97
+ LOGGER.debug("Cloning Repo: %s, to '%s', branch '%s'", repo_src, target_folder, branch)
98
+ repo = Repo.clone_from(repo_src, target_folder, branch=branch, **kwargs)
99
+ git_pull_checkout_reset(repo=repo, branch=branch, commit=commit, pull=pull)
100
+
101
+ LOGGER.info(
102
+ "Cloned Repo: '%s'. Branch='%s' Head='%s'",
103
+ repo_src,
104
+ repo.active_branch.name if not repo.head.is_detached else "Detached",
105
+ repo.head.commit,
106
+ )
107
+ return repo_src, repo.head.commit
108
+
109
+
110
+ def git_ensure_repo(
111
+ target_folder: Path,
112
+ repo_src: str,
113
+ branch: str = "master",
114
+ commit: str = "",
115
+ pull: str = "",
116
+ zip_mode: bool = False,
117
+ **kwargs,
118
+ ):
119
+ """Ensures git remote contents are in folder.
120
+
121
+ Parameters
122
+ ----------
123
+ target_folder : Path
124
+ Folder to clone/download into
125
+ repo_src : str
126
+ Git Remote Url
127
+ branch : str, optional
128
+ target branch, by default "master"
129
+ commit : str, optional
130
+ target ref, by default ""
131
+ pull : str, optional
132
+ ref to pull, by default ""
133
+ zip_mode : bool, optional
134
+ wether to download a zip (fast but no commit history) or clone and fetch (fully working git clone), by default False
135
+ **kwargs
136
+ get passed to git clone
137
+ """
138
+
139
+ if target_folder.exists() and any(target_folder.iterdir()) and not target_folder.glob(".git"):
140
+ LOGGER.info("Assuming '%s' got pulled in .Zip mode. Skipping Clone and commit check.", target_folder)
141
+ zip_mode = True
142
+
143
+ if zip_mode:
144
+ return git_download_zip(
145
+ target_folder=target_folder,
146
+ repo_url=repo_src,
147
+ branch=branch,
148
+ commit=commit,
149
+ )
150
+ return git_ensure_ref(
151
+ target_folder=target_folder,
152
+ repo_src=repo_src,
153
+ branch=branch,
154
+ commit=commit,
155
+ pull=pull,
156
+ **kwargs,
157
+ )
@@ -0,0 +1,152 @@
1
+ import re
2
+ from enum import Enum
3
+ from typing import Literal
4
+
5
+
6
+ class GitRemoteType(Enum):
7
+ gitlab = "gitlab"
8
+ github = "github"
9
+
10
+
11
+ class GitUrl:
12
+ """Class to Structurize Git URLs.
13
+ Works with SSH and HTTP(s) URLs
14
+ Can generate Compare Urls
15
+ """
16
+
17
+ url: str
18
+ url_type: Literal["http", "ssh"]
19
+ domain: str
20
+ path: str
21
+ user: str
22
+ port: int
23
+ name: str
24
+
25
+ def __init__(self, url: str) -> None:
26
+ self.url = url
27
+ if "http" in url:
28
+ http_regex = r"(?P<schema>https?):\/\/(?P<domain>[^\/]+)(?P<path>.*)"
29
+ http_match = re.search(http_regex, url)
30
+ self.url_type = http_match.group("schema")
31
+ self.domain = http_match.group("domain")
32
+ self.path = http_match.group("path")
33
+ else:
34
+ ssh_regex = r"(?P<user>\w+)@(?P<domain>[^:]+):(?:(?P<port>\d+)]?:)?(?P<path>.*)"
35
+ ssh_match = re.search(ssh_regex, url)
36
+ self.url_type = "ssh"
37
+ self.domain = ssh_match.group("domain")
38
+ self.path = ssh_match.group("path")
39
+ self.user = ssh_match.group("user")
40
+ self.port = ssh_match.group("port")
41
+
42
+ if self.path.endswith(".git"): # Todo Python 3.9 Use Removesuffix and RemovePrefix
43
+ self.path = self.path[:-4]
44
+ if self.path.endswith("/"):
45
+ self.path = self.path[:-1]
46
+ if self.path.startswith("/"):
47
+ self.path = self.path[1:]
48
+ self.name = self.path.split("/")[-1]
49
+
50
+ def _clean_http_url(self) -> str:
51
+ """Return HTTPs URL without .git suffix.
52
+
53
+ Returns
54
+ -------
55
+ str
56
+ Https:// url
57
+ """
58
+ return f"https://{self.domain}/{self.path}"
59
+
60
+ def _git_type(self) -> GitRemoteType:
61
+ """Get git Remote type.
62
+
63
+ Returns
64
+ -------
65
+ Literal
66
+ "github", "gitlab"
67
+
68
+ Raises
69
+ ------
70
+ ValueError
71
+ If Type cannot be determined.
72
+ """
73
+ if "gitlab" in self.domain:
74
+ return GitRemoteType.gitlab
75
+ elif "github" in self.domain:
76
+ return GitRemoteType.github
77
+ else:
78
+ raise ValueError(f"Cant get Git Service type from {self.domain}")
79
+
80
+ def get_compare_url(self, from_compare: str, to_compare: str) -> str:
81
+ """Get Compare url between two Refs.
82
+
83
+ Parameters
84
+ ----------
85
+ from_compare : str
86
+ from compare ref
87
+ to_compare : str
88
+ to compare ref
89
+
90
+ Returns
91
+ -------
92
+ str
93
+ Compare Url Like: https://github.com/odoo/odoo/compare/<commitSHA>...<branchName>
94
+ """
95
+ remote_type = self._git_type()
96
+ if from_compare == to_compare:
97
+ return # Nothing to Compare here
98
+ http_url = self._clean_http_url()
99
+ if remote_type in [GitRemoteType.github, GitRemoteType.gitlab]:
100
+ return f"{http_url}/compare/{from_compare}...{to_compare}"
101
+
102
+ def get_archive_url(self, ref: str) -> str:
103
+ """Get Download Url for Zip file.
104
+
105
+ Parameters
106
+ ----------
107
+ branch : str, optional
108
+ Repo Branch, by default ""
109
+ commit : str, optional
110
+ Repo download ref, by default ""
111
+
112
+ Returns
113
+ -------
114
+ str
115
+ Url from which to download a zip file
116
+
117
+ Raises
118
+ ------
119
+ ValueError
120
+ if ref is not specified
121
+ """
122
+ if not ref:
123
+ raise ValueError("Missing either download ref (e.g. branch or commit) to generate Archive URL.")
124
+ http_url = self._clean_http_url()
125
+ remote_type = self._git_type()
126
+ if remote_type == GitRemoteType.github:
127
+ return f"{http_url}/archive/{ref }.zip"
128
+ if remote_type == GitRemoteType.gitlab:
129
+ return f"{http_url}/-/archive/{ref}/{self.name}.zip"
130
+
131
+ def get_file_raw_url(self, ref: str, file_path: str) -> str:
132
+ """Gets the URL Pointing to the Raw file contents on the Remote.
133
+
134
+ Parameters
135
+ ----------
136
+ ref : str
137
+ Branch, Commit, Tag ,...
138
+ file_path : str
139
+ Relative file path in Repository
140
+
141
+ Returns
142
+ -------
143
+ str
144
+ URL Pointing to the Raw file contents on the Remote
145
+ """
146
+
147
+ http_url = self._clean_http_url()
148
+ remote_type = self._git_type()
149
+ if remote_type == GitRemoteType.github:
150
+ return f"{http_url.replace(self.domain,'raw.githubusercontent.com')}/{ref}/{file_path}"
151
+ if remote_type == GitRemoteType.gitlab:
152
+ return f"{http_url}/-/raw/{ref}/{file_path}"
@@ -0,0 +1,47 @@
1
+ import logging
2
+ import tempfile
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ from ..helpers import download_file
7
+ from .git_url import GitUrl
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ def git_download_zip(repo_url: str, target_folder: Path, branch: str, commit: str = ""):
13
+ """Download Repo Zip from Github.
14
+
15
+ Parameters
16
+ ----------
17
+ repo_url : str
18
+ Github Repo Url
19
+ target_folder : Path
20
+ Download Target
21
+ branch : str
22
+ BRanch to download
23
+ commit : str, optional
24
+ Specific Commit to download, by default ""
25
+
26
+ Raises
27
+ ------
28
+ FileNotFoundError
29
+ If Download failed
30
+ """
31
+ git_url = GitUrl(repo_url)
32
+ download_url = git_url.get_archive_url(ref=commit or branch)
33
+ with tempfile.TemporaryDirectory() as tdir:
34
+ zip_path = Path(tdir) / f"{git_url.name}.zip"
35
+ LOGGER.info("Downloading GitRepo Zip: '%s'", download_url)
36
+ LOGGER.debug("Target Path: '%s' ", zip_path)
37
+ download_file(download_url, zip_path)
38
+ if not zip_path.exists():
39
+ raise FileNotFoundError(f"Could not download Repo Zip from: {download_url}")
40
+ LOGGER.info("Extracting GitRepo Zip: %s", git_url.name)
41
+ ex_location = Path(tdir) / "extract"
42
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
43
+ zip_ref.extractall(ex_location)
44
+ for path in ex_location.glob("*"):
45
+ LOGGER.info("Moving %s to %s", path.stem, target_folder)
46
+ path.rename(target_folder)
47
+ break
@@ -0,0 +1,83 @@
1
+ """Provides Various Helper Functions for Odoo"""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import requests
9
+ from rich.logging import RichHandler
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ def ensure_dotenv(varname: str) -> str:
15
+ """
16
+ Load Env Var.
17
+ Raise Error if not Set.
18
+
19
+ Parameters
20
+ ----------
21
+ varname : str
22
+ env var name
23
+
24
+ Returns
25
+ -------
26
+ str
27
+ env value
28
+
29
+ Raises
30
+ ------
31
+ ReferenceError
32
+ if env var is none
33
+ """
34
+ var = os.getenv(varname)
35
+ if var is None:
36
+ raise ReferenceError(f"Env Variable: {varname} is not set")
37
+ return var
38
+
39
+
40
+ def set_logging(verbose: bool = False) -> None:
41
+ """
42
+ Set the Logging Config according to passed arguments.
43
+
44
+ Parameters
45
+ ----------
46
+ verbose : bool
47
+ Wether to Log debug Messages
48
+ """
49
+ if verbose:
50
+ logging.basicConfig(
51
+ level=logging.DEBUG,
52
+ format="{message}",
53
+ style="{",
54
+ handlers=[RichHandler(level=logging.DEBUG, rich_tracebacks=True)],
55
+ datefmt="%Y-%m-%d %H:%M:%S",
56
+ )
57
+ else:
58
+ logging.basicConfig(
59
+ level=logging.INFO,
60
+ format="{message}",
61
+ style="{",
62
+ handlers=[RichHandler(level=logging.INFO, show_path=False, rich_tracebacks=False)],
63
+ datefmt="%Y-%m-%d %H:%M:%S",
64
+ )
65
+
66
+
67
+ def download_file(url: str, save_path: Path, chunk_size: int = 128) -> None:
68
+ """Download file from URL.
69
+
70
+ Parameters
71
+ ----------
72
+ url : _type_
73
+ url to get file from
74
+ save_path : _type_
75
+ Where to save the file
76
+ chunk_size : int, optional
77
+ Chunk size to iterate over request, by default 128
78
+ """
79
+ LOGGER.debug("Downloading File: '%s' to '%s'", url, save_path)
80
+ r = requests.get(url, stream=True)
81
+ with open(save_path, "wb") as fd:
82
+ for chunk in r.iter_content(chunk_size=chunk_size):
83
+ fd.write(chunk)
@@ -0,0 +1,85 @@
1
+ import functools
2
+ import inspect
3
+ import logging
4
+ import os
5
+ import sys
6
+ from typing import Callable
7
+
8
+ import typer
9
+ from typer.models import ParameterInfo
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ def typer_retuner(ret):
15
+ """Exit Typer command with return code, if parent of calling function is not the typer_unpacker wrapper.
16
+ Will just return ret if ret is either not an integer, or the parent function was another python func.
17
+
18
+ Ensures, that we exit the CLI with a return code, only if the function is the CLI base command.
19
+
20
+ Parameters
21
+ ----------
22
+ ret : Any
23
+ Value to either return or Exit
24
+
25
+ Returns
26
+ -------
27
+ Any
28
+ ret
29
+ """
30
+ parent_func = sys._getframe(1).f_code.co_name
31
+ if isinstance(ret, int):
32
+ parent_name = sys._getframe(2).f_code.co_name
33
+ if parent_name != "typer_unwrapper":
34
+ LOGGER.debug("Exiting Typer Command: %s with code %s", parent_func, ret)
35
+ if ret > 255:
36
+ LOGGER.debug("Capping exit code to 255")
37
+ ret = 255
38
+ raise typer.Exit(code=ret)
39
+ LOGGER.debug("Returning Typer Command: %s", parent_func)
40
+ return ret
41
+
42
+
43
+ def typer_unpacker(f: Callable):
44
+ """
45
+ Apply as decorator to typer command function to make the function callable from python
46
+ if you used typer.Argument or typer.Option.
47
+
48
+ """
49
+
50
+ @functools.wraps(f)
51
+ def typer_unwrapper(*args, **kwargs):
52
+ # Get the default function argument that aren't passed in kwargs via the
53
+ # inspect module: https://stackoverflow.com/a/12627202
54
+ missing_default_values = {
55
+ k: (v.default, v._annotation)
56
+ for k, v in inspect.signature(f).parameters.items()
57
+ if v.default is not inspect.Parameter.empty and k not in kwargs
58
+ }
59
+
60
+ for name, (func_default, target_type) in missing_default_values.items():
61
+ # If the default value is a typer.Option or typer.Argument, we have to
62
+ # pull either the .default attribute and pass it in the function
63
+ # invocation, or call it first.
64
+ if isinstance(func_default, ParameterInfo):
65
+ if ev := func_default.envvar:
66
+ env_var = os.getenv(ev)
67
+ try:
68
+ if env_res := target_type(env_var) if target_type else env_var:
69
+ kwargs[name] = env_res
70
+ continue
71
+ except TypeError as e:
72
+ raise TypeError(
73
+ f"Type mismatch in: '{ev}' Expected: '{target_type}' Got: '{type(env_var)}"
74
+ ) from e
75
+ if callable(func_default.default):
76
+ kwargs[name] = func_default.default()
77
+ else:
78
+ if func_default.default is Ellipsis:
79
+ raise ValueError(f"Missing required Argument for: {name}")
80
+ kwargs[name] = func_default.default
81
+
82
+ # Call the wrapped function with the defaults injected if not specified.
83
+ return f(*args, **kwargs)
84
+
85
+ return typer_unwrapper