alibuild 2.0.0__tar.gz → 2.0.1__tar.gz
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.
- {alibuild-2.0.0 → alibuild-2.0.1}/PKG-INFO +1 -1
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/PKG-INFO +1 -1
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/_version.py +3 -3
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/build.py +1 -1
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/log.py +17 -1
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sync.py +20 -1
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sync_reapi.py +28 -12
- {alibuild-2.0.0 → alibuild-2.0.1}/alienv +22 -6
- alibuild-2.0.1/tests/test_log.py +90 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sync.py +47 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sync_reapi.py +64 -3
- alibuild-2.0.0/tests/test_log.py +0 -50
- {alibuild-2.0.0 → alibuild-2.0.1}/.claude/skills/s3-conditional-writes/SKILL.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.claude/skills/support-architecture/SKILL.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.flake8 +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.github/dependabot.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/documentation.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/homebrew.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/pr-check.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/release.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.gitignore +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/.pylintrc +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/ANALYTICS.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/DESIGN.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/LICENSE.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/PACKAGING.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/README.rst +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/REMOTE_STORE_CAS_AC.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alfaBuild +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/aliBuild +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/aliDeps +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/aliDoctor +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/SOURCES.txt +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/dependency_links.txt +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/requires.txt +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/top_level.txt +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/__init__.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/analytics.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/args.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/build_template.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/clean.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/cmd.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completion.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/__init__.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/bash.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/zsh.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/deps.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/doctor.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/git.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/init.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/install.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/keyring.json +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/migrate.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/reconstruct.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sandbox.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/scm.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/signing.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sl.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/source.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/templating_plugin.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/utilities.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/workarea.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/codecov.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/changelog +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/compat +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/control +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/copyright +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/files +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/debian/rules +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/SUPPORT +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/alice_logo.png +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/deps.png +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/index.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/quick.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/reference.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/stylesheets/extra.css +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/troubleshooting.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/user.md +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/docs/mkdocs.yml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alibuild_nix.jnj +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist-aliphysics.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist-helpers.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/flake.lock +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/flake.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/hyperloop.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/hyperloopTest.nix +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/nix-interactive.bash +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/update-envs.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/pb +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/pyproject.toml +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/requirements.txt +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/setup.cfg +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/setup.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/templates/alibuild_nix.jnj +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/templates/alibuild_to_please.jnj +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_analytics.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_args.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_build.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_clean.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_cmd.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_completion.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_deps.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_doctor.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_git.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_hashing.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_init.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_install.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_migrate.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_packagelist.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_parseRecipe.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_reconstruct.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sandbox.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_signing.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_source.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_utilities.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_workarea.py +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken1.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken2.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken3.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken4.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken5.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken6.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken7.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/clobber-initdotsh.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/defaults-o2.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/delete-etc.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/tracking-env.sh +0 -0
- {alibuild-2.0.0 → alibuild-2.0.1}/tox.ini +0 -0
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (2, 0,
|
|
31
|
+
__version__ = version = '2.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 0, 1)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g0096f2c8c'
|
|
@@ -1382,7 +1382,7 @@ def doBuild(args, parser):
|
|
|
1382
1382
|
published = True
|
|
1383
1383
|
if fileHash == spec["hash"] and not spec["is_devel_pkg"] and \
|
|
1384
1384
|
isinstance(syncHelper, REAPIRemoteSync) and getattr(syncHelper, "writeStore", ""):
|
|
1385
|
-
published = syncHelper.is_published(spec["remote_revision_hash"])
|
|
1385
|
+
published = syncHelper.is_published(spec["remote_revision_hash"], keep_alive=True)
|
|
1386
1386
|
if not published:
|
|
1387
1387
|
bound_unpublished_rebuild(p, rebuilt_unpublished, syncHelper.acWriteStore)
|
|
1388
1388
|
info("%s is built locally but not published to %s; rebuilding it so it can "
|
|
@@ -10,6 +10,22 @@ def dieOnError(err, msg) -> None:
|
|
|
10
10
|
error("%s", msg)
|
|
11
11
|
sys.exit(1)
|
|
12
12
|
|
|
13
|
+
# Credentials reach the log through command lines: the docker invocation carries
|
|
14
|
+
# every -e NAME=VALUE, and CI passes JALIEN_TOKEN_KEY (a PEM private key) and
|
|
15
|
+
# CCDB auth tokens that way. The value may be shell-quoted or, where the command
|
|
16
|
+
# is " ".join()ed, bare and spanning several lines. A key whose END marker is
|
|
17
|
+
# not in the same message is redacted to the end of the message.
|
|
18
|
+
_PRIVATE_KEY_RE = re.compile(
|
|
19
|
+
r"-----BEGIN ([A-Z ]*)PRIVATE KEY-----.*?(?:-----END \1PRIVATE KEY-----|\Z)", re.DOTALL)
|
|
20
|
+
_SECRET_ASSIGNMENT_RE = re.compile(
|
|
21
|
+
r"\b([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*=)"
|
|
22
|
+
r"('[^']*'|\"[^\"]*\"|[^\s'\"]+)")
|
|
23
|
+
|
|
24
|
+
def redact_secrets(msg):
|
|
25
|
+
"""Hide private keys and the values of *TOKEN*/*SECRET*/*PASSWORD* assignments."""
|
|
26
|
+
msg = _PRIVATE_KEY_RE.sub(r"-----BEGIN \1PRIVATE KEY----- <redacted>", msg)
|
|
27
|
+
return _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", msg)
|
|
28
|
+
|
|
13
29
|
class LogFormatter(logging.Formatter):
|
|
14
30
|
def __init__(self, fmtstr) -> None:
|
|
15
31
|
self.fmtstr = fmtstr
|
|
@@ -25,7 +41,7 @@ class LogFormatter(logging.Formatter):
|
|
|
25
41
|
# args to an already-expanded string -- "not all arguments converted".
|
|
26
42
|
# Harmless with only our handler attached, which is why it went unnoticed;
|
|
27
43
|
# under pytest, whose logging plugin adds its own handler, it aborts.
|
|
28
|
-
msg = record.getMessage()
|
|
44
|
+
msg = redact_secrets(record.getMessage())
|
|
29
45
|
if record.levelno == logging.BANNER and sys.stdout.isatty():
|
|
30
46
|
lines = msg.split("\n")
|
|
31
47
|
return "\n\033[1;34m==>\033[m \033[1m%s\033[m" % lines[0] + \
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Sync backends for alibuild."""
|
|
2
2
|
|
|
3
3
|
import glob
|
|
4
|
+
import hashlib
|
|
4
5
|
import os
|
|
5
6
|
import os.path
|
|
6
7
|
import re
|
|
@@ -93,6 +94,7 @@ class HttpRemoteSync:
|
|
|
93
94
|
# Destination specified -- file (dest) or buffer (returnResult).
|
|
94
95
|
# Use requests in stream mode
|
|
95
96
|
resp = get(url, stream=True, verify=not self.insecure, timeout=self.httpTimeoutSec)
|
|
97
|
+
cas_digest = None
|
|
96
98
|
if redirect_base:
|
|
97
99
|
# S3 only turns x-amz-website-redirect-location into a 301 when the
|
|
98
100
|
# bucket is served as a website; on the REST endpoint the object is
|
|
@@ -115,6 +117,16 @@ class HttpRemoteSync:
|
|
|
115
117
|
debug("Following store redirect to %s", url)
|
|
116
118
|
resp = get(url, stream=True, verify=not self.insecure,
|
|
117
119
|
timeout=self.httpTimeoutSec)
|
|
120
|
+
# A content-addressed target names its own digest: check against it.
|
|
121
|
+
cas_digest = re.search(r"(?:^|/)cas/sha256/[0-9a-f]{2}/([0-9a-f]{64})$", target)
|
|
122
|
+
if resp.status_code == 404:
|
|
123
|
+
# Retrying will not bring it back. After a store redirect this is a
|
|
124
|
+
# stub whose CAS blob is gone (an expired ephemeral artifact): not
|
|
125
|
+
# available, so the package is rebuilt, as the reapi backend does.
|
|
126
|
+
(warning if redirect_base else debug)("GET %s: not found", url)
|
|
127
|
+
return None
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
sha256 = hashlib.sha256()
|
|
118
130
|
size = int(resp.headers.get("content-length", "-1"))
|
|
119
131
|
downloaded = 0
|
|
120
132
|
reportTime = time.time()
|
|
@@ -125,6 +137,8 @@ class HttpRemoteSync:
|
|
|
125
137
|
for chunk in filter(bool, resp.iter_content(chunk_size=32768)):
|
|
126
138
|
if destFp:
|
|
127
139
|
destFp.write(chunk)
|
|
140
|
+
if cas_digest:
|
|
141
|
+
sha256.update(chunk)
|
|
128
142
|
if returnResult:
|
|
129
143
|
result.append(chunk)
|
|
130
144
|
downloaded += len(chunk)
|
|
@@ -141,6 +155,11 @@ class HttpRemoteSync:
|
|
|
141
155
|
|
|
142
156
|
if size not in (downloaded, -1):
|
|
143
157
|
raise PartialDownloadError(downloaded, size)
|
|
158
|
+
if cas_digest and sha256.hexdigest() != cas_digest.group(1):
|
|
159
|
+
warning("GET %s: content does not match its sha256, ignoring it", url)
|
|
160
|
+
if dest:
|
|
161
|
+
os.unlink(dest+".tmp")
|
|
162
|
+
return None
|
|
144
163
|
if dest:
|
|
145
164
|
os.rename(dest+".tmp", dest) # we should not have errors here
|
|
146
165
|
return b''.join(result) if returnResult else True
|
|
@@ -240,7 +259,7 @@ class HttpRemoteSync:
|
|
|
240
259
|
# Fetch manifest file with initial symlinks. This file is updated
|
|
241
260
|
# regularly; we use it to avoid many small network requests.
|
|
242
261
|
manifest = self.getRetry(f"{self.remoteStore}/{links_path}.manifest",
|
|
243
|
-
returnResult=True, session=session)
|
|
262
|
+
returnResult=True, session=session) or b""
|
|
244
263
|
symlinks = {
|
|
245
264
|
linkname.decode("utf-8"): target.decode("utf-8")
|
|
246
265
|
for linkname, sep, target in (line.partition(b"\t")
|
|
@@ -520,7 +520,7 @@ class REAPIRemoteSync(Boto3RemoteSync):
|
|
|
520
520
|
return None
|
|
521
521
|
return json.loads(obj["Body"].read())
|
|
522
522
|
|
|
523
|
-
def is_published(self, action_hash):
|
|
523
|
+
def is_published(self, action_hash, keep_alive=False):
|
|
524
524
|
"""Whether an action is fully published: its Action Cache entry exists *and* the
|
|
525
525
|
CAS blob that entry names is present.
|
|
526
526
|
|
|
@@ -531,7 +531,12 @@ class REAPIRemoteSync(Boto3RemoteSync):
|
|
|
531
531
|
entry and bytes) is not something a dependent can be published against either.
|
|
532
532
|
|
|
533
533
|
Two HEAD-class requests, no download -- the blob is checked by presence, since it
|
|
534
|
-
is content-addressed and its key is its own digest.
|
|
534
|
+
is content-addressed and its key is its own digest.
|
|
535
|
+
|
|
536
|
+
keep_alive also promotes or LRU-refreshes the blob (see _maybe_promote). The
|
|
537
|
+
build asks this for every package it reuses, rebuilt, fetched or installed
|
|
538
|
+
already: a fetched one never reaches the upload, which is the only other
|
|
539
|
+
place a blob is kept alive."""
|
|
535
540
|
entry = self.read_ac_entry(action_hash)
|
|
536
541
|
if entry is None:
|
|
537
542
|
return False
|
|
@@ -539,7 +544,12 @@ class REAPIRemoteSync(Boto3RemoteSync):
|
|
|
539
544
|
if not content_hash:
|
|
540
545
|
# validate-system nodes produce no tarball: the entry alone is the artifact.
|
|
541
546
|
return (entry.get("action") or {}).get("kind") == "validate-system"
|
|
542
|
-
|
|
547
|
+
cas_path = resolve_cas_path(content_hash, algo)
|
|
548
|
+
if not self._exists(self.remoteStore, cas_path):
|
|
549
|
+
return False
|
|
550
|
+
if keep_alive:
|
|
551
|
+
self._maybe_promote(cas_path)
|
|
552
|
+
return True
|
|
543
553
|
|
|
544
554
|
def download_blob(self, content_hash, dest, algo="sha256"):
|
|
545
555
|
"""Download a ledger (input) blob -- e.g. a source bundle -- to dest."""
|
|
@@ -601,8 +611,10 @@ class REAPIRemoteSync(Boto3RemoteSync):
|
|
|
601
611
|
|
|
602
612
|
def _maybe_promote(self, cas_path):
|
|
603
613
|
"""When uploading as 'permanent' and the blob already exists tagged
|
|
604
|
-
ephemeral, promote it to permanent so it is no longer LRU-expired.
|
|
614
|
+
ephemeral, promote it to permanent so it is no longer LRU-expired.
|
|
615
|
+
Otherwise LRU-refresh it: reusing a blob must keep it alive."""
|
|
605
616
|
if self.storage != "permanent":
|
|
617
|
+
self._touch_if_expiring(cas_path)
|
|
606
618
|
return
|
|
607
619
|
if self._retention_of(self.writeStore, cas_path) == "ephemeral":
|
|
608
620
|
self.s3.put_object_tagging(
|
|
@@ -628,21 +640,25 @@ class REAPIRemoteSync(Boto3RemoteSync):
|
|
|
628
640
|
def _touch_if_expiring(self, cas_path):
|
|
629
641
|
"""LRU-refresh: if the blob is ephemeral and within REFRESH_WITHIN_DAYS of
|
|
630
642
|
its EPHEMERAL_TTL_DAYS expiry, copy it onto itself (preserving the tag) to
|
|
631
|
-
reset last-modified. Best-effort
|
|
643
|
+
reset last-modified. Best-effort, in the bucket we write: an upload that
|
|
644
|
+
dedups needs its blob there to stay, wherever it reads from."""
|
|
632
645
|
from botocore.exceptions import ClientError
|
|
633
|
-
if not self.writeStore
|
|
646
|
+
if not self.writeStore:
|
|
634
647
|
return
|
|
635
648
|
try:
|
|
636
|
-
|
|
637
|
-
return
|
|
638
|
-
head = self.s3.head_object(Bucket=self.remoteStore, Key=cas_path)
|
|
649
|
+
head = self.s3.head_object(Bucket=self.writeStore, Key=cas_path)
|
|
639
650
|
age_days = (datetime.now(timezone.utc) - head["LastModified"]).days
|
|
640
|
-
if age_days < self.EPHEMERAL_TTL_DAYS - self.REFRESH_WITHIN_DAYS
|
|
651
|
+
if age_days < self.EPHEMERAL_TTL_DAYS - self.REFRESH_WITHIN_DAYS or \
|
|
652
|
+
self._retention_of(self.writeStore, cas_path) != "ephemeral":
|
|
641
653
|
return
|
|
642
654
|
debug("Refreshing ephemeral %s (%d days old)", cas_path, age_days)
|
|
655
|
+
# REPLACE, with the same metadata: S3 and Ceph RGW refuse to copy an
|
|
656
|
+
# object onto itself with MetadataDirective=COPY (InvalidRequest).
|
|
643
657
|
self.s3.copy_object(Bucket=self.writeStore, Key=cas_path,
|
|
644
|
-
CopySource={"Bucket": self.
|
|
645
|
-
MetadataDirective="
|
|
658
|
+
CopySource={"Bucket": self.writeStore, "Key": cas_path},
|
|
659
|
+
MetadataDirective="REPLACE", Metadata=head.get("Metadata", {}),
|
|
660
|
+
ContentType=head.get("ContentType", "binary/octet-stream"),
|
|
661
|
+
TaggingDirective="COPY")
|
|
646
662
|
except ClientError as exc:
|
|
647
663
|
debug("Could not refresh %s: %s", cas_path, exc)
|
|
648
664
|
|
|
@@ -122,10 +122,21 @@ function detectShell() {
|
|
|
122
122
|
|
|
123
123
|
function collectModules() {
|
|
124
124
|
if [[ -z "$NO_REFRESH" ]]; then
|
|
125
|
-
# Collect all modulefiles in one place
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
# Collect all modulefiles in one place. Other alienv calls may read the
|
|
126
|
+
# directory meanwhile, so it is never emptied: each modulefile is written
|
|
127
|
+
# next to it and renamed into place, and only then are the modulefiles of
|
|
128
|
+
# packages which are gone removed.
|
|
129
|
+
local DEST=$WORK_DIR/MODULES/$ARCHITECTURE STAGE PKG PKGVER PKGNAME MODFILE MODNAME
|
|
130
|
+
mkdir -p "$DEST"
|
|
131
|
+
STAGE=$(mktemp -d "$WORK_DIR/MODULES/.refresh-$ARCHITECTURE.XXXXXX") || return 1
|
|
132
|
+
function publish() {
|
|
133
|
+
# Install $1 (a file, or - for stdin) as the modulefile $2, relative to $DEST.
|
|
134
|
+
mkdir -p "$DEST/${2%/*}"
|
|
135
|
+
if [[ $1 == - ]]; then cat > "$STAGE/file"; else cp "$1" "$STAGE/file"; fi
|
|
136
|
+
mv -f "$STAGE/file" "$DEST/$2"
|
|
137
|
+
echo "$2" >> "$STAGE/published"
|
|
138
|
+
}
|
|
139
|
+
publish - BASE/1.0 <<EOF
|
|
129
140
|
#%Module1.0
|
|
130
141
|
set base_path $WORK_DIR/$ARCHITECTURE
|
|
131
142
|
setenv BASEDIR \$base_path
|
|
@@ -144,10 +155,15 @@ EOF
|
|
|
144
155
|
for MODFILE in "$PKG"/etc/modulefiles/*; do
|
|
145
156
|
[[ -f "$MODFILE" ]] || continue
|
|
146
157
|
MODNAME=${MODFILE##*/}
|
|
147
|
-
|
|
148
|
-
cp "$MODFILE" "$WORK_DIR/MODULES/$ARCHITECTURE/$MODNAME/$PKGVER"
|
|
158
|
+
publish "$MODFILE" "$MODNAME/$PKGVER"
|
|
149
159
|
done
|
|
150
160
|
done < <(find $WORK_DIR/$ARCHITECTURE -maxdepth 2 -mindepth 2 2> /dev/null)
|
|
161
|
+
# BASE/1.0 is always published, so the list is never empty here.
|
|
162
|
+
(cd "$DEST" && find . -type f ! -name '.*' | sed -e 's|^\./||') | grep -vxF -f "$STAGE/published" | \
|
|
163
|
+
while read MODFILE; do rm -f "$DEST/$MODFILE"; done
|
|
164
|
+
find "$DEST" -mindepth 1 -type d -empty -delete 2> /dev/null
|
|
165
|
+
rm -rf "$STAGE"
|
|
166
|
+
unset -f publish
|
|
151
167
|
else
|
|
152
168
|
printf "${EY}WARNING: not updating modulefiles${EZ}\n" >&2
|
|
153
169
|
fi
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from alibuild_helpers.log import dieOnError, ProgressPrint, LogFormatter, redact_secrets
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
FAKE_KEY = ("-----BEGIN RSA PRIVATE KEY-----\nMIIEfakefakefake\nAAAAsecretbody\n"
|
|
10
|
+
"-----END RSA PRIVATE KEY-----")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LogTestCase(unittest.TestCase):
|
|
14
|
+
def test_redact_docker_command(self) -> None:
|
|
15
|
+
"""The docker command line, joined unquoted, must not show the key."""
|
|
16
|
+
cmd = " ".join(["docker", "run", "-e", "JALIEN_TOKEN_KEY=" + FAKE_KEY,
|
|
17
|
+
"-e", "ALICEO2_CCDB_AUTH_TOKEN=abc123", "-e", "ARCHITECTURE=slc9_x86-64",
|
|
18
|
+
"-e", "ALIBUILD_PREFER_SYSTEM_KEY=gcc", "image"])
|
|
19
|
+
out = redact_secrets(cmd)
|
|
20
|
+
self.assertNotIn("secretbody", out)
|
|
21
|
+
self.assertNotIn("MIIEfake", out)
|
|
22
|
+
self.assertNotIn("abc123", out)
|
|
23
|
+
self.assertIn("ARCHITECTURE=slc9_x86-64", out)
|
|
24
|
+
self.assertIn("ALIBUILD_PREFER_SYSTEM_KEY=gcc", out)
|
|
25
|
+
self.assertIn("image", out)
|
|
26
|
+
|
|
27
|
+
def test_redact_quoted(self) -> None:
|
|
28
|
+
"""Shell-quoted values are redacted whole, including the quotes."""
|
|
29
|
+
out = redact_secrets("env GITHUB_TOKEN='tok en' JALIEN_TOKEN_KEY=\"%s\" bash" % FAKE_KEY)
|
|
30
|
+
self.assertEqual(out.count("<redacted>"), 2)
|
|
31
|
+
self.assertNotIn("tok en", out)
|
|
32
|
+
self.assertNotIn("secretbody", out)
|
|
33
|
+
self.assertTrue(out.endswith(" bash"))
|
|
34
|
+
|
|
35
|
+
def test_redact_unterminated_key(self) -> None:
|
|
36
|
+
"""A key cut off before its END marker is redacted to the end."""
|
|
37
|
+
out = redact_secrets("before " + FAKE_KEY.split("-----END")[0])
|
|
38
|
+
self.assertEqual(out, "before -----BEGIN RSA PRIVATE KEY----- <redacted>")
|
|
39
|
+
|
|
40
|
+
def test_formatter_redacts(self) -> None:
|
|
41
|
+
"""Everything going through the formatter is redacted, args included."""
|
|
42
|
+
record = logging.LogRecord("alibuild", logging.DEBUG, __file__, 1,
|
|
43
|
+
"Build command: %s", ("docker run -e JALIEN_TOKEN_KEY=" + FAKE_KEY,), None)
|
|
44
|
+
out = LogFormatter("%(levelname)s: %(message)s").format(record)
|
|
45
|
+
self.assertNotIn("secretbody", out)
|
|
46
|
+
self.assertIn("Build command: docker run -e JALIEN_TOKEN_KEY=<redacted>", out)
|
|
47
|
+
|
|
48
|
+
@patch("alibuild_helpers.log.error")
|
|
49
|
+
@patch("alibuild_helpers.log.sys")
|
|
50
|
+
def test_dieOnError(self, mock_sys, mock_error):
|
|
51
|
+
"""Check dieOnError dies on error."""
|
|
52
|
+
dieOnError(True, "Message")
|
|
53
|
+
mock_sys.exit.assert_called_once_with(1)
|
|
54
|
+
mock_error.assert_called_once_with("%s", "Message")
|
|
55
|
+
mock_sys.reset_mock()
|
|
56
|
+
mock_error.reset_mock()
|
|
57
|
+
dieOnError(False, "Message")
|
|
58
|
+
mock_error.assert_not_called()
|
|
59
|
+
mock_sys.exit.assert_not_called()
|
|
60
|
+
|
|
61
|
+
@patch("sys.stdout.isatty", new=MagicMock(return_value=True))
|
|
62
|
+
@patch("sys.stderr", new=MagicMock(return_value=True))
|
|
63
|
+
def test_ProgressPrint(self) -> None:
|
|
64
|
+
"""Make sure ProgressPrint updates correctly."""
|
|
65
|
+
# ProgressPrint only parses messages every 0.5s. Trick it into thinking
|
|
66
|
+
# the last message was <interval> seconds ago.
|
|
67
|
+
interval = 0.6
|
|
68
|
+
p = ProgressPrint("begin")
|
|
69
|
+
p("%s", "First message")
|
|
70
|
+
self.assertEqual(p.percent, -1)
|
|
71
|
+
p("%s", "[100/200] Update too fast")
|
|
72
|
+
self.assertEqual(p.percent, -1) # unchanged
|
|
73
|
+
p.lasttime -= interval
|
|
74
|
+
p("%s", "Has percentage: 80%")
|
|
75
|
+
self.assertEqual(p.percent, 80)
|
|
76
|
+
p.lasttime -= interval
|
|
77
|
+
p("%s", "No percentage")
|
|
78
|
+
self.assertEqual(p.percent, 80) # unchanged
|
|
79
|
+
p.lasttime -= interval
|
|
80
|
+
p("%s", "[100/200] Building CXX object this/is/a/ninja/test")
|
|
81
|
+
self.assertEqual(p.percent, 50)
|
|
82
|
+
p.lasttime -= interval
|
|
83
|
+
p("%s", "[100/0] Wrong-styled Ninja progress")
|
|
84
|
+
self.assertEqual(p.percent, 50) # unchanged
|
|
85
|
+
p.lasttime -= interval
|
|
86
|
+
p("%s", "Last message")
|
|
87
|
+
p.end()
|
|
88
|
+
|
|
89
|
+
if __name__ == '__main__':
|
|
90
|
+
unittest.main()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import os
|
|
2
3
|
import os.path
|
|
3
4
|
import sys
|
|
5
|
+
import tempfile
|
|
4
6
|
import unittest
|
|
5
7
|
from io import BytesIO
|
|
6
8
|
|
|
@@ -219,6 +221,51 @@ class SyncTestCase(unittest.TestCase):
|
|
|
219
221
|
self.assertFalse([u for u in requested if u.startswith(store + "/https")],
|
|
220
222
|
"the absolute target was joined onto the store: %s" % requested)
|
|
221
223
|
|
|
224
|
+
def fetch_through_stub(self, cas_response):
|
|
225
|
+
"""Fetch GOOD_SPEC from a store whose object redirects to a CAS blob, which
|
|
226
|
+
answers with cas_response. Returns (requested URLs, files left behind)."""
|
|
227
|
+
store = "https://localhost/legacy"
|
|
228
|
+
blob = "https://localhost/cas/cas/sha256/%s/%s" % (self.digest[:2], self.digest)
|
|
229
|
+
requested = []
|
|
230
|
+
|
|
231
|
+
def get(url, *args, **kw):
|
|
232
|
+
requested.append(url)
|
|
233
|
+
if url.endswith(tarball_name(GOOD_SPEC)) and "/store/" in url:
|
|
234
|
+
return MockRequest([{"name": tarball_name(GOOD_SPEC)}], redirect=blob)
|
|
235
|
+
if url == blob:
|
|
236
|
+
return cas_response
|
|
237
|
+
return self.mock_get(url, *args, **kw)
|
|
238
|
+
|
|
239
|
+
with tempfile.TemporaryDirectory() as workdir, \
|
|
240
|
+
patch("requests.Session.get", side_effect=get):
|
|
241
|
+
syncer = sync.HttpRemoteSync(remoteStore=store, architecture=ARCHITECTURE,
|
|
242
|
+
workdir=workdir, insecure=False)
|
|
243
|
+
syncer.httpBackoff = 0
|
|
244
|
+
syncer.fetch_tarball(GOOD_SPEC)
|
|
245
|
+
left = [f for _, _, files in os.walk(workdir) for f in files]
|
|
246
|
+
return [u for u in requested if u == blob], left
|
|
247
|
+
|
|
248
|
+
@patch("alibuild_helpers.sync.warning", new=MagicMock())
|
|
249
|
+
def test_http_missing_cas_blob_is_not_a_tarball(self):
|
|
250
|
+
"""A stub whose CAS blob is gone (expired) gives no tarball, no retries and
|
|
251
|
+
no exception, so the package is rebuilt. Writing the 404 body as the
|
|
252
|
+
tarball is what made a build fail with "gzip: not in gzip format"."""
|
|
253
|
+
self.digest = "fa" * 32
|
|
254
|
+
gone = MockRequest(None)
|
|
255
|
+
gone.iter_content = lambda chunk_size=10: iter([b"NoSuchKey"])
|
|
256
|
+
gone.headers["content-length"] = "9"
|
|
257
|
+
requested, left = self.fetch_through_stub(gone)
|
|
258
|
+
self.assertEqual(len(requested), 1)
|
|
259
|
+
self.assertEqual(left, [])
|
|
260
|
+
|
|
261
|
+
@patch("alibuild_helpers.sync.warning", new=MagicMock())
|
|
262
|
+
def test_http_cas_blob_checked_against_its_digest(self):
|
|
263
|
+
"""Bytes behind a cas/sha256/<digest> redirect must hash to <digest>."""
|
|
264
|
+
good = hashlib.sha256(b"x" * 123456).hexdigest()
|
|
265
|
+
for self.digest, kept in ((good, [tarball_name(GOOD_SPEC)]), ("ab" * 32, [])):
|
|
266
|
+
_, left = self.fetch_through_stub(MockRequest([{"name": "blob"}]))
|
|
267
|
+
self.assertEqual(left, kept)
|
|
268
|
+
|
|
222
269
|
def test_s3cmd_follows_store_redirect(self):
|
|
223
270
|
"""s3cmd cannot see the redirect header, so the stub is spotted by content."""
|
|
224
271
|
commands = []
|
|
@@ -78,18 +78,23 @@ class REAPIRemoteSyncTestCase(unittest.TestCase):
|
|
|
78
78
|
reapi.s3 = client
|
|
79
79
|
return reapi
|
|
80
80
|
|
|
81
|
-
def make_client(self, existing=()):
|
|
81
|
+
def make_client(self, existing=(), age_days=0, retention=None):
|
|
82
82
|
"""Mock S3 client: head_object 404s unless the key is in `existing`,
|
|
83
|
-
and all directory listings are empty (so uploads see no conflicts).
|
|
83
|
+
and all directory listings are empty (so uploads see no conflicts).
|
|
84
|
+
Existing objects are `age_days` old and tagged with `retention`."""
|
|
84
85
|
from botocore.exceptions import ClientError
|
|
86
|
+
from datetime import datetime, timezone, timedelta
|
|
85
87
|
|
|
86
88
|
def head_object(Bucket, Key):
|
|
87
89
|
if Key in existing:
|
|
88
|
-
return {"ContentLength": 4096
|
|
90
|
+
return {"ContentLength": 4096, "ContentType": "application/gzip",
|
|
91
|
+
"LastModified": datetime.now(timezone.utc) - timedelta(days=age_days)}
|
|
89
92
|
raise ClientError({"Error": {"Code": "404"}}, "head_object")
|
|
90
93
|
|
|
91
94
|
return MagicMock(
|
|
92
95
|
head_object=MagicMock(side_effect=head_object),
|
|
96
|
+
get_object_tagging=MagicMock(return_value={"TagSet": [
|
|
97
|
+
{"Key": "retention", "Value": retention}] if retention else []}),
|
|
93
98
|
get_paginator=lambda method: MagicMock(
|
|
94
99
|
paginate=lambda **kw: [{"Contents": []}]),
|
|
95
100
|
put_object=MagicMock(return_value=None),
|
|
@@ -231,6 +236,62 @@ class REAPIRemoteSyncTestCase(unittest.TestCase):
|
|
|
231
236
|
client.upload_file.assert_not_called()
|
|
232
237
|
self.assertIn(resolve_ac_path(ARCHITECTURE, REAPI_HASH), self.put_keys(client))
|
|
233
238
|
|
|
239
|
+
@patch("alibuild_helpers.sync_reapi.file_digest",
|
|
240
|
+
new=MagicMock(return_value=REAPI_CONTENT_HASH))
|
|
241
|
+
@patch("os.path.getsize", new=MagicMock(return_value=4096))
|
|
242
|
+
@patch("os.listdir",
|
|
243
|
+
new=lambda path: [tarball_name(REAPI_SPEC)] if path.endswith("-1") else [])
|
|
244
|
+
@patch("os.readlink", new=MagicMock(return_value="../../store/de/dead/x.tar.gz"))
|
|
245
|
+
@patch("os.path.islink", new=MagicMock(return_value=True))
|
|
246
|
+
def test_upload_dedup_refreshes_expiring_ephemeral(self) -> None:
|
|
247
|
+
"""Identical rebuilds always dedup, so the dedup must keep the blob alive:
|
|
248
|
+
otherwise a package that never changes expires 90 days after its first
|
|
249
|
+
build, while its AC entry and store stub still point at it."""
|
|
250
|
+
cas = resolve_cas_path(REAPI_CONTENT_HASH)
|
|
251
|
+
for age_days, refreshed in ((75, True), (10, False)):
|
|
252
|
+
client = self.make_client(existing={cas}, age_days=age_days,
|
|
253
|
+
retention="ephemeral")
|
|
254
|
+
self.make_sync(client).upload_symlinks_and_tarball(REAPI_SPEC)
|
|
255
|
+
client.upload_file.assert_not_called()
|
|
256
|
+
if not refreshed:
|
|
257
|
+
client.copy_object.assert_not_called()
|
|
258
|
+
continue
|
|
259
|
+
# Copied onto itself in the bucket we write, tags kept. COPY would be
|
|
260
|
+
# refused by S3 and Ceph RGW for a self-copy, so it must be REPLACE.
|
|
261
|
+
client.copy_object.assert_called_once()
|
|
262
|
+
kw = client.copy_object.call_args.kwargs
|
|
263
|
+
self.assertEqual((kw["Bucket"], kw["Key"]), ("bucket", cas))
|
|
264
|
+
self.assertEqual(kw["CopySource"], {"Bucket": "bucket", "Key": cas})
|
|
265
|
+
self.assertEqual(kw["MetadataDirective"], "REPLACE")
|
|
266
|
+
self.assertEqual(kw["ContentType"], "application/gzip")
|
|
267
|
+
self.assertEqual(kw["TaggingDirective"], "COPY")
|
|
268
|
+
|
|
269
|
+
def test_is_published_keep_alive(self) -> None:
|
|
270
|
+
"""A package the build takes from the store never reaches the upload, so
|
|
271
|
+
is_published(keep_alive=True), asked for every reused package, is what
|
|
272
|
+
promotes its blob on a permanent build (or LRU-refreshes it)."""
|
|
273
|
+
cas = resolve_cas_path(REAPI_CONTENT_HASH)
|
|
274
|
+
entry = {"result": {"outputDigest": "sha256:" + REAPI_CONTENT_HASH}}
|
|
275
|
+
for keep_alive in (False, True):
|
|
276
|
+
client = self.make_client(existing={cas}, age_days=5, retention="ephemeral")
|
|
277
|
+
reapi = self.make_sync(client)
|
|
278
|
+
reapi.storage = "permanent"
|
|
279
|
+
reapi.read_ac_entry = MagicMock(return_value=entry)
|
|
280
|
+
self.assertTrue(reapi.is_published(REAPI_HASH, keep_alive=keep_alive))
|
|
281
|
+
if keep_alive:
|
|
282
|
+
client.put_object_tagging.assert_called_once_with(
|
|
283
|
+
Bucket="bucket", Key=cas,
|
|
284
|
+
Tagging={"TagSet": [{"Key": "retention", "Value": "permanent"}]})
|
|
285
|
+
else:
|
|
286
|
+
client.put_object_tagging.assert_not_called()
|
|
287
|
+
# An ephemeral build refreshes it only when it is close to expiry.
|
|
288
|
+
client = self.make_client(existing={cas}, age_days=70, retention="ephemeral")
|
|
289
|
+
reapi = self.make_sync(client)
|
|
290
|
+
reapi.read_ac_entry = MagicMock(return_value=entry)
|
|
291
|
+
self.assertTrue(reapi.is_published(REAPI_HASH, keep_alive=True))
|
|
292
|
+
client.copy_object.assert_called_once()
|
|
293
|
+
client.put_object_tagging.assert_not_called()
|
|
294
|
+
|
|
234
295
|
@patch("alibuild_helpers.sync_reapi.file_digest",
|
|
235
296
|
new=MagicMock(return_value=REAPI_CONTENT_HASH))
|
|
236
297
|
@patch("os.path.getsize", new=MagicMock(return_value=4096))
|
alibuild-2.0.0/tests/test_log.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest.mock import MagicMock, patch
|
|
3
|
-
|
|
4
|
-
from alibuild_helpers.log import dieOnError, ProgressPrint
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class LogTestCase(unittest.TestCase):
|
|
8
|
-
@patch("alibuild_helpers.log.error")
|
|
9
|
-
@patch("alibuild_helpers.log.sys")
|
|
10
|
-
def test_dieOnError(self, mock_sys, mock_error):
|
|
11
|
-
"""Check dieOnError dies on error."""
|
|
12
|
-
dieOnError(True, "Message")
|
|
13
|
-
mock_sys.exit.assert_called_once_with(1)
|
|
14
|
-
mock_error.assert_called_once_with("%s", "Message")
|
|
15
|
-
mock_sys.reset_mock()
|
|
16
|
-
mock_error.reset_mock()
|
|
17
|
-
dieOnError(False, "Message")
|
|
18
|
-
mock_error.assert_not_called()
|
|
19
|
-
mock_sys.exit.assert_not_called()
|
|
20
|
-
|
|
21
|
-
@patch("sys.stdout.isatty", new=MagicMock(return_value=True))
|
|
22
|
-
@patch("sys.stderr", new=MagicMock(return_value=True))
|
|
23
|
-
def test_ProgressPrint(self) -> None:
|
|
24
|
-
"""Make sure ProgressPrint updates correctly."""
|
|
25
|
-
# ProgressPrint only parses messages every 0.5s. Trick it into thinking
|
|
26
|
-
# the last message was <interval> seconds ago.
|
|
27
|
-
interval = 0.6
|
|
28
|
-
p = ProgressPrint("begin")
|
|
29
|
-
p("%s", "First message")
|
|
30
|
-
self.assertEqual(p.percent, -1)
|
|
31
|
-
p("%s", "[100/200] Update too fast")
|
|
32
|
-
self.assertEqual(p.percent, -1) # unchanged
|
|
33
|
-
p.lasttime -= interval
|
|
34
|
-
p("%s", "Has percentage: 80%")
|
|
35
|
-
self.assertEqual(p.percent, 80)
|
|
36
|
-
p.lasttime -= interval
|
|
37
|
-
p("%s", "No percentage")
|
|
38
|
-
self.assertEqual(p.percent, 80) # unchanged
|
|
39
|
-
p.lasttime -= interval
|
|
40
|
-
p("%s", "[100/200] Building CXX object this/is/a/ninja/test")
|
|
41
|
-
self.assertEqual(p.percent, 50)
|
|
42
|
-
p.lasttime -= interval
|
|
43
|
-
p("%s", "[100/0] Wrong-styled Ninja progress")
|
|
44
|
-
self.assertEqual(p.percent, 50) # unchanged
|
|
45
|
-
p.lasttime -= interval
|
|
46
|
-
p("%s", "Last message")
|
|
47
|
-
p.end()
|
|
48
|
-
|
|
49
|
-
if __name__ == '__main__':
|
|
50
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|