wowool-build 0.0.1__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,195 @@
1
+ from os import environ
2
+ from sys import stderr
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Union
5
+ from pathlib import Path
6
+ from subprocess import run
7
+ import hashlib
8
+ import json
9
+ import re
10
+ from exceptions import UploadError
11
+
12
+
13
+ @dataclass
14
+ class AwsCredentials:
15
+ """
16
+ Credentials to access AWS
17
+ """
18
+
19
+ domain: str
20
+ domain_owner: int
21
+ region: str
22
+
23
+ @staticmethod
24
+ def from_environment():
25
+ return AwsCredentials(
26
+ domain=environ["AWS_DOMAIN"],
27
+ domain_owner=int(environ["AWS_DOMAIN_OWNER"]),
28
+ region=environ["AWS_REGION"],
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class CodeArtifactCredentials:
34
+ """
35
+ Credentials to access a CodeArtifact repository
36
+ """
37
+
38
+ repository: str
39
+
40
+ @staticmethod
41
+ def from_environment():
42
+ return CodeArtifactCredentials(repository=environ["AWS_CODEARTIFACT_REPOSITORY"])
43
+
44
+
45
+ def _check_not_dirty(fn: Path):
46
+ if "dirty" in fn.name:
47
+ raise UploadError(f"Attempted to upload a dirty version: {fn}")
48
+
49
+
50
+ def upload_pypi(fp: Path, expression: str = "*"):
51
+ """
52
+ Upload a Python package to AWS CodeArtifact
53
+ """
54
+ fp_dist = fp / "dist"
55
+ for fn in fp_dist.glob(expression):
56
+ _check_not_dirty(fn)
57
+ cmd = f"twine upload {fn} --repository codeartifact"
58
+ try:
59
+ run(cmd, shell=True, check=True, cwd=fp)
60
+ except Exception as error:
61
+ raise UploadError(f"AWS CodeArtifact Twine upload failed: {error}")
62
+
63
+
64
+ def _sha256sum(fn: Path):
65
+ with open(fn, "rb", buffering=0) as ifs:
66
+ return hashlib.file_digest(ifs, "sha256").hexdigest() # pyright: ignore
67
+
68
+
69
+ def delete_raw(
70
+ package: str,
71
+ version: str,
72
+ namespace: str | None = None,
73
+ format: str = "generic",
74
+ aws: Optional[AwsCredentials] = None,
75
+ codeartifact: Optional[CodeArtifactCredentials] = None,
76
+ ):
77
+ aws = aws or AwsCredentials.from_environment()
78
+ codeartifact = codeartifact or CodeArtifactCredentials.from_environment()
79
+ options = f"--namespace {namespace}" if namespace != None else ""
80
+ options += f" --format {format}"
81
+ cmd = f"""aws codeartifact delete-package-versions --domain {aws.domain} --domain-owner {aws.domain_owner} --region {aws.region} --repository {codeartifact.repository} {options} --package {package} --versions {version} """
82
+ result = run(cmd, shell=True, check=False, capture_output=True)
83
+ print(result.stdout.decode())
84
+ if result.returncode != 0:
85
+ print(result.stderr.decode(), file=stderr)
86
+ raise UploadError(f"AWS CodeArtifact generic upload failed {cmd=}")
87
+
88
+
89
+ def upload_raw(
90
+ fn: Path,
91
+ namespace: str,
92
+ package: str,
93
+ version: str,
94
+ aws: Optional[AwsCredentials] = None,
95
+ codeartifact: Optional[CodeArtifactCredentials] = None,
96
+ overwrite=False,
97
+ ):
98
+ fn = Path(fn)
99
+ aws = aws or AwsCredentials.from_environment()
100
+ codeartifact = codeartifact or CodeArtifactCredentials.from_environment()
101
+
102
+ if overwrite:
103
+ try:
104
+ delete_raw(namespace=namespace, package=package, version=version)
105
+ except Exception as error:
106
+ print(f"AWS CodeArtifact generic delete failed: {error}", file=stderr)
107
+
108
+ try:
109
+ asset_hash = _sha256sum(fn)
110
+ except Exception as error:
111
+ raise UploadError(f"Could not produce sha256 hash for {fn}: {error}")
112
+ try:
113
+ cmd = f"""aws codeartifact publish-package-version --domain {aws.domain} --domain-owner {aws.domain_owner} --region {aws.region} --repository {codeartifact.repository} --format generic --namespace {namespace} --package {package} --package-version {version} --asset-content {fn} --asset-name {fn.name} --asset-sha256 '{asset_hash}' --unfinished"""
114
+ result = run(cmd, shell=True, check=False, capture_output=True)
115
+ print(result.stdout.decode())
116
+ if result.returncode != 0:
117
+ print(result.stderr.decode(), file=stderr)
118
+ raise UploadError(f"AWS CodeArtifact generic upload failed {cmd=}")
119
+ except Exception as error:
120
+ raise UploadError(f"AWS CodeArtifact generic upload failed: {error}")
121
+
122
+
123
+ def run_codeartifact(
124
+ cmd: str,
125
+ aws: Optional[AwsCredentials] = None,
126
+ codeartifact: Optional[CodeArtifactCredentials] = None,
127
+ ):
128
+ aws = aws or AwsCredentials.from_environment()
129
+ codeartifact = codeartifact or CodeArtifactCredentials.from_environment()
130
+ try:
131
+ codeartifact_cmd = f"aws codeartifact {cmd}"
132
+ print(f"Running: {codeartifact_cmd}")
133
+ result = run(codeartifact_cmd, shell=True, check=False, capture_output=True)
134
+
135
+ if result.returncode != 0:
136
+ print(result.stderr.decode(), file=stderr)
137
+ raise UploadError(f"AWS CodeArtifact generic cmd failed {cmd=}")
138
+ else:
139
+ return result.stdout.decode()
140
+ except Exception as error:
141
+ raise UploadError(f"AWS CodeArtifact generic cmd failed: {error}")
142
+
143
+
144
+ def download_raw(
145
+ output: Union[str, Path],
146
+ namespace: str,
147
+ package: str,
148
+ version: str,
149
+ aws: Optional[AwsCredentials] = None,
150
+ codeartifact: Optional[CodeArtifactCredentials] = None,
151
+ ):
152
+ output = Path(output)
153
+ aws = aws or AwsCredentials.from_environment()
154
+ codeartifact = codeartifact or CodeArtifactCredentials.from_environment()
155
+ try:
156
+ cmd = f"""aws codeartifact get-package-version-asset --package-version {version} --domain {aws.domain} --domain-owner {aws.domain_owner} --region {aws.region} --repository {codeartifact.repository} --format generic --namespace {namespace} --package {package} --asset {output.name} {output}"""
157
+ result = run(cmd, shell=True, check=False, capture_output=True)
158
+ print(result.stdout.decode())
159
+ if result.returncode != 0:
160
+ print(result.stderr.decode(), file=stderr)
161
+ raise UploadError(f"AWS CodeArtifact generic upload failed {cmd=}")
162
+ except Exception as error:
163
+ raise UploadError(f"AWS CodeArtifact generic upload failed: {error}")
164
+
165
+
166
+ def iter_packages(expression=".*", aws: Optional[AwsCredentials] = None, codeartifact: Optional[CodeArtifactCredentials] = None):
167
+
168
+ exp_ = re.compile(expression)
169
+ cmd = "list-packages --domain wowool --repository wowool --max-items 1000 "
170
+ data = run_codeartifact(cmd, aws, codeartifact)
171
+ jo = json.loads(data)
172
+ for package in jo["packages"]:
173
+ if exp_.search(package["package"]):
174
+ yield package["package"]
175
+
176
+
177
+ def list_package_info(
178
+ expression: str = ".*",
179
+ version_expression: str = ".*",
180
+ aws: Optional[AwsCredentials] = None,
181
+ codeartifact: Optional[CodeArtifactCredentials] = None,
182
+ ):
183
+
184
+ version_exp_ = re.compile(version_expression)
185
+ for name in iter_packages(expression, aws, codeartifact):
186
+ try:
187
+ cmd = f"list-package-versions --domain wowool --repository wowool --format pypi --package {name}"
188
+ jo_version = json.loads(run_codeartifact(cmd))
189
+ # print(jo_version)
190
+ # yield jo_version['defaultDisplayVersion']
191
+ for version in jo_version["versions"]:
192
+ if version_exp_.match(version["version"]):
193
+ yield jo_version["namespace"], jo_version["package"], version["version"], jo_version["format"]
194
+ except UploadError as error:
195
+ print(f"Error: {error}", file=stderr)
@@ -0,0 +1,2 @@
1
+ class UploadError(RuntimeError):
2
+ pass
wowool/build/git.py ADDED
@@ -0,0 +1,88 @@
1
+ from logging import getLogger
2
+ from os import getcwd
3
+ from pathlib import Path
4
+ from subprocess import run, CalledProcessError
5
+ from typing import Optional, Union
6
+ from os import environ
7
+
8
+ logger = getLogger(__name__)
9
+
10
+
11
+ def _get_repository_path(fp_repo: Optional[Union[str, Path]] = None) -> Path:
12
+ if fp_repo is None:
13
+ return Path(getcwd())
14
+ else:
15
+ return Path(fp_repo).parent if Path(fp_repo).is_file() else Path(fp_repo)
16
+
17
+
18
+ def increment_version(version: str) -> str:
19
+ try:
20
+ parts = version.split(".")
21
+ parts[-1] = str(int(parts[-1]) + 1)
22
+ return ".".join(parts)
23
+ except ValueError:
24
+ return version
25
+
26
+
27
+ def make_version(tag: str, nr_commits: int, has_changes: bool):
28
+ postfix = environ.get("WOWOOL_VERSION_POSTFIX", f".dev{nr_commits}+dirty")
29
+ if "dev0" in tag:
30
+ tag = tag.replace(".dev0", "")
31
+ version = f"{tag}.dev{nr_commits}" if has_changes == 0 else f"{tag}{postfix}"
32
+ return version
33
+ else:
34
+ if has_changes == 0:
35
+ if nr_commits == 0:
36
+ return tag
37
+ else:
38
+ tag = increment_version(tag)
39
+ if "WOWOOL_VERSION_POSTFIX" not in environ:
40
+ version = f"{tag}.dev{nr_commits}"
41
+ else:
42
+ version = f"{tag}{postfix}"
43
+ return version
44
+ else:
45
+ tag = increment_version(tag)
46
+ version = f"{tag}{postfix}"
47
+ return version
48
+
49
+
50
+ def run_safe(cmd: str, capture_output: bool = True, cwd: Optional[Union[str, Path]] = None) -> str:
51
+ try:
52
+ res = run(cmd, shell=True, check=True, cwd=cwd, capture_output=capture_output)
53
+ return res.stdout.decode("utf-8").strip()
54
+ except CalledProcessError as ex:
55
+ logger.error(f"Error running command: {cmd}")
56
+ logger.error(ex)
57
+ print(ex.stderr.decode("utf-8"))
58
+ print(ex.stderr.decode("utf-8"))
59
+ raise ex
60
+
61
+
62
+ def get_version_info(fp_repo: Optional[Union[str, Path]] = None) -> dict:
63
+ tag = run_safe("git describe --tags --abbrev=0", cwd=fp_repo)
64
+ nr_commits_result = run_safe(f"git log {tag}..HEAD --oneline", cwd=fp_repo)
65
+ nr_commits = len(nr_commits_result.splitlines())
66
+ has_changes = run("git diff --quiet --exit-code HEAD", shell=True).returncode != 0
67
+ return {"tag": tag, "nr_commits": nr_commits, "has_changes": has_changes}
68
+
69
+
70
+ def get_version(fp_repo: Optional[Union[str, Path]] = None) -> str:
71
+ """
72
+ Get the version from the git history of the given repository folder
73
+
74
+ :param fp_repo: Optional repository folder. If not provided, the current
75
+ working directory is used
76
+ """
77
+
78
+ fp_repo = _get_repository_path(fp_repo)
79
+
80
+ _git_info = get_version_info(fp_repo)
81
+ return make_version(_git_info["tag"], _git_info["nr_commits"], _git_info["has_changes"])
82
+
83
+
84
+ def git_hash(fp_repo: Optional[Union[str, Path]] = None) -> str:
85
+ fp_repo = _get_repository_path(fp_repo)
86
+ result = run("git rev-parse HEAD", capture_output=True, shell=True, check=True, cwd=fp_repo)
87
+ git_hash_rev = result.stdout.decode().strip()
88
+ return git_hash_rev
@@ -0,0 +1,156 @@
1
+ from contextlib import contextmanager
2
+ from dataclasses import dataclass
3
+ from logging import (
4
+ CRITICAL,
5
+ DEBUG,
6
+ ERROR,
7
+ Formatter as StandardFormatter,
8
+ INFO,
9
+ Logger,
10
+ StreamHandler,
11
+ WARNING,
12
+ basicConfig,
13
+ getLogger as _getLogger,
14
+ )
15
+ from os import getcwd
16
+ from pathlib import Path
17
+ from tomllib import TOMLDecodeError, loads
18
+ from typing import Any, Union
19
+
20
+ _pyproject_tool_id = "wowool_build_logging"
21
+ _default_loggers_names = ["root", "wowool"]
22
+ _pyproject_toml_filename = "pyproject.toml"
23
+
24
+
25
+ def _get_fn_pyproject_toml_from_identifier(identifier: Union[str, Path, None] = None) -> Path:
26
+ if identifier is None:
27
+ identifier = getcwd()
28
+ if isinstance(identifier, str):
29
+ identifier = Path(identifier).parent.resolve()
30
+ fn = identifier if identifier.is_file() else identifier / _pyproject_toml_filename
31
+ if not fn.exists() or not fn.is_file():
32
+ raise LoggingConfigError(f"Expected {fn} to be an existing file")
33
+ return fn
34
+
35
+
36
+ # TODO: Once supoport for Python<3.11 is dropped, the imports can be moved to global scope
37
+ def _parse_pyproject_toml(fn_pyproject_toml: Path) -> dict:
38
+ try:
39
+ return loads(fn_pyproject_toml.read_text())
40
+ except TOMLDecodeError as error:
41
+ raise LoggingConfigError(f"Configuration error in {fn_pyproject_toml}: {error}")
42
+
43
+
44
+ class LoggingConfigError(RuntimeError):
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class LoggerConfig:
50
+ level: Union[str, int]
51
+ format: str
52
+ colored: bool
53
+
54
+
55
+ def configure_logger(logger_name: str, config: LoggerConfig):
56
+ logger = _getLogger(logger_name)
57
+ logger.propagate = False
58
+ logger.handlers.clear()
59
+ logger.setLevel(config.level)
60
+
61
+ handler = StreamHandler()
62
+ Formatter = ColoredFormatter if config.colored else StandardFormatter
63
+ formatter = Formatter(config.format)
64
+ handler.setFormatter(formatter)
65
+ logger.addHandler(handler)
66
+
67
+
68
+ def default_config():
69
+ return LoggerConfig(
70
+ level="INFO",
71
+ format="%(levelname)-8s %(name)s: %(message)s",
72
+ colored=False,
73
+ )
74
+
75
+
76
+ def read_config(config_raw: dict) -> LoggerConfig:
77
+ if not isinstance(config_raw, dict):
78
+ raise LoggingConfigError(f"Incorrect logging configuration, most likely no logger was specified")
79
+
80
+ def get_parameter(key: str, default_value: Any) -> Any:
81
+ return config_raw[key] if key in config_raw else default_value
82
+
83
+ defaults = default_config()
84
+ return LoggerConfig(
85
+ level=get_parameter("level", defaults.level),
86
+ format=get_parameter("format", defaults.format),
87
+ colored=get_parameter("colored", defaults.colored),
88
+ )
89
+
90
+
91
+ class ColoredFormatter(StandardFormatter):
92
+ def __init__(self, format: str):
93
+ grey = "\x1b[0;30m"
94
+ blue = "\x1b[0;34m"
95
+ red = "\x1b[31;20m"
96
+ red_bold = "\x1b[31;1m"
97
+ yellow = "\x1b[33;20m"
98
+
99
+ reset = "\x1b[0m"
100
+ formats = {
101
+ DEBUG: grey + format + reset,
102
+ INFO: blue + format + reset,
103
+ WARNING: yellow + format + reset,
104
+ ERROR: red + format + reset,
105
+ CRITICAL: red_bold + format + reset,
106
+ }
107
+ self.formatter = {level: StandardFormatter(config) for level, config in formats.items()}
108
+
109
+ def format(self, record):
110
+ return self.formatter[record.levelno].format(record)
111
+
112
+
113
+ def init_logging(configuration_path_identifier: Union[str, Path, None] = None, name: str = ""):
114
+ fn_pyproject_toml = _get_fn_pyproject_toml_from_identifier(configuration_path_identifier)
115
+ pyproject = _parse_pyproject_toml(fn_pyproject_toml)
116
+ if not "tool" in pyproject or not _pyproject_tool_id in pyproject["tool"]:
117
+ basicConfig(level="DEBUG")
118
+ else:
119
+ wowool_build_logging = pyproject["tool"][_pyproject_tool_id]
120
+ logger_names = list(wowool_build_logging.keys())
121
+ logger_names.extend(_default_loggers_names)
122
+ logger_names = set(logger_names)
123
+ for logger_name in logger_names:
124
+ config = read_config(wowool_build_logging[logger_name]) if logger_name in wowool_build_logging else default_config()
125
+ configure_logger(logger_name, config)
126
+
127
+ if name:
128
+ return _getLogger(name)
129
+
130
+
131
+ def get_logger(name: str, configuration_path_identifier: Union[str, Path, None] = None) -> Logger:
132
+ if configuration_path_identifier is not None:
133
+ init_logging(configuration_path_identifier)
134
+ return _getLogger(name)
135
+
136
+
137
+ def getLogger(*args, **kwargs):
138
+ return get_logger(*args, **kwargs)
139
+
140
+
141
+ @contextmanager
142
+ def set_logger_level(logger: Union[Logger, str], level): # pyright: ignore
143
+ if isinstance(logger, str):
144
+ logger = _getLogger(logger)
145
+ level_previous = logger.level
146
+ try:
147
+ logger.debug(f"Switching logger level to {level}")
148
+ logger.setLevel(level)
149
+ yield
150
+ finally:
151
+ logger.debug(f"Switching logger level to {level_previous}")
152
+ logger.setLevel(level_previous)
153
+
154
+
155
+ def silence_logger(logger: Union[Logger, str], level=100):
156
+ return set_logger_level(logger, level)
@@ -0,0 +1,33 @@
1
+ import platform
2
+ import sys
3
+ from typing import Optional
4
+
5
+
6
+ def _get_system_name() -> Optional[str]:
7
+ platform_name = platform.system()
8
+ if "Linux" == platform_name:
9
+ return (
10
+ f"{platform.system()}-{platform.machine()}-{platform.libc_ver()[0]}"
11
+ )
12
+ elif "Darwin" == platform_name:
13
+ return f"{platform.system()}-{platform.machine()}"
14
+ elif "Windows" == platform_name:
15
+ return f"{platform.system()}-{platform.machine()}-{platform.win32_ver()[0]}"
16
+
17
+
18
+ def _get_os_name():
19
+ platforms = {
20
+ "linux1": "linux",
21
+ "linux2": "linux",
22
+ "darwin": "macos",
23
+ "win32": "windows",
24
+ }
25
+ return (
26
+ platforms[sys.platform] if sys.platform in platforms else sys.platform
27
+ )
28
+
29
+
30
+ SYSTEM_NAME = _get_system_name()
31
+ OS_NAME = _get_os_name()
32
+ IS_PTY = sys.stdout.isatty() if OS_NAME != "windows" else False
33
+ IS_POSIX = OS_NAME != "win32"
wowool/build/pydist.py ADDED
@@ -0,0 +1,119 @@
1
+ from logging import getLogger, DEBUG
2
+ from os import chmod, getcwd, stat
3
+ from pathlib import Path
4
+ from shutil import rmtree
5
+ from stat import S_IEXEC
6
+ from subprocess import run
7
+ from sys import path as sys_path, prefix
8
+ from typing import Optional
9
+
10
+ logger = getLogger(__name__)
11
+ logger_cmd = getLogger("wowool.cmd")
12
+
13
+
14
+ def make_environment_entry_points(fp: Path):
15
+ """
16
+ Create the console scripts from the entry points defined in setup.py
17
+
18
+ :param fp: Path to the folder holding setup.py
19
+ """
20
+ egg_dirs = [fp for fp in fp.glob("*.egg-info")]
21
+ if not egg_dirs:
22
+ raise RuntimeError("Missing *.egg-info directories")
23
+ for egg_dir in egg_dirs:
24
+ entry_points = egg_dir / "entry_points.txt"
25
+ from configparser import ConfigParser
26
+
27
+ configur = ConfigParser()
28
+ configur.read(entry_points)
29
+ try:
30
+ console_scripts = configur["console_scripts"]
31
+ except KeyError:
32
+ raise RuntimeError("No console scripts defined in setup.py")
33
+ for cs in console_scripts:
34
+ fn_cs = Path(f"{prefix}/bin/{cs}")
35
+ print(f"Added entry point {cs} as alias to {fn_cs}")
36
+ (imp, func) = console_scripts[cs].split(":")
37
+ sys_path.append(f"{Path(__file__).parent.parent.resolve()}/core-build-py")
38
+ template = rf"""#!{prefix}/bin/python
39
+ import re
40
+ import sys
41
+ sys.path.append("{fp}")
42
+ for p in {sys_path}:
43
+ sys.path.append(p)
44
+ from {imp} import {func}
45
+ if __name__ == '__main__':
46
+ sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
47
+ sys.exit({func}())
48
+ """
49
+ with open(fn_cs, "w") as fh:
50
+ fh.write(template)
51
+ st = stat(fn_cs)
52
+ chmod(fn_cs, st.st_mode | S_IEXEC)
53
+
54
+
55
+ def get_install_requires(fp: Optional[Path] = None):
56
+ """
57
+ Return the contents of the install_requires*.txt files in the given path
58
+
59
+ :param fp: Path containing the install_requires*.txt files. If not set, the
60
+ current working directory is used
61
+ """
62
+
63
+ def is_valid(requirement: str) -> bool:
64
+ return len(requirement) != 0 and not requirement.startswith("#")
65
+
66
+ fp = fp if fp is not None else Path(getcwd())
67
+ install_requires = []
68
+ for fn in fp.glob("install_requires*.txt"):
69
+ requirements = filter(is_valid, [line.strip() for line in open(fn).readlines()])
70
+ install_requires.extend(requirements)
71
+ return install_requires
72
+
73
+
74
+ def resolve_template(template, __globals, __locals):
75
+ return eval("f'''" + template + "'''", __globals, __locals)
76
+
77
+
78
+ def build_wheel_package(fp: Path, no_deps: bool = True, build_isolation: bool = False):
79
+ """
80
+ Build a wheel package
81
+
82
+ :param fp: Folder holding the requirement files
83
+ """
84
+ template_pyproject_fn = fp / "pyproject_template.toml"
85
+ if template_pyproject_fn.exists():
86
+ from wowool.build.git import get_version
87
+
88
+ pyproject_fn = fp / "pyproject.toml"
89
+ data = template_pyproject_fn.read_text()
90
+ version = get_version(fp)
91
+ dependencies = get_install_requires(fp)
92
+ package_name: str = fp.name
93
+ if package_name.startswith("comp-"):
94
+ package_name = package_name[5:]
95
+ if package_name.endswith("-py"):
96
+ package_name = package_name[:-3]
97
+ print(f"Package name is {package_name}")
98
+ resolve_data = resolve_template(data, globals(), locals())
99
+ pyproject_fn.write_text(resolve_data)
100
+
101
+ options = []
102
+ if not build_isolation:
103
+ options.append("--no-isolation")
104
+ # cmd = f"python -m pip wheel {' '.join(options)} -w dist ."
105
+ cmd = f"python -m build --wheel {' '.join(options)} ."
106
+ print(f"CMD: {cmd}")
107
+ run(cmd, shell=True, check=True, cwd=fp)
108
+
109
+
110
+ def clean_package_build(fp: Path):
111
+ """
112
+ Clean the package build
113
+ """
114
+ for dir in [
115
+ "build",
116
+ "dist",
117
+ "var",
118
+ ] + list(fp.glob("*.egg-info")):
119
+ rmtree(fp / dir, ignore_errors=True)
wowool/build/pypi.py ADDED
@@ -0,0 +1,28 @@
1
+ from os import environ
2
+ from pathlib import Path
3
+ from subprocess import run
4
+
5
+
6
+ from wowool.build.exceptions import UploadError
7
+
8
+
9
+ def _check_not_dirty(fn: Path):
10
+ if "dirty" in fn.name:
11
+ raise UploadError(f"Attempted to upload a dirty version: {fn}")
12
+
13
+
14
+ def upload_pypi(fp: Path, expression: str = "*", repository: str | None = None):
15
+ """
16
+ Upload a Python package to pypi
17
+ """
18
+ fp_dist = fp / "dist"
19
+ for fn in fp_dist.glob(expression):
20
+ _check_not_dirty(fn)
21
+ repository = environ.get("TWINE_REPOSITORY", None)
22
+ repository_option = f"--repository-url {repository}" if repository else ""
23
+ cmd = f"python -m twine upload {repository_option} dist/*"
24
+ try:
25
+ print(f"cmd: {cmd} {fn=} {fp_dist=}")
26
+ run(cmd, shell=True, check=True, cwd=str(fp))
27
+ except Exception as error:
28
+ raise UploadError(f"PyPi Twine upload failed: {error}")
wowool/build/pytask.py ADDED
@@ -0,0 +1,110 @@
1
+ from os import getcwd
2
+ from pathlib import Path
3
+ from typing import Union
4
+
5
+ from invoke.collection import Collection
6
+ from invoke.tasks import task
7
+
8
+
9
+ def create_py_tasks(fp_root: Union[str, Path, None] = None) -> Collection:
10
+ fp_root = Path(fp_root) if fp_root is not None else Path(getcwd())
11
+ if fp_root.is_file():
12
+ fp_root = fp_root.parent
13
+
14
+ @task
15
+ def version(_):
16
+ """
17
+ Print the version from Git
18
+ """
19
+ from wowool.build.git import get_version
20
+
21
+ print(get_version(fp_root))
22
+
23
+ @task
24
+ def test(
25
+ _,
26
+ verbosity: int = 2,
27
+ failfast: bool = True,
28
+ list: bool = False,
29
+ expression: str = "",
30
+ xclude: str = "",
31
+ ):
32
+ """
33
+ Run the Python unit-tests
34
+ """
35
+ from wowool.build.pytest import list_unittest_tests, run_unittest_tests
36
+
37
+ (
38
+ list_unittest_tests(
39
+ fp_root,
40
+ pattern_include=expression,
41
+ pattern_exclude=xclude,
42
+ )
43
+ if list
44
+ else run_unittest_tests(
45
+ fp_root,
46
+ verbosity=verbosity,
47
+ failfast=failfast,
48
+ pattern_include=expression,
49
+ pattern_exclude=xclude,
50
+ )
51
+ )
52
+
53
+ @task
54
+ def clean(_):
55
+ """
56
+ Clean the Python package build
57
+ """
58
+ from wowool.build.pydist import clean_package_build
59
+
60
+ clean_package_build(fp_root)
61
+
62
+ @task(pre=[clean])
63
+ def build(_, no_deps: bool = True):
64
+ """
65
+ Build the Python package
66
+ """
67
+ from wowool.build.pydist import build_wheel_package
68
+
69
+ build_wheel_package(fp_root, no_deps)
70
+
71
+ @task(aliases=("mep",))
72
+ def make_entry_points(_):
73
+ """
74
+ Make entry points to the Python CLIs
75
+ """
76
+ from wowool.build.pydist import make_environment_entry_points
77
+
78
+ make_environment_entry_points(fp_root)
79
+
80
+ @task
81
+ def upload(_, expression="wowool*"):
82
+ """
83
+ Upload the Python package to AWS CodeArtifact
84
+ """
85
+ from wowool.build.codeartifact import upload_pypi
86
+
87
+ upload_pypi(fp_root, expression=expression)
88
+
89
+ @task
90
+ def upload_raw(_, fn: Path, namespace: str, package: str, version: str):
91
+ """
92
+ Upload the raw package to AWS CodeArtifact
93
+ """
94
+ from wowool.build.codeartifact import upload_raw
95
+
96
+ upload_raw(fn, namespace, package, version)
97
+
98
+ @task
99
+ def upload_pypi(_, expression="wowool*"):
100
+ """Uploading the Python package to pypi"""
101
+ from wowool.build.pypi import upload_pypi
102
+
103
+ upload_pypi(fp_root, expression=expression)
104
+
105
+ return Collection(version, test, clean, build, make_entry_points, upload, upload_raw, upload_pypi)
106
+
107
+
108
+ # Allow both default Invoke namespaces to be available for import
109
+ ns = create_py_tasks()
110
+ namespace = ns
wowool/build/pytest.py ADDED
@@ -0,0 +1,149 @@
1
+ import ast
2
+ from difflib import unified_diff
3
+ from logging import getLogger
4
+ from os import environ, pathsep, sep
5
+ from pathlib import Path
6
+ import re
7
+ from subprocess import check_output, run
8
+ from typing import Callable, List, Optional
9
+ from unittest import TestLoader, TestResult, TestSuite, TextTestRunner, skipIf
10
+
11
+ from contextlib import contextmanager
12
+ from importlib import reload
13
+ from io import StringIO
14
+ import sys
15
+ from typing import Optional
16
+
17
+
18
+ logger = getLogger(__name__)
19
+
20
+
21
+ # Test environment
22
+ def is_ci_environment():
23
+ return "CI" in environ and not environ["CI"].lower() in ("0", "false")
24
+
25
+
26
+ # Unit-test skip decorators
27
+ skipIfCI = skipIf(is_ci_environment(), "CI environment detected")
28
+ skipIfNotCI = skipIf(not is_ci_environment(), "CI environment not detected")
29
+
30
+
31
+ class UnittestError(RuntimeError):
32
+ def __init__(self, result: TestResult):
33
+ self.result = result
34
+ message = f"Unit-test(s) failed: {len(result.errors)} errors and {len(result.failures)} failures"
35
+ super(UnittestError, self).__init__(message)
36
+
37
+
38
+ def get_unittest_names(
39
+ fp: Path,
40
+ pattern_include: str = "",
41
+ pattern_exclude: Optional[str] = "",
42
+ ):
43
+ names: List[str] = []
44
+ files = [fn for fn in fp.glob("tests/**/test_*.py")]
45
+ for filename in files:
46
+ node = ast.parse(filename.read_text())
47
+ offset = 0 if str(fp).endswith("/") else 1
48
+ module_name = str(filename.with_suffix(""))[len(str(fp)) + offset :].replace(sep, ".")
49
+ classes = [n for n in node.body if isinstance(n, ast.ClassDef)]
50
+
51
+ for class_ in classes:
52
+ methods = [n for n in class_.body if isinstance(n, ast.FunctionDef)]
53
+ for method in methods:
54
+ if method.name.startswith("test_"):
55
+ names.append(f"{module_name}.{class_.name}.{method.name}")
56
+
57
+ matcher_include = re.compile(f".*{pattern_include}")
58
+ names = [name for name in names if matcher_include.match(name)]
59
+ if pattern_exclude:
60
+ matcher_exclude = re.compile(f".*{pattern_exclude}")
61
+ names = [name for name in names if not matcher_exclude.match(name)]
62
+ return sorted(names)
63
+
64
+
65
+ def get_unittest_suite(
66
+ fp: Path,
67
+ pattern_include: str = "",
68
+ pattern_exclude: Optional[str] = "",
69
+ ) -> TestSuite:
70
+ tests = get_unittest_names(fp, pattern_include, pattern_exclude)
71
+ return TestLoader().loadTestsFromNames(tests)
72
+
73
+
74
+ def list_unittest_tests(fp: Path, printer: Callable[[str], None] = print, pattern_include: str = "", pattern_exclude: Optional[str] = ""):
75
+ """
76
+ List the unit tests found in the given directory
77
+
78
+ :param fp: Search directory
79
+ :param printer: Printer function that accepts a string
80
+ """
81
+ printer("\n".join(get_unittest_names(fp, pattern_include, pattern_exclude)))
82
+
83
+
84
+ def run_unittest_tests(
85
+ fp: Path, verbosity: int, failfast: bool, pattern_include: str = "", pattern_exclude: Optional[str] = "", exit_on_error: bool = True
86
+ ) -> TestResult:
87
+ """
88
+ Run the unittest tests found in the given directory
89
+
90
+ :param fp: Search directory
91
+ :param verbosity: Verbosity level
92
+ :param failfast: Fail on first error
93
+ :param pattern: Only run tests matching the given pattern
94
+ :param exit_on_error: Exit directly in case of a failure, otherwise raise an error
95
+ """
96
+
97
+ suite = get_unittest_suite(fp, pattern_include, pattern_exclude)
98
+ result = TextTestRunner(verbosity=verbosity, failfast=failfast).run(suite)
99
+ if not result.wasSuccessful():
100
+ if exit_on_error:
101
+ exit(1)
102
+ else:
103
+ raise UnittestError(result)
104
+ return result
105
+
106
+
107
+ def run_unittest_samples(fp: Path, pattern: str = ""):
108
+ """
109
+ Check the samples
110
+ """
111
+ matcher = re.compile(f".*{pattern}")
112
+ pypath = f"{pathsep}{environ['PYTHONPATH']}" if "PYTHONPATH" in environ else ""
113
+ environ["PYTHONPATH"] = f"{str(fp)}{pypath}"
114
+ samples_dir = fp / "samples"
115
+ files = sorted([fn for fn in samples_dir.glob("**/*.py") if not (str(fn).endswith("_setup.py") or str(fn).endswith("_cleanup.py"))])
116
+ for fn in files:
117
+ if matcher.match(str(fn)):
118
+ logger.info(f"Running sample: {fn}")
119
+
120
+ fn_setup = Path(str(fn).replace(".py", "_setup.py"))
121
+ if fn_setup.exists():
122
+ run(f"python {fn_setup}", check=True, shell=True, cwd=fn.parent)
123
+
124
+ out_data = check_output(f"python {fn}", shell=True, cwd=fn.parent).decode().strip()
125
+ logger.debug(f"{fn} stdout:[{out_data}]")
126
+
127
+ fn_ref = Path(str(fn).replace(".py", "_output.txt"))
128
+ if fn_ref.exists():
129
+ expected_data = fn_ref.read_text().strip()
130
+ lines = [line for line in unified_diff(expected_data, out_data)]
131
+ if 0 != len(lines):
132
+ raise RuntimeError(f"Unexpected output:\n[{expected_data}]\n!=\n[{out_data}]")
133
+
134
+ fn_cleanup = Path(str(fn).replace(".py", "_cleanup.py"))
135
+ if fn_cleanup.exists():
136
+ run(f"python {fn_cleanup}", check=True, shell=True, cwd=fn.parent)
137
+
138
+
139
+ @contextmanager
140
+ def capture_stream(stream: str = "stdout"):
141
+ stream_original = getattr(sys, stream)
142
+ stream_captured = StringIO()
143
+ setattr(sys, stream, stream_captured)
144
+ reload(sys)
145
+ try:
146
+ yield stream_captured
147
+ finally:
148
+ sys.stdout = stream_original
149
+ reload(sys)
@@ -0,0 +1,37 @@
1
+ from contextlib import contextmanager
2
+ from os import chdir, getcwd
3
+ from pathlib import Path
4
+ from typing import Union
5
+
6
+
7
+ def ask(question, default: bool = True) -> bool:
8
+ """
9
+ Ask a simple yes/no question
10
+
11
+ :param question: The question to present to the user
12
+ :param default: The default answer to assume if no valid answer is provided
13
+ """
14
+ postfix = "Y/n" if default else "y/N"
15
+ while True:
16
+ answer = input(question + " [{}] ".format(postfix)).lower()
17
+ if "" == answer:
18
+ return default
19
+ elif "y" == answer:
20
+ return True
21
+ elif "n" == answer:
22
+ return False
23
+
24
+
25
+ @contextmanager
26
+ def in_cwd(fp: Union[Path, str]):
27
+ """
28
+ Temporarily change the working directory
29
+
30
+ :param fp: The folder to change to
31
+ """
32
+ oldpwd = getcwd()
33
+ chdir(str(fp))
34
+ try:
35
+ yield
36
+ finally:
37
+ chdir(oldpwd)
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: wowool-build
3
+ Version: 0.0.1
4
+ Summary: Wowool Build System
5
+ Home-page: https://www.wowool.com/
6
+ Author: Wowool
7
+ Author-email: info@wowool.com
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: invoke
11
+ Requires-Dist: build
12
+ Requires-Dist: wheel
13
+ Requires-Dist: setuptools
14
+ Requires-Dist: setuptools-scm
15
+
@@ -0,0 +1,14 @@
1
+ wowool/build/codeartifact.py,sha256=m2YlJgzElY2yfMCBun_cdjabBYSKouHA4Zf80JUnFsc,7249
2
+ wowool/build/exceptions.py,sha256=GFWI0F_nTEpDid8dMjMQCDQ66muzTvfLzRm-KSJ1-Uw,42
3
+ wowool/build/git.py,sha256=oD9CB7MHOTYTO7HPZJ7NIuDOjO5ME_DIcUpcx02OM6w,3111
4
+ wowool/build/logging.py,sha256=4ycT471N7V6vrEBXURF0-lho1hbmWA7PJyCHhfHVjeA,4988
5
+ wowool/build/platform.py,sha256=dYEr4Pw0VyChQRnd0ZXwETXZsLVQ0tC9aC4Mu9KJZcc,901
6
+ wowool/build/pydist.py,sha256=j_OiN2G-RVsQ8NwEv2gn19IBpJEcALIFUeYiDn_4NPY,3924
7
+ wowool/build/pypi.py,sha256=fdPRdbPq2ZxWy3AdncL0qTWmUn_dA62NMWamwpC2Tto,921
8
+ wowool/build/pytask.py,sha256=wlwnQGfVkEUiA_IcRYnz9_BM7hNmzpT_1turmwYHFEM,2879
9
+ wowool/build/pytest.py,sha256=8F1ZklSvpTAnwODXZvzhF6qcND-MlDVbl-e4F1Pf3mI,5304
10
+ wowool/build/utility.py,sha256=q5RwGFD5O9pewuIUxbYt5sA7lj6u1CjWi6PVWQwCxnQ,890
11
+ wowool_build-0.0.1.dist-info/METADATA,sha256=_hkITQ5R0Eyx_y9OXgXgbfKhKxk0LafBQ-3BIlhFAXw,350
12
+ wowool_build-0.0.1.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
13
+ wowool_build-0.0.1.dist-info/top_level.txt,sha256=zerqoDN-iHSHwsS1SN2h0q4H6RI1FOVeHW8fNQC9YdU,7
14
+ wowool_build-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.45.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ wowool