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
135 LOG.error(f"Unknown error with {repo_path}")
138 # If we get here and expect formatting changes something is up
139 if project_config["expect_formatting_changes"]:
140 results.stats["failed"] += 1
141 results.failed_projects[repo_path.name] = CalledProcessError(
142 0, cmd, b"Expected formatting changes but didn't get any!", b""
146 results.stats["success"] += 1
149 async def git_checkout_or_rebase(
151 project_config: Dict[str, Any],
152 rebase: bool = False,
156 """git Clone project or rebase"""
157 git_bin = str(which(GIT_BIANRY))
159 LOG.error("No git binary found")
162 repo_url_parts = urlparse(project_config["git_clone_url"])
163 path_parts = repo_url_parts.path[1:].split("/", maxsplit=1)
165 repo_path: Path = work_path / path_parts[1].replace(".git", "")
166 cmd = [git_bin, "clone", "--depth", str(depth), project_config["git_clone_url"]]
168 if repo_path.exists() and rebase:
169 cmd = [git_bin, "pull", "--rebase"]
171 elif repo_path.exists():
175 _stdout, _stderr = await _gen_check_output(cmd, cwd=cwd)
176 except (asyncio.TimeoutError, CalledProcessError) as e:
177 LOG.error(f"Unable to git clone / pull {project_config['git_clone_url']}: {e}")
183 def handle_PermissionError(
184 func: Callable, path: Path, exc: Tuple[Any, Any, Any]
187 Handle PermissionError during shutil.rmtree.
189 This checks if the erroring function is either 'os.rmdir' or 'os.unlink', and that
190 the error was EACCES (i.e. Permission denied). If true, the path is set writable,
191 readable, and executable by everyone. Finally, it tries the error causing delete
194 If the check is false, then the original error will be reraised as this function
198 LOG.debug(f"Handling {excvalue} from {func.__name__}... ")
199 if func in (os.rmdir, os.unlink) and excvalue.errno == errno.EACCES:
200 LOG.debug(f"Setting {path} writable, readable, and executable by everyone... ")
201 os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # chmod 0777
202 func(path) # Try the error causing delete operation again
207 async def load_projects_queue(
209 ) -> Tuple[Dict[str, Any], asyncio.Queue]:
210 """Load project config and fill queue with all the project names"""
211 with config_path.open("r") as cfp:
212 config = json.load(cfp)
214 # TODO: Offer more options here
215 # e.g. Run on X random packages or specific sub list etc.
216 project_names = sorted(config["projects"].keys())
217 queue: asyncio.Queue = asyncio.Queue(maxsize=len(project_names))
218 for project in project_names:
219 await queue.put(project)
224 async def project_runner(
226 config: Dict[str, Any],
227 queue: asyncio.Queue,
230 long_checkouts: bool = False,
231 rebase: bool = False,
234 """Check out project and run Black on it + record result"""
235 loop = asyncio.get_event_loop()
236 py_version = f"{version_info[0]}.{version_info[1]}"
239 project_name = queue.get_nowait()
240 except asyncio.QueueEmpty:
241 LOG.debug(f"project_runner {idx} exiting")
243 LOG.debug(f"worker {idx} working on {project_name}")
245 project_config = config["projects"][project_name]
247 # Check if disabled by config
248 if "disabled" in project_config and project_config["disabled"]:
249 results.stats["disabled"] += 1
250 LOG.info(f"Skipping {project_name} as it's disabled via config")
253 # Check if we should run on this version of Python
255 "all" not in project_config["py_versions"]
256 and py_version not in project_config["py_versions"]
258 results.stats["wrong_py_ver"] += 1
259 LOG.debug(f"Skipping {project_name} as it's not enabled for {py_version}")
262 # Check if we're doing big projects / long checkouts
263 if not long_checkouts and project_config["long_checkout"]:
264 results.stats["skipped_long_checkout"] += 1
265 LOG.debug(f"Skipping {project_name} as it's configured as a long checkout")
268 repo_path = await git_checkout_or_rebase(work_path, project_config, rebase)
271 await black_run(repo_path, project_config, results)
274 LOG.debug(f"Removing {repo_path}")
275 rmtree_partial = partial(
276 rmtree, path=repo_path, onerror=handle_PermissionError
278 await loop.run_in_executor(None, rmtree_partial)
280 LOG.info(f"Finished {project_name}")
283 async def process_queue(
288 long_checkouts: bool = False,
289 rebase: bool = False,
292 Process the queue with X workers and evaluate results
293 - Success is guaged via the config "expect_formatting_changes"
295 Integer return equals the number of failed projects
298 results.stats["disabled"] = 0
299 results.stats["failed"] = 0
300 results.stats["skipped_long_checkout"] = 0
301 results.stats["success"] = 0
302 results.stats["wrong_py_ver"] = 0
304 config, queue = await load_projects_queue(Path(config_file))
305 project_count = queue.qsize()
306 s = "" if project_count == 1 else "s"
307 LOG.info(f"{project_count} project{s} to run Black over")
308 if project_count < 1:
311 s = "" if workers == 1 else "s"
312 LOG.debug(f"Using {workers} parallel worker{s} to run Black")
313 # Wait until we finish running all the projects before analyzing
314 await asyncio.gather(
317 i, config, queue, work_path, results, long_checkouts, rebase, keep
319 for i in range(workers)
323 LOG.info("Analyzing results")
324 return analyze_results(project_count, results)
327 if __name__ == "__main__": # pragma: nocover
328 raise NotImplementedError("lib is a library, funnily enough.")