UEPyScripts 1.4.1__tar.gz → 1.4.3__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.
Files changed (38) hide show
  1. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/PKG-INFO +1 -1
  2. uepyscripts-1.4.3/src/uepyscripts/tools/ugs/git_utils.py +110 -0
  3. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ugs/push.py +84 -16
  4. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ugs/ugs_types.py +10 -0
  5. uepyscripts-1.4.1/src/uepyscripts/tools/ugs/git_utils.py +0 -49
  6. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/.gitignore +0 -0
  7. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/LICENSE +0 -0
  8. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/README.md +0 -0
  9. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/pyproject.toml +0 -0
  10. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/__init__.py +0 -0
  11. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/context/__init__.py +0 -0
  12. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/internal/__init__.py +0 -0
  13. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/internal/config.py +0 -0
  14. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/internal/engine.py +0 -0
  15. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/internal/engine_resolver.py +0 -0
  16. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/internal/project.py +0 -0
  17. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/py.typed +0 -0
  18. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/run/__init__.py +0 -0
  19. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/run/buildgraph.py +0 -0
  20. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/run/uat.py +0 -0
  21. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/run/ubt.py +0 -0
  22. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/__init__.py +0 -0
  23. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ci/buildgraph.py +0 -0
  24. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ci/cleanup.py +0 -0
  25. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/helpers.py +0 -0
  26. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/subprocess.py +0 -0
  27. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/__init__.py +0 -0
  28. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/check_engine_installation.py +0 -0
  29. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/close_editor.py +0 -0
  30. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/compile_editor.py +0 -0
  31. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/engine_installation/engine_destination.py +0 -0
  32. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/engine_installation/engine_installer.py +0 -0
  33. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/engine_installation/engine_source.py +0 -0
  34. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/generate_solution.py +0 -0
  35. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ue/run_editor.py +0 -0
  36. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ugs/cleanup.py +0 -0
  37. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/ugs/pull.py +0 -0
  38. {uepyscripts-1.4.1 → uepyscripts-1.4.3}/src/uepyscripts/tools/winreg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: UEPyScripts
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: Collection of Python tools and helpers designed to automate common Unreal Engine project tasks (builds, packaging, editor control, CI tasks, etc.).
5
5
  Project-URL: Changelog, https://github.com/TheEmidee/UEPyScripts/releases
6
6
  Project-URL: Homepage, https://github.com/TheEmidee/UEPyScripts
