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.
10 from functools import partial
11 from pathlib import Path
12 from platform import system
13 from shutil import rmtree, which
14 from subprocess import CalledProcessError
15 from sys import version_info
16 from typing import Any, Callable, Dict, NamedTuple, Optional, Sequence, Tuple
17 from urllib.parse import urlparse
22 WINDOWS = system() == "Windows"
23 BLACK_BINARY = "black.exe" if WINDOWS else "black"
24 GIT_BIANRY = "git.exe" if WINDOWS else "git"
25 LOG = logging.getLogger(__name__)
28 # Windows needs a ProactorEventLoop if you want to exec subprocesses
29 # Starting with 3.8 this is the default - can remove when Black >= 3.8
30 # mypy only respects sys.platform if directly in the evaluation
31 # https://mypy.readthedocs.io/en/latest/common_issues.html#python-version-and-system-platform-checks # noqa: B950
32 if sys.platform == "win32":
33 asyncio.set_event_loop(asyncio.ProactorEventLoop())
36 class Results(NamedTuple):
37 stats: Dict[str, int] = {}
38 failed_projects: Dict[str, CalledProcessError] = {}
41 async def _gen_check_output(
44 env: Optional[Dict[str, str]] = None,
45 cwd: Optional[Path] = None,
46 ) -> Tuple[bytes, bytes]:
47 process = await asyncio.create_subprocess_exec(
49 stdout=asyncio.subprocess.PIPE,
50 stderr=asyncio.subprocess.STDOUT,
55 (stdout, stderr) = await asyncio.wait_for(process.communicate(), timeout)
56 except asyncio.TimeoutError:
61 if process.returncode != 0:
62 cmd_str = " ".join(cmd)
63 raise CalledProcessError(
64 process.returncode, cmd_str, output=stdout, stderr=stderr
67 return (stdout, stderr)
70 def analyze_results(project_count: int, results: Results) -> int:
71 failed_pct = round(((results.stats["failed"] / project_count) * 100), 2)
72 success_pct = round(((results.stats["success"] / project_count) * 100), 2)
74 click.secho("-- primer results 📊 --\n", bold=True)
76 f"{results.stats['success']} / {project_count} succeeded ({success_pct}%) ✅",
81 f"{results.stats['failed']} / {project_count} FAILED ({failed_pct}%) 💩",
82 bold=bool(results.stats["failed"]),
85 s = "" if results.stats["disabled"] == 1 else "s"
86 click.echo(f" - {results.stats['disabled']} project{s} disabled by config")
87 s = "" if results.stats["wrong_py_ver"] == 1 else "s"
89 f" - {results.stats['wrong_py_ver']} project{s} skipped due to Python version"
92 f" - {results.stats['skipped_long_checkout']} skipped due to long checkout"
95 if results.failed_projects:
96 click.secho("\nFailed projects:\n", bold=True)
98 for project_name, project_cpe in results.failed_projects.items():
99 print(f"## {project_name}:")
100 print(f" - Returned {project_cpe.returncode}")
101 if project_cpe.stderr:
102 print(f" - stderr:\n{project_cpe.stderr.decode('utf8')}")
103 if project_cpe.stdout:
104 print(f" - stdout:\n{project_cpe.stdout.decode('utf8')}")
107 return results.stats["failed"]
111 repo_path: Path, project_config: Dict[str, Any], results: Results
113 """Run Black and record failures"""
114 cmd = [str(which(BLACK_BINARY))]
115 if "cli_arguments" in project_config and project_config["cli_arguments"]:
116 cmd.extend(*project_config["cli_arguments"])
117 cmd.extend(["--check", "--diff", "."])
120 _stdout, _stderr = await _gen_check_output(cmd, cwd=repo_path)
121 except asyncio.TimeoutError:
122 results.stats["failed"] += 1
123 LOG.error(f"Running black for {repo_path} timed out ({cmd})")
124 except CalledProcessError as cpe:
125 # TODO: Tune for smarter for higher signal
126 # If any other return value than 1 we raise - can disable project in config
127 if cpe.returncode == 1:
128 if not project_config["expect_formatting_changes"]:
129 results.stats["failed"] += 1
130 results.failed_projects[repo_path.name] = cpe
132 results.stats["success"] += 1
134 elif cpe.returncode > 1:
135 results.stats["failed"] += 1
136 results.failed_projects[repo_path.name] = cpe
139 LOG.error(f"Unknown error with {repo_path}")
142 # If we get here and expect formatting changes something is up
143 if project_config["expect_formatting_changes"]:
144 results.stats["failed"] += 1
145 results.failed_projects[repo_path.name] = CalledProcessError(
146 0, cmd, b"Expected formatting changes but didn't get any!", b""
150 results.stats["success"] += 1
153 async def git_checkout_or_rebase(
155 project_config: Dict[str, Any],
156 rebase: bool = False,
160 """git Clone project or rebase"""
161 git_bin = str(which(GIT_BIANRY))
163 LOG.error("No git binary found")
166 repo_url_parts = urlparse(project_config["git_clone_url"])
167 path_parts = repo_url_parts.path[1:].split("/", maxsplit=1)
169 repo_path: Path = work_path / path_parts[1].replace(".git", "")
170 cmd = [git_bin, "clone", "--depth", str(depth), project_config["git_clone_url"]]
172 if repo_path.exists() and rebase:
173 cmd = [git_bin, "pull", "--rebase"]
175 elif repo_path.exists():
179 _stdout, _stderr = await _gen_check_output(cmd, cwd=cwd)
180 except (asyncio.TimeoutError, CalledProcessError) as e:
181 LOG.error(f"Unable to git clone / pull {project_config['git_clone_url']}: {e}")
187 def handle_PermissionError(
188 func: Callable, path: Path, exc: Tuple[Any, Any, Any]
191 Handle PermissionError during shutil.rmtree.
193 This checks if the erroring function is either 'os.rmdir' or 'os.unlink', and that
194 the error was EACCES (i.e. Permission denied). If true, the path is set writable,
195 readable, and executable by everyone. Finally, it tries the error causing delete
198 If the check is false, then the original error will be reraised as this function
202 LOG.debug(f"Handling {excvalue} from {func.__name__}... ")
203 if func in (os.rmdir, os.unlink) and excvalue.errno == errno.EACCES:
204 LOG.debug(f"Setting {path} writable, readable, and executable by everyone... ")
205 os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # chmod 0777
206 func(path) # Try the error causing delete operation again
211 async def load_projects_queue(
213 ) -> Tuple[Dict[str, Any], asyncio.Queue]:
214 """Load project config and fill queue with all the project names"""
215 with config_path.open("r") as cfp:
216 config = json.load(cfp)
218 # TODO: Offer more options here
219 # e.g. Run on X random packages or specific sub list etc.
220 project_names = sorted(config["projects"].keys())
221 queue: asyncio.Queue = asyncio.Queue(maxsize=len(project_names))
222 for project in project_names:
223 await queue.put(project)
228 async def project_runner(
230 config: Dict[str, Any],
231 queue: asyncio.Queue,
234 long_checkouts: bool = False,
235 rebase: bool = False,
238 """Check out project and run Black on it + record result"""
239 loop = asyncio.get_event_loop()
240 py_version = f"{version_info[0]}.{version_info[1]}"
243 project_name = queue.get_nowait()
244 except asyncio.QueueEmpty:
245 LOG.debug(f"project_runner {idx} exiting")
247 LOG.debug(f"worker {idx} working on {project_name}")
249 project_config = config["projects"][project_name]
251 # Check if disabled by config
252 if "disabled" in project_config and project_config["disabled"]:
253 results.stats["disabled"] += 1
254 LOG.info(f"Skipping {project_name} as it's disabled via config")
257 # Check if we should run on this version of Python
259 "all" not in project_config["py_versions"]
260 and py_version not in project_config["py_versions"]
262 results.stats["wrong_py_ver"] += 1
263 LOG.debug(f"Skipping {project_name} as it's not enabled for {py_version}")
266 # Check if we're doing big projects / long checkouts
267 if not long_checkouts and project_config["long_checkout"]:
268 results.stats["skipped_long_checkout"] += 1
269 LOG.debug(f"Skipping {project_name} as it's configured as a long checkout")
272 repo_path = await git_checkout_or_rebase(work_path, project_config, rebase)
275 await black_run(repo_path, project_config, results)
278 LOG.debug(f"Removing {repo_path}")
279 rmtree_partial = partial(
280 rmtree, path=repo_path, onerror=handle_PermissionError
282 await loop.run_in_executor(None, rmtree_partial)
284 LOG.info(f"Finished {project_name}")
287 async def process_queue(
292 long_checkouts: bool = False,
293 rebase: bool = False,
296 Process the queue with X workers and evaluate results
297 - Success is guaged via the config "expect_formatting_changes"
299 Integer return equals the number of failed projects
302 results.stats["disabled"] = 0
303 results.stats["failed"] = 0
304 results.stats["skipped_long_checkout"] = 0
305 results.stats["success"] = 0
306 results.stats["wrong_py_ver"] = 0
308 config, queue = await load_projects_queue(Path(config_file))
309 project_count = queue.qsize()
310 s = "" if project_count == 1 else "s"
311 LOG.info(f"{project_count} project{s} to run Black over")
312 if project_count < 1:
315 s = "" if workers == 1 else "s"
316 LOG.debug(f"Using {workers} parallel worker{s} to run Black")
317 # Wait until we finish running all the projects before analyzing
318 await asyncio.gather(
321 i, config, queue, work_path, results, long_checkouts, rebase, keep
323 for i in range(workers)
327 LOG.info("Analyzing results")
328 return analyze_results(project_count, results)
331 if __name__ == "__main__": # pragma: nocover
332 raise NotImplementedError("lib is a library, funnily enough.")