Initial upload (#1)

* feat: minimal working version

* chore: clean swap files

* chore: amend gitignore to include swaps

* chore: typecheck

* wip: ignore git, support dir

* wip: ignores, directory handling

* wip: add prompting, better path management

* refactor: centralize printing

* wip: handle jsondecodeerror

* docs: README

* chore: add inquirer to dependencies

* wip: error handling when not in git
This commit is contained in:
Marc Cataford 2020-01-05 14:46:10 -05:00 committed by GitHub
parent 099403cf7d
commit 02a7b2d5d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 401 additions and 1 deletions

3
.gitignore vendored
View file

@ -2,7 +2,10 @@
__pycache__/
*.py[cod]
*$py.class
*.sw[a-z]
#Temporarily
tests/
# C extensions
*.so

View file

@ -1 +1,23 @@
# carboncopy
# carboncopy
Keep your repositories up-to-date with their templates in a few keystrokes.
## :question: Why `carboncopy`?
[Github Template Repositories](https://github.blog/2019-06-06-generate-new-repositories-with-repository-templates/) made it really easy to skip project boilerplate setup steps and to produce "new project kits" that ensure that all your (or your organization's) new projects have all the must-haves. Problem is, templates aren't set in stone and it's likely that templates get updated after some projects have been spawned from it.
Because template repositories are different than forks, you can't simply rebase your project to gulp in the latest templated goodies -- leaving you with the gnarly task of manually moving files over. No more.
With `carboncopy`, you are one command away from pulling in the latest changes from your template repositories as if it were a regular base branch. You can configure it via its RC file to ignore certain files from the template, and more!
## :package: Installation
As it is not yet published on `PyPi`, you can simply clone this repository and use `pip install <path>` to install it in your local environment.
## :hammer: Usage
From your repository, simply type `carboncopy` in your terminal to bring up a prompt asking you what to pull from your template repository. __Any change made is left as an unstaged change so you can commit and merge it however you want.__
## :wrench: Configuration
You can configure the way `carboncopy` handles your template's contents by creating a `.carboncopyrc` file at the root of your repository. Documentation TBD. See `src/carboncopy/config_defaults.py` for general layout.

32
requirements.txt Normal file
View file

@ -0,0 +1,32 @@
appdirs==1.4.3
attrs==19.3.0
black==19.10b0
blessings==1.7
certifi==2019.11.28
chardet==3.0.4
Click==7.0
idna==2.8
importlib-metadata==1.3.0
inquirer==2.6.3
invoke==1.4.0
more-itertools==8.0.2
mypy==0.761
mypy-extensions==0.4.3
packaging==19.2
pathspec==0.7.0
pluggy==0.13.1
py==1.8.1
pyparsing==2.4.6
pytest==5.3.2
python-editor==1.0.4
readchar==2.0.1
regex==2019.12.20
requests==2.22.0
six==1.13.0
syrupy==0.0.12
toml==0.10.0
typed-ast==1.4.0
typing-extensions==3.7.4.1
urllib3==1.25.7
wcwidth==0.1.8
zipp==0.6.0

9
script/bootstrap Normal file
View file

@ -0,0 +1,9 @@
VENV_NAME="carboncopy.venv"
REQ_FILE="./requirements.txt"
pyenv uninstall -f $VENV_NAME
pyenv install -s
pyenv virtualenv $VENV_NAME
pyenv activate $VENV_NAME
pip install -r $REQ_FILE

19
setup.py Normal file
View file

@ -0,0 +1,19 @@
import setuptools
with open("README.md", "r") as fh:
long_description = fh.read()
setuptools.setup(
entry_points={"console_scripts": ["carboncopy = src.carboncopy.main:run"]},
name="carboncopy",
version="0.0.1",
author="Marc Cataford",
author_email="c.marcandre@gmail.com",
description="A small CLI utility to keep your repositories up-to-date with their templates",
long_description=long_description,
url="",
packages=setuptools.find_packages(),
classifiers=[],
install_requires=["requests>=2.22.0", "inquirer==2.6.3"],
python_requires=">=3.6",
)

0
src/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,20 @@
import inquirer
from typing import List
from pathlib import Path
def prompt_staging_files_confirmation(
staged: List[Path], template_repository_name: str
):
questions = [
inquirer.Checkbox(
"suggested_changes",
message="The following files can be pulled from {}. Select the ones to be merged in:".format(
template_repository_name
),
choices=[str(staged_file) for staged_file in staged],
)
]
return inquirer.prompt(questions)

View file

@ -0,0 +1,6 @@
CONFIG_DEFAULTS = {
# This is used as the scratch space where the template is cloned.
# It is cleaned once done.
"temp_directory": ".carboncopy",
"ignore": [],
}

View file

@ -0,0 +1,6 @@
RCFILE_PATH = ".carboncopyrc"
FORCED_IGNORE_PATTERNS = r"^.git/*"
FETCH_URL_PATTERN = r"Fetch URL"
GIT_LINK_PATTERN = r"(?<=git@github.com:)[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+"
GIT_EXT_PATTERN = r"\.git$"

View file

@ -0,0 +1,77 @@
import shutil
from pathlib import Path
from typing import Union, List, Dict, Any
import os
from .print_utils import pretty_print
class Transform:
def __init__(self, source: Path, destination: Path):
self.source = source
self.destination = destination
def get_source(self, as_str: bool = False):
return str(self.source) if as_str else self.source
def get_destination(self, as_str: bool = False):
return str(self.destination) if as_str else self.destination
def __repr__(self):
return "<{classname} {source} -> {destination}>".format(
classname=self.__class__,
source=str(self.source),
destination=str(self.destination),
)
def clean_temp_files(path: Path) -> None:
if path:
shutil.rmtree(path, True)
def get_template_transforms(path: Path) -> List[Transform]:
file_paths = []
stack = [path]
while stack:
current_path = stack.pop()
if not current_path.is_dir():
file_paths.append(current_path)
continue
for child in current_path.iterdir():
child_path = Path(child)
stack.append(child_path)
return [
Transform(source=filename, destination=filename.relative_to(path))
for filename in file_paths
]
def squash(transform: Transform) -> None:
destination = transform.get_destination()
source = transform.get_source()
if not destination.parent.exists():
os.makedirs(destination.parent)
try:
shutil.copy(source, destination)
pretty_print(
"Copied {source} -> {destination}".format(
source=source, destination=destination
)
)
except IsADirectoryError:
pretty_print(
"Failed to copy {source} -> {destination}".format(
source=source, destination=destination
)
)
except Exception as e:
pretty_print(e.__class__)
pretty_print(e)

View file

@ -0,0 +1,52 @@
from pathlib import Path
import subprocess
import re
import requests
from .constants import FETCH_URL_PATTERN, GIT_EXT_PATTERN, GIT_LINK_PATTERN
class NotInAGitRepositoryError(Exception):
pass
class NoTemplateError(Exception):
pass
def clone_template_head(url: str, destination: Path) -> None:
_run(
"git clone {url} {location}".format(url=url, location=destination.resolve()),
stdout=subprocess.DEVNULL,
)
def get_local_repository_meta():
stdout = _run("git remote show origin")
stdout_split = stdout.decode().split("\n")
for line in stdout_split:
if re.search(FETCH_URL_PATTERN, line):
match = re.search(GIT_LINK_PATTERN, line)
org, repo = match.group(0).split("/")
return org, re.sub(GIT_EXT_PATTERN, "", repo)
return None, None
def get_repo_metadata(owner, repo):
headers = {"Accept": "application/vnd.github.baptiste-preview+json"}
r = requests.get(
"https://api.github.com/repos/{owner}/{repo}".format(owner=owner, repo=repo),
headers=headers,
)
repo_data = r.json()
template_repo = repo_data.get("template_repository")
return template_repo
def _run(command, stdout=subprocess.PIPE):
return subprocess.run(command.split(" "), stdout=stdout, stderr=stdout).stdout

27
src/carboncopy/main.py Normal file
View file

@ -0,0 +1,27 @@
from .use_cases import get_local_config, UseCases
from .print_utils import pretty_print
from .git_utils import NoTemplateError, NotInAGitRepositoryError
def run():
config = get_local_config()
use_cases = UseCases(config)
try:
use_cases.fetch_template_repository_details()
except NotInAGitRepositoryError:
pretty_print("Not in a git repository.")
return 1
except NoTemplateError:
pretty_print("This repository does not have a template associated with it.")
return 1
try:
use_cases.clone_template_repository()
paths = use_cases.stage_changes()
use_cases.apply_changes(paths)
except Exception as e:
pretty_print(e.__class__)
pretty_print(e)
finally:
use_cases.clean_up()

View file

@ -0,0 +1,2 @@
def pretty_print(message: str):
print(message)

109
src/carboncopy/use_cases.py Normal file
View file

@ -0,0 +1,109 @@
import subprocess
import re
from pathlib import Path
import requests
import shutil
import os
import json
import uuid
from typing import List, Dict, Any
from .config_defaults import CONFIG_DEFAULTS
from .constants import RCFILE_PATH, FORCED_IGNORE_PATTERNS
from .fs_utils import Transform, squash, clean_temp_files, get_template_transforms
from .git_utils import (
NoTemplateError,
NotInAGitRepositoryError,
get_local_repository_meta,
get_repo_metadata,
clone_template_head,
)
from .cli_utils import prompt_staging_files_confirmation
from .print_utils import pretty_print
def get_local_config(root_path: Path = Path(".")) -> Dict[str, Any]:
config_path = root_path.joinpath(RCFILE_PATH)
try:
with open(config_path, "r") as config_file:
loaded_config = json.load(config_file)
merged_config = CONFIG_DEFAULTS.copy()
for key in loaded_config:
merged_config[key] = loaded_config.get(key)
return merged_config
except FileNotFoundError:
pretty_print(
"No config file found in current directory! Proceeding with defaults."
)
except ValueError:
pretty_print("Invalid RC file!")
return CONFIG_DEFAULTS
class UseCases:
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self.template_repo: Dict[str, str] = {}
self.org = None
self.repo = None
def fetch_template_repository_details(self) -> None:
org, repo = get_local_repository_meta()
template_repo_data = get_repo_metadata(org, repo)
if not (org and repo):
raise NotInAGitRepositoryError()
if not template_repo_data:
raise NoTemplateError()
self.org = org
self.repo = repo
self.template_repo = template_repo_data
def clone_template_repository(self) -> None:
clone_template_head(
self.template_repo["clone_url"], Path(self.config["temp_directory"])
)
def stage_changes(self) -> List[Transform]:
path = Path(self.config["temp_directory"])
available_transforms = get_template_transforms(path)
def can_stage(path_str: str) -> bool:
return not re.match(FORCED_IGNORE_PATTERNS, path_str) and all(
[re.match(patt, path_str) for patt in self.config["ignore"]]
)
allowed_transforms = [
transform
for transform in available_transforms
if can_stage(transform.get_destination(as_str=True))
]
destinations = [
transform.get_destination(as_str=True) for transform in allowed_transforms
]
chosen_files = prompt_staging_files_confirmation(
destinations, "{}/{}".format(self.org, self.repo)
)
chosen_transforms = [
transform
for transform in allowed_transforms
if transform.get_destination(as_str=True)
in chosen_files["suggested_changes"]
]
return chosen_transforms
def apply_changes(self, paths: List[Transform]) -> None:
for path in paths:
squash(path)
def clean_up(self) -> None:
clean_temp_files(Path(self.config["temp_directory"]))

16
tasks.py Normal file
View file

@ -0,0 +1,16 @@
from invoke import Collection, task
@task
def format_all(ctx):
ctx.run("black src *.py")
@task
def typecheck(ctx):
ctx.run("mypy src")
ns = Collection()
ns.add_task(format_all, name="format")
ns.add_task(typecheck)