11#!/usr/bin/env python3
22"""Run Sublime Text UnitTesting in a Docker container.
33
4+ Usually invoked via the sibling launcher script `ut-run-tests`.
45Examples:
5- uv run docker/run_tests.py .
6- uv run docker/run_tests.py . --file tests/test_main.py
6+ ./ docker/ut-run-tests .
7+ ./ docker/ut-run-tests . --file tests/test_main.py
78"""
89
910from __future__ import annotations
1011
1112import argparse
12- from datetime import datetime , timezone
13+ import hashlib
1314import shutil
1415import subprocess
1516import sys
1819
1920DEFAULT_IMAGE = "unittesting-local"
2021DEFAULT_CACHE_VOLUME = "unittesting-home"
22+ DOCKER_CONTEXT_HASH_LABEL = "org.sublimetext.unittesting.context-hash"
23+ DOCKER_CONTEXT_INPUTS = (
24+ "Dockerfile" ,
25+ "docker.sh" ,
26+ "entrypoint.sh" ,
27+ "xvfb" ,
28+ )
2129
2230
2331def main (argv : list [str ] | None = None ) -> int :
@@ -171,8 +179,10 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None:
171179 if not context_dir .is_dir ():
172180 raise SystemExit (f"Error: missing docker build context: { context_dir } " )
173181
182+ context_hash = docker_context_hash (context_dir )
174183 image_exists = docker_image_exists (image )
175- context_changed = image_exists and docker_context_changed (context_dir , image )
184+ image_hash = docker_image_context_hash (image ) if image_exists else None
185+ context_changed = image_exists and image_hash != context_hash
176186
177187 should_build = args .build_image
178188 should_build = should_build or (args .build_if_missing and not image_exists )
@@ -185,7 +195,15 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None:
185195 print ("Docker context changed since last image build, rebuilding..." )
186196
187197 print (f"Building docker image '{ image } ' from { context_dir } ..." )
188- run_checked (["docker" , "build" , "-t" , image , str (context_dir )])
198+ run_checked ([
199+ "docker" ,
200+ "build" ,
201+ "--label" ,
202+ f"{ DOCKER_CONTEXT_HASH_LABEL } ={ context_hash } " ,
203+ "-t" ,
204+ image ,
205+ str (context_dir ),
206+ ])
189207
190208
191209def docker_image_exists (image : str ) -> bool :
@@ -197,53 +215,43 @@ def docker_image_exists(image: str) -> bool:
197215 return result .returncode == 0
198216
199217
200- def docker_context_changed (context_dir : Path , image : str ) -> bool :
201- image_created = docker_image_created_at (image )
202- if image_created is None :
203- return True
218+ def docker_context_hash (context_dir : Path ) -> str :
219+ digest = hashlib .sha256 ()
220+ for rel_path in DOCKER_CONTEXT_INPUTS :
221+ file_path = context_dir / rel_path
222+ if not file_path .is_file ():
223+ raise SystemExit (f"Error: missing docker context file: { file_path } " )
204224
205- context_mtime = newest_mtime (context_dir )
206- return context_mtime > image_created
225+ digest .update (rel_path .encode ("utf-8" ))
226+ digest .update (b"\0 " )
227+ digest .update (file_path .read_bytes ())
228+ digest .update (b"\0 " )
207229
230+ return digest .hexdigest ()
208231
209- def docker_image_created_at (image : str ) -> float | None :
232+
233+ def docker_image_context_hash (image : str ) -> str | None :
210234 result = subprocess .run (
211- ["docker" , "image" , "inspect" , image , "--format" , "{{.Created}}" ],
235+ [
236+ "docker" ,
237+ "image" ,
238+ "inspect" ,
239+ image ,
240+ "--format" ,
241+ "{{ index .Config.Labels \" %s\" }}" % DOCKER_CONTEXT_HASH_LABEL ,
242+ ],
212243 stdout = subprocess .PIPE ,
213244 stderr = subprocess .DEVNULL ,
214245 text = True ,
215246 )
216247 if result .returncode != 0 :
217248 return None
218249
219- created = result .stdout .strip ()
220- if not created :
221- return None
222-
223- # Example: 2026-03-13T21:47:06.123456789Z
224- if created .endswith ("Z" ):
225- created = created [:- 1 ]
226- if "." in created :
227- created = created .split ("." , 1 )[0 ]
228-
229- try :
230- dt = datetime .strptime (created , "%Y-%m-%dT%H:%M:%S" ).replace (tzinfo = timezone .utc )
231- except ValueError :
250+ value = result .stdout .strip ()
251+ if not value or value == "<no value>" :
232252 return None
233253
234- return dt .timestamp ()
235-
236-
237- def newest_mtime (path : Path ) -> float :
238- latest = path .stat ().st_mtime
239- for child in path .rglob ("*" ):
240- try :
241- mtime = child .stat ().st_mtime
242- except OSError :
243- continue
244- if mtime > latest :
245- latest = mtime
246- return latest
254+ return value
247255
248256
249257def ensure_docker_volume (name : str ) -> None :
0 commit comments