@@ -0,0 +1,110 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from uepyscripts import logger
6
+
7
+ ANCESTRY_LOOKBACK = 500
8
+
9
+
10
+ def get_current_sha(cwd: Path) -> str:
11
+ sha = os.environ.get("GIT_COMMIT") # Jenkins sets this automatically
12
+ if sha:
13
+ return sha
14
+ return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, text=True).strip()
15
+
16
+
17
+ def get_current_branch(cwd: Path) -> str:
18
+ branch = os.environ.get("GIT_BRANCH") # Jenkins' Git plugin sets this, sometimes as "origin/<branch>"
19
+ if branch:
20
+ return branch.split("/", 1)[1] if "/" in branch else branch
21
+
22
+ branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, text=True).strip()
23
+ if branch != "HEAD":
24
+ return branch
25
+
26
+ # Detached HEAD (e.g. a CI checkout of an explicit ref rather than a
27
+ # tracked branch, with no GIT_BRANCH env var set either) — fall back to
28
+ # whichever local or remote-tracking ref currently points at this commit.
29
+ refs = subprocess.check_output(
30
+ ["git", "for-each-ref", "--points-at=HEAD", "--format=%(refname:short)", "refs/heads/", "refs/remotes/"],
31
+ cwd=cwd,
32
+ text=True,
33
+ ).splitlines()
34
+ for ref in refs:
35
+ return ref.split("/", 1)[1] if "/" in ref else ref
36
+
37
+ logger.warning("Could not resolve current branch: HEAD is detached and no ref points at it")
38
+ return branch
39
+
40
+
41
+ def is_shallow_repository(cwd: Path) -> bool:
42
+ out = subprocess.check_output(["git", "rev-parse", "--is-shallow-repository"], cwd=cwd, text=True).strip()
43
+ return out == "true"
44
+
45
+
46
+ def deepen_shallow_history(cwd: Path, limit: int, remote: str = "origin") -> bool:
47
+ """Pulls down the commits missing from a shallow checkout, and only the
48
+ commits: `--filter=tree:0` keeps trees and blobs on the server, so this
49
+ stays a metadata-sized fetch even on a repository the size of an engine
50
+ tree. Returns False (after logging) when the remote refuses, so callers
51
+ can carry on with whatever history is available locally."""
52
+ try:
53
+ subprocess.run(
54
+ ["git", "fetch", "--no-tags", "--filter=tree:0", f"--deepen={limit}", remote],
55
+ cwd=cwd,
56
+ check=True,
57
+ capture_output=True,
58
+ text=True,
59
+ )
60
+ return True
61
+ except subprocess.CalledProcessError as e:
62
+ reason = e.stderr.strip() or e
63
+ logger.warning(f"Could not deepen shallow history from '{remote}' ({reason}); ancestry walking is limited to the local history")
64
+ return False
65
+
66
+
67
+ def get_local_ancestry(cwd: Path, limit: int = ANCESTRY_LOOKBACK) -> list[str]:
68
+ ancestry = _log_ancestry(cwd, limit)
69
+
70
+ # CI checkouts are typically shallow clones (Jenkins' cloneOption(depth: 1)),
71
+ # where HEAD is a grafted boundary commit: `git log HEAD` stops at HEAD
72
+ # itself and every already-published parent looks unreachable, which would
73
+ # downgrade an incremental publish into a full fresh sync. Deepen first so
74
+ # the walk sees the real ancestry.
75
+ if len(ancestry) < limit and is_shallow_repository(cwd):
76
+ logger.info(f"Shallow checkout detected ({len(ancestry)} commit(s) available); deepening history to {limit} commits")
77
+ if deepen_shallow_history(cwd, limit):
78
+ ancestry = _log_ancestry(cwd, limit)
79
+
80
+ return ancestry
81
+
82
+
83
+ def _log_ancestry(cwd: Path, limit: int) -> list[str]:
84
+ out = subprocess.check_output(["git", "log", f"-{limit}", "--format=%H", "HEAD"], cwd=cwd, text=True)
85
+ return out.splitlines()
86
+
87
+
88
+ def get_remote_reachable_shas(cwd: Path, remote: str = "origin") -> set[str]:
89
+ """All commit SHAs reachable from any current branch tip on `remote`, after
90
+ pruning local remote-tracking refs for branches deleted on the remote."""
91
+ subprocess.run(["git", "fetch", "--prune", remote], cwd=cwd, check=True, capture_output=True, text=True)
92
+ out = subprocess.check_output(["git", "rev-list", f"--remotes={remote}"], cwd=cwd, text=True)
93
+ return set(out.splitlines())
94
+
95
+
96
+ def resolve_nearest_published_ancestor(index: list[str], ancestry: list[str]) -> tuple[str | None, bool]:
97
+ """Finds the nearest ancestor SHA (including HEAD itself) present in the
98
+ index. Returns None if nothing has ever been published reachable from HEAD
99
+ within the walked ancestry. The bool is True when the match was found by
100
+ walking the ancestry itself (an exact match), False when falling back to
101
+ the most recent published version because nothing in the ancestry matched."""
102
+ index_set = set(index)
103
+ for sha in ancestry:
104
+ if sha in index_set:
105
+ return sha, True
106
+
107
+ if index:
108
+ return index[-1], False
109
+
110
+ return None, True
@@ -35,10 +35,9 @@ from uepyscripts.tools.ugs.git_utils import (
35
35
  get_local_ancestry,
36
36
  resolve_nearest_published_ancestor,
37
37
  )
38
- from uepyscripts.tools.ugs.ugs_types import Manifest, VersionManifest
38
+ from uepyscripts.tools.ugs.ugs_types import HashCacheEntry, HashCacheManifest, Manifest, VersionManifest
39
39
 
40
40
  # ---- CONFIG ----------------------------------------------------------
41
- LOCAL_HASH_CACHE = ".hash-cache.json"
42
41
 
43
42
  # Extensions that go through the delta/checkpoint sync pipeline (no PDBs — see symstore below)
44
43
  SYNC_EXT = {".dll", ".exe", ".lib", ".modules", ".target"}
@@ -72,18 +71,18 @@ class HashCacheInfos:
72
71
 
73
72
 
74
73
  class HashCache:
75
- def __init__(self) -> None:
74
+ def __init__(self, commit_sha: str) -> None:
75
+ self.commit_sha = commit_sha
76
76
  self.cache: dict[str, HashCacheInfos] = dict()
77
- self.local_hash_cache = Path(LOCAL_HASH_CACHE)
78
77
 
79
- if self.local_hash_cache.exists():
80
- raw = json.loads(self.local_hash_cache.read_text())
81
- self.cache = {path: HashCacheInfos(**entry) for path, entry in raw.items()}
78
+ def seed_from(self, files: dict[str, HashCacheEntry]) -> None:
79
+ self.cache = {path: HashCacheInfos(**entry) for path, entry in files.items()}
82
80
 
