├── collage ├── __init__.py ├── _collage.py └── cli.py ├── .gitignore ├── environment.yml ├── README.md ├── pyproject.toml └── LICENSE /collage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | ._mypy_cache 3 | .ipynb_checkpoints 4 | *.egg-info 5 | benchmarker/_version.py 6 | *.swp 7 | *.un~ 8 | build 9 | _version.py 10 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: collage 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python==3.10.* 6 | - numpy 7 | - matplotlib-base 8 | - pillow 9 | - requests 10 | - click 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # collage: build image of SimPEG contributors 2 | 3 | ## Install 4 | 5 | Install it with pip directly from the repo: 6 | 7 | ```sh 8 | pip install git+https://github.com/simpeg/collage 9 | ``` 10 | 11 | ## How to use 12 | 13 | Run the `collage` command to generate the image, passing the path to the output 14 | image as argument. For example: 15 | 16 | ```sh 17 | collage image.png 18 | ``` 19 | 20 | Explore the available options with `collage --help`. 21 | 22 | ## Examples 23 | 24 | ### April 2024 25 | 26 | Running this helped to generate a nice looking and updated version of the 27 | SimPEG contributors: 28 | 29 | ```sh 30 | collage \ 31 | --ignore thibaut-kobold \ 32 | --ignore cgohlke \ 33 | --add leonfoks \ 34 | --ncols 8 \ 35 | --fontsize 24 \ 36 | image.png 37 | ``` 38 | 39 | ## License 40 | 41 | Released under the [MIT License](LICENSE). 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "collage" 3 | description = "Collage of SimPEG contributors" 4 | dynamic = ["version"] 5 | authors = [ 6 | {name ="Santiago Soler", email="santisoler@fastmail.com"} 7 | ] 8 | maintainers = [ 9 | {name ="Santiago Soler", email="santisoler@fastmail.com"} 10 | ] 11 | readme="README.md" 12 | license = {text="MIT"} 13 | requires-python = ">=3.10" 14 | dependencies = [ 15 | "requests", 16 | "click", 17 | "numpy", 18 | "matplotlib", 19 | "pillow", 20 | ] 21 | 22 | [project.scripts] 23 | collage = "collage.cli:cli" 24 | 25 | [build-system] 26 | requires = ["setuptools>=61", "wheel", "setuptools_scm[toml]>=8.0.3"] 27 | build-backend = "setuptools.build_meta" 28 | 29 | [tool.setuptools_scm] 30 | version_scheme = "post-release" 31 | local_scheme = "node-and-date" 32 | write_to = "collage/_version.py" 33 | 34 | [tool.setuptools.packages.find] 35 | where = ["."] 36 | include = ["collage*"] 37 | exclude = [] # empty by default 38 | namespaces = true # true by default 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 SimPEG 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /collage/_collage.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to build the collage of contributors 3 | """ 4 | import re 5 | import numpy as np 6 | import matplotlib.pyplot as plt 7 | import requests 8 | from PIL import Image 9 | from urllib import request 10 | 11 | 12 | def get_contributors( 13 | organization, 14 | repository, 15 | github_username=None, 16 | github_token=None, 17 | contributors_per_page=1000, 18 | ): 19 | if not isinstance(contributors_per_page, int): 20 | raise ValueError( 21 | f"Invalid 'contributors_per_page' of type {type(contributors_per_page)}." 22 | "It must be an int." 23 | ) 24 | url = ( 25 | "https://api.github.com/repos/" 26 | f"{organization}/" 27 | f"{repository}/" 28 | f"contributors?per_page={contributors_per_page}" 29 | ) 30 | auth = None 31 | if github_username is not None and github_token is not None: 32 | auth = (github_username, github_token) 33 | response = requests.get(url, auth=auth) 34 | response.raise_for_status() 35 | contributors = [entry["login"] for entry in response.json()] 36 | return contributors 37 | 38 | 39 | def get_authors(organization, repo, authors_fname="AUTHORS.rst"): 40 | # Get content of authors file in repo 41 | url = ( 42 | f"https://raw.githubusercontent.com/{organization}/{repo}/main/{authors_fname}" 43 | ) 44 | response = requests.get(url) 45 | response.raise_for_status() 46 | # Get github handle of authors 47 | authors = re.findall("@([A-Za-z0-9-]+)", response.text) 48 | authors.sort() 49 | return authors 50 | 51 | 52 | def generate_figure(contributors, ncols=7, title_fontsize=18): 53 | # Rows and columns size 54 | n_rows = int(np.ceil(len(contributors) / ncols)) 55 | figsize = (4 * ncols, 4 * n_rows) 56 | 57 | # Make the figure 58 | fig, axes = plt.subplots(n_rows, ncols, figsize=figsize) 59 | for ax in axes.ravel(): 60 | ax.set_axis_off() 61 | for contributor, ax in zip(contributors, axes.ravel()): 62 | url = f"https://github.com/{contributor}.png" 63 | image = np.array(Image.open(request.urlopen(url))) 64 | ax.imshow(image) 65 | ax.set_title(contributor, fontsize=title_fontsize) 66 | return fig 67 | -------------------------------------------------------------------------------- /collage/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | import requests 4 | 5 | IGNORE = set(["quantifiedcode-bot"]) 6 | 7 | 8 | @click.command(context_settings={"help_option_names": ["-h", "--help"]}) 9 | @click.argument("image", type=click.File("wb")) 10 | @click.option( 11 | "--organization", 12 | "-o", 13 | default="simpeg", 14 | show_default=True, 15 | help="GitHub organization", 16 | ) 17 | @click.option( 18 | "--repositories", 19 | "-r", 20 | multiple=True, 21 | default=["simpeg", "discretize", "pydiso", "geoana", "aurora", "pymatsolver"], 22 | show_default=True, 23 | help="List of repositories", 24 | ) 25 | @click.option( 26 | "--ignore", 27 | "-i", 28 | multiple=True, 29 | default=None, 30 | show_default=True, 31 | help="Ignore this contributor from the collage.", 32 | ) 33 | @click.option( 34 | "--add", 35 | "-a", 36 | multiple=True, 37 | default=None, 38 | show_default=True, 39 | help="Add this contributor to the collage.", 40 | ) 41 | @click.option( 42 | "--ncols", 43 | default=7, 44 | show_default=True, 45 | help="Number of columns used in the collage picture", 46 | ) 47 | @click.option( 48 | "--dpi", 49 | default=72, 50 | show_default=True, 51 | help="DPI for the output image.", 52 | ) 53 | @click.option( 54 | "--fontsize", 55 | default=18, 56 | show_default=True, 57 | help="Fontsize for contributors names.", 58 | ) 59 | @click.option( 60 | "--gh-username", 61 | default=None, 62 | show_default=True, 63 | help="GitHub username to use with the token.", 64 | ) 65 | @click.option( 66 | "--gh-token", 67 | default=None, 68 | show_default=True, 69 | help="GitHub token for the passed GitHub username.", 70 | ) 71 | @click.option( 72 | "--contributors-per-page", 73 | default=1000, 74 | show_default=True, 75 | help=( 76 | "Number of contributors that will be requested to GitHub. " 77 | "Make sure this number is higher than the amount of contributors to " 78 | "the repository." 79 | ), 80 | ) 81 | def cli( 82 | image, 83 | organization, 84 | repositories, 85 | ignore, 86 | add, 87 | ncols, 88 | dpi, 89 | fontsize, 90 | gh_username, 91 | gh_token, 92 | contributors_per_page, 93 | ): 94 | from ._collage import get_contributors, get_authors, generate_figure # lazy imports 95 | 96 | # Get default list of ignored contributors from the global variable or from 97 | # an env variable 98 | if (key := "COLLAGE_IGNORE") in os.environ: 99 | default_ignore = set([c.strip() for c in os.environ[key].split(",")]) 100 | else: 101 | default_ignore = IGNORE 102 | 103 | # Sanitize inputs 104 | repositories = [r.strip() for r in repositories] 105 | 106 | if ignore is None: 107 | ignore = set() 108 | else: 109 | ignore = set([c.strip() for c in ignore]) 110 | ignore |= default_ignore # union of the two sets 111 | 112 | if add is None: 113 | add = set() 114 | else: 115 | add = set([c.strip() for c in add]) 116 | 117 | # Get contributors 118 | contributors = [] 119 | for repo in repositories: 120 | contributors += get_contributors( 121 | organization, 122 | repo, 123 | gh_username, 124 | gh_token, 125 | contributors_per_page=contributors_per_page, 126 | ) 127 | contributors = set(contributors) 128 | 129 | # Get authors 130 | authors = [] 131 | for repo in repositories: 132 | try: 133 | new_authors = get_authors(organization, repo) 134 | except requests.HTTPError as e: 135 | click.echo(e, err=True) 136 | else: 137 | authors += new_authors 138 | authors = set(authors) 139 | 140 | # Put them together 141 | contributors |= authors # union of the two sets 142 | 143 | # Remove unwanted ones 144 | contributors -= ignore # remove contributors that should be ignored 145 | 146 | # Add required ones 147 | contributors |= add 148 | 149 | # Sort them with a case-insensitive manner 150 | contributors = list(contributors) 151 | contributors.sort(key=lambda s: s.lower()) 152 | 153 | # Verbose 154 | click.echo("\nCollected contributors:") 155 | click.echo("-----------------------") 156 | for contributor in contributors: 157 | click.echo(f"- {contributor}") 158 | 159 | # Generate image 160 | click.echo("\nGenerating image...") 161 | fig = generate_figure(contributors, ncols=ncols, title_fontsize=fontsize) 162 | 163 | # Save image 164 | fig.savefig(image, dpi=dpi, bbox_inches="tight", pad_inches=0.1) 165 | click.echo(f"\nDone! 🎉 Collage image saved in '{image.name}'.") 166 | --------------------------------------------------------------------------------