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     projects_to_run: List[str],
 
 287 ) -> Tuple[Dict[str, Any], asyncio.Queue]:
 
 288     """Load project config and fill queue with all the project names"""
 
 289     with config_path.open("r") as cfp:
 
 290         config = json.load(cfp)
 
 292     # TODO: Offer more options here
 
 293     # e.g. Run on X random packages etc.
 
 294     queue: asyncio.Queue = asyncio.Queue(maxsize=len(projects_to_run))
 
 295     for project in projects_to_run:
 
 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(
 
 368     projects_to_run: List[str],
 
 370     long_checkouts: bool = False,
 
 371     rebase: bool = False,
 
 372     no_diff: bool = False,
 
 375     Process the queue with X workers and evaluate results
 
 376     - Success is guaged via the config "expect_formatting_changes"
 
 378     Integer return equals the number of failed projects
 
 381     results.stats["disabled"] = 0
 
 382     results.stats["failed"] = 0
 
 383     results.stats["skipped_long_checkout"] = 0
 
 384     results.stats["success"] = 0
 
 385     results.stats["wrong_py_ver"] = 0
 
 387     config, queue = await load_projects_queue(Path(config_file), projects_to_run)
 
 388     project_count = queue.qsize()
 
 389     s = "" if project_count == 1 else "s"
 
 390     LOG.info(f"{project_count} project{s} to run Black over")
 
 391     if project_count < 1:
 
 394     s = "" if workers == 1 else "s"
 
 395     LOG.debug(f"Using {workers} parallel worker{s} to run Black")
 
 396     # Wait until we finish running all the projects before analyzing
 
 397     await asyncio.gather(
 
 410             for i in range(workers)
 
 414     LOG.info("Analyzing results")
 
 415     return analyze_results(project_count, results)
 
 418 if __name__ == "__main__":  # pragma: nocover
 
 419     raise NotImplementedError("lib is a library, funnily enough.")