83
- def save(self) -> None:
84
- self.local_hash_cache.parent.mkdir(parents=True, exist_ok=True)
85
- serializable = {path: asdict(entry) for path, entry in self.cache.items()}
86
- self.local_hash_cache.write_text(json.dumps(serializable, indent=2, sort_keys=True))
81
+ def to_manifest(self) -> HashCacheManifest:
82
+ return {
83
+ "commit_sha": self.commit_sha,
84
+ "files": {path: cast(HashCacheEntry, asdict(entry)) for path, entry in self.cache.items()},
85
+ }
87
86
 
88
87
  def get(self, path: str) -> HashCacheInfos | None:
89
88
  return self.cache.get(path)
@@ -102,10 +101,35 @@ class Context:
102
101
  )
103
102
  self.symbol_store_path: str = args.symbol_store_path
104
103
  self.tmp_root: Path = Path(tempfile.mkdtemp(prefix="publish-"))
105
- self.hash_cache = HashCache()
106
104
  self.root_path = root_path
107
105
  self.current_sha = get_current_sha(root_path)
108
106
  self.current_branch = get_current_branch(root_path)
107
+ self.ancestry: list[str] = get_local_ancestry(root_path)
108
+ self.hash_cache, self.hash_cache_index = self._load_hash_cache()
109
+
110
+ def _load_hash_cache(self) -> tuple[HashCache, list[str]]:
111
+ """Resolves the nearest commit with an uploaded hash cache and seeds a
112
+ fresh HashCache from it. Unlike the manifest baseline used for diffing,
113
+ an inexact match is fine here: a fingerprint mismatch just falls back to
114
+ a full hash for that file, no worse than a cold-cache case, so
115
+ there's no correctness risk in seeding from a non-ancestor."""
116
+ bucket_name = self.args.s3_bucket_name
117
+ hash_cache_index = cast(list[str], self.s3_client.download_json(bucket_name, "hash_caches/index.json", default=[]))
118
+ baseline_sha, _ = resolve_nearest_published_ancestor(hash_cache_index, self.ancestry)
119
+
120
+ hash_cache = HashCache(self.current_sha)
121
+ if baseline_sha is None:
122
+ logger.info("No hash cache baseline found — starting with a cold hash cache")
123
+ return hash_cache, hash_cache_index
124
+
125
+ try:
126
+ manifest = cast(HashCacheManifest, self.s3_client.download_json(bucket_name, f"hash_caches/{baseline_sha}.json"))
127
+ hash_cache.seed_from(manifest["files"])
128
+ logger.info(f"Seeded hash cache from {baseline_sha} ({len(manifest['files'])} entries)")
129
+ except Exception as e:
130
+ logger.warning(f"Failed to load hash cache from hash_caches/{baseline_sha}.json ({e}); starting with a cold hash cache")
131
+
132
+ return hash_cache, hash_cache_index
109
133
 
110
134
  def finalize(self) -> None:
111
135
  if not self.args.keep_temp_directory:
