All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
8 from functools import partial
9 from pathlib import Path
10 from platform import system
11 from shutil import rmtree, which
12 from subprocess import CalledProcessError
13 from sys import version_info
14 from tempfile import TemporaryDirectory
26 from urllib.parse import urlparse
31 TEN_MINUTES_SECONDS = 600
32 WINDOWS = system() == "Windows"
33 BLACK_BINARY = "black.exe" if WINDOWS else "black"
34 GIT_BINARY = "git.exe" if WINDOWS else "git"
35 LOG = logging.getLogger(__name__)
38 # Windows needs a ProactorEventLoop if you want to exec subprocesses
39 # Starting with 3.8 this is the default - can remove when Black >= 3.8
40 # mypy only respects sys.platform if directly in the evaluation
41 # https://mypy.readthedocs.io/en/latest/common_issues.html#python-version-and-system-platform-checks # noqa: B950
42 if sys.platform == "win32":
43 asyncio.set_event_loop(asyncio.ProactorEventLoop())
46 class Results(NamedTuple):
47 stats: Dict[str, int] = {}
48 failed_projects: Dict[str, CalledProcessError] = {}
51 async def _gen_check_output(
53 timeout: float = TEN_MINUTES_SECONDS,
54 env: Optional[Dict[str, str]] = None,
55 cwd: Optional[Path] = None,
56 stdin: Optional[bytes] = None,
57 ) -> Tuple[bytes, bytes]:
58 process = await asyncio.create_subprocess_exec(
60 stdin=asyncio.subprocess.PIPE,
61 stdout=asyncio.subprocess.PIPE,
62 stderr=asyncio.subprocess.STDOUT,
67 (stdout, stderr) = await asyncio.wait_for(process.communicate(stdin), timeout)
68 except asyncio.TimeoutError:
73 # A non-optional timeout was supplied to asyncio.wait_for, guaranteeing
74 # a timeout or completed process. A terminated Python process will have a
75 # non-empty returncode value.
76 assert process.returncode is not None
78 if process.returncode != 0:
79 cmd_str = " ".join(cmd)
80 raise CalledProcessError(
81 process.returncode, cmd_str, output=stdout, stderr=stderr
84 return (stdout, stderr)
87 def analyze_results(project_count: int, results: Results) -> int:
88 failed_pct = round(((results.stats["failed"] / project_count) * 100), 2)
89 success_pct = round(((results.stats["success"] / project_count) * 100), 2)
91 click.secho("-- primer results 📊 --\n", bold=True)
93 f"{results.stats['success']} / {project_count} succeeded ({success_pct}%) ✅",
98 f"{results.stats['failed']} / {project_count} FAILED ({failed_pct}%) 💩",
99 bold=bool(results.stats["failed"]),
102 s = "" if results.stats["disabled"] == 1 else "s"
103 click.echo(f" - {results.stats['disabled']} project{s} disabled by config")
104 s = "" if results.stats["wrong_py_ver"] == 1 else "s"
106 f" - {results.stats['wrong_py_ver']} project{s} skipped due to Python version"
109 f" - {results.stats['skipped_long_checkout']} skipped due to long checkout"
112 if results.failed_projects:
113 click.secho("\nFailed projects:\n", bold=True)
115 for project_name, project_cpe in results.failed_projects.items():
116 print(f"## {project_name}:")
117 print(f" - Returned {project_cpe.returncode}")
118 if project_cpe.stderr:
119 print(f" - stderr:\n{project_cpe.stderr.decode('utf8')}")
120 if project_cpe.stdout:
121 print(f" - stdout:\n{project_cpe.stdout.decode('utf8')}")
124 return results.stats["failed"]
127 def _flatten_cli_args(cli_args: List[Union[Sequence[str], str]]) -> List[str]:
128 """Allow a user to put long arguments into a list of strs
129 to make the JSON human readable"""
132 if isinstance(arg, str):
133 flat_args.append(arg)
136 args_as_str = "".join(arg)
137 flat_args.append(args_as_str)
144 repo_path: Optional[Path],
145 project_config: Dict[str, Any],
147 no_diff: bool = False,
149 """Run Black and record failures"""
151 results.stats["failed"] += 1
152 results.failed_projects[project_name] = CalledProcessError(
153 69, [], f"{project_name} has no repo_path: {repo_path}".encode(), b""
157 stdin_test = project_name.upper() == "STDIN"
158 cmd = [str(which(BLACK_BINARY))]
159 if "cli_arguments" in project_config and project_config["cli_arguments"]:
160 cmd.extend(_flatten_cli_args(project_config["cli_arguments"]))
161 cmd.append("--check")
165 # Workout if we should read in a python file or search from cwd
169 stdin = repo_path.read_bytes()
170 elif "base_path" in project_config:
171 cmd.append(project_config["base_path"])
176 project_config["timeout_seconds"]
177 if "timeout_seconds" in project_config
178 else TEN_MINUTES_SECONDS
180 with TemporaryDirectory() as tmp_path:
181 # Prevent reading top-level user configs by manipulating environment variables
184 "XDG_CONFIG_HOME": tmp_path, # Unix-like
185 "USERPROFILE": tmp_path, # Windows (changes `Path.home()` output)
188 cwd_path = repo_path.parent if stdin_test else repo_path
190 LOG.debug(f"Running black for {project_name}: {' '.join(cmd)}")
191 _stdout, _stderr = await _gen_check_output(
192 cmd, cwd=cwd_path, env=env, stdin=stdin, timeout=timeout
194 except asyncio.TimeoutError:
195 results.stats["failed"] += 1
196 LOG.error(f"Running black for {repo_path} timed out ({cmd})")
197 except CalledProcessError as cpe:
198 # TODO: Tune for smarter for higher signal
199 # If any other return value than 1 we raise - can disable project in config
200 if cpe.returncode == 1:
201 if not project_config["expect_formatting_changes"]:
202 results.stats["failed"] += 1
203 results.failed_projects[repo_path.name] = cpe
205 results.stats["success"] += 1
207 elif cpe.returncode > 1:
208 results.stats["failed"] += 1
209 results.failed_projects[repo_path.name] = cpe
212 LOG.error(f"Unknown error with {repo_path}")
215 # If we get here and expect formatting changes something is up
216 if project_config["expect_formatting_changes"]:
217 results.stats["failed"] += 1
218 results.failed_projects[repo_path.name] = CalledProcessError(
219 0, cmd, b"Expected formatting changes but didn't get any!", b""
223 results.stats["success"] += 1
226 async def git_checkout_or_rebase(
228 project_config: Dict[str, Any],
229 rebase: bool = False,
233 """git Clone project or rebase"""
234 git_bin = str(which(GIT_BINARY))
236 LOG.error("No git binary found")
239 repo_url_parts = urlparse(project_config["git_clone_url"])
240 path_parts = repo_url_parts.path[1:].split("/", maxsplit=1)
242 repo_path: Path = work_path / path_parts[1].replace(".git", "")
243 cmd = [git_bin, "clone", "--depth", str(depth), project_config["git_clone_url"]]
245 if repo_path.exists() and rebase:
246 cmd = [git_bin, "pull", "--rebase"]
248 elif repo_path.exists():
252 _stdout, _stderr = await _gen_check_output(cmd, cwd=cwd)
253 except (asyncio.TimeoutError, CalledProcessError) as e:
254 LOG.error(f"Unable to git clone / pull {project_config['git_clone_url']}: {e}")
260 def handle_PermissionError(
261 func: Callable[..., None], path: Path, exc: Tuple[Any, Any, Any]
264 Handle PermissionError during shutil.rmtree.
266 This checks if the erroring function is either 'os.rmdir' or 'os.unlink', and that
267 the error was EACCES (i.e. Permission denied). If true, the path is set writable,
268 readable, and executable by everyone. Finally, it tries the error causing delete
271 If the check is false, then the original error will be reraised as this function
275 LOG.debug(f"Handling {excvalue} from {func.__name__}... ")
276 if func in (os.rmdir, os.unlink) and excvalue.errno == errno.EACCES:
277 LOG.debug(f"Setting {path} writable, readable, and executable by everyone... ")
278 os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # chmod 0777
279 func(path) # Try the error causing delete operation again
284 async def load_projects_queue(
286 ) -> Tuple[Dict[str, Any], asyncio.Queue]:
287 """Load project config and fill queue with all the project names"""
288 with config_path.open("r") as cfp:
289 config = json.load(cfp)
291 # TODO: Offer more options here
292 # e.g. Run on X random packages or specific sub list etc.
293 project_names = sorted(config["projects"].keys())
294 queue: asyncio.Queue = asyncio.Queue(maxsize=len(project_names))
295 for project in project_names:
296 await queue.put(project)
301 async def project_runner(
303 config: Dict[str, Any],
304 queue: asyncio.Queue,
307 long_checkouts: bool = False,
308 rebase: bool = False,
310 no_diff: bool = False,
312 """Check out project and run Black on it + record result"""
313 loop = asyncio.get_event_loop()
314 py_version = f"{version_info[0]}.{version_info[1]}"
317 project_name = queue.get_nowait()
318 except asyncio.QueueEmpty:
319 LOG.debug(f"project_runner {idx} exiting")
321 LOG.debug(f"worker {idx} working on {project_name}")
323 project_config = config["projects"][project_name]
325 # Check if disabled by config
326 if "disabled" in project_config and project_config["disabled"]:
327 results.stats["disabled"] += 1
328 LOG.info(f"Skipping {project_name} as it's disabled via config")
331 # Check if we should run on this version of Python
333 "all" not in project_config["py_versions"]
334 and py_version not in project_config["py_versions"]
336 results.stats["wrong_py_ver"] += 1
337 LOG.debug(f"Skipping {project_name} as it's not enabled for {py_version}")
340 # Check if we're doing big projects / long checkouts
341 if not long_checkouts and project_config["long_checkout"]:
342 results.stats["skipped_long_checkout"] += 1
343 LOG.debug(f"Skipping {project_name} as it's configured as a long checkout")
346 repo_path: Optional[Path] = Path(__file__)
347 stdin_project = project_name.upper() == "STDIN"
348 if not stdin_project:
349 repo_path = await git_checkout_or_rebase(work_path, project_config, rebase)
352 await black_run(project_name, repo_path, project_config, results, no_diff)
354 if not keep and not stdin_project:
355 LOG.debug(f"Removing {repo_path}")
356 rmtree_partial = partial(
357 rmtree, path=repo_path, onerror=handle_PermissionError
359 await loop.run_in_executor(None, rmtree_partial)
361 LOG.info(f"Finished {project_name}")
364 async def process_queue(
369 long_checkouts: bool = False,
370 rebase: bool = False,
371 no_diff: bool = False,
374 Process the queue with X workers and evaluate results
375 - Success is guaged via the config "expect_formatting_changes"
377 Integer return equals the number of failed projects
380 results.stats["disabled"] = 0
381 results.stats["failed"] = 0
382 results.stats["skipped_long_checkout"] = 0
383 results.stats["success"] = 0
384 results.stats["wrong_py_ver"] = 0
386 config, queue = await load_projects_queue(Path(config_file))
387 project_count = queue.qsize()
388 s = "" if project_count == 1 else "s"
389 LOG.info(f"{project_count} project{s} to run Black over")
390 if project_count < 1:
393 s = "" if workers == 1 else "s"
394 LOG.debug(f"Using {workers} parallel worker{s} to run Black")
395 # Wait until we finish running all the projects before analyzing
396 await asyncio.gather(
409 for i in range(workers)
413 LOG.info("Analyzing results")
414 return analyze_results(project_count, results)
417 if __name__ == "__main__": # pragma: nocover
418 raise NotImplementedError("lib is a library, funnily enough.")