Compare commits

...

9 Commits

4 changed files with 291 additions and 27 deletions

View File

@ -3,3 +3,39 @@
> **WARNING**: still in prototype stage...
Automate your mass typo updates.
## CLI
- **--reset**: run `git-checkout` on all of your glob matches and exit
- **--validate**: ensure your `Migration` sub-class is valid
- **--yaml**: tell `tyop` that it should try to load the YAML with [ruamel.yaml](https://yaml.readthedocs.io/en/latest/)
- **--debug**: enable debug logging
## Logic
- Match all files specified by the `GLOB` pattern and apply the following
- Check out main/master, `git-pull` and `git-checkout` for clean branch
- Feed matched file contents into `Migration` sub-class and show the diff
- Show a few diffs to make sure things are running smoothly and then apply to all matches
- Iterate through all matches and `git-commit`/`git-push` changes (Use `s` to skip changes)
## Example migration
```python
from tyop import Migration
class InternalTrueMigration(Migration):
GLOB = "~/.abra/apps/**/compose*.yml"
def migrate(self, compose):
try:
if "internal" in compose["networks"]:
compose["networks"]["internal"] = {"internal": True}
return compose
except Exception:
return compose
InternalTrueMigration()
```

View File

@ -1,13 +0,0 @@
from tyop import Migration
class InternalTrueMigration(Migration):
"""Add `internal: true` to all internal network definitions."""
GLOB = "~/.abra/apps/**/**.yml"
def migrate(compose):
pass
InternalTrueMigration()

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
ruamel.yaml

268
tyop.py
View File

@ -1,24 +1,264 @@
"""Automate your mass typo updates."""
from argparse import ArgumentParser
from glob import glob
from logging import DEBUG, INFO, basicConfig, getLogger
from os.path import expanduser
from pathlib import Path
from shlex import split
from subprocess import check_output, run
from sys import exit
from ruamel.yaml import YAML
class Migration:
DIFF_LIMIT = 3
def __init__(self):
self.validate()
self.run()
self.args = self._parse()
self.yaml = self._init_yaml()
self.log = self._init_logging()
self.commit_msg = None
def validate(self):
self._validate()
if self.args.validate:
self._exit(code=0, output=False)
self.matches = glob(expanduser(self.GLOB))
self.paths = list(set([Path(p).parent for p in self.matches]))
self.log.debug(f"Discovered '{self.GLOB}' as user defined glob")
self.log.debug(f"Matched paths are: {[m for m in self.matches]}")
if self.args.reset:
self._clean()
self._exit(code=0, output=False)
try:
self.log.info("=" * 79)
self.log.info("RUNNING MIGRATIONS + CHECKS (ONLY GIT LOCAL CHANGES)")
self.log.info("=" * 79)
self._run()
except Exception as exception:
self._exit(msg=f"Failed to run migration, saw: {exception}")
def _init_yaml(self):
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.allow_duplicate_keys = True
yaml.width = 999999
return yaml
def _exit(self, msg="Bailing out on request...", code=1, output=True):
if output:
self.log.info(msg)
exit(code)
def _parse(self):
description = "Tyop: automate your mass typo updates"
parser = ArgumentParser(description=description)
parser.add_argument(
"-d",
"--debug",
help="enable verbose debug logs",
action="store_const",
dest="log",
const=DEBUG,
default=INFO,
)
parser.add_argument(
"-v",
"--validate",
default=False,
action="store_true",
dest="validate",
help="Validate end-user defined migrationa and exit",
)
parser.add_argument(
"-r",
"--reset",
default=False,
action="store_true",
dest="reset",
help="Reset changes without running migrations (git-checkout)",
)
parser.add_argument(
"-y",
"--yaml",
default=False,
action="store_true",
dest="yaml",
help="Expect YAML and load for parsing",
)
return parser.parse_args()
def _init_logging(self):
basicConfig(level=self.args.log, format="%(levelname)-8s %(message)s")
return getLogger(__name__)
def _shell(self, cmd, shell=False, check=True, **kwargs):
runner = check_output
args = [split(cmd)]
if shell:
args = [cmd]
kwargs = {"shell": shell}
if not check:
runner = run
try:
output = runner(*args, **kwargs)
if check:
return output.decode("utf-8").strip()
except Exception as exception:
self._exit(msg=f"Failed to run {cmd}, saw {str(exception)}")
def _confirm(self, bail=True, match=None):
answer = ""
while answer not in ["y", "n", "s"]:
answer = input("Does this look good? [y/n/s]? ").lower()
if answer == "s":
if match:
self._clean(match=match)
self.log.debug(f"Skipping changes to {match}...")
return answer
if not answer == "y" and bail:
self._exit()
return answer
def _message(self):
return input("Commit message? ")
def _commit(self):
for path in self.paths:
command = "git --no-pager diff"
output = self._shell(command, cwd=path)
if not output:
self.log.debug(f"No changes detected in {path}, continuing...")
continue
self.log.info("=" * 79)
self.log.info(f"{path}")
self.log.info("=" * 79)
self._shell(command, check=False, cwd=path)
if self._confirm() == "s":
self._shell("git checkout .", check=False, cwd=path)
self.log.debug(f"Skipping {path} as requested...")
continue
if not self.commit_msg:
self.commit_msg = self._message()
self._shell("git add .", check=False, cwd=path)
self._shell(f"git commit -m '{self.commit_msg}'", check=False, cwd=path)
self._shell("git push", check=False, cwd=path)
def _validate(self):
if not hasattr(self, "GLOB"):
print("Define `GLOB` on your migration!")
exit(1)
self._exit(msg="Missing GLOB attribute!")
self.log.debug("Validation succeeded!")
def run(self):
pass
# TODO:
# For each path in the GLOB
# Load it and pass it into the migrate function
# Once it comes back, save the contents back to the FS
# Run a diff without pager and ask whether that looks ok
# After like 3 tries, ask if "this looks ok" + skip the rest
# Run through each repo and ask to commit + push
def _diff(self, match, idx, check=True):
command = "git --no-pager diff"
root_path = Path(match).parent
if check:
output = self._shell(command, cwd=root_path)
if not output:
self.log.debug("No changes detected, moving on...")
return False
self.log.info("=" * 79)
self.log.info(f"{match.upper()}")
self.log.info("=" * 79)
self.log.debug(f"Diffing {root_path} ({idx+1}/{self.DIFF_LIMIT})")
self._shell(command, check=False, cwd=root_path)
return self._confirm(match=match)
def _clean(self, match=None, branch=False, pull=False):
if match:
_paths = [Path(match).parent]
else:
_paths = self.paths
for _path in _paths:
self.log.debug(f"Cleaning {_path} of local changes...")
self._shell("git checkout .", check=False, cwd=_path)
if pull:
self.log.debug("Pulling latest changes in {path}...")
self._shell("git pull --rebase", check=False, cwd=_path)
if branch:
self.log.debug("Checking out the default branch...")
self._shell(
(
"git checkout main > /dev/null 2>&1 "
"|| git checkout master > /dev/null 2>&1"
),
check=False,
shell=True,
cwd=_path,
)
def _run(self):
idx = 0
for match in self.matches:
self._clean(match=match, branch=True, pull=True)
with open(match, "r") as handle:
self.log.debug(f"Processing {match}...")
contents = handle.read()
if self.args.yaml:
self.log.debug("Attempting to load YAML...")
if "---" in contents:
self.yaml.explicit_start = True
else:
self.yaml.explicit_start = False
contents = self.yaml.load(contents)
migrated = self.migrate(contents)
self.log.debug(f"Migrated {match}...")
with open(match, "w") as handle:
if self.args.yaml:
self.yaml.dump(migrated, handle)
else:
handle.write(migrated)
if idx < self.DIFF_LIMIT:
if self._diff(match, idx=idx, check=True):
idx += 1
self.log.debug(f"Saved {match} back to the file system...")
self.log.debug("Finished migrating files...")
self.log.debug("Commencing change commit run...")
self.log.info("=" * 79)
self.log.info("COMMIT AND PUSH (CHANGES APPLIED)")
self.log.info("=" * 79)
self._commit()
self.log.debug("Finished committing changes...")
self.log.info("Finished! May your tyops be ever glorious!")