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
15 from typing import Any, Callable, Dict, NamedTuple, Optional, Sequence, Tuple
16 from urllib.parse import urlparse
21 WINDOWS = system() == "Windows"
22 BLACK_BINARY = "black.exe" if WINDOWS else "black"
23 GIT_BINARY = "git.exe" if WINDOWS else "git"
24 LOG = logging.getLogger(__name__)
27 # Windows needs a ProactorEventLoop if you want to exec subprocesses
28 # Starting with 3.8 this is the default - can remove when Black >= 3.8
29 # mypy only respects sys.platform if directly in the evaluation
30 # https://mypy.readthedocs.io/en/latest/common_issues.html#python-version-and-system-platform-checks # noqa: B950
31 if sys.platform == "win32":
32 asyncio.set_event_loop(asyncio.ProactorEventLoop())
35 class Results(NamedTuple):
36 stats: Dict[str, int] = {}
37 failed_projects: Dict[str, CalledProcessError] = {}
40 async def _gen_check_output(
43 env: Optional[Dict[str, str]] = None,
44 cwd: Optional[Path] = None,
45 stdin: Optional[bytes] = None,
46 ) -> Tuple[bytes, bytes]:
47 process = await asyncio.create_subprocess_exec(
49 stdin=asyncio.subprocess.PIPE,
50 stdout=asyncio.subprocess.PIPE,
51 stderr=asyncio.subprocess.STDOUT,
56 (stdout, stderr) = await asyncio.wait_for(process.communicate(stdin), timeout)
57 except asyncio.TimeoutError:
62 # A non-optional timeout was supplied to asyncio.wait_for, guaranteeing
63 # a timeout or completed process. A terminated Python process will have a
64 # non-empty returncode value.
65 assert process.returncode is not None
67 if process.returncode != 0:
68 cmd_str = " ".join(cmd)
69 raise CalledProcessError(
70 process.returncode, cmd_str, output=stdout, stderr=stderr
73 return (stdout, stderr)
76 def analyze_results(project_count: int, results: Results) -> int:
77 failed_pct = round(((results.stats["failed"] / project_count) * 100), 2)
78 success_pct = round(((results.stats["success"] / project_count) * 100), 2)
80 click.secho("-- primer results 📊 --\n", bold=True)
82 f"{results.stats['success']} / {project_count} succeeded ({success_pct}%) ✅",
87 f"{results.stats['failed']} / {project_count} FAILED ({failed_pct}%) 💩",
88 bold=bool(results.stats["failed"]),
91 s = "" if results.stats["disabled"] == 1 else "s"
92 click.echo(f" - {results.stats['disabled']} project{s} disabled by config")
93 s = "" if results.stats["wrong_py_ver"] == 1 else "s"
95 f" - {results.stats['wrong_py_ver']} project{s} skipped due to Python version"
98 f" - {results.stats['skipped_long_checkout']} skipped due to long checkout"
101 if results.failed_projects:
102 click.secho("\nFailed projects:\n", bold=True)
104 for project_name, project_cpe in results.failed_projects.items():
105 print(f"## {project_name}:")
106 print(f" - Returned {project_cpe.returncode}")
107 if project_cpe.stderr:
108 print(f" - stderr:\n{project_cpe.stderr.decode('utf8')}")
109 if project_cpe.stdout:
110 print(f" - stdout:\n{project_cpe.stdout.decode('utf8')}")
113 return results.stats["failed"]
118 repo_path: Optional[Path],
119 project_config: Dict[str, Any],
121 no_diff: bool = False,
123 """Run Black and record failures"""
125 results.stats["failed"] += 1
126 results.failed_projects[project_name] = CalledProcessError(
127 69, [], f"{project_name} has no repo_path: {repo_path}".encode(), b""
131 stdin_test = project_name.upper() == "STDIN"
132 cmd = [str(which(BLACK_BINARY))]
133 if "cli_arguments" in project_config and project_config["cli_arguments"]:
134 cmd.extend(project_config["cli_arguments"])
135 cmd.append("--check")
139 # Workout if we should read in a python file or search from cwd
143 stdin = repo_path.read_bytes()
147 with TemporaryDirectory() as tmp_path:
148 # Prevent reading top-level user configs by manipulating environment variables
151 "XDG_CONFIG_HOME": tmp_path, # Unix-like
152 "USERPROFILE": tmp_path, # Windows (changes `Path.home()` output)
155 cwd_path = repo_path.parent if stdin_test else repo_path
157 _stdout, _stderr = await _gen_check_output(
158 cmd, cwd=cwd_path, env=env, stdin=stdin
160 except asyncio.TimeoutError:
161 results.stats["failed"] += 1
162 LOG.error(f"Running black for {repo_path} timed out ({cmd})")
163 except CalledProcessError as cpe:
164 # TODO: Tune for smarter for higher signal
165 # If any other return value than 1 we raise - can disable project in config
166 if cpe.returncode == 1:
167 if not project_config["expect_formatting_changes"]:
168 results.stats["failed"] += 1
169 results.failed_projects[repo_path.name] = cpe
171 results.stats["success"] += 1
173 elif cpe.returncode > 1:
174 results.stats["failed"] += 1
175 results.failed_projects[repo_path.name] = cpe
178 LOG.error(f"Unknown error with {repo_path}")
181 # If we get here and expect formatting changes something is up
182 if project_config["expect_formatting_changes"]:
183 results.stats["failed"] += 1
184 results.failed_projects[repo_path.name] = CalledProcessError(
185 0, cmd, b"Expected formatting changes but didn't get any!", b""
189 results.stats["success"] += 1
192 async def git_checkout_or_rebase(
194 project_config: Dict[str, Any],
195 rebase: bool = False,
199 """git Clone project or rebase"""
200 git_bin = str(which(GIT_BINARY))
202 LOG.error("No git binary found")
205 repo_url_parts = urlparse(project_config["git_clone_url"])
206 path_parts = repo_url_parts.path[1:].split("/", maxsplit=1)
208 repo_path: Path = work_path / path_parts[1].replace(".git", "")
209 cmd = [git_bin, "clone", "--depth", str(depth), project_config["git_clone_url"]]
211 if repo_path.exists() and rebase:
212 cmd = [git_bin, "pull", "--rebase"]
214 elif repo_path.exists():
218 _stdout, _stderr = await _gen_check_output(cmd, cwd=cwd)
219 except (asyncio.TimeoutError, CalledProcessError) as e:
220 LOG.error(f"Unable to git clone / pull {project_config['git_clone_url']}: {e}")
226 def handle_PermissionError(
227 func: Callable, path: Path, exc: Tuple[Any, Any, Any]
230 Handle PermissionError during shutil.rmtree.
232 This checks if the erroring function is either 'os.rmdir' or 'os.unlink', and that
233 the error was EACCES (i.e. Permission denied). If true, the path is set writable,
234 readable, and executable by everyone. Finally, it tries the error causing delete
237 If the check is false, then the original error will be reraised as this function
241 LOG.debug(f"Handling {excvalue} from {func.__name__}... ")
242 if func in (os.rmdir, os.unlink) and excvalue.errno == errno.EACCES:
243 LOG.debug(f"Setting {path} writable, readable, and executable by everyone... ")
244 os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # chmod 0777
245 func(path) # Try the error causing delete operation again
250 async def load_projects_queue(
252 ) -> Tuple[Dict[str, Any], asyncio.Queue]:
253 """Load project config and fill queue with all the project names"""
254 with config_path.open("r") as cfp:
255 config = json.load(cfp)
257 # TODO: Offer more options here
258 # e.g. Run on X random packages or specific sub list etc.
259 project_names = sorted(config["projects"].keys())
260 queue: asyncio.Queue = asyncio.Queue(maxsize=len(project_names))
261 for project in project_names:
262 await queue.put(project)
267 async def project_runner(
269 config: Dict[str, Any],
270 queue: asyncio.Queue,
273 long_checkouts: bool = False,
274 rebase: bool = False,
276 no_diff: bool = False,
278 """Check out project and run Black on it + record result"""
279 loop = asyncio.get_event_loop()
280 py_version = f"{version_info[0]}.{version_info[1]}"
283 project_name = queue.get_nowait()
284 except asyncio.QueueEmpty:
285 LOG.debug(f"project_runner {idx} exiting")
287 LOG.debug(f"worker {idx} working on {project_name}")
289 project_config = config["projects"][project_name]
291 # Check if disabled by config
292 if "disabled" in project_config and project_config["disabled"]:
293 results.stats["disabled"] += 1
294 LOG.info(f"Skipping {project_name} as it's disabled via config")
297 # Check if we should run on this version of Python
299 "all" not in project_config["py_versions"]
300 and py_version not in project_config["py_versions"]
302 results.stats["wrong_py_ver"] += 1
303 LOG.debug(f"Skipping {project_name} as it's not enabled for {py_version}")
306 # Check if we're doing big projects / long checkouts
307 if not long_checkouts and project_config["long_checkout"]:
308 results.stats["skipped_long_checkout"] += 1
309 LOG.debug(f"Skipping {project_name} as it's configured as a long checkout")
312 repo_path: Optional[Path] = Path(__file__)
313 stdin_project = project_name.upper() == "STDIN"
314 if not stdin_project:
315 repo_path = await git_checkout_or_rebase(work_path, project_config, rebase)
318 await black_run(project_name, repo_path, project_config, results, no_diff)
320 if not keep and not stdin_project:
321 LOG.debug(f"Removing {repo_path}")
322 rmtree_partial = partial(
323 rmtree, path=repo_path, onerror=handle_PermissionError
325 await loop.run_in_executor(None, rmtree_partial)
327 LOG.info(f"Finished {project_name}")
330 async def process_queue(
335 long_checkouts: bool = False,
336 rebase: bool = False,
337 no_diff: bool = False,
340 Process the queue with X workers and evaluate results
341 - Success is guaged via the config "expect_formatting_changes"
343 Integer return equals the number of failed projects
346 results.stats["disabled"] = 0
347 results.stats["failed"] = 0
348 results.stats["skipped_long_checkout"] = 0
349 results.stats["success"] = 0
350 results.stats["wrong_py_ver"] = 0
352 config, queue = await load_projects_queue(Path(config_file))
353 project_count = queue.qsize()
354 s = "" if project_count == 1 else "s"
355 LOG.info(f"{project_count} project{s} to run Black over")
356 if project_count < 1:
359 s = "" if workers == 1 else "s"
360 LOG.debug(f"Using {workers} parallel worker{s} to run Black")
361 # Wait until we finish running all the projects before analyzing
362 await asyncio.gather(
375 for i in range(workers)
379 LOG.info("Analyzing results")
380 return analyze_results(project_count, results)
383 if __name__ == "__main__": # pragma: nocover
384 raise NotImplementedError("lib is a library, funnily enough.")