@@ -327,6 +351,24 @@ def prune_old_versions(context: Context, cfg: FilesConfiguration, index: list[st
327
351
  return index[cutoff_idx:], checkpoints_to_keep
328
352
 
329
353
 
354
+ def prune_old_hash_caches(context: Context, hash_cache_index: list[str], keep: int) -> list[str]:
355
+ """Simple count-based retention for the hash-cache side-channel: unlike
356
+ index.json (only appended on actual content changes), hash_cache_index
357
+ grows once per run, so it needs its own, faster-turnover retention
358
+ rather than piggybacking on prune_old_versions's checkpoint-aligned
359
+ logic."""
360
+ if len(hash_cache_index) <= keep:
361
+ return hash_cache_index
362
+
363
+ to_delete = hash_cache_index[:-keep]
364
+ to_keep = hash_cache_index[-keep:]
365
+
366
+ logger.info(f"Pruning {len(to_delete)} old hash cache(s)")
367
+ context.s3_client.delete_keys(context.args.s3_bucket_name, [f"hash_caches/{sha}.json" for sha in to_delete])
368
+
369
+ return to_keep
370
+
371
+
330
372
  def publish_to_symbol_store(context: Context, paths: list[str], cfg: FilesConfiguration) -> None:
331
373
  """Indexes PDB/DLL/EXE files into the symbol store via symstore.exe."""
332
374
  logger.info("Publish to symbol store")
@@ -397,6 +439,12 @@ def parse_arguments() -> argparse.Namespace:
397
439
  default=2,
398
440
  help="Number of checkpoints to retain; older ones (and their deltas) get pruned (default: 2)",
399
441
  )
442
+ parser.add_argument(
443
+ "--keep_hash_caches",
444
+ type=int,
445
+ default=100,
446
+ help="Number of hash cache entries to retain in hash_caches/index.json; older ones get pruned (default: 100)",
447
+ )
400
448
  parser.add_argument(
401
449
  "--checkpoint-branch",
402
450
  type=str,
@@ -460,8 +508,7 @@ def main() -> None:
460
508
  logger.info(f"Finished scan. Found {len(new_state)} files.")
461
509
 
462
510
  logger.info("Resolve diff baseline from git ancestry")
463
- ancestry = get_local_ancestry(context.root_path)
464
- baseline_sha, is_exact = resolve_nearest_published_ancestor(index, ancestry)
511
+ baseline_sha, is_exact = resolve_nearest_published_ancestor(index, context.ancestry)
465
512
 
466
513
  old_state: dict[str, str]
467
514
  if baseline_sha is None:
@@ -573,8 +620,29 @@ def main() -> None:
573
620
 
574
621
  logger.info("=== Finished processing binary files ===")
575
622
 
576
- logger.info("Save hash cache")
577
- context.hash_cache.save()
623
+ if not context.args.skip_upload:
624
+ logger.info("Upload hash cache")
625
+ context.s3_client.upload_bytes(
626
+ context.args.s3_bucket_name,
627
+ f"hash_caches/{context.current_sha}.json",
628
+ json.dumps(context.hash_cache.to_manifest()).encode(),
629
+ content_type="application/json",
630
+ )
631
+
632
+ if context.current_sha not in context.hash_cache_index:
633
+ context.hash_cache_index.append(context.current_sha)
634
+
635
+ context.hash_cache_index = prune_old_hash_caches(context, context.hash_cache_index, context.args.keep_hash_caches)
636
+
637
+ logger.info("Upload hash_caches/index.json")
638
+ context.s3_client.upload_bytes(
639
+ context.args.s3_bucket_name,
640
+ "hash_caches/index.json",
641
+ json.dumps(context.hash_cache_index).encode(),
642
+ content_type="application/json",
643
+ )
644
+ else:
645
+ logger.info("Skipping hash cache upload (--skip-upload)")
578
646
  finally:
579
647
  context.finalize()
580
648
 
@@ -7,3 +7,13 @@ class VersionManifest(TypedDict):
7
7
  files: Manifest # full state at this version: {relative_path: sha256}
8
8
  changed: list[str] # files added or modified since the previous version
9
9
  removed: list[str] # files removed since the previous version
10
+
11
+
12
+ class HashCacheEntry(TypedDict):
13
+ fingerprint: str # "mtime_ns:size"
14
+ hash: str # blake3 hex digest
15
+
16
+
17
+ class HashCacheManifest(TypedDict):
18
+ commit_sha: str # commit this cache was captured at
19
+ files: dict[str, HashCacheEntry]
@@ -1,49 +0,0 @@
1
- import os
2
- import subprocess
3
- from pathlib import Path
4
-
5
- ANCESTRY_LOOKBACK = 2000
6
-
7
-
8
- def get_current_sha(cwd: Path) -> str:
9
- sha = os.environ.get("GIT_COMMIT") # Jenkins sets this automatically
10
- if sha:
11
- return sha
12
- return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, text=True).strip()
13
-
14
-
15
- def get_current_branch(cwd: Path) -> str:
16
- branch = os.environ.get("GIT_BRANCH") # Jenkins' Git plugin sets this, sometimes as "origin/<branch>"
17
- if branch:
18
- return branch.split("/", 1)[1] if "/" in branch else branch
19
- return subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, text=True).strip()
20
-
21
-
22
- def get_local_ancestry(cwd: Path, limit: int = ANCESTRY_LOOKBACK) -> list[str]:
23
- out = subprocess.check_output(["git", "log", f"-{limit}", "--format=%H", "HEAD"], cwd=cwd, text=True)
24
- return out.splitlines()
25
-
26
-
27
- def get_remote_reachable_shas(cwd: Path, remote: str = "origin") -> set[str]:
28
- """All commit SHAs reachable from any current branch tip on `remote`, after
29
- pruning local remote-tracking refs for branches deleted on the remote."""
30
- subprocess.run(["git", "fetch", "--prune", remote], cwd=cwd, check=True, capture_output=True, text=True)
31
- out = subprocess.check_output(["git", "rev-list", f"--remotes={remote}"], cwd=cwd, text=True)
32
- return set(out.splitlines())
33
-
34
-
35
- def resolve_nearest_published_ancestor(index: list[str], ancestry: list[str]) -> tuple[str | None, bool]:
36
- """Finds the nearest ancestor SHA (including HEAD itself) present in the
37
- index. Returns None if nothing has ever been published reachable from HEAD
38
- within the walked ancestry. The bool is True when the match was found by
39
- walking the ancestry itself (an exact match), False when falling back to
40
- the most recent published version because nothing in the ancestry matched."""
41
- index_set = set(index)
42
- for sha in ancestry:
43
- if sha in index_set:
44
- return sha, True
45
-
46
- if index:
47
- return index[-1], False
48
-
49
- return None, True
File without changes
File without changes
File without changes
File without changes