diff --git a/.github/workflows/run-formatter.yaml b/.github/workflows/run-formatter.yaml new file mode 100644 index 0000000..2d42054 --- /dev/null +++ b/.github/workflows/run-formatter.yaml @@ -0,0 +1,23 @@ +name: Run formatter + +on: [push] + +jobs: + format: + runs-on: ubuntu-latest + name: Format code + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: 3.9 + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.lock + timeout-minutes: 5 + - name: Run black + run: black --check . + timeout-minutes: 5 diff --git a/.github/workflows/run-linter.yaml b/.github/workflows/run-linter.yaml new file mode 100644 index 0000000..0607441 --- /dev/null +++ b/.github/workflows/run-linter.yaml @@ -0,0 +1,23 @@ +name: Run linter + +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint code + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: 3.9 + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.lock + timeout-minutes: 5 + - name: Run pylint + run: pylint --exit-zero src/mepo + timeout-minutes: 5 diff --git a/.github/workflows/mepo.yaml b/.github/workflows/run-tests.yaml similarity index 77% rename from .github/workflows/mepo.yaml rename to .github/workflows/run-tests.yaml index 4e7710c..bbddc13 100644 --- a/.github/workflows/mepo.yaml +++ b/.github/workflows/run-tests.yaml @@ -1,4 +1,4 @@ -name: Unit testing of mepo +name: Run tests on: [push] @@ -23,9 +23,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.lock timeout-minutes: 5 - - name: Run unit tests - run: python3 mepo.d/utest/test_mepo_commands.py -v + - name: Run tests + run: | + export PYTHONPATH=$(pwd)/src:$PYTHONPATH + python tests/test_mepo_commands.py -v timeout-minutes: 5 diff --git a/.gitignore b/.gitignore index 2f836aa..07e88d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ *~ *.pyc +*.egg-info +dist +venv + +# This is generated by docs/make_md_docs.py +Mepo-Commands.md +.python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..983fce4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + # Using this mirror lets us use mypyc-compiled black, which is about 2x faster + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.4.2 + hooks: + - id: black + # It is recommended to specify the latest version of Python + # supported by your project here, or alternatively use + # pre-commit's default_language_version, see + # https://pre-commit.com/#top_level-default_language_version + language_version: python3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b26be0..8f0d8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -### Removed +## [2.0.0] - 2024-08-09 + +### Fixed + +### Added + +- Added `pyproject.toml` to aid with `pip` installation. + +- Engineering + -- Formatting with Black + -- Linting with Pylint + -- Dependency management and packaging with Rye + +- Added tests to cover more `mepo` commands + +- Add new command `update-state` to permanently convert mepo1 style state to mepo2 + +### Changed + +- Converted `mepo` to a Python project via the following renaming + -- Added `src/mepo/__init__.py` + -- Renamed `mepo.d` -> `src/mepo` + -- Renamed `mepo.d/utest` -> `tests` + -- Renamed `doc` --> `docs` + -- A `mepo` config file is now called a `mepo` registry + -- More code reorganization + +- Helper script `mepo`, used for development, moved to the `bin` directory. +- Added README for `docs/make_md_docs.py` script + +- State: pickle format (mepo1 style) to json format (mepo2 style) + -- If mepo1 style state is detected, print warning and suggest running `mepo update-state` ## [1.52.0] - 2024-01-10 diff --git a/README.md b/README.md index 2a9b289..5bf8a88 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# mepo [![Actions Status](https://github.com/pchakraborty/mepo/workflows/Unit%20testing%20of%20mepo/badge.svg)](https://github.com/pchakraborty/mepo/actions) [![DOI](https://zenodo.org/badge/215067850.svg)](https://zenodo.org/badge/latestdoi/215067850) +# mepo [![Actions Status](https://github.com/pchakraborty/mepo/workflows/Unit%20testing%20of%20mepo/badge.svg)](https://github.com/pchakraborty/mepo/actions) [![DOI](https://zenodo.org/badge/215067850.svg)](https://zenodo.org/badge/latestdoi/215067850) [![Rye](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/rye/main/artwork/badge.json)](https://rye-up.com) -`mepo` is a tool, written in Python3 (3.6.0+), to manage (m)ultiple git r(epo)sitories, by attempting to create an illusion of a 'single repository' for multi-repository projects. Please see the [Wiki](../../wiki) for examples of `mepo` workflows. +`mepo` is a tool, written in Python3 (3.9.0+), to manage (m)ultiple git r(epo)sitories, by attempting to create an illusion of a 'single repository' for multi-repository projects. Please see the [Wiki](../../wiki) for examples of `mepo` workflows. + +## Installation + +`pip install mepo` ## Commands diff --git a/bin/mepo b/bin/mepo new file mode 100755 index 0000000..225acac --- /dev/null +++ b/bin/mepo @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +import os +import sys +import traceback + +# Version check +if sys.version_info < (3, 9, 0): + sys.exit('ERROR: Python version needs to be >= 3.9.0') + +# Add directory containing mepo to path +SRC_D = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "src") +sys.path.insert(0, SRC_D) + +if __name__ == '__main__': + from mepo.__main__ import main + main() diff --git a/doc/.gitignore b/docs/.gitignore similarity index 100% rename from doc/.gitignore rename to docs/.gitignore diff --git a/doc/make_md_docs.py b/docs/make_md_docs.py similarity index 51% rename from doc/make_md_docs.py rename to docs/make_md_docs.py index c8a1961..485cc65 100644 --- a/doc/make_md_docs.py +++ b/docs/make_md_docs.py @@ -1,61 +1,49 @@ #!/usr/bin/env python3 import os -import io +import glob from mdutils.mdutils import MdUtils import subprocess as sp -preamble=''' +preamble = """ mepo provides many different commands for working with a multi-repository fixture. -''' +""" # Assume this script is in mepo/doc. Then we need to get to the mepo/mepo.d/command directory doc_dir_path = os.path.dirname(os.path.realpath(__file__)) # Then we need to get to the mepo/mepo.d/command directory. First the "main" dir main_dir_path = os.path.dirname(doc_dir_path) -# Now add 'mepo.d' -mepod_dir_path = os.path.join(main_dir_path,'mepo.d') -# And then 'command' -command_dir_path = os.path.join(mepod_dir_path,'command') +# Now add "src/mepo" +mepod_dir_path = os.path.join(main_dir_path, "src", "mepo") +# And then "command" +command_dir_path = os.path.join(mepod_dir_path, "command") -mepo_command_path = os.path.join(main_dir_path,'mepo') +mepo_command_path = os.path.join(main_dir_path, "bin", "mepo") -def get_command_list(directory): - # Walk the tree - roots = [x[0] for x in os.walk(directory)] - - # Now remove "." from the list - roots = roots[1:] - - # Just get the relative paths - rel_roots = [os.path.relpath(x,directory) for x in roots] - # Now exclude __pycache__ - command_dirs = [x for x in rel_roots if '__pycache__' not in x] - - # Convert slashes to spaces - all_commands = [x.replace('/',' ') for x in command_dirs] +def get_command_list(directory): + # Get all commands + all_commands_py = glob.glob(os.path.join(directory, "*.py")) + all_commands = [os.path.basename(x).replace(".py", "") for x in all_commands_py] # Now let's find the commands that have subcommands - ## First we get commands with spaces - commands_with_spaces = [x for x in all_commands if ' ' in x] - ## Now let's just get the first elements - temp = [x.split()[0] for x in commands_with_spaces] - ## Get the uniques - commands_with_subcommands = list(set(temp)) - - # Now remove those from our list - all_useful_commands = [x for x in all_commands if x not in commands_with_subcommands] + ## First we get commands with underscore + ## Then replace underscore with a space + commands_with_underscore = [x for x in all_commands if "_" in x] + commands_with_subcommands = [x.replace("_", " ") for x in commands_with_underscore] + all_useful_commands = [x for x in all_commands if x not in commands_with_underscore] + all_useful_commands += commands_with_subcommands return sorted(all_useful_commands) + def create_markdown_from_usage(command, mdFile): - cmd = [mepo_command_path,command,'--help'] + cmd = [mepo_command_path, command, "--help"] # Some commands have spaces, so we need to break it up again - cmd = ' '.join(cmd).split() + cmd = " ".join(cmd).split() - result = sp.run(cmd,capture_output=True,universal_newlines=True,env={'COLUMNS':'256'}) + result = sp.run(cmd, capture_output=True, universal_newlines=True) output = result.stdout output_list = output.split("\n") @@ -66,25 +54,32 @@ def create_markdown_from_usage(command, mdFile): # Usage usage = output_list[0] - usage = usage.replace('usage: ','') + usage = usage.replace("usage: ", "") mdFile.new_header(level=3, title="Usage") mdFile.insert_code(usage) - positional_arguments = output.partition('positional arguments:\n')[2].partition('\n\n')[0] + positional_arguments = output.partition("positional arguments:\n")[2].partition( + "\n\n" + )[0] if positional_arguments: mdFile.new_header(level=3, title="Positional Arguments") mdFile.insert_code(positional_arguments) - optional_arguments = output.partition('optional arguments:\n')[2].partition('\n\n')[0] + optional_arguments = output.partition("optional arguments:\n")[2].partition("\n\n")[ + 0 + ] # Remove extra blank lines - optional_arguments = os.linesep.join([s for s in optional_arguments.splitlines() if s]) + optional_arguments = os.linesep.join( + [s for s in optional_arguments.splitlines() if s] + ) if optional_arguments: mdFile.new_header(level=3, title="Optional Arguments") mdFile.insert_code(optional_arguments) + if __name__ == "__main__": - doc_file='Mepo-Commands.md' + doc_file = "Mepo-Commands.md" mdFile = MdUtils(file_name=doc_file) mdFile.new_header(level=1, title="Overview") @@ -94,9 +89,9 @@ def create_markdown_from_usage(command, mdFile): command_list = get_command_list(command_dir_path) for command in command_list: mdFile.new_header(level=2, title=command) - create_markdown_from_usage(command,mdFile) + print(f"mepo command: {command}") + create_markdown_from_usage(command, mdFile) - mdFile.new_table_of_contents(table_title='Table of Contents', depth=2) + mdFile.new_table_of_contents(table_title="Table of Contents", depth=2) mdFile.create_md_file() - print(f'Generated {doc_file}.') - + print(f"Generated {doc_file}.") diff --git a/mepo b/mepo deleted file mode 100755 index c53176f..0000000 --- a/mepo +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys -import traceback - -# Version check -if sys.version_info < (3, 6, 0): - sys.exit('ERROR: Python version needs to be >= 3.6.0') - -# Add mepo.d to path -MEPO_D = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'mepo.d') -sys.path.insert(0, MEPO_D) - -if __name__ == '__main__': - from main import main - main() diff --git a/mepo.d/cmdline/branch_parser.py b/mepo.d/cmdline/branch_parser.py deleted file mode 100644 index 57d2347..0000000 --- a/mepo.d/cmdline/branch_parser.py +++ /dev/null @@ -1,58 +0,0 @@ -import argparse - -class MepoBranchArgParser(object): - - def __init__(self, branch): - self.branch_subparsers = branch.add_subparsers() - self.branch_subparsers.title = 'mepo branch sub-commands' - self.branch_subparsers.dest = 'mepo_branch_cmd' - self.branch_subparsers.required = True - self.__list() - self.__create() - self.__delete() - - def __list(self): - brlist = self.branch_subparsers.add_parser( - 'list', - description = 'List local branches. If no component is specified, runs over all components') - brlist.add_argument( - '-a', '--all', - action = 'store_true', - help = 'list all (local+remote) branches') - brlist.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to list branches in') - - def __create(self): - create = self.branch_subparsers.add_parser( - 'create', - description = 'Create branch in component ') - create.add_argument( - 'branch_name', - metavar = 'branch-name', - help = "Name of branch") - create.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to create branches in') - - def __delete(self): - delete = self.branch_subparsers.add_parser( - 'delete', - description = 'Delete branch in component ') - delete.add_argument( - 'branch_name', - metavar = 'branch-name', - help = "Name of branch") - delete.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to delete branches in') - delete.add_argument( - '--force', - action = 'store_true', - help = 'Delete branch even if it has not been fully merged') diff --git a/mepo.d/cmdline/config_parser.py b/mepo.d/cmdline/config_parser.py deleted file mode 100644 index 46cd1c8..0000000 --- a/mepo.d/cmdline/config_parser.py +++ /dev/null @@ -1,56 +0,0 @@ -import argparse -import textwrap - -class MepoConfigArgParser(object): - - def __init__(self, config): - self.config = config.add_subparsers() - self.config.title = 'mepo config sub-commands' - self.config.dest = 'mepo_config_cmd' - self.config.required = True - self.__get() - self.__set() - self.__delete() - self.__print() - - def __get(self): - get = self.config.add_parser( - 'get', - description = ('Get config `entry` in `.mepoconfig`. ' - 'Note this uses gitconfig style where `entry` is of the form `section.option`. ' - 'So to get an `alias` `st` You would run `mepo config get alias.st`')) - get.add_argument( - 'entry', - metavar = 'entry', - help = 'Entry to display.') - - def __set(self): - set = self.config.add_parser( - 'set', - description = ('Set config `entry` to `value` in `.mepoconfig`. ' - 'Note this uses gitconfig style where `entry` is of the form `section.option`. ' - 'So to set an `alias` for `status` of `st` You would run `mepo config set alias.st status`')) - set.add_argument( - 'entry', - metavar = 'entry', - help = 'Entry to set.') - set.add_argument( - 'value', - metavar = 'value', - help = 'Value to set entry to.') - - def __delete(self): - delete = self.config.add_parser( - 'delete', - description = ('Delete config `entry` in `.mepoconfig`. ' - 'Note this uses gitconfig style where `entry` is of the form `section.option`. ' - 'So to delete an `alias` `st` You would run `mepo config delete alias.st`')) - delete.add_argument( - 'entry', - metavar = 'entry', - help = 'Entry to delete.') - - def __print(self): - print = self.config.add_parser( - 'print', - description = 'Print contents of `.mepoconfig`') diff --git a/mepo.d/cmdline/parser.py b/mepo.d/cmdline/parser.py deleted file mode 100644 index 34f9045..0000000 --- a/mepo.d/cmdline/parser.py +++ /dev/null @@ -1,467 +0,0 @@ -import argparse - -from cmdline.branch_parser import MepoBranchArgParser -from cmdline.stash_parser import MepoStashArgParser -from cmdline.tag_parser import MepoTagArgParser -from cmdline.config_parser import MepoConfigArgParser -from utilities import mepoconfig - -class MepoArgParser(object): - - __slots__ = ['parser', 'subparsers'] - - def __init__(self): - self.parser = argparse.ArgumentParser( - description = 'Tool to manage (m)ultiple r(epo)s') - self.subparsers = self.parser.add_subparsers() - self.subparsers.title = 'mepo commands' - self.subparsers.required = True - self.subparsers.dest = 'mepo_cmd' - - def parse(self): - self.__init() - self.__clone() - self.__list() - self.__status() - self.__restore_state() - self.__diff() - self.__fetch() - self.__checkout() - self.__checkout_if_exists() - self.__changed_files() - self.__branch() - self.__tag() - self.__stash() - self.__develop() - self.__pull() - self.__pull_all() - self.__compare() - self.__reset() - self.__whereis() - self.__stage() - self.__unstage() - self.__commit() - self.__push() - self.__save() - self.__config() - return self.parser.parse_args() - - def __init(self): - init = self.subparsers.add_parser( - 'init', - description = 'Initialize mepo based on `config-file`', - aliases=mepoconfig.get_command_alias('init')) - init.add_argument( - '--config', - metavar = 'config-file', - nargs = '?', - default = 'components.yaml', - help = 'default: %(default)s') - init.add_argument( - '--style', - metavar = 'style-type', - nargs = '?', - default = None, - choices = ['naked', 'prefix','postfix'], - help = 'Style of directory file, default: prefix, allowed options: %(choices)s') - - def __clone(self): - clone = self.subparsers.add_parser( - 'clone', - description = "Clone repositories.", - aliases=mepoconfig.get_command_alias('clone')) - clone.add_argument( - 'repo_url', - metavar = 'URL', - nargs = '?', - default = None, - help = 'URL to clone') - clone.add_argument( - 'directory', - nargs = '?', - default = None, - help = "Directory to clone into (Only allowed with URL!)") - clone.add_argument( - '--branch','-b', - metavar = 'name', - nargs = '?', - default = None, - help = 'Branch/tag of URL to initially clone (Only allowed with URL!)') - clone.add_argument( - '--config', - metavar = 'config-file', - nargs = '?', - default = None, - help = 'Configuration file (ignored if init already called)') - clone.add_argument( - '--style', - metavar = 'style-type', - nargs = '?', - default = None, - choices = ['naked', 'prefix','postfix'], - help = 'Style of directory file, default: prefix, allowed options: %(choices)s (ignored if init already called)') - clone.add_argument( - '--allrepos', - action = 'store_true', - help = 'Must be passed with -b/--branch. When set, it not only checkouts out the branch/tag for the fixture, but for all the subrepositories as well.') - clone.add_argument( - '--partial', - metavar = 'partial-type', - nargs = '?', - default = None, - choices = ['off','blobless','treeless'], - help = 'Style of partial clone, default: None, allowed options: %(choices)s. Off means a "normal" full git clone, blobless means cloning with "--filter=blob:none" and treeless means cloning with "--filter=tree:0". NOTE: We do *not* recommend using "treeless" as it is very aggressive and will cause problems with many git commands.') - - def __list(self): - listcomps = self.subparsers.add_parser( - 'list', - description = 'List all components that are being tracked', - aliases=mepoconfig.get_command_alias('list')) - - def __status(self): - status = self.subparsers.add_parser( - 'status', - description = 'Check current status of all components', - aliases=mepoconfig.get_command_alias('status')) - status.add_argument( - '--ignore-permissions', - action = 'store_true', - help = 'Tells command to ignore changes in file permissions.') - status.add_argument( - '--nocolor', - action = 'store_true', - help = 'Tells status to not display colors.') - status.add_argument( - '--hashes', - action = 'store_true', - help = 'Print the exact hash of the HEAD.') - - def __restore_state(self): - restore_state = self.subparsers.add_parser( - 'restore-state', - description = 'Restores all components to the last saved state.', - aliases=mepoconfig.get_command_alias('restore-state')) - - def __diff(self): - diff = self.subparsers.add_parser( - 'diff', - description = 'Diff all components', - aliases=mepoconfig.get_command_alias('diff')) - diff.add_argument( - '--name-only', - action = 'store_true', - help = 'Show only names of changed files') - diff.add_argument( - '--name-status', - action = 'store_true', - help = 'Show name-status of changed files') - diff.add_argument( - '--ignore-permissions', - action = 'store_true', - help = 'Tells command to ignore changes in file permissions.') - diff.add_argument( - '--staged', - action = 'store_true', - help = 'Show diff of staged changes') - diff.add_argument( - '-b','--ignore-space-change', - action = 'store_true', - help = 'Ignore changes in amount of whitespace') - diff.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to list branches in') - - def __checkout(self): - checkout = self.subparsers.add_parser( - 'checkout', - description = "Switch to branch/tag `branch-name` in component `comp-name`. " - "If no components listed, checkout from all. " - "Specifying `-b` causes the branch `branch-name` to be created and checked out.", - aliases=mepoconfig.get_command_alias('checkout')) - checkout.add_argument( - 'branch_name', - metavar = 'branch-name', - help = "Name of branch") - checkout.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Components to checkout branch in') - checkout.add_argument( - '-b', - action = 'store_true', - help = 'create the branch') - checkout.add_argument( - '-q', '--quiet', - action = 'store_true', - help = 'Suppress prints') - checkout.add_argument( - '--detach', - action = 'store_true', - help = 'Detach upon checkout') - - def __checkout_if_exists(self): - checkout_if_exists = self.subparsers.add_parser( - 'checkout-if-exists', - description = 'Switch to branch or tag `ref-name` in any component where it is present. ', - aliases=mepoconfig.get_command_alias('checkout-if-exists')) - checkout_if_exists.add_argument( - 'ref_name', - metavar = 'ref-name', - help = "Name of branch or tag") - checkout_if_exists.add_argument( - '-q', '--quiet', - action = 'store_true', - help = 'Suppress prints') - checkout_if_exists.add_argument( - '--detach', - action = 'store_true', - help = 'Detach on checkout') - checkout_if_exists.add_argument( - '-n','--dry-run', - action = 'store_true', - help = 'Dry-run only (lists repos where branch exists)') - - def __changed_files(self): - changed_files = self.subparsers.add_parser( - 'changed-files', - description = 'List files that have changes versus the state. By default runs against all components.', - aliases=mepoconfig.get_command_alias('changed-files')) - changed_files.add_argument( - '--full-path', - action = 'store_true', - help = 'Print with full path') - changed_files.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to list branches in') - - def __fetch(self): - fetch = self.subparsers.add_parser( - 'fetch', - description = 'Download objects and refs from in component `comp-name`. ' - 'If no components listed, fetches from all', - aliases=mepoconfig.get_command_alias('fetch')) - fetch.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = "Components to fetch in") - fetch.add_argument( - '--all', - action = 'store_true', - help = 'Fetch all remotes.') - fetch.add_argument( - '-p','--prune', - action = 'store_true', - help = 'Prune remote branches.') - fetch.add_argument( - '-t','--tags', - action = 'store_true', - help = 'Fetch tags.') - fetch.add_argument( - '-f','--force', - action = 'store_true', - help = 'Force action.') - - def __branch(self): - branch = self.subparsers.add_parser( - 'branch', - description = "Runs branch commands.", - aliases=mepoconfig.get_command_alias('branch')) - MepoBranchArgParser(branch) - - def __stash(self): - stash = self.subparsers.add_parser( - 'stash', - description = "Runs stash commands.", - aliases=mepoconfig.get_command_alias('stash')) - MepoStashArgParser(stash) - - def __tag(self): - tag = self.subparsers.add_parser( - 'tag', - description = "Runs tag commands.", - aliases=mepoconfig.get_command_alias('tag')) - MepoTagArgParser(tag) - - def __develop(self): - develop = self.subparsers.add_parser( - 'develop', - description = "Checkout current version of 'develop' branches of specified components", - aliases=mepoconfig.get_command_alias('develop')) - develop.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - default = None, - help = "Component(s) to checkout development branches") - develop.add_argument( - '-q','--quiet', - action = 'store_true', - help = 'Suppress prints') - - def __pull(self): - pull = self.subparsers.add_parser( - 'pull', - description = "Pull branches of specified components", - aliases=mepoconfig.get_command_alias('pull')) - pull.add_argument('comp_name', - metavar = 'comp-name', - nargs = '+', - default = None, - help = "Components to pull in") - pull.add_argument( - '-q','--quiet', - action = 'store_true', - help = 'Suppress prints') - - def __pull_all(self): - pull_all = self.subparsers.add_parser( - 'pull-all', - description = "Pull branches of all components (only those in non-detached HEAD state)", - aliases=mepoconfig.get_command_alias('pull-all')) - pull_all.add_argument( - '-q','--quiet', - action = 'store_true', - help = 'Suppress prints') - - def __compare(self): - compare = self.subparsers.add_parser( - 'compare', - description = 'Compare current and original states of all components. ' - 'Will only show differing repos unless --all is passed in', - aliases=mepoconfig.get_command_alias('compare')) - compare.add_argument( - '--all', - action = 'store_true', - help = 'Show all repos, not only differing repos') - compare.add_argument( - '--nocolor', - action = 'store_true', - help = 'Tells command to not display colors.') - compare.add_argument( - '--wrap', - action = 'store_true', - help = 'Tells command to ignore terminal size and wrap') - - def __reset(self): - reset = self.subparsers.add_parser( - 'reset', - description = 'Reset the current mepo clone to the original state. ' - 'This will delete all subrepos and does not check for uncommitted changes! ' - 'Must be run in the root of the mepo clone.', - aliases=mepoconfig.get_command_alias('reset')) - reset.add_argument( - '-f','--force', - action = 'store_true', - help = 'Force action.') - reset.add_argument( - '--reclone', - action = 'store_true', - help = 'Reclone repos after reset.') - reset.add_argument( - '-n','--dry-run', - action = 'store_true', - help = 'Dry-run only') - - def __whereis(self): - whereis = self.subparsers.add_parser( - 'whereis', - description = 'Get the location of component `comp-name` ' - 'relative to my current location. If `comp-name` is not present, ' - 'get the relative locations of ALL components.', - aliases=mepoconfig.get_command_alias('whereis')) - whereis.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '?', - default = None, - help = "Component to get location of") - whereis.add_argument( - '-i','--ignore-case', - action = 'store_true', - help = 'Ignore case for whereis') - - def __stage(self): - stage = self.subparsers.add_parser( - 'stage', - description = 'Stage modified & untracked files in the specified component(s)', - aliases=mepoconfig.get_command_alias('stage')) - stage.add_argument( - '--untracked', - action = 'store_true', - help = 'Stage untracked files as well') - stage.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to stage file in') - - def __unstage(self): - unstage = self.subparsers.add_parser( - 'unstage', - description = 'Un-stage staged files. ' - 'If a component is specified, files are un-staged only for that component.', - aliases=mepoconfig.get_command_alias('unstage')) - unstage.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to unstage in', - default = None) - - def __commit(self): - commit = self.subparsers.add_parser( - 'commit', - description = 'Commit staged files in the specified components', - aliases=mepoconfig.get_command_alias('commit')) - commit.add_argument( - '-a', '--all', - action = 'store_true', - help = 'Stage all tracked files and then commit') - commit.add_argument( - '-m', '--message', - type=str, - metavar = 'message', - default=None, - help = "Message to commit with") - commit.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to commit file in') - - def __push(self): - push = self.subparsers.add_parser( - 'push', - description = 'Push local commits to remote for specified component. ' - 'Use mepo tag push to push tags', - aliases=mepoconfig.get_command_alias('push')) - push.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to push to remote') - - def __save(self): - save = self.subparsers.add_parser( - 'save', - description = 'Save current state in a yaml config file', - aliases=mepoconfig.get_command_alias('save')) - save.add_argument( - 'config_file', - metavar = 'config-file', - nargs = '?', - default = 'components-new.yaml', - help = 'default: %(default)s') - - def __config(self): - config = self.subparsers.add_parser( - 'config', - description = "Runs config commands.", - aliases=mepoconfig.get_command_alias('config')) - MepoConfigArgParser(config) diff --git a/mepo.d/cmdline/stash_parser.py b/mepo.d/cmdline/stash_parser.py deleted file mode 100644 index 0d34920..0000000 --- a/mepo.d/cmdline/stash_parser.py +++ /dev/null @@ -1,69 +0,0 @@ -import argparse - -class MepoStashArgParser(object): - - def __init__(self, stash): - self.stash = stash.add_subparsers() - self.stash.title = 'mepo stash sub-commands' - self.stash.dest = 'mepo_stash_cmd' - self.stash.required = True - self.__push() - self.__list() - self.__pop() - self.__apply() - self.__show() - - def __push(self): - stpush = self.stash.add_parser( - 'push', - description = 'Push (create) stash in component ') - stpush.add_argument( - '-m', '--message', - type=str, - metavar = 'message', - default=None, - help = 'Message for the stash') - stpush.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to push stash in') - - def __show(self): - stshow = self.stash.add_parser( - 'show', - description = 'show stash in component ') - stshow.add_argument( - '-p', '--patch', - action = 'store_true', - help = 'Message for the stash') - stshow.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to show stash in') - - def __list(self): - stlist = self.stash.add_parser( - 'list', - description = 'List local stashes of all components') - - def __pop(self): - stpop = self.stash.add_parser( - 'pop', - description = 'Pop stash in component ') - stpop.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to pop stash in') - - def __apply(self): - stapply = self.stash.add_parser( - 'apply', - description = 'apply stash in component ') - stapply.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '+', - help = 'Component to apply stash in') diff --git a/mepo.d/cmdline/tag_parser.py b/mepo.d/cmdline/tag_parser.py deleted file mode 100644 index 0be42e7..0000000 --- a/mepo.d/cmdline/tag_parser.py +++ /dev/null @@ -1,84 +0,0 @@ -import argparse - -class MepoTagArgParser(object): - - def __init__(self, tag): - self.tag = tag.add_subparsers() - self.tag.title = 'mepo tag sub-commands' - self.tag.dest = 'mepo_tag_cmd' - self.tag.required = True - self.__list() - self.__create() - self.__delete() - self.__push() - - def __list(self): - tglist = self.tag.add_parser( - 'list', - description = 'List tags. If no component is specified, runs over all components') - tglist.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to list tags in') - - def __create(self): - create = self.tag.add_parser( - 'create', - description = 'Create tag in component . If no component is specified, runs over all components') - create.add_argument( - 'tag_name', - metavar = 'tag-name', - help = "Name of tag") - create.add_argument( - '-a', '--annotate', - action = 'store_true', - help = "Make an annotated tag") - create.add_argument( - '-m', '--message', - type=str, - metavar = 'message', - default = None, - help = "Message for the tag" - ) - create.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to create tags in') - - def __delete(self): - delete = self.tag.add_parser( - 'delete', - description = 'Delete tag in component . If no component is specified, runs over all components') - delete.add_argument( - 'tag_name', - metavar = 'tag-name', - help = "Name of tag") - delete.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to delete tags in') - - def __push(self): - push = self.tag.add_parser( - 'push', - description = 'Push tag in component . If no component is specified, runs over all components') - push.add_argument( - 'tag_name', - metavar = 'tag-name', - help = "Name of tag") - push.add_argument( - '-f', '--force', - action = 'store_true', - help = "Force push (be careful!)") - push.add_argument( - '-d', '--delete', - action = 'store_true', - help = "Delete (be careful!)") - push.add_argument( - 'comp_name', - metavar = 'comp-name', - nargs = '*', - help = 'Component to push tags in') diff --git a/mepo.d/command/branch/branch.py b/mepo.d/command/branch/branch.py deleted file mode 100644 index 06b3ec1..0000000 --- a/mepo.d/command/branch/branch.py +++ /dev/null @@ -1,15 +0,0 @@ -import subprocess as sp - -from state.state import MepoState - -from command.branch.list import list -from command.branch.create import create -from command.branch.delete import delete - -def run(args): - d = { - 'list': list, - 'create': create, - 'delete': delete, - } - d[args.mepo_branch_cmd].run(args) diff --git a/mepo.d/command/checkout-if-exists/checkout-if-exists.py b/mepo.d/command/checkout-if-exists/checkout-if-exists.py deleted file mode 100644 index 528afb1..0000000 --- a/mepo.d/command/checkout-if-exists/checkout-if-exists.py +++ /dev/null @@ -1,25 +0,0 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from utilities import colors - -def run(args): - allcomps = MepoState.read_state() - for comp in allcomps: - git = GitRepository(comp.remote, comp.local) - ref_name = args.ref_name - status, ref_type = git.verify_branch_or_tag(ref_name) - - if status == 0: - if args.dry_run: - print("%s %s exists in %s" % - (ref_type, - colors.YELLOW + ref_name + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - else: - if not args.quiet: - print("Checking out %s %s in %s" % - (ref_type.lower(), - colors.YELLOW + ref_name + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - git.checkout(ref_name,args.detach) diff --git a/mepo.d/command/checkout/checkout.py b/mepo.d/command/checkout/checkout.py deleted file mode 100644 index 0e18eb4..0000000 --- a/mepo.d/command/checkout/checkout.py +++ /dev/null @@ -1,32 +0,0 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from utilities import colors - -def run(args): - allcomps = MepoState.read_state() - comps2checkout = _get_comps_to_checkout(args.comp_name, allcomps) - for comp in comps2checkout: - git = GitRepository(comp.remote, comp.local) - branch = args.branch_name - if args.b: - git.create_branch(branch) - if not args.quiet: - #print('+ {}: {}'.format(comp.name, branch)) - print("Creating and checking out branch %s in %s" % - (colors.YELLOW + branch + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - else: - if not args.quiet: - print("Checking out %s in %s" % - (colors.YELLOW + branch + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - git.checkout(branch,args.detach) - -def _get_comps_to_checkout(specified_comps, allcomps): - comps_to_list = allcomps - if specified_comps: - verify.valid_components(specified_comps, allcomps) - comps_to_list = [x for x in allcomps if x.name in specified_comps] - return comps_to_list - diff --git a/mepo.d/command/command.py b/mepo.d/command/command.py deleted file mode 100644 index 221c926..0000000 --- a/mepo.d/command/command.py +++ /dev/null @@ -1,11 +0,0 @@ -from importlib import import_module -from utilities import mepoconfig - -def run(args): - mepo_cmd = mepoconfig.get_alias_command(args.mepo_cmd) - - # Load the module containing the 'run' method of specified mepo command - cmd_module = import_module('command.{}.{}'.format(mepo_cmd, mepo_cmd)) - - # Execute 'run' method of the specified mepo command - cmd_module.run(args) diff --git a/mepo.d/command/commit/commit.py b/mepo.d/command/commit/commit.py deleted file mode 100644 index 7b2ac34..0000000 --- a/mepo.d/command/commit/commit.py +++ /dev/null @@ -1,61 +0,0 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from command.stage.stage import stage_files - -# Popping up an EDITOR is based on https://stackoverflow.com/a/39989442 -import os, tempfile, subprocess - -def run(args): - allcomps = MepoState.read_state() - verify.valid_components(args.comp_name, allcomps) - comps2commit = [x for x in allcomps if x.name in args.comp_name] - - tf_file = None - - # Pop up an editor if a message is not provided - if not args.message: - EDITOR = git_var('GIT_EDITOR') - initial_message = b"" # set up the file - - # Use delete=False to keep the file around as we send the file name to git commit -F - tf = tempfile.NamedTemporaryFile(delete=False) - tf_file = tf.name - tf.write(initial_message) - tf.flush() - subprocess.call([EDITOR, tf.name]) - - for comp in comps2commit: - git = GitRepository(comp.remote, comp.local) - if args.all: - stage_files(git, comp, commit=True) - - staged_files = git.get_staged_files() - if staged_files: - git.commit_files(args.message,tf_file) - - for myfile in staged_files: - print('+ {}: {}'.format(comp.name, myfile)) - - # Now close and by-hand delete the temp file - if not args.message: - tf.close() - os.unlink(tf.name) - -def git_var(what): - ''' - return GIT_EDITOR or GIT_PAGER, for instance - - Found at https://stackoverflow.com/a/44174750/1876449 - - ''' - proc = subprocess.Popen(['git', 'var', what], shell=False, - stdout=subprocess.PIPE) - output = proc.stdout.read() - status = proc.wait() - if status != 0: - raise Exception("git_var failed with [%]" % what) - output = output.rstrip(b'\n') - output = output.decode('utf8', errors='ignore') # or similar for py3k - return output - diff --git a/mepo.d/command/config/config.py b/mepo.d/command/config/config.py deleted file mode 100644 index 689ccaa..0000000 --- a/mepo.d/command/config/config.py +++ /dev/null @@ -1,17 +0,0 @@ -import subprocess as sp - -from state.state import MepoState - -from command.config.get import get -from command.config.set import set -from command.config.delete import delete -from command.config.print import print - -def run(args): - d = { - 'get': get, - 'set': set, - 'delete': delete, - 'print': print - } - d[args.mepo_config_cmd].run(args) diff --git a/mepo.d/command/config/get/get.py b/mepo.d/command/config/get/get.py deleted file mode 100644 index 6ca8346..0000000 --- a/mepo.d/command/config/get/get.py +++ /dev/null @@ -1,13 +0,0 @@ -from utilities import mepoconfig - -def run(args): - section, option = mepoconfig.split_entry(args.entry) - if not mepoconfig.has_section(section): - raise Exception(f'Section [{section}] does not exist in .mepoconfig') - if not mepoconfig.has_option(section, option): - raise Exception(f'Option [{option}] does not exist in section [{section}] in .mepoconfig') - value = mepoconfig.get(section, option) - print(f''' - [{section}] - {option} = {value} - ''') diff --git a/mepo.d/command/init/init.py b/mepo.d/command/init/init.py deleted file mode 100644 index c868f08..0000000 --- a/mepo.d/command/init/init.py +++ /dev/null @@ -1,22 +0,0 @@ -from state.state import MepoState -from utilities import mepoconfig - -def run(args): - if args.style: - style = args.style - elif mepoconfig.has_option('init','style'): - allowed_styles = ['naked','prefix','postfix'] - style = mepoconfig.get('init','style') - if style not in allowed_styles: - raise Exception(f'Detected style [{style}] from .mepoconfig is not an allowed style: {allowed_styles}') - else: - print(f'Found style [{style}] in .mepoconfig') - else: - style = None - - allcomps = MepoState.initialize(args.config,style) - - if not style: - print(f'Initializing mepo using {args.config}') - else: - print(f'Initializing mepo using {args.config} with {style} style') diff --git a/mepo.d/command/list/list.py b/mepo.d/command/list/list.py deleted file mode 100644 index d60e932..0000000 --- a/mepo.d/command/list/list.py +++ /dev/null @@ -1,7 +0,0 @@ -from state.state import MepoState - -def run(args): - allcomps = MepoState.read_state() - for comp in allcomps: - print(comp.name, end=' ') - print() diff --git a/mepo.d/command/pull-all/pull-all.py b/mepo.d/command/pull-all/pull-all.py deleted file mode 100644 index c902b56..0000000 --- a/mepo.d/command/pull-all/pull-all.py +++ /dev/null @@ -1,22 +0,0 @@ -from state.state import MepoState -from repository.git import GitRepository -from state.component import MepoVersion -from utilities import colors - -def run(args): - allcomps = MepoState.read_state() - detached_comps=[] - for comp in allcomps: - git = GitRepository(comp.remote, comp.local) - name, tYpe, detached = MepoVersion(*git.get_version()) - if detached: - detached_comps.append(comp.name) - else: - print("Pulling branch %s in %s " % - (colors.YELLOW + name + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - output = git.pull() - if not args.quiet: print(output) - if len(detached_comps) > 0: - print("The following repos were not pulled (detached HEAD): %s" % (', '.join(map(str, detached_comps)))) - diff --git a/mepo.d/command/pull/pull.py b/mepo.d/command/pull/pull.py deleted file mode 100644 index 92aa0bf..0000000 --- a/mepo.d/command/pull/pull.py +++ /dev/null @@ -1,21 +0,0 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from state.component import MepoVersion -from utilities import colors - -def run(args): - allcomps = MepoState.read_state() - verify.valid_components(args.comp_name, allcomps) - comps2pull = [x for x in allcomps if x.name in args.comp_name] - for comp in comps2pull: - git = GitRepository(comp.remote, comp.local) - name, tYpe, is_detached = MepoVersion(*git.get_version()) - if is_detached: - raise Exception('{} has detached head! Cannot pull.'.format(comp.name)) - else: - print("Pulling branch %s in %s " % - (colors.YELLOW + name + colors.RESET, - colors.RESET + comp.name + colors.RESET)) - output = git.pull() - if not args.quiet: print(output) diff --git a/mepo.d/command/restore-state/restore-state.py b/mepo.d/command/restore-state/restore-state.py deleted file mode 100644 index 38a124b..0000000 --- a/mepo.d/command/restore-state/restore-state.py +++ /dev/null @@ -1,31 +0,0 @@ -import sys -import time -import multiprocessing as mp -import atexit - -from state.state import MepoState -from repository.git import GitRepository -from utilities.version import version_to_string -from utilities import colors - -def run(args): - print('Checking status...'); sys.stdout.flush() - allcomps = MepoState.read_state() - pool = mp.Pool() - atexit.register(pool.close) - result = pool.map(check_component_status, allcomps) - restore_state(allcomps, result) - -def check_component_status(comp): - git = GitRepository(comp.remote, comp.local) - curr_ver = version_to_string(git.get_version(),git) - return (curr_ver, git.check_status()) - -def restore_state(allcomps, result): - for index, comp in enumerate(allcomps): - git = GitRepository(comp.remote, comp.local) - current_version = result[index][0].split(' ')[1] - orig_version = comp.version.name - if current_version != orig_version: - print(colors.YELLOW + "Restoring " + colors.RESET + "{} to {} from {}.".format(comp.name, colors.GREEN + orig_version + colors.RESET, colors.RED + current_version + colors.RESET)) - git.checkout(comp.version.name) diff --git a/mepo.d/command/stash/stash.py b/mepo.d/command/stash/stash.py deleted file mode 100644 index 05d6575..0000000 --- a/mepo.d/command/stash/stash.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess as sp - -from state.state import MepoState - -from command.stash.list import list -from command.stash.pop import pop -from command.stash.apply import apply -from command.stash.push import push -from command.stash.show import show - -def run(args): - d = { - 'list': list, - 'pop': pop, - 'apply': apply, - 'push': push, - 'show': show, - } - d[args.mepo_stash_cmd].run(args) diff --git a/mepo.d/command/status/status.py b/mepo.d/command/status/status.py deleted file mode 100644 index 9172059..0000000 --- a/mepo.d/command/status/status.py +++ /dev/null @@ -1,73 +0,0 @@ -import sys -import time -import multiprocessing as mp -import atexit - -from state.state import MepoState -from repository.git import GitRepository -from utilities.version import version_to_string, sanitize_version_string -from utilities import colors, shellcmd -from command.whereis.whereis import _get_relative_path -import shlex - -def run(args): - print('Checking status...'); sys.stdout.flush() - allcomps = MepoState.read_state() - pool = mp.Pool() - atexit.register(pool.close) - result = pool.starmap(check_component_status, [(comp, args.ignore_permissions) for comp in allcomps]) - print_status(allcomps, result, args.nocolor, args.hashes) - -def check_component_status(comp, ignore_permissions): - git = GitRepository(comp.remote, comp.local) - - # Older mepo clones will not have ignore_submodules in comp, so - # we need to handle this gracefully - try: - _ignore_submodules = comp.ignore_submodules - except AttributeError: - _ignore_submodules = None - - # version_to_string can strip off 'origin/' for display purposes - # so we save the "internal" name for comparison - internal_state_branch_name = git.get_version()[0] - - # This can return non "origin/" names for detached head branches - curr_ver = version_to_string(git.get_version(),git) - orig_ver = version_to_string(comp.version,git) - - # This command is to try and work with git tag oddities - curr_ver = sanitize_version_string(orig_ver,curr_ver,git) - - return (curr_ver, internal_state_branch_name, git.check_status(ignore_permissions,_ignore_submodules)) - -def print_status(allcomps, result, nocolor=False, hashes=False): - orig_width = len(max([comp.name for comp in allcomps], key=len)) - for index, comp in enumerate(allcomps): - time.sleep(0.025) - current_version, internal_state_branch_name, output = result[index] - if hashes: - comp_path = _get_relative_path(comp.local) - comp_hash = shellcmd.run( - cmd=shlex.split(f"git -C {comp_path} rev-parse HEAD"), - output=True - ).replace("\n", "") - current_version = f"{current_version} ({comp_hash})" - # This should handle tag weirdness... - if current_version.split()[1] == comp.version.name: - component_name = comp.name - width = orig_width - # Check to see if the current tag/branch is the same as the - # original... if the above check didn't succeed, we are - # different and we colorize if asked for - elif (internal_state_branch_name not in comp.version.name) and not nocolor: - component_name = colors.RED + comp.name + colors.RESET - width = orig_width + len(colors.RED) + len(colors.RESET) - else: - component_name = comp.name - width = orig_width - FMT0 = '{:<%s.%ss} | {:'): # an actual branch - detached = False - name = output.split(',')[0].split('->')[1].strip() - tYpe = 'b' - elif output.startswith('HEAD,'): # detached head - detached = True - tmp = output.split(',')[1].strip() - if tmp.startswith('tag:'): # tag - name = tmp[5:] - tYpe = 't' - else: - # This was needed for when we weren't explicitly detaching on clone - #cmd_for_branch = self.__git + ' reflog HEAD -n 1' - #reflog_output = shellcmd.run(shlex.split(cmd_for_branch), output=True) - #name = reflog_output.split()[-1].strip() - name = output.split()[-1].strip() - tYpe = 'b' - elif output.startswith('HEAD'): # Assume hash - cmd = self.__git + ' rev-parse HEAD' - hash_out = shellcmd.run(shlex.split(cmd), output=True) - detached = True - name = hash_out.rstrip() - tYpe = 'h' - elif output.startswith('grafted'): - cmd = self.__git + ' describe --always' - hash_out = shellcmd.run(shlex.split(cmd), output=True) - detached = True - name = hash_out.rstrip() - tYpe = 'h' - return (name, tYpe, detached) - -def get_current_remote_url(): - cmd = 'git remote get-url origin' - output = shellcmd.run(shlex.split(cmd), output=True).strip() - return output diff --git a/mepo.d/state/component.py b/mepo.d/state/component.py deleted file mode 100644 index e121075..0000000 --- a/mepo.d/state/component.py +++ /dev/null @@ -1,214 +0,0 @@ -import os -import shlex -import textwrap -from collections import namedtuple -from utilities.version import MepoVersion -from utilities import shellcmd, mepoconfig -from urllib.parse import urlparse - -# This will be used to store the "final nodes" from each subrepo -original_final_node_list = [] - -class MepoComponent(object): - - __slots__ = ['name', 'local', 'remote', 'version', 'sparse', 'develop', 'recurse_submodules', 'fixture', 'ignore_submodules'] - - def __init__(self): - self.name = None - self.local = None - self.remote = None - self.version = None - self.sparse = None - self.develop = None - self.recurse_submodules = None - self.fixture = None - self.ignore_submodules = None - - def __repr__(self): - # Older mepo clones will not have ignore_submodules in comp, so - # we need to handle this gracefully - try: - _ignore_submodules = self.ignore_submodules - except AttributeError: - _ignore_submodules = None - - return '{} - local: {}, remote: {}, version: {}, sparse: {}, develop: {}, recurse_submodules: {}, fixture: {}, ignore_submodules: {}'.format( - self.name, self.local, self.remote, self.version, self.sparse, self.develop, self.recurse_submodules, self.fixture, _ignore_submodules) - - def __set_original_version(self, comp_details): - if self.fixture: - cmd_if_branch = 'git symbolic-ref HEAD' - # Have to use 'if not' since 0 is a good status - if not shellcmd.run(cmd_if_branch.split(),status=True): - output = shellcmd.run(cmd_if_branch.split(),output=True).rstrip() - ver_name = output.replace('refs/heads/','') - ver_type = 'b' - is_detached = False - else: - # On some CI systems, git is handled oddly. As such, sometimes - # tags aren't found due to shallow clones - cmd_for_tag = 'git describe --tags' - # Have to use 'if not' since 0 is a good status - if not shellcmd.run(cmd_for_tag.split(),status=True): - ver_name = shellcmd.run(cmd_for_tag.split(),output=True).rstrip() - ver_type = 't' - is_detached = True - else: - # Per internet, describe always should always work, though mepo - # will return weirdness (a grafted branch, probably a hash) - cmd_for_always = 'git describe --always' - ver_name = shellcmd.run(cmd_for_always.split(),output=True).rstrip() - ver_type = 'h' - is_detached = True - else: - if comp_details.get('branch', None): - # SPECIAL HANDLING of 'detached head' branches - ver_name = 'origin/' + comp_details['branch'] - ver_type = 'b' - # we always detach branches from components.yaml - is_detached = True - elif comp_details.get('hash', None): - # Hashes don't have to exist - ver_name = comp_details['hash'] - ver_type = 'h' - is_detached = True - else: - ver_name = comp_details['tag'] # 'tag' key has to exist - ver_type = 't' - is_detached = True - self.version = MepoVersion(ver_name, ver_type, is_detached) - - def __validate_fixture(self, comp_details): - unallowed_keys = ['remote', 'local', 'branch', 'hash', 'tag', 'sparse', 'recurse_submodules', 'ignore_submodules'] - if any([comp_details.get(key) for key in unallowed_keys]): - raise Exception("Fixtures are only allowed fixture and develop") - - def __validate_component(self, comp_name, comp_details): - types_of_git_tags = ['branch', 'tag', 'hash'] - git_tag_intersection = set(types_of_git_tags).intersection(set(comp_details.keys())) - if len(git_tag_intersection) == 0: - raise Exception(textwrap.fill(textwrap.dedent(f''' - Component {comp_name} has none of {types_of_git_tags}. mepo - requires one of them.'''))) - elif len(git_tag_intersection) != 1: - raise Exception(textwrap.fill(textwrap.dedent(f''' - Component {comp_name} has {git_tag_intersection} and only one of - {types_of_git_tags} are allowed.'''))) - - def to_component(self, comp_name, comp_details, comp_style): - self.name = comp_name - self.fixture = comp_details.get('fixture', False) - if self.fixture: - self.__validate_fixture(comp_details) - - self.local = '.' - repo_url = get_current_remote_url() - p = urlparse(repo_url) - last_url_node = p.path.rsplit('/')[-1] - self.remote = "../"+last_url_node - else: - self.__validate_component(comp_name, comp_details) - #print(f"original self.local: {comp_details['local']}") - - # Assume the flag for repostories is commercial-at - repo_flag = '@' - - # To make it easier to loop over the local path, split into a list - local_list = splitall(comp_details['local']) - - # The last node of the path is what we will decorate - last_node = local_list[-1] - - # Add that final node to a list - original_final_node_list.append(last_node) - - # Now we need to decorate all the final nodes since we can have - # nested repos with mepo - for item in original_final_node_list: - try: - # Find the index of every "final node" in a local path - # for nesting - index = local_list.index(item) - - # Decorate all final nodes - local_list[index] = decorate_node(item, repo_flag, comp_style) - except ValueError: - pass - - # Now pull the list of nodes back into a path - self.local = os.path.join(*local_list) - #print(f'final self.local: {self.local}') - - self.remote = comp_details['remote'] - self.sparse = comp_details.get('sparse', None) # sparse is optional - self.develop = comp_details.get('develop', None) # develop is optional - self.recurse_submodules = comp_details.get('recurse_submodules', None) # recurse_submodules is optional - self.ignore_submodules = comp_details.get('ignore_submodules', None) # ignore_submodules is optional - self.__set_original_version(comp_details) - return self - - def to_dict(self, start): - details = dict() - # Fixtures are allowed exactly two entries - if self.fixture: - details['fixture'] = self.fixture - if self.develop: - details['develop'] = self.develop - else: - details['local'] = self.local - details['remote'] = self.remote - if self.version.type == 't': - details['tag'] = self.version.name - elif self.version.type == 'h': - details['hash'] = self.version.name - else: # if not tag or hash, version has to be a branch - if self.version.detached: # SPECIAL HANDLING of 'detached head' branches - details['branch'] = self.version.name.replace('origin/', '') - else: - details['branch'] = self.version.name - if self.sparse: - details['sparse'] = self.sparse - if self.develop: - details['develop'] = self.develop - if self.recurse_submodules: - details['recurse_submodules'] = self.recurse_submodules - if self.ignore_submodules: - details['ignore_submodules'] = self.ignore_submodules - return {self.name: details} - -def get_current_remote_url(): - cmd = 'git remote get-url origin' - output = shellcmd.run(shlex.split(cmd), output=True).strip() - return output - -def decorate_node(item, flag, style): - # If we do not pass in a style... - if not style: - # Just use what's in components.yaml - return item - # else use the style - else: - item = item.replace(flag,'') - if style == 'naked': - output = item - elif style == 'prefix': - output = flag + item - elif style == 'postfix': - output = item + flag - return output - -# From https://learning.oreilly.com/library/view/python-cookbook/0596001673/ch04s16.html -def splitall(path): - allparts = [] - while 1: - parts = os.path.split(path) - if parts[0] == path: # sentinel for absolute paths - allparts.insert(0, parts[0]) - break - elif parts[1] == path: # sentinel for relative paths - allparts.insert(0, parts[1]) - break - else: - path = parts[0] - allparts.insert(0, parts[1]) - return allparts diff --git a/mepo.d/state/state.py b/mepo.d/state/state.py deleted file mode 100644 index 45ced34..0000000 --- a/mepo.d/state/state.py +++ /dev/null @@ -1,104 +0,0 @@ -import os -import sys -import yaml -import glob -import pickle - -from config.config_file import ConfigFile -from state.component import MepoComponent -from utilities import shellcmd -from pathlib import Path -from state.exceptions import StateDoesNotExistError, StateAlreadyInitializedError - -class MepoState(object): - - __state_dir_name = '.mepo' - __state_fileptr_name = 'state.pkl' - - @staticmethod - def get_parent_dirs(): - mypath = os.getcwd() - parentdirs = [mypath] - while mypath != '/': - mypath = os.path.dirname(mypath) - parentdirs.append(mypath) - return parentdirs - - @classmethod - def get_dir(cls): - for mydir in cls.get_parent_dirs(): - state_dir = os.path.join(mydir, cls.__state_dir_name) - if os.path.exists(state_dir): - return state_dir - raise OSError('mepo state dir [.mepo] does not exist') - - @classmethod - def get_root_dir(cls): - '''Return directory that contains .mepo''' - return os.path.dirname(cls.get_dir()) - - @classmethod - def get_file(cls): - state_file = os.path.join(cls.get_dir(), cls.__state_fileptr_name) - if os.path.exists(state_file): - return state_file - raise OSError('mepo state file [%s] does not exist' % state_file) - - @classmethod - def exists(cls): - try: - cls.get_file() - return True - except OSError: - return False - - @classmethod - def initialize(cls, project_config_file, directory_style): - if cls.exists(): - raise StateAlreadyInitializedError('Error! mepo state already exists') - input_components = ConfigFile(project_config_file).read_file() - - num_fixture = 0 - complist = list() - for name, comp in input_components.items(): - # We only allow one fixture - if 'fixture' in comp: - num_fixture += comp['fixture'] - if num_fixture > 1: - raise Exception("Only one fixture allowed") - - complist.append(MepoComponent().to_component(name, comp, directory_style)) - cls.write_state(complist) - - @classmethod - def read_state(cls): - if not cls.exists(): - raise StateDoesNotExistError('Error! mepo state does not exist') - with open(cls.get_file(), 'rb') as fin: - allcomps = pickle.load(fin) - return allcomps - - @classmethod - def write_state(cls, state_details): - if cls.exists(): - state_dir = cls.get_dir() - pattern = os.path.join(cls.get_dir(), 'state.*.pkl') - states = [os.path.basename(x) for x in glob.glob(os.path.join(pattern))] - new_state_id = max([int(x.split('.')[1]) for x in states]) + 1 - state_file_name = 'state.' + str(new_state_id) + '.pkl' - else: - state_dir = os.path.join(os.getcwd(), cls.__state_dir_name) - os.mkdir(state_dir) - state_file_name = 'state.0.pkl' - new_state_file = os.path.join(state_dir, state_file_name) - with open(new_state_file, 'wb') as fout: - pickle.dump(state_details, fout, -1) - state_fileptr = cls.__state_fileptr_name - state_fileptr_fullpath = os.path.join(state_dir, state_fileptr) - if os.path.isfile(state_fileptr_fullpath): - os.remove(state_fileptr_fullpath) - #os.symlink(new_state_file, state_fileptr_fullpath) - curr_dir=os.getcwd() - os.chdir(state_dir) - os.symlink(state_file_name, state_fileptr) - os.chdir(curr_dir) diff --git a/mepo.d/utest/input/args.py b/mepo.d/utest/input/args.py deleted file mode 100644 index 7e2cb0c..0000000 --- a/mepo.d/utest/input/args.py +++ /dev/null @@ -1,2 +0,0 @@ -config_file = None -allrepos = None diff --git a/mepo.d/utest/output/compare_brief_output.txt b/mepo.d/utest/output/compare_brief_output.txt deleted file mode 100644 index f7f32d0..0000000 --- a/mepo.d/utest/output/compare_brief_output.txt +++ /dev/null @@ -1,5 +0,0 @@ -Repo | Original | Current --------- | -------------------- | ------- -env | (t) v4.8.0 (DH) | (b) main -cmake | (t) v3.21.0 (DH) | (b) develop -fvdycore | (t) geos/v1.5.0 (DH) | (b) geos/develop diff --git a/mepo.d/utest/output/compare_full_output.txt b/mepo.d/utest/output/compare_full_output.txt deleted file mode 100644 index 02aaa0b..0000000 --- a/mepo.d/utest/output/compare_full_output.txt +++ /dev/null @@ -1,11 +0,0 @@ -Repo | Original | Current ----------------------- | -------------------------------- | ------- -GEOSfvdycore | (t) v1.13.0 (DH) | (t) v1.13.0 (DH) -env | (t) v4.8.0 (DH) | (b) main -cmake | (t) v3.21.0 (DH) | (b) develop -ecbuild | (t) geos/v1.3.0 (DH) | (t) geos/v1.3.0 (DH) -GMAO_Shared | (t) v1.6.3 (DH) | (t) v1.6.3 (DH) -MAPL | (t) v2.33.0 (DH) | (t) v2.33.0 (DH) -FMS | (t) geos/2019.01.02+noaff.8 (DH) | (t) geos/2019.01.02+noaff.8 (DH) -FVdycoreCubed_GridComp | (t) v1.12.1 (DH) | (t) v1.12.1 (DH) -fvdycore | (t) geos/v1.5.0 (DH) | (b) geos/develop diff --git a/mepo.d/utest/output/list_output.txt b/mepo.d/utest/output/list_output.txt deleted file mode 100644 index 608fc12..0000000 --- a/mepo.d/utest/output/list_output.txt +++ /dev/null @@ -1 +0,0 @@ -GEOSfvdycore env cmake ecbuild GMAO_Shared MAPL FMS FVdycoreCubed_GridComp fvdycore diff --git a/mepo.d/utest/test_mepo_commands.py b/mepo.d/utest/test_mepo_commands.py deleted file mode 100644 index c1fc5a6..0000000 --- a/mepo.d/utest/test_mepo_commands.py +++ /dev/null @@ -1,116 +0,0 @@ -import os -import sys -THIS_DIR = os.path.dirname(os.path.realpath(__file__)) -sys.path.insert(0, os.path.join(THIS_DIR, '..')) -import shutil -import shlex -import unittest -import subprocess as sp -from io import StringIO - -from input import args - -from command.init import init as mepo_init -from command.clone import clone as mepo_clone -from command.list import list as mepo_list -from command.status import status as mepo_status -from command.compare import compare as mepo_compare -from command.develop import develop as mepo_develop - -class TestMepoCommands(unittest.TestCase): - - maxDiff=None - - @classmethod - def __checkout_fixture(cls): - remote = 'https://github.com/GEOS-ESM/{}.git'.format(cls.fixture) - cmd = 'git clone -b {} {} {}'.format(cls.tag, remote, cls.fixture_dir) - sp.run(shlex.split(cmd)) - - @classmethod - def __copy_config_file(cls): - src = os.path.join(cls.input_dir, 'components.yaml') - dst = os.path.join(cls.fixture_dir) - shutil.copy(src, dst) - - @classmethod - def setUpClass(cls): - cls.input_dir = os.path.join(THIS_DIR, 'input') - cls.output_dir = os.path.join(THIS_DIR, 'output') - cls.fixture = 'GEOSfvdycore' - cls.tag = 'v1.13.0' - cls.tmpdir = os.path.join(THIS_DIR, 'tmp') - cls.fixture_dir = os.path.join(cls.tmpdir, cls.fixture) - if os.path.isdir(cls.fixture_dir): - shutil.rmtree(cls.fixture_dir) - cls.__checkout_fixture() - #cls.__copy_config_file() - args.config = 'components.yaml' - args.style = 'prefix' - os.chdir(cls.fixture_dir) - mepo_init.run(args) - args.config = None - args.repo_url = None - args.branch = None - args.directory = None - args.partial = 'blobless' - mepo_clone.run(args) - # In order to better test compare, we need to do *something* - args.comp_name = ['env','cmake','fvdycore'] - args.quiet = False - mepo_develop.run(args) - - def setUp(self): - pass - - def test_list(self): - sys.stdout = output = StringIO() - mepo_list.run(args) - sys.stdout = sys.__stdout__ - with open(os.path.join(self.__class__.output_dir, 'list_output.txt'), 'r') as fin: - saved_output = fin.read() - self.assertEqual(output.getvalue(), saved_output) - - def test_status(self): - sys.stdout = output = StringIO() - args.ignore_permissions=False - args.nocolor=True - args.hashes=False - mepo_status.run(args) - sys.stdout = sys.__stdout__ - with open(os.path.join(self.__class__.output_dir, 'status_output.txt'), 'r') as fin: - saved_output = fin.read() - self.assertEqual(output.getvalue(), saved_output) - - def test_compare_brief(self): - sys.stdout = output = StringIO() - args.all=False - args.nocolor=True - args.wrap=True - mepo_compare.run(args) - sys.stdout = sys.__stdout__ - with open(os.path.join(self.__class__.output_dir, 'compare_brief_output.txt'), 'r') as fin: - saved_output = fin.read() - self.assertEqual(output.getvalue(), saved_output) - - def test_compare_full(self): - sys.stdout = output = StringIO() - args.all=True - args.nocolor=True - args.wrap=True - mepo_compare.run(args) - sys.stdout = sys.__stdout__ - with open(os.path.join(self.__class__.output_dir, 'compare_full_output.txt'), 'r') as fin: - saved_output = fin.read() - self.assertEqual(output.getvalue(), saved_output) - - def tearDown(self): - pass - - @classmethod - def tearDownClass(cls): - os.chdir(THIS_DIR) - shutil.rmtree(cls.tmpdir) - -if __name__ == '__main__': - unittest.main() diff --git a/mepo.d/utilities/colors.py b/mepo.d/utilities/colors.py deleted file mode 100644 index 78ca5e6..0000000 --- a/mepo.d/utilities/colors.py +++ /dev/null @@ -1,17 +0,0 @@ -try: - import colorama - from colorama import Fore, Back, Style - - RED = Fore.RED - BLUE = Fore.BLUE - CYAN = Fore.CYAN - GREEN = Fore.GREEN - YELLOW = Fore.YELLOW - RESET = Style.RESET_ALL -except ImportError: - RED = "\x1b[1;31m" - BLUE = "\x1b[1;34m" - CYAN = "\x1b[1;36m" - GREEN = "\x1b[1;32m" - YELLOW = "\x1b[1;33m" - RESET = "\x1b[0;0m" diff --git a/mepo.spec b/mepo.spec new file mode 100644 index 0000000..ef59192 --- /dev/null +++ b/mepo.spec @@ -0,0 +1,51 @@ +# -*- mode: python ; coding: utf-8 -*- + +import os +import glob + +cmd_dir = os.path.join(SPECPATH, 'src/mepo/command') +cmd_list = [os.path.basename(x).split('.')[0] for x in glob.glob(os.path.join(cmd_dir, '*.py'))] +hidden_imports = [f'mepo.command.{x}' for x in cmd_list if '_' not in x] # exclude subcommands +print(f'hidden_imports: {hidden_imports}') + +a = Analysis( + ['src/mepo/__main__.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='mepo', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='mepo', +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..308021b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "mepo" +version = "2.0.0" +description = "A tool for managing (m)ultiple r(epo)s" +authors = [{name="GMAO SI Team", email="siteam@gmao.gsfc.nasa.gov"}] +dependencies = [ + "pyyaml>=6.0.1", + "colorama>=0.4.6", +] +readme = "README.md" +license = "Apache-2.0" +repository = "https://github.com/GEOS-ESM/mepo.git" +requires-python = ">= 3.9" + +[project.scripts] +mepo = "mepo.__main__:main" + +[tool.rye] +managed = true +dev-dependencies = [ + "black>=24.4.2", + "pylint>=3.2.0", + "flake8>=7.0.0", + "pre-commit>=3.7.1", + "mdutils>=1.6.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.sdist] +only_include = [ + "docs", + "etc", + "src/mepo", + "tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/mepo"] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..15f19a7 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,68 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +astroid==3.2.1 + # via pylint +black==24.4.2 +cfgv==3.4.0 + # via pre-commit +click==8.1.7 + # via black +colorama==0.4.6 + # via mepo +dill==0.3.8 + # via pylint +distlib==0.3.8 + # via virtualenv +filelock==3.14.0 + # via virtualenv +flake8==7.0.0 +identify==2.5.36 + # via pre-commit +isort==5.13.2 + # via pylint +mccabe==0.7.0 + # via flake8 + # via pylint +mdutils==1.6.0 +mypy-extensions==1.0.0 + # via black +nodeenv==1.8.0 + # via pre-commit +packaging==24.0 + # via black +pathspec==0.12.1 + # via black +platformdirs==4.2.2 + # via black + # via pylint + # via virtualenv +pre-commit==3.7.1 +pycodestyle==2.11.1 + # via flake8 +pyflakes==3.2.0 + # via flake8 +pylint==3.2.0 +pyyaml==6.0.1 + # via mepo + # via pre-commit +setuptools==70.0.0 + # via nodeenv +tomli==2.0.1 + # via black + # via pylint +tomlkit==0.12.5 + # via pylint +typing-extensions==4.11.0 + # via astroid + # via black + # via pylint +virtualenv==20.26.2 + # via pre-commit diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..7cecfb7 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,14 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +colorama==0.4.6 + # via mepo +pyyaml==6.0.1 + # via mepo diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c0822dc..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -PyYAML>=5.4 diff --git a/src/mepo/__init__.py b/src/mepo/__init__.py new file mode 100644 index 0000000..85aa9dc --- /dev/null +++ b/src/mepo/__init__.py @@ -0,0 +1,7 @@ +"""Ensure Python version""" + +import sys + +PY_VERSION = (3, 9, 0) +if sys.version_info < PY_VERSION: + sys.exit(f"ERROR: Python version needs to be >= {PY_VERSION}") diff --git a/src/mepo/__main__.py b/src/mepo/__main__.py new file mode 100644 index 0000000..3da1557 --- /dev/null +++ b/src/mepo/__main__.py @@ -0,0 +1,19 @@ +from importlib import import_module + +from mepo.cmdline.parser import MepoArgParser +from mepo.utilities import mepoconfig + + +def main(): + args = MepoArgParser().parse() + mepo_cmd = mepoconfig.get_alias_command(args.mepo_cmd) + + # Load the module containing the "run" method of specified command + cmd_module = import_module(f"mepo.command.{mepo_cmd}") + + # Execute "run" method of specified command + cmd_module.run(args) + + +if __name__ == "__main__": + main() diff --git a/src/mepo/cmdline/__init__.py b/src/mepo/cmdline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mepo/cmdline/branch_parser.py b/src/mepo/cmdline/branch_parser.py new file mode 100644 index 0000000..02f9bd9 --- /dev/null +++ b/src/mepo/cmdline/branch_parser.py @@ -0,0 +1,57 @@ +class MepoBranchArgParser: + + def __init__(self, branch): + self.branch_subparsers = branch.add_subparsers() + self.branch_subparsers.title = "mepo branch sub-commands" + self.branch_subparsers.dest = "mepo_branch_cmd" + self.branch_subparsers.required = True + self.__list() + self.__create() + self.__delete() + + def __list(self): + brlist = self.branch_subparsers.add_parser( + "list", + description="List local branches. If no component is specified, runs over all components", + ) + brlist.add_argument( + "-a", "--all", action="store_true", help="list all (local+remote) branches" + ) + brlist.add_argument( + "--nocolor", action="store_true", help="do not display color" + ) + brlist.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="component to list branches in", + ) + + def __create(self): + create = self.branch_subparsers.add_parser( + "create", description="Create branch in component " + ) + create.add_argument("branch_name", metavar="branch-name", help="name of branch") + create.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="component to create branches in", + ) + + def __delete(self): + delete = self.branch_subparsers.add_parser( + "delete", description="Delete branch in component " + ) + delete.add_argument("branch_name", metavar="branch-name", help="name of branch") + delete.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="component to delete branches in", + ) + delete.add_argument( + "--force", + action="store_true", + help="delete branch even if it has not been fully merged", + ) diff --git a/src/mepo/cmdline/config_parser.py b/src/mepo/cmdline/config_parser.py new file mode 100644 index 0000000..361c682 --- /dev/null +++ b/src/mepo/cmdline/config_parser.py @@ -0,0 +1,50 @@ +class MepoConfigArgParser: + + def __init__(self, config): + self.config = config.add_subparsers() + self.config.title = "mepo config sub-commands" + self.config.dest = "mepo_config_cmd" + self.config.required = True + self.__get() + self.__set() + self.__delete() + self.__print() + + def __get(self): + get = self.config.add_parser( + "get", + description=( + "Get config `entry` in `.mepoconfig`. " + "Note this uses gitconfig style where `entry` is of the form `section.option`. " + "So to get an `alias` `st` You would run `mepo config get alias.st`" + ), + ) + get.add_argument("entry", metavar="entry", help="Entry to display.") + + def __set(self): + set_ = self.config.add_parser( + "set", + description=( + "Set config `entry` to `value` in `.mepoconfig`. " + "Note this uses gitconfig style where `entry` is of the form `section.option`. " + "So to set an `alias` for `status` of `st` You would run `mepo config set alias.st status`" + ), + ) + set_.add_argument("entry", metavar="entry", help="Entry to set.") + set_.add_argument("value", metavar="value", help="Value to set entry to.") + + def __delete(self): + delete = self.config.add_parser( + "delete", + description=( + "Delete config `entry` in `.mepoconfig`. " + "Note this uses gitconfig style where `entry` is of the form `section.option`. " + "So to delete an `alias` `st` You would run `mepo config delete alias.st`" + ), + ) + delete.add_argument("entry", metavar="entry", help="Entry to delete.") + + def __print(self): + _ = self.config.add_parser( + "print", description="Print contents of `.mepoconfig`" + ) diff --git a/src/mepo/cmdline/parser.py b/src/mepo/cmdline/parser.py new file mode 100644 index 0000000..cddd3f2 --- /dev/null +++ b/src/mepo/cmdline/parser.py @@ -0,0 +1,503 @@ +import argparse + +from .branch_parser import MepoBranchArgParser +from .stash_parser import MepoStashArgParser +from .tag_parser import MepoTagArgParser +from .config_parser import MepoConfigArgParser + +from ..utilities import mepoconfig + + +class MepoArgParser: + + __slots__ = ["parser", "subparsers"] + + def __init__(self): + self.parser = argparse.ArgumentParser( + description="Tool to manage (m)ultiple r(epo)s" + ) + self.subparsers = self.parser.add_subparsers() + self.subparsers.title = "mepo commands" + self.subparsers.required = True + self.subparsers.dest = "mepo_cmd" + + def parse(self): + self.__init() + self.__clone() + self.__list() + self.__status() + self.__restore_state() + self.__diff() + self.__fetch() + self.__checkout() + self.__checkout_if_exists() + self.__changed_files() + self.__branch() + self.__tag() + self.__stash() + self.__develop() + self.__pull() + self.__pull_all() + self.__compare() + self.__reset() + self.__whereis() + self.__stage() + self.__unstage() + self.__commit() + self.__push() + self.__save() + self.__config() + self.__update_state() + return self.parser.parse_args() + + def __init(self): + init = self.subparsers.add_parser( + "init", + description="Initialize mepo based on `config-file`", + aliases=mepoconfig.get_command_alias("init"), + ) + init.add_argument( + "--registry", + nargs="?", + default="components.yaml", + help="default: %(default)s", + ) + init.add_argument( + "--style", + metavar="style-type", + nargs="?", + default=None, + choices=["naked", "prefix", "postfix"], + help="Style of directory file, default: prefix, allowed options: %(choices)s", + ) + + def __clone(self): + clone = self.subparsers.add_parser( + "clone", + description="Clone repositories.", + aliases=mepoconfig.get_command_alias("clone"), + ) + clone.add_argument( + "repo_url", metavar="URL", nargs="?", default=None, help="URL to clone" + ) + clone.add_argument( + "directory", + nargs="?", + default=None, + help="Directory to clone into (Only allowed with URL!)", + ) + clone.add_argument( + "--branch", + "-b", + metavar="name", + nargs="?", + default=None, + help="Branch/tag of URL to initially clone (Only allowed with URL!)", + ) + clone.add_argument( + "--registry", + metavar="registry", + nargs="?", + default=None, + help="Registry (default: components.yaml)", + ) + clone.add_argument( + "--style", + metavar="style-type", + nargs="?", + default=None, + choices=["naked", "prefix", "postfix"], + help="Style of directory file, default: prefix, allowed options: %(choices)s (ignored if init already called)", + ) + clone.add_argument( + "--allrepos", + action="store_true", + help="Must be passed with -b/--branch. When set, it not only checkouts out the branch/tag for the fixture, but for all the subrepositories as well.", + ) + clone.add_argument( + "--partial", + metavar="partial-type", + nargs="?", + default=None, + choices=["off", "blobless", "treeless"], + help='Style of partial clone, default: None, allowed options: %(choices)s. Off means a "normal" full git clone, blobless means cloning with "--filter=blob:none" and treeless means cloning with "--filter=tree:0". NOTE: We do *not* recommend using "treeless" as it is very aggressive and will cause problems with many git commands.', + ) + + def __list(self): + listcomps = self.subparsers.add_parser( + "list", + description="List all components that are being tracked", + aliases=mepoconfig.get_command_alias("list"), + ) + listcomps.add_argument( + "-1", "--one-per-line", action="store_true", help="one component per line" + ) + + def __status(self): + status = self.subparsers.add_parser( + "status", + description="Check current status of all components", + aliases=mepoconfig.get_command_alias("status"), + ) + status.add_argument( + "--ignore-permissions", + action="store_true", + help="Tells command to ignore changes in file permissions.", + ) + status.add_argument( + "--nocolor", action="store_true", help="Tells status to not display colors." + ) + status.add_argument( + "--hashes", action="store_true", help="Print the exact hash of the HEAD." + ) + status.add_argument( + "--serial", action="store_true", help="Run the serial version." + ) + + def __restore_state(self): + restore_state = self.subparsers.add_parser( + "restore-state", + description="Restores all components to the last saved state.", + aliases=mepoconfig.get_command_alias("restore-state"), + ) + restore_state.add_argument( + "--serial", action="store_true", help="Run the serial version." + ) + + def __diff(self): + diff = self.subparsers.add_parser( + "diff", + description="Diff all components", + aliases=mepoconfig.get_command_alias("diff"), + ) + diff.add_argument( + "--name-only", action="store_true", help="Show only names of changed files" + ) + diff.add_argument( + "--name-status", + action="store_true", + help="Show name-status of changed files", + ) + diff.add_argument( + "--ignore-permissions", + action="store_true", + help="Tells command to ignore changes in file permissions.", + ) + diff.add_argument( + "--staged", action="store_true", help="Show diff of staged changes" + ) + diff.add_argument( + "-b", + "--ignore-space-change", + action="store_true", + help="Ignore changes in amount of whitespace", + ) + diff.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to list branches in", + ) + + def __checkout(self): + checkout = self.subparsers.add_parser( + "checkout", + description="Switch to branch/tag `branch-name` in component `comp-name`. " + "If no components listed, checkout from all. " + "Specifying `-b` causes the branch `branch-name` to be created and checked out.", + aliases=mepoconfig.get_command_alias("checkout"), + ) + checkout.add_argument( + "branch_name", metavar="branch-name", help="Name of branch" + ) + checkout.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Components to checkout branch in", + ) + checkout.add_argument("-b", action="store_true", help="create the branch") + checkout.add_argument( + "-q", "--quiet", action="store_true", help="Suppress prints" + ) + checkout.add_argument( + "--detach", action="store_true", help="Detach upon checkout" + ) + + def __checkout_if_exists(self): + checkout_if_exists = self.subparsers.add_parser( + "checkout-if-exists", + description="Switch to branch or tag `ref-name` in any component where it is present. ", + aliases=mepoconfig.get_command_alias("checkout-if-exists"), + ) + checkout_if_exists.add_argument( + "ref_name", metavar="ref-name", help="Name of branch or tag" + ) + checkout_if_exists.add_argument( + "-q", "--quiet", action="store_true", help="Suppress prints" + ) + checkout_if_exists.add_argument( + "--detach", action="store_true", help="Detach on checkout" + ) + checkout_if_exists.add_argument( + "-n", + "--dry-run", + action="store_true", + help="Dry-run only (lists repos where branch exists)", + ) + + def __changed_files(self): + changed_files = self.subparsers.add_parser( + "changed-files", + description="List files that have changes versus the state. By default runs against all components.", + aliases=mepoconfig.get_command_alias("changed-files"), + ) + changed_files.add_argument( + "--full-path", action="store_true", help="Print with full path" + ) + changed_files.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to list branches in", + ) + + def __fetch(self): + fetch = self.subparsers.add_parser( + "fetch", + description="Download objects and refs from in component `comp-name`. " + "If no components listed, fetches from all", + aliases=mepoconfig.get_command_alias("fetch"), + ) + fetch.add_argument( + "comp_name", metavar="comp-name", nargs="*", help="Components to fetch in" + ) + fetch.add_argument("--all", action="store_true", help="Fetch all remotes.") + fetch.add_argument( + "-p", "--prune", action="store_true", help="Prune remote branches." + ) + fetch.add_argument("-t", "--tags", action="store_true", help="Fetch tags.") + fetch.add_argument("-f", "--force", action="store_true", help="Force action.") + + def __branch(self): + branch = self.subparsers.add_parser( + "branch", + description="Runs branch commands.", + aliases=mepoconfig.get_command_alias("branch"), + ) + MepoBranchArgParser(branch) + + def __stash(self): + stash = self.subparsers.add_parser( + "stash", + description="Runs stash commands.", + aliases=mepoconfig.get_command_alias("stash"), + ) + MepoStashArgParser(stash) + + def __tag(self): + tag = self.subparsers.add_parser( + "tag", + description="Runs tag commands.", + aliases=mepoconfig.get_command_alias("tag"), + ) + MepoTagArgParser(tag) + + def __develop(self): + develop = self.subparsers.add_parser( + "develop", + description="Checkout current version of 'develop' branches of specified components", + aliases=mepoconfig.get_command_alias("develop"), + ) + develop.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + default=None, + help="Component(s) to checkout development branches", + ) + develop.add_argument( + "-q", "--quiet", action="store_true", help="Suppress prints" + ) + + def __pull(self): + pull = self.subparsers.add_parser( + "pull", + description="Pull branches of specified components", + aliases=mepoconfig.get_command_alias("pull"), + ) + pull.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + default=None, + help="Components to pull in", + ) + pull.add_argument("-q", "--quiet", action="store_true", help="Suppress prints") + + def __pull_all(self): + pull_all = self.subparsers.add_parser( + "pull-all", + description="Pull branches of all components (only those in non-detached HEAD state)", + aliases=mepoconfig.get_command_alias("pull-all"), + ) + pull_all.add_argument( + "-q", "--quiet", action="store_true", help="Suppress prints" + ) + + def __compare(self): + compare = self.subparsers.add_parser( + "compare", + description="Compare current and original states of all components. " + "Will only show differing repos unless --all is passed in", + aliases=mepoconfig.get_command_alias("compare"), + ) + compare.add_argument( + "--all", + action="store_true", + help="Show all repos, not only differing repos", + ) + compare.add_argument( + "--nocolor", + action="store_true", + help="Tells command to not display colors.", + ) + compare.add_argument( + "--wrap", + action="store_true", + help="Tells command to ignore terminal size and wrap", + ) + + def __reset(self): + reset = self.subparsers.add_parser( + "reset", + description="Reset the current mepo clone to the original state. " + "This will delete all subrepos and does not check for uncommitted changes! " + "Must be run in the root of the mepo clone.", + aliases=mepoconfig.get_command_alias("reset"), + ) + reset.add_argument("-f", "--force", action="store_true", help="Force action.") + reset.add_argument( + "--reclone", action="store_true", help="Reclone repos after reset." + ) + reset.add_argument("-n", "--dry-run", action="store_true", help="Dry-run only") + + def __whereis(self): + whereis = self.subparsers.add_parser( + "whereis", + description="Get the location of component `comp-name` " + "relative to my current location. If `comp-name` is not present, " + "get the relative locations of ALL components.", + aliases=mepoconfig.get_command_alias("whereis"), + ) + whereis.add_argument( + "comp_name", + metavar="comp-name", + nargs="?", + default=None, + help="Component to get location of", + ) + whereis.add_argument( + "-i", "--ignore-case", action="store_true", help="Ignore case for whereis" + ) + + def __stage(self): + stage = self.subparsers.add_parser( + "stage", + description="Stage modified & untracked files in the specified component(s)", + aliases=mepoconfig.get_command_alias("stage"), + ) + stage.add_argument( + "--untracked", action="store_true", help="Stage untracked files as well" + ) + stage.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to stage file in", + ) + + def __unstage(self): + unstage = self.subparsers.add_parser( + "unstage", + description="Un-stage staged files. " + "If a component is specified, files are un-staged only for that component.", + aliases=mepoconfig.get_command_alias("unstage"), + ) + unstage.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to unstage in", + default=None, + ) + + def __commit(self): + commit = self.subparsers.add_parser( + "commit", + description="Commit staged files in the specified components", + aliases=mepoconfig.get_command_alias("commit"), + ) + commit.add_argument( + "-a", + "--all", + action="store_true", + help="Stage all tracked files and then commit", + ) + commit.add_argument( + "-m", + "--message", + type=str, + metavar="message", + default=None, + help="Message to commit with", + ) + commit.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to commit file in", + ) + + def __push(self): + push = self.subparsers.add_parser( + "push", + description="Push local commits to remote for specified component. " + "Use mepo tag push to push tags", + aliases=mepoconfig.get_command_alias("push"), + ) + push.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to push to remote", + ) + + def __save(self): + save = self.subparsers.add_parser( + "save", + description="Save current state in a yaml registry", + aliases=mepoconfig.get_command_alias("save"), + ) + save.add_argument( + "registry", + metavar="registry", + nargs="?", + default="components-new.yaml", + help="default: %(default)s", + ) + + def __config(self): + config = self.subparsers.add_parser( + "config", + description="Runs config commands.", + aliases=mepoconfig.get_command_alias("config"), + ) + MepoConfigArgParser(config) + + def __update_state(self): + _ = self.subparsers.add_parser( + "update-state", + description="Permanently update mepo1 state to current", + aliases=mepoconfig.get_command_alias("update-state"), + ) diff --git a/src/mepo/cmdline/stash_parser.py b/src/mepo/cmdline/stash_parser.py new file mode 100644 index 0000000..b6edf7e --- /dev/null +++ b/src/mepo/cmdline/stash_parser.py @@ -0,0 +1,72 @@ +class MepoStashArgParser: + + def __init__(self, stash): + self.stash = stash.add_subparsers() + self.stash.title = "mepo stash sub-commands" + self.stash.dest = "mepo_stash_cmd" + self.stash.required = True + self.__push() + self.__list() + self.__pop() + self.__apply() + self.__show() + + def __push(self): + stpush = self.stash.add_parser( + "push", description="Push (create) stash in component " + ) + stpush.add_argument( + "-m", + "--message", + type=str, + metavar="message", + default=None, + help="Message for the stash", + ) + stpush.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to push stash in", + ) + + def __show(self): + stshow = self.stash.add_parser( + "show", description="show stash in component " + ) + stshow.add_argument( + "-p", "--patch", action="store_true", help="Message for the stash" + ) + stshow.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to show stash in", + ) + + def __list(self): + _ = self.stash.add_parser( + "list", description="List local stashes of all components" + ) + + def __pop(self): + stpop = self.stash.add_parser( + "pop", description="Pop stash in component " + ) + stpop.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to pop stash in", + ) + + def __apply(self): + stapply = self.stash.add_parser( + "apply", description="apply stash in component " + ) + stapply.add_argument( + "comp_name", + metavar="comp-name", + nargs="+", + help="Component to apply stash in", + ) diff --git a/src/mepo/cmdline/tag_parser.py b/src/mepo/cmdline/tag_parser.py new file mode 100644 index 0000000..cb2e89c --- /dev/null +++ b/src/mepo/cmdline/tag_parser.py @@ -0,0 +1,79 @@ +class MepoTagArgParser: + + def __init__(self, tag): + self.tag = tag.add_subparsers() + self.tag.title = "mepo tag sub-commands" + self.tag.dest = "mepo_tag_cmd" + self.tag.required = True + self.__list() + self.__create() + self.__delete() + self.__push() + + def __list(self): + tglist = self.tag.add_parser( + "list", + description="List tags. If no component is specified, runs over all components", + ) + tglist.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to list tags in", + ) + + def __create(self): + create = self.tag.add_parser( + "create", + description="Create tag in component . If no component is specified, runs over all components", + ) + create.add_argument("tag_name", metavar="tag-name", help="Name of tag") + create.add_argument( + "-a", "--annotate", action="store_true", help="Make an annotated tag" + ) + create.add_argument( + "-m", + "--message", + type=str, + metavar="message", + default=None, + help="Message for the tag", + ) + create.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to create tags in", + ) + + def __delete(self): + delete = self.tag.add_parser( + "delete", + description="Delete tag in component . If no component is specified, runs over all components", + ) + delete.add_argument("tag_name", metavar="tag-name", help="Name of tag") + delete.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to delete tags in", + ) + + def __push(self): + push = self.tag.add_parser( + "push", + description="Push tag in component . If no component is specified, runs over all components", + ) + push.add_argument("tag_name", metavar="tag-name", help="Name of tag") + push.add_argument( + "-f", "--force", action="store_true", help="Force push (be careful!)" + ) + push.add_argument( + "-d", "--delete", action="store_true", help="Delete (be careful!)" + ) + push.add_argument( + "comp_name", + metavar="comp-name", + nargs="*", + help="Component to push tags in", + ) diff --git a/src/mepo/command/__init__.py b/src/mepo/command/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mepo/command/branch.py b/src/mepo/command/branch.py new file mode 100644 index 0000000..b419358 --- /dev/null +++ b/src/mepo/command/branch.py @@ -0,0 +1,12 @@ +from .branch_list import run as branch_list_run +from .branch_create import run as branch_create_run +from .branch_delete import run as branch_delete_run + + +def run(args): + d = { + "list": branch_list_run, + "create": branch_create_run, + "delete": branch_delete_run, + } + d[args.mepo_branch_cmd](args) diff --git a/mepo.d/command/branch/create/create.py b/src/mepo/command/branch_create.py similarity index 64% rename from mepo.d/command/branch/create/create.py rename to src/mepo/command/branch_create.py index d6772a6..d22ec96 100644 --- a/mepo.d/command/branch/create/create.py +++ b/src/mepo/command/branch_create.py @@ -1,6 +1,7 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() @@ -9,4 +10,4 @@ def run(args): for comp in comps2crtbr: git = GitRepository(comp.remote, comp.local) git.create_branch(args.branch_name) - print('+ {}: {}'.format(comp.name, args.branch_name)) + print(f"+ {comp.name}: {args.branch_name}") diff --git a/mepo.d/command/branch/delete/delete.py b/src/mepo/command/branch_delete.py similarity index 65% rename from mepo.d/command/branch/delete/delete.py rename to src/mepo/command/branch_delete.py index 3206b3d..5c6f718 100644 --- a/mepo.d/command/branch/delete/delete.py +++ b/src/mepo/command/branch_delete.py @@ -1,6 +1,7 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() @@ -9,4 +10,4 @@ def run(args): for comp in comps2delbr: git = GitRepository(comp.remote, comp.local) git.delete_branch(args.branch_name, args.force) - print('- {}: {}'.format(comp.name, args.branch_name)) + print("- {}: {}".format(comp.name, args.branch_name)) diff --git a/mepo.d/command/branch/list/list.py b/src/mepo/command/branch_list.py similarity index 68% rename from mepo.d/command/branch/list/list.py rename to src/mepo/command/branch_list.py index 7174024..62972e1 100644 --- a/mepo.d/command/branch/list/list.py +++ b/src/mepo/command/branch_list.py @@ -1,18 +1,20 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() comps2list = _get_comps_to_list(args.comp_name, allcomps) max_namelen = len(max([x.name for x in comps2list], key=len)) - FMT = '{:<%s.%ss} | {: columns): - print(FMT1.format(name, orig + ' ...')) - print(FMT2.format(name_blank, '...', curr)) + print(FMT1.format(name, orig + " ...")) + print(FMT2.format(name_blank, "...", curr)) else: print(FMT0.format(name, orig, curr)) - diff --git a/src/mepo/command/config.py b/src/mepo/command/config.py new file mode 100644 index 0000000..0b0c7d0 --- /dev/null +++ b/src/mepo/command/config.py @@ -0,0 +1,14 @@ +from .config_get import run as config_get_run +from .config_set import run as config_set_run +from .config_delete import run as config_delete_run +from .config_print import run as config_print_run + + +def run(args): + d = { + "get": config_get_run, + "set": config_set_run, + "delete": config_delete_run, + "print": config_print_run, + } + d[args.mepo_config_cmd](args) diff --git a/mepo.d/command/config/delete/delete.py b/src/mepo/command/config_delete.py similarity index 79% rename from mepo.d/command/config/delete/delete.py rename to src/mepo/command/config_delete.py index dbf58da..11734c7 100644 --- a/mepo.d/command/config/delete/delete.py +++ b/src/mepo/command/config_delete.py @@ -1,4 +1,5 @@ -from utilities import mepoconfig +from ..utilities import mepoconfig + def run(args): section, option = mepoconfig.split_entry(args.entry) diff --git a/src/mepo/command/config_get.py b/src/mepo/command/config_get.py new file mode 100644 index 0000000..c278089 --- /dev/null +++ b/src/mepo/command/config_get.py @@ -0,0 +1,18 @@ +from ..utilities import mepoconfig + + +def run(args): + section, option = mepoconfig.split_entry(args.entry) + if not mepoconfig.has_section(section): + raise Exception(f"Section [{section}] does not exist in .mepoconfig") + if not mepoconfig.has_option(section, option): + raise Exception( + f"Option [{option}] does not exist in section [{section}] in .mepoconfig" + ) + value = mepoconfig.get(section, option) + print( + f""" + [{section}] + {option} = {value} + """ + ) diff --git a/mepo.d/command/config/print/print.py b/src/mepo/command/config_print.py similarity index 52% rename from mepo.d/command/config/print/print.py rename to src/mepo/command/config_print.py index 16d85c6..6c78542 100644 --- a/mepo.d/command/config/print/print.py +++ b/src/mepo/command/config_print.py @@ -1,4 +1,5 @@ -from utilities import mepoconfig +from ..utilities import mepoconfig + def run(args): mepoconfig.print() diff --git a/mepo.d/command/config/set/set.py b/src/mepo/command/config_set.py similarity index 81% rename from mepo.d/command/config/set/set.py rename to src/mepo/command/config_set.py index 6ac0a4c..94e4bf3 100644 --- a/mepo.d/command/config/set/set.py +++ b/src/mepo/command/config_set.py @@ -1,4 +1,5 @@ -from utilities import mepoconfig +from ..utilities import mepoconfig + def run(args): section, option = mepoconfig.split_entry(args.entry) diff --git a/mepo.d/command/develop/develop.py b/src/mepo/command/develop.py similarity index 51% rename from mepo.d/command/develop/develop.py rename to src/mepo/command/develop.py index 8c2aa92..e679955 100644 --- a/mepo.d/command/develop/develop.py +++ b/src/mepo/command/develop.py @@ -1,7 +1,8 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from utilities import colors +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository +from ..utilities import colors + def run(args): allcomps = MepoState.read_state() @@ -12,8 +13,12 @@ def run(args): if comp.develop is None: raise Exception("'develop' branch not specified for {}".format(comp.name)) if not args.quiet: - print("Checking out development branch %s in %s" % - (colors.YELLOW + comp.develop + colors.RESET, - colors.RESET + comp.name + colors.RESET)) + print( + "Checking out development branch %s in %s" + % ( + colors.YELLOW + comp.develop + colors.RESET, + colors.RESET + comp.name + colors.RESET, + ) + ) git.checkout(comp.develop) - output = git.pull() + _ = git.pull() diff --git a/mepo.d/command/diff/diff.py b/src/mepo/command/diff.py similarity index 73% rename from mepo.d/command/diff/diff.py rename to src/mepo/command/diff.py index 8353196..e32de83 100644 --- a/mepo.d/command/diff/diff.py +++ b/src/mepo/command/diff.py @@ -1,14 +1,12 @@ -import sys -import time -import multiprocessing as mp import os -from state.state import MepoState -from repository.git import GitRepository -from utilities import verify - from shutil import get_terminal_size +from ..state import MepoState +from ..git import GitRepository +from ..utilities import verify + + def run(args): foundDiff = False @@ -19,12 +17,13 @@ def run(args): result = check_component_diff(comp, args) if result: if not foundDiff: - print('Diffing...'); sys.stdout.flush() + print("Diffing...", flush=True) foundDiff = True print_diff(comp, args, result) if not foundDiff: - print('No diffs found') + print("No diffs found") + def _get_comps_to_diff(specified_comps, allcomps): comps_to_diff = allcomps @@ -33,6 +32,7 @@ def _get_comps_to_diff(specified_comps, allcomps): comps_to_diff = [x for x in allcomps if x.name in specified_comps] return comps_to_diff + def check_component_diff(comp, args): git = GitRepository(comp.remote, comp.local) @@ -44,17 +44,19 @@ def check_component_diff(comp, args): _ignore_submodules = None return git.run_diff(args, _ignore_submodules) + def print_diff(comp, args, output): - columns, lines = get_terminal_size(fallback=(80,20)) - horiz_line = u'\u2500'*columns + columns, lines = get_terminal_size(fallback=(80, 20)) + horiz_line = "\u2500" * columns - print("{} (location: {}):".format(comp.name,_get_relative_path(comp.local))) + print("{} (location: {}):".format(comp.name, _get_relative_path(comp.local))) print() - for line in output.split('\n'): - #print(' |', line.rstrip()) + for line in output.split("\n"): + # print(' |', line.rstrip()) print(line.rstrip()) print(horiz_line) + def _get_relative_path(local_path): """ Get the relative path when given a local path. @@ -63,7 +65,7 @@ def _get_relative_path(local_path): """ # This creates a full path on the disk from the root of mepo and the local_path - full_local_path=os.path.join(MepoState.get_root_dir(),local_path) + full_local_path = os.path.join(MepoState.get_root_dir(), local_path) # We return the path relative to where we currently are return os.path.relpath(full_local_path, os.getcwd()) diff --git a/mepo.d/command/fetch/fetch.py b/src/mepo/command/fetch.py similarity index 68% rename from mepo.d/command/fetch/fetch.py rename to src/mepo/command/fetch.py index cddbbcb..02c0e3b 100644 --- a/mepo.d/command/fetch/fetch.py +++ b/src/mepo/command/fetch.py @@ -1,17 +1,18 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from utilities import colors +from ..state import MepoState +from ..utilities import colors +from ..utilities import verify +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() comps2fetch = _get_comps_to_list(args.comp_name, allcomps) for comp in comps2fetch: git = GitRepository(comp.remote, comp.local) - print("Fetching %s" % - colors.YELLOW + comp.name + colors.RESET) + print("Fetching %s" % colors.YELLOW + comp.name + colors.RESET) git.fetch(args) + def _get_comps_to_list(specified_comps, allcomps): comps_to_list = allcomps if specified_comps: diff --git a/src/mepo/command/init.py b/src/mepo/command/init.py new file mode 100644 index 0000000..83d15e3 --- /dev/null +++ b/src/mepo/command/init.py @@ -0,0 +1,25 @@ +from ..state import MepoState +from ..utilities import mepoconfig + + +def run(args): + if args.style: + style = args.style + elif mepoconfig.has_option("init", "style"): + allowed_styles = ["naked", "prefix", "postfix"] + style = mepoconfig.get("init", "style") + if style not in allowed_styles: + raise Exception( + f"Detected style [{style}] from .mepoconfig is not an allowed style: {allowed_styles}" + ) + else: + print(f"Found style [{style}] in .mepoconfig") + else: + style = None + + _ = MepoState.initialize(args.registry, style) + + if not style: + print(f"Initializing mepo using {args.registry}") + else: + print(f"Initializing mepo using {args.registry} with {style} style") diff --git a/src/mepo/command/list.py b/src/mepo/command/list.py new file mode 100644 index 0000000..bf6513b --- /dev/null +++ b/src/mepo/command/list.py @@ -0,0 +1,9 @@ +from ..state import MepoState + + +def run(args): + _end = "\n" if args.one_per_line else " " + allcomps = MepoState.read_state() + for comp in allcomps[:-1]: + print(comp.name, end=_end) + print(allcomps[-1].name) diff --git a/src/mepo/command/pull-all.py b/src/mepo/command/pull-all.py new file mode 100644 index 0000000..c54773d --- /dev/null +++ b/src/mepo/command/pull-all.py @@ -0,0 +1,30 @@ +from ..state import MepoState +from ..component import MepoVersion +from ..git import GitRepository +from ..utilities import colors + + +def run(args): + allcomps = MepoState.read_state() + detached_comps = [] + for comp in allcomps: + git = GitRepository(comp.remote, comp.local) + name, tYpe, detached = MepoVersion(*git.get_version()) + if detached: + detached_comps.append(comp.name) + else: + print( + "Pulling branch %s in %s " + % ( + colors.YELLOW + name + colors.RESET, + colors.RESET + comp.name + colors.RESET, + ) + ) + output = git.pull() + if not args.quiet: + print(output) + if len(detached_comps) > 0: + print( + "The following repos were not pulled (detached HEAD): %s" + % (", ".join(map(str, detached_comps))) + ) diff --git a/src/mepo/command/pull.py b/src/mepo/command/pull.py new file mode 100644 index 0000000..f3bb97c --- /dev/null +++ b/src/mepo/command/pull.py @@ -0,0 +1,27 @@ +from ..state import MepoState +from ..component import MepoVersion +from ..utilities import verify +from ..utilities import colors +from ..git import GitRepository + + +def run(args): + allcomps = MepoState.read_state() + verify.valid_components(args.comp_name, allcomps) + comps2pull = [x for x in allcomps if x.name in args.comp_name] + for comp in comps2pull: + git = GitRepository(comp.remote, comp.local) + name, tYpe, is_detached = MepoVersion(*git.get_version()) + if is_detached: + raise Exception("{} has detached head! Cannot pull.".format(comp.name)) + else: + print( + "Pulling branch %s in %s " + % ( + colors.YELLOW + name + colors.RESET, + colors.RESET + comp.name + colors.RESET, + ) + ) + output = git.pull() + if not args.quiet: + print(output) diff --git a/mepo.d/command/push/push.py b/src/mepo/command/push.py similarity index 65% rename from mepo.d/command/push/push.py rename to src/mepo/command/push.py index adcf2ae..78869d6 100644 --- a/mepo.d/command/push/push.py +++ b/src/mepo/command/push.py @@ -1,6 +1,7 @@ -from utilities import verify -from state.state import MepoState -from repository.git import GitRepository +from ..utilities import verify +from ..state import MepoState +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() @@ -9,5 +10,5 @@ def run(args): for comp in comps2push: git = GitRepository(comp.remote, comp.local) output = git.push() - print('----------\nPushed: {}\n----------'.format(comp.name)) + print("----------\nPushed: {}\n----------".format(comp.name)) print(output) diff --git a/mepo.d/command/reset/reset.py b/src/mepo/command/reset.py similarity index 61% rename from mepo.d/command/reset/reset.py rename to src/mepo/command/reset.py index 41cf79e..87cfce6 100644 --- a/mepo.d/command/reset/reset.py +++ b/src/mepo/command/reset.py @@ -1,16 +1,17 @@ import os import shutil -from state.state import MepoState -from state.exceptions import NotInRootDirError +from ..state import MepoState +from ..utilities.exceptions import NotInRootDirError -from command.clone import clone as mepo_clone +from .clone import run as mepo_clone_run # This command will "reset" the mepo clone. This will delete all -# the subrepos, remove the .mepo directory, and then re-clone all the +# the subrepos, remove the mepo state directory, and then re-clone all the # subrepos. This is useful if you want to start over with a fresh clone # of the project. + def run(args): allcomps = MepoState.read_state() @@ -21,14 +22,19 @@ def run(args): curdir = os.getcwd() ## Then check that they are the same, if they are not, then throw a NotInRootDirError if rootdir != curdir: - raise NotInRootDirError('Error! As a safety precaution, you must be in the root directory of the project to reset') + raise NotInRootDirError( + "Error! As a safety precaution, you must be in the root directory of the project to reset" + ) # If we get this far, then we are in the root directory of the project # If a user has called this command without the force flag, we # will ask them to confirm that they want to reset the project if not args.force and not args.dry_run: - print(f"Are you sure you want to reset the project? If so, type 'yes' and press enter.", end=' ') + print( + "Are you sure you want to reset the project? If so, type 'yes' and press enter.", + end=" ", + ) answer = input() if answer != "yes": print("Reset cancelled.") @@ -43,21 +49,21 @@ def run(args): else: # Get the relative path to the component relpath = _get_relative_path(comp.local) - print(f'Removing {relpath}', end='...') + print(f"Removing {relpath}", end="...") # Remove the component if not dry run if not args.dry_run: shutil.rmtree(relpath) - print('done.') + print("done.") else: - print(f'dry-run only. Not removing {relpath}') + print(f"dry-run only. Not removing {relpath}") # Next, we need to remove the .mepo directory - print(f'Removing .mepo', end='...') + print("Removing mepo state", end="...") if not args.dry_run: - shutil.rmtree('.mepo') - print('done.') + shutil.rmtree(MepoState.get_dir()) + print("done.") else: - print(f'dry-run only. Not removing .mepo') + print("dry-run only. Not removing mepo state") # If they pass in the --reclone flag, then we will re-clone all the subrepos if args.reclone: @@ -65,23 +71,35 @@ def run(args): # mepo_clone requires args which is an Argparse Namespace object # We will create a new Namespace object with the correct arguments # for mepo_clone - clone_args = type('Namespace', (object,), {'repo_url': None, 'directory': None, 'branch': None, 'config': None, 'allrepos': False, 'style': None}) + clone_args = type( + "Namespace", + (object,), + { + "repo_url": None, + "directory": None, + "branch": None, + "registry": None, + "allrepos": False, + "style": None, + }, + ) if not args.dry_run: - print('Re-cloning all subrepos') - mepo_clone.run(clone_args) - print('Recloning done.') + print("Re-cloning all subrepos") + mepo_clone_run(clone_args) + print("Recloning done.") else: - print(f'Dry-run only. Not re-cloning all subrepos') + print("Dry-run only. Not re-cloning all subrepos") + def _get_relative_path(local_path): """ Get the relative path when given a local path. - local_path: The path to a subrepo as known by mepo (relative to the .mepo directory) + local_path: The path to a subrepo as known by mepo (relative to the mepo state directory) """ # This creates a full path on the disk from the root of mepo and the local_path - full_local_path=os.path.join(MepoState.get_root_dir(),local_path) + full_local_path = os.path.join(MepoState.get_root_dir(), local_path) # We return the path relative to where we currently are return os.path.relpath(full_local_path, os.getcwd()) diff --git a/src/mepo/command/restore-state.py b/src/mepo/command/restore-state.py new file mode 100644 index 0000000..bb33ee4 --- /dev/null +++ b/src/mepo/command/restore-state.py @@ -0,0 +1,44 @@ +import multiprocessing as mp + +from ..state import MepoState +from ..git import GitRepository +from ..utilities.version import version_to_string +from ..utilities import colors + + +def run(args): + print("Checking status...", flush=True) + allcomps = MepoState.read_state() + if args.serial: + result = [] + for comp in allcomps: + result.append(check_component_status(comp)) + else: + with mp.Pool() as pool: + result = pool.map(check_component_status, allcomps) + restore_state(allcomps, result) + + +def check_component_status(comp): + git = GitRepository(comp.remote, comp.local) + curr_ver = version_to_string(git.get_version(), git) + return (curr_ver, git.check_status()) + + +def restore_state(allcomps, result): + for index, comp in enumerate(allcomps): + git = GitRepository(comp.remote, comp.local) + current_version = result[index][0].split(" ")[1] + orig_version = comp.version.name + if current_version != orig_version: + print( + colors.YELLOW + + "Restoring " + + colors.RESET + + "{} to {} from {}.".format( + comp.name, + colors.GREEN + orig_version + colors.RESET, + colors.RED + current_version + colors.RESET, + ) + ) + git.checkout(comp.version.name) diff --git a/mepo.d/command/save/save.py b/src/mepo/command/save.py similarity index 50% rename from mepo.d/command/save/save.py rename to src/mepo/command/save.py index 336b3f7..0e678b5 100644 --- a/mepo.d/command/save/save.py +++ b/src/mepo/command/save.py @@ -1,11 +1,12 @@ -from state.state import MepoState -from state.component import MepoVersion -from repository.git import GitRepository -from config.config_file import ConfigFile -from utilities.version import sanitize_version_string - import os +from ..state import MepoState +from ..component import MepoVersion +from ..git import GitRepository +from ..registry import Registry +from ..utilities.version import sanitize_version_string + + def run(args): allcomps = MepoState.read_state() for comp in allcomps: @@ -16,50 +17,63 @@ def run(args): complist = dict() relpath_start = MepoState.get_root_dir() for comp in allcomps: - complist.update(comp.to_dict(relpath_start)) - config_file_root_dir=os.path.join(relpath_start,args.config_file) - ConfigFile(config_file_root_dir).write_yaml(complist) - print(f"Components written to '{config_file_root_dir}'") + complist.update(comp.to_registry_format()) + registry_root_dir = os.path.join(relpath_start, args.registry) + Registry(registry_root_dir).write_yaml(complist) + print(f"Components written to '{registry_root_dir}'") + def _update_comp(comp): git = GitRepository(comp.remote, comp.local) orig_ver = comp.version curr_ver = MepoVersion(*git.get_version()) - orig_ver_is_tag_or_hash = (orig_ver.type == 't' or orig_ver.type == 'h') - curr_ver_is_tag_or_hash = (curr_ver.type == 't' or curr_ver.type == 'h') + orig_ver_is_tag_or_hash = orig_ver.type == "t" or orig_ver.type == "h" + curr_ver_is_tag_or_hash = curr_ver.type == "t" or curr_ver.type == "h" if orig_ver_is_tag_or_hash and curr_ver_is_tag_or_hash: # This command is to try and work with git tag oddities - curr_ver_to_use = sanitize_version_string(orig_ver.name,curr_ver.name,git) + curr_ver_to_use = sanitize_version_string(orig_ver.name, curr_ver.name, git) if curr_ver_to_use == orig_ver.name: comp.version = orig_ver else: - _verify_local_and_remote_commit_ids_match(git, curr_ver_to_use, comp.name, curr_ver.type) + _verify_local_and_remote_commit_ids_match( + git, curr_ver_to_use, comp.name, curr_ver.type + ) comp.version = curr_ver else: if _version_has_changed(curr_ver, orig_ver, comp.name): - _verify_local_and_remote_commit_ids_match(git, curr_ver.name, comp.name, curr_ver.type) + _verify_local_and_remote_commit_ids_match( + git, curr_ver.name, comp.name, curr_ver.type + ) comp.version = curr_ver + def _version_has_changed(curr_ver, orig_ver, name): result = False if curr_ver != orig_ver: - if curr_ver.type == 'b': - assert curr_ver.detached is False, f'You cannot save a detached branch, have you committed your code in {name}?\n {curr_ver}' + if curr_ver.type == "b": + assert ( + curr_ver.detached is False + ), f"You cannot save a detached branch, have you committed your code in {name}?\n {curr_ver}" result = True - elif curr_ver.type == 't': + elif curr_ver.type == "t": result = True - elif curr_ver.type == 'h': + elif curr_ver.type == "h": result = True else: raise Exception("This should not happen") return result -def _verify_local_and_remote_commit_ids_match(git, curr_ver_name, comp_name, curr_ver_type): + +def _verify_local_and_remote_commit_ids_match( + git, curr_ver_name, comp_name, curr_ver_type +): remote_id = git.get_remote_latest_commit_id(curr_ver_name, curr_ver_type) local_id = git.get_local_latest_commit_id() - failmsg = "{} (remote commit) != {} (local commit) for {}:{}. Did you try 'mepo push'?" + failmsg = ( + "{} (remote commit) != {} (local commit) for {}:{}. Did you try 'mepo push'?" + ) if remote_id != local_id: msg = failmsg.format(remote_id, local_id, comp_name, curr_ver_name) raise Exception(msg) diff --git a/mepo.d/command/stage/stage.py b/src/mepo/command/stage.py similarity index 79% rename from mepo.d/command/stage/stage.py rename to src/mepo/command/stage.py index ea93d9a..7a3b996 100644 --- a/mepo.d/command/stage/stage.py +++ b/src/mepo/command/stage.py @@ -1,7 +1,8 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository -from state.component import MepoVersion +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository +from ..component import MepoVersion + def run(args): allcomps = MepoState.read_state() @@ -11,9 +12,10 @@ def run(args): git = GitRepository(comp.remote, comp.local) stage_files(git, comp, args.untracked) + def stage_files(git, comp, untracked=False, commit=False): curr_ver = MepoVersion(*git.get_version()) - if curr_ver.detached: # detached head + if curr_ver.detached: # detached head raise Exception(f"{comp.name} has detached head! Cannot stage.") for myfile in git.get_changed_files(untracked=untracked): git.stage_file(myfile) @@ -22,4 +24,3 @@ def stage_files(git, comp, untracked=False, commit=False): print(f"Staged: {print_output}") else: print(f"+ {print_output}") - diff --git a/src/mepo/command/stash.py b/src/mepo/command/stash.py new file mode 100644 index 0000000..4da7932 --- /dev/null +++ b/src/mepo/command/stash.py @@ -0,0 +1,16 @@ +from .stash_list import run as stash_list_run +from .stash_pop import run as stash_pop_run +from .stash_apply import run as stash_apply_run +from .stash_push import run as stash_push_run +from .stash_show import run as stash_show_run + + +def run(args): + d = { + "list": stash_list_run, + "pop": stash_pop_run, + "apply": stash_apply_run, + "push": stash_push_run, + "show": stash_show_run, + } + d[args.mepo_stash_cmd](args) diff --git a/mepo.d/command/stash/apply/apply.py b/src/mepo/command/stash_apply.py similarity index 66% rename from mepo.d/command/stash/apply/apply.py rename to src/mepo/command/stash_apply.py index 04f7e37..54617ce 100644 --- a/mepo.d/command/stash/apply/apply.py +++ b/src/mepo/command/stash_apply.py @@ -1,6 +1,7 @@ -from state.state import MepoState -from utilities import verify -from repository.git import GitRepository +from ..state import MepoState +from ..utilities import verify +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() @@ -9,4 +10,4 @@ def run(args): for comp in comps2appst: git = GitRepository(comp.remote, comp.local) git.apply_stash() - #print('+ {}'.format(comp.name)) + # print('+ {}'.format(comp.name)) diff --git a/mepo.d/command/stash/list/list.py b/src/mepo/command/stash_list.py similarity index 54% rename from mepo.d/command/stash/list/list.py rename to src/mepo/command/stash_list.py index 62eb0d9..a93537c 100644 --- a/mepo.d/command/stash/list/list.py +++ b/src/mepo/command/stash_list.py @@ -1,13 +1,14 @@ -from state.state import MepoState -from repository.git import GitRepository +from ..state import MepoState +from ..git import GitRepository + def run(args): allcomps = MepoState.read_state() max_namelen = len(max([x.name for x in allcomps], key=len)) - FMT = '{:<%s.%ss} | {: namedtuple + v = MepoVersion(*v) # * for arg unpacking + setattr(self, k, v) + return self + + def serialize(self): + d = {} + for k in self.__slots__: + v = getattr(self, k) + if k == "version": + # namedtuple -> list + v = list(v) + d.update({k: v}) + return d + + +def get_current_remote_url(): + cmd = "git remote get-url origin" + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output + + +def decorate_node(item, flag, style): + # If we do not pass in a style... + if not style: + # Just use what's in components.yaml + return item + # else use the style + else: + item = item.replace(flag, "") + if style == "naked": + output = item + elif style == "prefix": + output = flag + item + elif style == "postfix": + output = item + flag + return output + + +# From https://learning.oreilly.com/library/view/python-cookbook/0596001673/ch04s16.html +def splitall(path): + allparts = [] + while 1: + parts = os.path.split(path) + if parts[0] == path: # sentinel for absolute paths + allparts.insert(0, parts[0]) + break + elif parts[1] == path: # sentinel for relative paths + allparts.insert(0, parts[1]) + break + else: + path = parts[0] + allparts.insert(0, parts[1]) + return allparts diff --git a/src/mepo/git.py b/src/mepo/git.py new file mode 100644 index 0000000..4518a54 --- /dev/null +++ b/src/mepo/git.py @@ -0,0 +1,579 @@ +import os +import shutil +import shlex +import subprocess as sp + +from urllib.parse import urljoin + +from .state import MepoState +from .utilities import shellcmd +from .utilities import colors +from .utilities.exceptions import RepoAlreadyClonedError + + +def get_editor(): + """ + Return GIT_EDITOR + """ + result = sp.run( + "git var GIT_EDITOR".split(), stdout=sp.PIPE, stderr=sp.PIPE, check=True + ) + return result.stdout.rstrip().decode("utf-8") # byte to utf-8 + + +class GitRepository: + """ + Class to consolidate git commands + """ + + __slots__ = ["__local", "__full_local_path", "__remote", "__git"] + + def __init__(self, remote_url, local_path): + self.__local = local_path + + if remote_url.startswith(".."): + rel_remote = os.path.basename(remote_url) + fixture_url = get_current_remote_url() + self.__remote = urljoin(fixture_url, rel_remote) + else: + self.__remote = remote_url + + root_dir = MepoState.get_root_dir() + full_local_path = os.path.normpath(os.path.join(root_dir, local_path)) + self.__full_local_path = full_local_path + self.__git = 'git -C "{}"'.format(self.__full_local_path) + + def get_local_path(self): + return self.__local + + def get_full_local_path(self): + return self.__full_local_path + + def get_remote_url(self): + return self.__remote + + def clone(self, version, recurse, type, comp_name, partial=None): + cmd1 = "git clone " + + if partial == "blobless": + cmd1 += "--filter=blob:none " + elif partial == "treeless": + cmd1 += "--filter=tree:0 " + + if recurse: + cmd1 += "--recurse-submodules " + + cmd1 += "--quiet {} {}".format(self.__remote, self.__full_local_path) + try: + shellcmd.run(shlex.split(cmd1)) + except sp.CalledProcessError: + raise RepoAlreadyClonedError(f"Error! Repo [{comp_name}] already cloned") + + cmd2 = "git -C {} checkout {}".format(self.__full_local_path, version) + shellcmd.run(shlex.split(cmd2)) + cmd3 = "git -C {} checkout --detach".format(self.__full_local_path) + shellcmd.run(shlex.split(cmd3)) + + # NOTE: The above looks odd because of a quirk of git. You can't do + # git checkout --detach branch unless the branch is local. But + # since this is at clone time, all branches are remote. Thus, + # we have to do a git checkout branch and then detach. + + def checkout(self, version, detach=False): + cmd = self.__git + " checkout " + cmd += "--quiet {}".format(version) + shellcmd.run(shlex.split(cmd)) + if detach: + cmd2 = self.__git + " checkout --detach" + shellcmd.run(shlex.split(cmd2)) + + def sparsify(self, sparse_config): + dst = os.path.join(self.__full_local_path, ".git", "info", "sparse-checkout") + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy(sparse_config, dst) + cmd1 = self.__git + " config core.sparseCheckout true" + shellcmd.run(shlex.split(cmd1)) + cmd2 = self.__git + " read-tree -mu HEAD" + shellcmd.run(shlex.split(cmd2)) + + def list_branch(self, all=False, nocolor=False): + cmd = self.__git + " branch" + if all: + cmd += " -a" + if nocolor: + cmd += " --color=never" + return shellcmd.run(shlex.split(cmd), output=True) + + def list_tags(self): + cmd = self.__git + " tag" + return shellcmd.run(shlex.split(cmd), output=True) + + def rev_list(self, tag): + cmd = self.__git + " rev-list -n 1 {}".format(tag) + return shellcmd.run(shlex.split(cmd), output=True) + + def rev_parse(self, short=False): + cmd = self.__git + " rev-parse --verify HEAD" + if short: + cmd += " --short" + return shellcmd.run(shlex.split(cmd), output=True) + + def list_stash(self): + cmd = self.__git + " stash list" + return shellcmd.run(shlex.split(cmd), output=True) + + def pop_stash(self): + cmd = self.__git + " stash pop" + return shellcmd.run(shlex.split(cmd), output=True) + + def apply_stash(self): + cmd = self.__git + " stash apply" + return shellcmd.run(shlex.split(cmd), output=True) + + def push_stash(self, message): + cmd = self.__git + " stash push" + if message: + cmd += " -m {}".format(message) + return shellcmd.run(shlex.split(cmd), output=True) + + def show_stash(self, patch): + cmd = self.__git + " stash show" + if patch: + cmd += " -p --color" + output = shellcmd.run(shlex.split(cmd), output=True) + return output.rstrip() + + def run_diff(self, args=None, ignore_submodules=False): + cmd = "git -C {}".format(self.__full_local_path) + if args.ignore_permissions: + cmd += " -c core.fileMode=false" + cmd += " diff --color" + if args.name_only: + cmd += " --name-only" + if args.name_status: + cmd += " --name-status" + if args.staged: + cmd += " --staged" + if args.ignore_space_change: + cmd += " --ignore-space-change" + if ignore_submodules: + cmd += " --ignore-submodules=all" + output = shellcmd.run(shlex.split(cmd), output=True) + return output.rstrip() + + def fetch(self, args=None): + cmd = self.__git + " fetch" + if args.all: + cmd += " --all" + if args.prune: + cmd += " --prune" + if args.tags: + cmd += " --tags" + if args.force: + cmd += " --force" + return shellcmd.run(shlex.split(cmd), output=True) + + def create_branch(self, branch_name): + cmd = self.__git + " branch {}".format(branch_name) + shellcmd.run(shlex.split(cmd)) + + def create_tag(self, tag_name, annotate, message, tf_file=None): + if annotate: + if tf_file: + cmd = [ + "git", + "-C", + self.__full_local_path, + "tag", + "-a", + "-F", + tf_file, + tag_name, + ] + elif message: + cmd = [ + "git", + "-C", + self.__full_local_path, + "tag", + "-a", + "-m", + message, + tag_name, + ] + else: + raise Exception("This should not happen") + else: + cmd = ["git", "-C", self.__full_local_path, "tag", tag_name] + shellcmd.run(cmd) + + def delete_branch(self, branch_name, force): + delete = "-d" + if force: + delete = "-D" + cmd = self.__git + " branch {} {}".format(delete, branch_name) + shellcmd.run(shlex.split(cmd)) + + def delete_tag(self, tag_name): + cmd = self.__git + " tag -d {}".format(tag_name) + shellcmd.run(shlex.split(cmd)) + + def push_tag(self, tag_name, force, delete): + cmd = self.__git + " push" + if force: + cmd += " --force" + if delete: + cmd += " --delete" + cmd += " origin {}".format(tag_name) + shellcmd.run(shlex.split(cmd)) + + def verify_branch_or_tag(self, ref_name): + branch_cmd = self.__git + f" show-branch remotes/origin/{ref_name}" + tag_cmd = self.__git + f" rev-parse {ref_name}" + branch_status = shellcmd.run(shlex.split(branch_cmd), status=True) + ref_type = "UNKNOWN" + if branch_status != 0: + status = shellcmd.run(shlex.split(tag_cmd), status=True) + ref_type = "Tag" + else: + status = branch_status + ref_type = "Branch" + return status, ref_type + + def check_status(self, ignore_permissions=False, ignore_submodules=False): + cmd = "git -C {}".format(self.__full_local_path) + if ignore_permissions: + cmd += " -c core.fileMode=false" + cmd += " status --porcelain=v2" + if ignore_submodules: + cmd += " --ignore-submodules=all" + output = shellcmd.run(shlex.split(cmd), output=True) + if output.strip(): + output_list = output.splitlines() + + # Grab the file names first for pretty printing + file_name_list = [item.split()[-1] for item in output_list] + max_file_name_length = len(max(file_name_list, key=len)) + + verbose_output_list = [] + for item in output_list: + + index_field = item.split()[0] + if index_field == "2": + new_file_name = colors.YELLOW + item.split()[-2] + colors.RESET + + file_name = item.split()[-1] + + short_status = item.split()[1] + + if index_field == "?": + verbose_status = colors.RED + "untracked file" + colors.RESET + + elif short_status == ".D": + verbose_status = colors.RED + "deleted, not staged" + colors.RESET + elif short_status == ".M": + verbose_status = colors.RED + "modified, not staged" + colors.RESET + elif short_status == ".A": + verbose_status = colors.RED + "added, not staged" + colors.RESET + elif short_status == ".T": + verbose_status = ( + colors.RED + "typechange, not staged" + colors.RESET + ) + + elif short_status == "D.": + verbose_status = colors.GREEN + "deleted, staged" + colors.RESET + elif short_status == "M.": + verbose_status = colors.GREEN + "modified, staged" + colors.RESET + elif short_status == "A.": + verbose_status = colors.GREEN + "added, staged" + colors.RESET + elif short_status == "T.": + verbose_status = colors.GREEN + "typechange, staged" + colors.RESET + + elif short_status == "MM": + verbose_status = ( + colors.GREEN + + "modified, staged" + + colors.RESET + + " with " + + colors.RED + + "unstaged changes" + + colors.RESET + ) + elif short_status == "MD": + verbose_status = ( + colors.GREEN + + "modified, staged" + + colors.RESET + + " but " + + colors.RED + + "deleted, not staged" + + colors.RESET + ) + + elif short_status == "AM": + verbose_status = ( + colors.GREEN + + "added, staged" + + colors.RESET + + " with " + + colors.RED + + "unstaged changes" + + colors.RESET + ) + elif short_status == "AD": + verbose_status = ( + colors.GREEN + + "added, staged" + + colors.RESET + + " but " + + colors.RED + + "deleted, not staged" + + colors.RESET + ) + + elif short_status == "TM": + verbose_status = ( + colors.GREEN + + "typechange, staged" + + colors.RESET + + " with " + + colors.RED + + "unstaged changes" + + colors.RESET + ) + elif short_status == "TD": + verbose_status = ( + colors.GREEN + + "typechange, staged" + + colors.RESET + + " but " + + colors.RED + + "deleted, not staged" + + colors.RESET + ) + + elif short_status == "R.": + verbose_status = ( + colors.GREEN + + "renamed" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + ) + elif short_status == "RM": + verbose_status = ( + colors.GREEN + + "renamed, staged" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + + " with " + + colors.RED + + "unstaged changes" + + colors.RESET + ) + elif short_status == "RD": + verbose_status = ( + colors.GREEN + + "renamed, staged" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + + " but " + + colors.RED + + "deleted, not staged" + + colors.RESET + ) + + elif short_status == "C.": + verbose_status = ( + colors.GREEN + + "copied" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + ) + elif short_status == "CM": + verbose_status = ( + colors.GREEN + + "copied, staged" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + + " with " + + colors.RED + + "unstaged changes" + + colors.RESET + ) + elif short_status == "CD": + verbose_status = ( + colors.GREEN + + "copied, staged" + + colors.RESET + + " as " + + colors.YELLOW + + new_file_name + + colors.RESET + + " but " + + colors.RED + + "deleted, not staged" + + colors.RESET + ) + + else: + verbose_status = ( + colors.CYAN + + "unknown" + + colors.RESET + + " (please contact mepo maintainer)" + ) + + verbose_status_string = ( + "{file_name:>{file_name_length}}: {verbose_status}".format( + file_name=file_name, + file_name_length=max_file_name_length, + verbose_status=verbose_status, + ) + ) + verbose_output_list.append(verbose_status_string) + + output = "\n".join(verbose_output_list) + + return output.rstrip() + + def __get_modified_files(self, orig_ver, comp_type): + if not orig_ver: + cmd = self.__git + " diff --name-only" + else: + if comp_type == "b": + cmd = self.__git + " diff --name-only origin/{}".format(orig_ver) + else: + cmd = self.__git + " diff --name-only {}".format(orig_ver) + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output.split("\n") if output else [] + + def __get_untracked_files(self): + cmd = self.__git + " ls-files --others --exclude-standard" + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output.split("\n") if output else [] + + def get_changed_files(self, untracked=False, orig_ver=None, comp_type=None): + changed_files = self.__get_modified_files(orig_ver, comp_type) + if untracked: + changed_files += self.__get_untracked_files() + return changed_files + + def stage_file(self, myfile): + cmd = self.__git + " add {}".format(myfile) + shellcmd.run(shlex.split(cmd)) + + def get_staged_files(self): + cmd = self.__git + " diff --name-only --staged" + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output.split("\n") if output else [] + + def unstage_file(self, myfile): + cmd = self.__git + " reset -- {}".format(myfile) + shellcmd.run(shlex.split(cmd)) + + def commit_files(self, message, tf_file=None): + if tf_file: + cmd = ["git", "-C", self.__full_local_path, "commit", "-F", tf_file] + elif message: + cmd = ["git", "-C", self.__full_local_path, "commit", "-m", message] + else: + raise Exception("This should not happen") + shellcmd.run(cmd) + + def push(self): + cmd = self.__git + " push -u {}".format(self.__remote) + return shellcmd.run(shlex.split(cmd), output=True).strip() + + def get_remote_latest_commit_id(self, branch, commit_type): + if commit_type == "h": + cmd = self.__git + " cat-file -e {}".format(branch) + status = shellcmd.run(shlex.split(cmd), status=True) + if status != 0: + msg = "Hash {} does not exist on {}".format(branch, self.__remote) + msg += " Have you run 'mepo push'?" + raise RuntimeError(msg) + return branch + else: + # If we are a branch... + if commit_type == "b": + msgtype = "Branch" + reftype = "heads" + elif commit_type == "t": + msgtype = "Tag" + reftype = "tags" + else: + raise RuntimeError("Should not get here") + cmd = self.__git + " ls-remote {} refs/{}/{}".format( + self.__remote, reftype, branch + ) + output = shellcmd.run(shlex.split(cmd), stdout=True).strip() + if not output: + # msg = '{} {} does not exist on {}'.format(msgtype, branch, self.__remote) + # msg += " Have you run 'mepo push'?" + # raise RuntimeError(msg) + cmd = self.__git + " rev-parse HEAD" + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output.split()[0] + + def get_local_latest_commit_id(self): + cmd = self.__git + " rev-parse HEAD" + return shellcmd.run(shlex.split(cmd), output=True).strip() + + def pull(self): + cmd = self.__git + " pull" + return shellcmd.run(shlex.split(cmd), output=True).strip() + + def get_version(self): + cmd = self.__git + " show -s --pretty=%D HEAD" + output = shellcmd.run(shlex.split(cmd), output=True) + if output.startswith("HEAD ->"): # an actual branch + detached = False + name = output.split(",")[0].split("->")[1].strip() + tYpe = "b" + elif output.startswith("HEAD,"): # detached head + detached = True + tmp = output.split(",")[1].strip() + if tmp.startswith("tag:"): # tag + name = tmp[5:] + tYpe = "t" + else: + # This was needed for when we weren't explicitly detaching on clone + # cmd_for_branch = self.__git + ' reflog HEAD -n 1' + # reflog_output = shellcmd.run(shlex.split(cmd_for_branch), output=True) + # name = reflog_output.split()[-1].strip() + name = output.split()[-1].strip() + tYpe = "b" + elif output.startswith("HEAD"): # Assume hash + cmd = self.__git + " rev-parse HEAD" + hash_out = shellcmd.run(shlex.split(cmd), output=True) + detached = True + name = hash_out.rstrip() + tYpe = "h" + elif output.startswith("grafted"): + cmd = self.__git + " describe --always" + hash_out = shellcmd.run(shlex.split(cmd), output=True) + detached = True + name = hash_out.rstrip() + tYpe = "h" + return (name, tYpe, detached) + + +def get_current_remote_url(): + cmd = "git remote get-url origin" + output = shellcmd.run(shlex.split(cmd), output=True).strip() + return output diff --git a/src/mepo/registry.py b/src/mepo/registry.py new file mode 100644 index 0000000..e5eb9cf --- /dev/null +++ b/src/mepo/registry.py @@ -0,0 +1,80 @@ +import yaml +import pathlib + +from .utilities.exceptions import SuffixNotRecognizedError + + +# From https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484 +class AddBlankLinesDumper(yaml.SafeDumper): + # HACK: insert blank lines between top-level objects + # inspired by https://stackoverflow.com/a/44284819/3786245 + def write_line_break(self, data=None): + super().write_line_break(data) + + if len(self.indents) == 1: + super().write_line_break() + + +class Registry(object): + + __slots__ = ["__filename", "__filetype"] + + def __init__(self, filename): + self.__filename = filename + SUFFIX_LIST = [".yaml", ".json", ".cfg"] + file_suffix = pathlib.Path(filename).suffix + if file_suffix in SUFFIX_LIST: + self.__filetype = file_suffix[1:] + else: + raise SuffixNotRecognizedError( + "suffix {} not supported".format(file_suffix) + ) + + def __validate(self, d): + git_tag_types = {"branch", "tag", "hash"} + num_fixtures = 0 + for k, v in d.items(): + if "fixture" in v: + # In case of a fixture, develop is the only additional key + num_fixtures += 1 + assert list(v.keys()) == ["fixture", "develop"] + else: + # For non-fixture, one and only one of branch/tag/hash allowed + xsection = git_tag_types.intersection(set(v.keys())) + if len(xsection) != 1: + raise ValueError(f"{k} needs one and only one of {git_tag_types}") + # Can have one and only one fixture + assert num_fixtures == 1 + + def read_file(self): + """Call read_yaml, read_json etc. using dispatch pattern""" + return getattr(self, "read_" + self.__filetype)() + + def read_yaml(self): + """Read yaml registry and return a dict containing contents""" + import yaml + + with open(self.__filename, "r") as fin: + d = yaml.safe_load(fin) + self.__validate(d) + return d + + def read_json(self): + """Read json registry and return a dict containing contents""" + import json + + with open(self.__filename, "r") as fin: + d = json.load(fin) + self.__validate(d) + return d + + def read_cfg(self): + """Read python registry and return a dict containing contents""" + raise NotImplementedError("Reading of cfg file has not yet been implemented") + + def write_yaml(self, d): + """Dump dict d into a yaml file""" + import yaml + + with open(self.__filename, "w") as fout: + yaml.dump(d, fout, sort_keys=False, Dumper=AddBlankLinesDumper) diff --git a/src/mepo/state.py b/src/mepo/state.py new file mode 100644 index 0000000..a2e196f --- /dev/null +++ b/src/mepo/state.py @@ -0,0 +1,170 @@ +import os +import sys +import json +import glob +import stat +import pickle + +from .registry import Registry +from .component import MepoComponent +from .utilities import colors +from .utilities.exceptions import StateDoesNotExistError +from .utilities.exceptions import StateAlreadyInitializedError +from .utilities.chdir import chdir as mepo_chdir + + +class MepoState(object): + + __state_dir_name = ".mepo" + __state_fileptr_name = "state.json" + __state_fileptr_name_old = "state.pkl" + + @staticmethod + def get_parent_dirs(): + mypath = os.getcwd() + parentdirs = [mypath] + while mypath != "/": + mypath = os.path.dirname(mypath) + parentdirs.append(mypath) + return parentdirs + + @classmethod + def get_dir(cls): + """Return location of mepo state dir""" + for mydir in cls.get_parent_dirs(): + state_dir = os.path.join(mydir, cls.__state_dir_name) + if os.path.exists(state_dir): + return state_dir + raise OSError("mepo state dir [.mepo] does not exist") + + @classmethod + def get_root_dir(cls): + """Return fixture (root) directory that contains mepo state dir""" + return os.path.dirname(cls.get_dir()) + + @classmethod + def get_file(cls, old_style=False): + """Return location of mepo state file""" + if old_style: + fileptr_name = cls.__state_fileptr_name_old + else: + fileptr_name = cls.__state_fileptr_name + state_file = os.path.join(cls.get_dir(), fileptr_name) + if os.path.exists(state_file): + return state_file + else: + raise OSError(f"mepo state file [{state_file}] does not exist") + + @classmethod + def state_exists(cls, old_style=False): + try: + cls.get_file(old_style) + return True + except OSError: + return False + + @classmethod + def initialize(cls, project_registry, directory_style): + if cls.state_exists(): + raise StateAlreadyInitializedError("Error! mepo state already exists") + input_components = Registry(project_registry).read_file() + complist = list() + for name, comp in input_components.items(): + complist.append( + MepoComponent().registry_to_component(name, comp, directory_style) + ) + cls.write_state(complist) + + @staticmethod + def __mepo1_patch(): + """ + mepo1 to mepo2 includes renaming of directories + Since pickle requires that "the class definition must be importable + and live in the same module as when the object was stored", we need to + patch sys.modules to be able to read mepo1 state + """ + import mepo + + sys.modules["state"] = mepo.state + sys.modules["state.component"] = mepo.component + sys.modules["utilities"] = mepo.utilities + + @staticmethod + def mepo1_patch_undo(): + """ + Undo changes made my __mepo1_patch(). Called during + """ + entries_to_remove = ["state", "state.component", "utilities"] + for key in entries_to_remove: + sys.modules.pop(key, None) + + @classmethod + def read_state(cls): + if cls.state_exists(): + with open(cls.get_file(), "r") as fin: + allcomps_s = json.load(fin) + # List of dicts -> state (list of MepoComponent objects) + allcomps = [] + for comp_s in allcomps_s: + comp = MepoComponent().deserialize(comp_s) + # Relative path to absolute + comp.local = os.path.join(cls.get_root_dir(), comp.local) + allcomps.append(comp) + return allcomps + elif cls.state_exists(old_style=True): + print( + colors.YELLOW + + "Detected mepo1 style state\n" + + "Run to permanently convert to mepo2 style" + + colors.RESET + ) + cls.__mepo1_patch() + with open(cls.get_file(old_style=True), "rb") as fin: + allcomps = pickle.load(fin) + for comp in allcomps: + comp.local = os.path.join(cls.get_root_dir(), comp.local) + else: + raise StateDoesNotExistError("Error! mepo state does not exist") + return allcomps + + @classmethod + def __get_new_state_file(cls): + """Return full path to the new state file to write to""" + if cls.state_exists(): + state_dir = cls.get_dir() + pattern = os.path.join(cls.get_dir(), "state.*.json") + states = [os.path.basename(x) for x in glob.glob(pattern)] + new_state_id = max([int(x.split(".")[1]) for x in states]) + 1 + state_filename = "state." + str(new_state_id) + ".json" + elif cls.state_exists(old_style=True): + state_dir = cls.get_dir() + pattern = os.path.join(cls.get_dir(), "state.*.pkl") + states = [os.path.basename(x) for x in glob.glob(pattern)] + new_state_id = max([int(x.split(".")[1]) for x in states]) + state_filename = "state." + str(new_state_id) + ".json" + else: + state_dir = os.path.join(os.getcwd(), cls.__state_dir_name) + os.makedirs(state_dir, exist_ok=True) + state_filename = "state.0.json" + return os.path.join(state_dir, state_filename) + + @classmethod + def write_state(cls, allcomps): + new_state_file = cls.__get_new_state_file() + allcomps_s = [] + for comp in allcomps: + # Save relative path (to fixture dir) to state + comp.local = os.path.relpath(comp.local, start=cls.get_root_dir()) + allcomps_s.append(comp.serialize()) + with open(new_state_file, "w") as fout: + json.dump(allcomps_s, fout) + # Make the state file read-only + os.chmod(new_state_file, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) + # Update symlink + state_dir = os.path.dirname(new_state_file) + state_fileptr_fullpath = os.path.join(state_dir, cls.__state_fileptr_name) + if os.path.isfile(state_fileptr_fullpath): + os.remove(state_fileptr_fullpath) + with mepo_chdir(state_dir): + new_state_filename = os.path.basename(new_state_file) + os.symlink(new_state_filename, cls.__state_fileptr_name) diff --git a/src/mepo/utilities/__init__.py b/src/mepo/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mepo/utilities/chdir.py b/src/mepo/utilities/chdir.py new file mode 100644 index 0000000..1b8922d --- /dev/null +++ b/src/mepo/utilities/chdir.py @@ -0,0 +1,14 @@ +"""contextlib.chdir was introduced in Python 3.11""" + +import os +from contextlib import contextmanager + + +@contextmanager +def chdir(path): + cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(cwd) diff --git a/src/mepo/utilities/colors.py b/src/mepo/utilities/colors.py new file mode 100644 index 0000000..2a33156 --- /dev/null +++ b/src/mepo/utilities/colors.py @@ -0,0 +1,16 @@ +try: + from colorama import Fore, Style + + RED = Fore.RED + BLUE = Fore.BLUE + CYAN = Fore.CYAN + GREEN = Fore.GREEN + YELLOW = Fore.YELLOW + RESET = Style.RESET_ALL +except ImportError: + RED = "\x1b[1;31m" + BLUE = "\x1b[1;34m" + CYAN = "\x1b[1;36m" + GREEN = "\x1b[1;32m" + YELLOW = "\x1b[1;33m" + RESET = "\x1b[0;0m" diff --git a/mepo.d/state/exceptions.py b/src/mepo/utilities/exceptions.py similarity index 75% rename from mepo.d/state/exceptions.py rename to src/mepo/utilities/exceptions.py index a6e0aa0..084a1b6 100644 --- a/mepo.d/state/exceptions.py +++ b/src/mepo/utilities/exceptions.py @@ -1,23 +1,34 @@ class StateDoesNotExistError(SystemExit): """Raised when the mepo state does not exist""" + pass + class StateAlreadyInitializedError(SystemExit): """Raised when the mepo state has already been initialized""" + pass + class RepoAlreadyClonedError(SystemExit): """Raised when the repository has already been cloned""" + pass -class ConfigFileNotFoundError(FileNotFoundError): - """Raised when the config file is not found""" + +class RegistryNotFoundError(FileNotFoundError): + """Raised when the registry is not found""" + pass + class SuffixNotRecognizedError(RuntimeError): - """Raised when the config suffix is not recognized""" + """Raised when the registry suffix is not recognized""" + pass + class NotInRootDirError(SystemExit): """Raised when a command is run not in the root directory""" + pass diff --git a/mepo.d/utilities/mepoconfig.py b/src/mepo/utilities/mepoconfig.py similarity index 74% rename from mepo.d/utilities/mepoconfig.py rename to src/mepo/utilities/mepoconfig.py index 0fc8c5d..6451e2d 100644 --- a/mepo.d/utilities/mepoconfig.py +++ b/src/mepo/utilities/mepoconfig.py @@ -2,62 +2,76 @@ import os import sys -config_file = os.path.expanduser('~/.mepoconfig') +config_file = os.path.expanduser("~/.mepoconfig") config = configparser.ConfigParser() config.read(config_file) + def split_entry(entry): - entry_list = entry.split('.') + entry_list = entry.split(".") if len(entry_list) != 2: - raise Exception(f'Invalid entry [{entry}]. Must be of form section.option, e.g., "alias.st"') + raise Exception( + f'Invalid entry [{entry}]. Must be of form section.option, e.g., "alias.st"' + ) section = entry_list[0] option = entry_list[1] return section, option + def write(): - with open(config_file,'w') as fp: + with open(config_file, "w") as fp: config.write(fp) + def print_sections(): print(config.sections()) + def print_options(section): print(config.options(section)) + def print(): config.write(sys.stdout) + def has_section(section): return config.has_section(section) + def has_option(section, option): return config.has_option(section, option) + def get(section, option): return config[section][option] + def remove_option(section, option): config.remove_option(section, option) if not config[section]: config.remove_section(section) + def set(section, option, value): if not has_section(section): config[section] = {} config[section][option] = value + def get_command_alias(command): output = [] - if has_section('alias'): - for key,value in config.items('alias'): + if has_section("alias"): + for key, value in config.items("alias"): if value == command: output.append(key) return output + def get_alias_command(alias): command = alias - if has_section('alias'): - for key,value in config.items('alias'): + if has_section("alias"): + for key, value in config.items("alias"): if key == alias: command = value return command diff --git a/mepo.d/utilities/shellcmd.py b/src/mepo/utilities/shellcmd.py similarity index 76% rename from mepo.d/utilities/shellcmd.py rename to src/mepo/utilities/shellcmd.py index f735932..f69da20 100644 --- a/mepo.d/utilities/shellcmd.py +++ b/src/mepo/utilities/shellcmd.py @@ -1,11 +1,12 @@ import subprocess as sp + def run(cmd, output=None, stdout=None, status=None): result = sp.run( cmd, - stdout = sp.PIPE, - stderr = sp.PIPE, - universal_newlines = True # result byte sequence -> string + stdout=sp.PIPE, + stderr=sp.PIPE, + universal_newlines=True, # result byte sequence -> string ) if status: diff --git a/mepo.d/utilities/verify.py b/src/mepo/utilities/verify.py similarity index 85% rename from mepo.d/utilities/verify.py rename to src/mepo/utilities/verify.py index d5fadf0..8e7dfa3 100644 --- a/mepo.d/utilities/verify.py +++ b/src/mepo/utilities/verify.py @@ -11,7 +11,9 @@ def valid_components(specified_comp_names, allcomps, ignore_case=False): """ # Make a list of all the component names depending on ignore_case - all_component_names = [x.name.casefold() if ignore_case else x.name for x in allcomps] + all_component_names = [ + x.name.casefold() if ignore_case else x.name for x in allcomps + ] # Loop over all the components we want to verify... for component_name in specified_comp_names: @@ -22,6 +24,7 @@ def valid_components(specified_comp_names, allcomps, ignore_case=False): # Validate the component _validate_component(component_to_find, all_component_names) + def _validate_component(component, all_components): """ Function to raise exception on invalid component name @@ -32,4 +35,4 @@ def _validate_component(component, all_components): """ if component not in all_components: - raise Exception('Unknown component name [{}]'.format(component)) + raise Exception("Unknown component name [{}]".format(component)) diff --git a/mepo.d/utilities/version.py b/src/mepo/utilities/version.py similarity index 71% rename from mepo.d/utilities/version.py rename to src/mepo/utilities/version.py index 62d74ff..aa4c70f 100644 --- a/mepo.d/utilities/version.py +++ b/src/mepo/utilities/version.py @@ -1,27 +1,29 @@ from collections import namedtuple -MepoVersion = namedtuple('MepoVersion', ['name', 'type', 'detached']) +MepoVersion = namedtuple("MepoVersion", ["name", "type", "detached"]) -def version_to_string(version,git=None): - version_name = version[0] - version_type = version[1] + +def version_to_string(version, git=None): + version_name = version[0] + version_type = version[1] version_detached = version[2] - if version_detached: # detached head + if version_detached: # detached head # We remove the "origin/" from the internal detached branch name # for clarity in mepo status output - version_name = version_name.replace('origin/','') - if version_type == 'b' and git: + version_name = version_name.replace("origin/", "") + if version_type == "b" and git: cur_hash = git.rev_parse(short=True).strip() - s = f'({version_type}) {version_name} (DH, {cur_hash})' + s = f"({version_type}) {version_name} (DH, {cur_hash})" else: - s = f'({version_type}) {version_name} (DH)' + s = f"({version_type}) {version_name} (DH)" else: - s = f'({version_type}) {version_name}' + s = f"({version_type}) {version_name}" return s -def sanitize_version_string(orig,curr,git): - ''' + +def sanitize_version_string(orig, curr, git): + """ This routine tries to figure out if two tags are the same. The issue is that git sometimes returns the "wrong" tag in @@ -29,19 +31,19 @@ def sanitize_version_string(orig,curr,git): if that commit is also tagged with foo, then sometimes mepo will say that things have changed because it thinks it's on foo. - ''' + """ # The trick below only works on tags and hashes (I think), so # if not a tag or hash, just do nothing for now - is_tag = '(t)' - is_hash = '(h)' + is_tag = "(t)" + is_hash = "(h)" # For status, we pass in space-delimited strings that are: # 'type version dh' # So let's split into lists and pull the type # But for save, we are passing in one single string orig_list = orig.split() - if (len(orig_list) > 1): + if len(orig_list) > 1: # Pull out the type orig_type = orig_list[0] # Pull out the version string... @@ -53,7 +55,7 @@ def sanitize_version_string(orig,curr,git): orig_ver = orig_list[0] curr_list = curr.split() - if (len(curr_list) > 1): + if len(curr_list) > 1: # Pull out the type curr_type = curr_list[0] # Pull out the version string... @@ -64,8 +66,8 @@ def sanitize_version_string(orig,curr,git): # version is the only element curr_ver = curr_list[0] - orig_type_is_tag_or_hash = (orig_type == is_tag or orig_type == is_hash) - curr_type_is_tag_or_hash = (curr_type == is_tag or curr_type == is_hash) + orig_type_is_tag_or_hash = orig_type == is_tag or orig_type == is_hash + curr_type_is_tag_or_hash = curr_type == is_tag or curr_type == is_hash # Now if a type or hash... if orig_type_is_tag_or_hash and curr_type_is_tag_or_hash: @@ -84,7 +86,7 @@ def sanitize_version_string(orig,curr,git): curr_list[curr_list.index(curr_type)] = orig_type # And then remake the curr string - curr = ' '.join(curr_list) + curr = " ".join(curr_list) # And return curr return curr diff --git a/mepo.d/utest/input/components.yaml b/tests/input/components.yaml similarity index 100% rename from mepo.d/utest/input/components.yaml rename to tests/input/components.yaml diff --git a/tests/output/output_branch_create.txt b/tests/output/output_branch_create.txt new file mode 100644 index 0000000..7d782bb --- /dev/null +++ b/tests/output/output_branch_create.txt @@ -0,0 +1 @@ ++ ecbuild: the-best-branch-ever diff --git a/tests/output/output_branch_delete.txt b/tests/output/output_branch_delete.txt new file mode 100644 index 0000000..8292ada --- /dev/null +++ b/tests/output/output_branch_delete.txt @@ -0,0 +1 @@ +- ecbuild: the-best-branch-ever diff --git a/tests/output/output_branch_list.txt b/tests/output/output_branch_list.txt new file mode 100644 index 0000000..13438e5 --- /dev/null +++ b/tests/output/output_branch_list.txt @@ -0,0 +1,11 @@ +ecbuild | * (HEAD detached at geos/v1.3.0) + | release/stable + | remotes/origin/HEAD -> origin/release/stable + | remotes/origin/develop + | remotes/origin/feature/FindMKL-portability-improvement + | remotes/origin/feature/ecbuild_use_package_quiet + | remotes/origin/feature/mathomp4/add-jemalloc + | remotes/origin/feature/netcdf4-cmake + | remotes/origin/geos/main + | remotes/origin/master + | remotes/origin/release/stable diff --git a/tests/output/output_clone_status.txt b/tests/output/output_clone_status.txt new file mode 100644 index 0000000..c59943d --- /dev/null +++ b/tests/output/output_clone_status.txt @@ -0,0 +1,11 @@ +Checking status... +GEOSfvdycore | (t) v2.13.0 (DH) +env | (t) v4.25.1 (DH) +cmake | (t) v3.38.0 (DH) +ecbuild | (t) geos/v1.3.0 (DH) +GMAO_Shared | (t) v1.9.6 (DH) +GEOS_Util | (t) v2.0.5 (DH) +MAPL | (t) v2.43.0 (DH) +FMS | (t) geos/2019.01.02+noaff.8 (DH) +FVdycoreCubed_GridComp | (t) v2.10.0 (DH) +fvdycore | (t) geos/v2.8.1 (DH) diff --git a/tests/output/output_compare.txt b/tests/output/output_compare.txt new file mode 100644 index 0000000..59da485 --- /dev/null +++ b/tests/output/output_compare.txt @@ -0,0 +1,3 @@ +Repo | Original | Current +---- | ---------------- | ------- +MAPL | (t) v2.43.0 (DH) | (b) develop diff --git a/tests/output/output_compare_all.txt b/tests/output/output_compare_all.txt new file mode 100644 index 0000000..ddf0ea7 --- /dev/null +++ b/tests/output/output_compare_all.txt @@ -0,0 +1,12 @@ +Repo | Original | Current +---------------------- | -------------------------------- | ------- +GEOSfvdycore | (t) v2.13.0 (DH) | (t) v2.13.0 (DH) +env | (t) v4.25.1 (DH) | (t) v4.25.1 (DH) +cmake | (t) v3.38.0 (DH) | (t) v3.38.0 (DH) +ecbuild | (t) geos/v1.3.0 (DH) | (t) geos/v1.3.0 (DH) +GMAO_Shared | (t) v1.9.6 (DH) | (t) v1.9.6 (DH) +GEOS_Util | (t) v2.0.5 (DH) | (t) v2.0.5 (DH) +MAPL | (t) v2.43.0 (DH) | (b) develop +FMS | (t) geos/2019.01.02+noaff.8 (DH) | (t) geos/2019.01.02+noaff.8 (DH) +FVdycoreCubed_GridComp | (t) v2.10.0 (DH) | (t) v2.10.0 (DH) +fvdycore | (t) geos/v2.8.1 (DH) | (t) geos/v2.8.1 (DH) diff --git a/mepo.d/utest/output/status_output.txt b/tests/output/output_develop_status.txt similarity index 53% rename from mepo.d/utest/output/status_output.txt rename to tests/output/output_develop_status.txt index 1b4a156..777faf4 100644 --- a/mepo.d/utest/output/status_output.txt +++ b/tests/output/output_develop_status.txt @@ -1,10 +1,11 @@ Checking status... -GEOSfvdycore | (t) v1.13.0 (DH) +GEOSfvdycore | (t) v2.13.0 (DH) env | (b) main cmake | (b) develop ecbuild | (t) geos/v1.3.0 (DH) -GMAO_Shared | (t) v1.6.3 (DH) -MAPL | (t) v2.33.0 (DH) +GMAO_Shared | (t) v1.9.6 (DH) +GEOS_Util | (t) v2.0.5 (DH) +MAPL | (t) v2.43.0 (DH) FMS | (t) geos/2019.01.02+noaff.8 (DH) -FVdycoreCubed_GridComp | (t) v1.12.1 (DH) +FVdycoreCubed_GridComp | (t) v2.10.0 (DH) fvdycore | (b) geos/develop diff --git a/tests/output/output_diff.txt b/tests/output/output_diff.txt new file mode 100644 index 0000000..b952162 --- /dev/null +++ b/tests/output/output_diff.txt @@ -0,0 +1,5 @@ +Diffing... +FVdycoreCubed_GridComp (location: .): + +GEOS_FV3_Utilities.F90 +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/tests/output/output_list.txt b/tests/output/output_list.txt new file mode 100644 index 0000000..323e5c3 --- /dev/null +++ b/tests/output/output_list.txt @@ -0,0 +1 @@ +GEOSfvdycore env cmake ecbuild GMAO_Shared GEOS_Util MAPL FMS FVdycoreCubed_GridComp fvdycore diff --git a/tests/output/output_pull_all.txt b/tests/output/output_pull_all.txt new file mode 100644 index 0000000..108a2d1 --- /dev/null +++ b/tests/output/output_pull_all.txt @@ -0,0 +1 @@ +The following repos were not pulled (detached HEAD): GEOSfvdycore, env, cmake, ecbuild, GMAO_Shared, GEOS_Util, MAPL, FMS, FVdycoreCubed_GridComp, fvdycore diff --git a/tests/output/output_push.txt b/tests/output/output_push.txt new file mode 100644 index 0000000..9e7089a --- /dev/null +++ b/tests/output/output_push.txt @@ -0,0 +1,7 @@ +fatal: You are not currently on a branch. +To push the history leading to the current (detached HEAD) +state now, use + + git push https://github.com/GEOS-ESM/FVdycoreCubed_GridComp.git HEAD: + + diff --git a/tests/output/output_reset.txt b/tests/output/output_reset.txt new file mode 100644 index 0000000..a51043d --- /dev/null +++ b/tests/output/output_reset.txt @@ -0,0 +1,10 @@ +Removing src/Components/@FVdycoreCubed_GridComp/@fvdycore...done. +Removing src/Components/@FVdycoreCubed_GridComp...done. +Removing src/Shared/@FMS...done. +Removing src/Shared/@MAPL...done. +Removing src/Shared/@GMAO_Shared/@GEOS_Util...done. +Removing src/Shared/@GMAO_Shared...done. +Removing @cmake/@ecbuild...done. +Removing @cmake...done. +Removing @env...done. +Removing mepo state...done. diff --git a/tests/output/output_tag_create.txt b/tests/output/output_tag_create.txt new file mode 100644 index 0000000..2ed1aeb --- /dev/null +++ b/tests/output/output_tag_create.txt @@ -0,0 +1,2 @@ ++ MAPL: new-awesome-tag ++ FMS: new-awesome-tag diff --git a/tests/output/output_tag_delete.txt b/tests/output/output_tag_delete.txt new file mode 100644 index 0000000..58291cc --- /dev/null +++ b/tests/output/output_tag_delete.txt @@ -0,0 +1,2 @@ +- MAPL: new-awesome-tag +- FMS: new-awesome-tag diff --git a/tests/output/output_whereis.txt b/tests/output/output_whereis.txt new file mode 100644 index 0000000..1daad36 --- /dev/null +++ b/tests/output/output_whereis.txt @@ -0,0 +1,10 @@ +GEOSfvdycore | . +env | @env +cmake | @cmake +ecbuild | @cmake/@ecbuild +GMAO_Shared | src/Shared/@GMAO_Shared +GEOS_Util | src/Shared/@GMAO_Shared/@GEOS_Util +MAPL | src/Shared/@MAPL +FMS | src/Shared/@FMS +FVdycoreCubed_GridComp | src/Components/@FVdycoreCubed_GridComp +fvdycore | src/Components/@FVdycoreCubed_GridComp/@fvdycore diff --git a/tests/test_mepo_commands.py b/tests/test_mepo_commands.py new file mode 100644 index 0000000..b610f82 --- /dev/null +++ b/tests/test_mepo_commands.py @@ -0,0 +1,348 @@ +import os + +import io +import shutil +import shlex +import unittest +import importlib +import contextlib +import subprocess as sp +from types import SimpleNamespace + +import mepo.command.clone as mepo_clone +import mepo.command.list as mepo_list +import mepo.command.status as mepo_status +import mepo.command.compare as mepo_compare +import mepo.command.develop as mepo_develop +import mepo.command.checkout as mepo_checkout +import mepo.command.branch_list as mepo_branch_list +import mepo.command.branch_create as mepo_branch_create +import mepo.command.branch_delete as mepo_branch_delete +import mepo.command.tag_list as mepo_tag_list +import mepo.command.tag_create as mepo_tag_create +import mepo.command.tag_delete as mepo_tag_delete +import mepo.command.fetch as mepo_fetch +import mepo.command.pull as mepo_pull +import mepo.command.push as mepo_push +import mepo.command.diff as mepo_diff +import mepo.command.whereis as mepo_whereis +import mepo.command.reset as mepo_reset + +# Import commands with dash in the name +mepo_restore_state = importlib.import_module("mepo.command.restore-state") +mepo_checkout_if_exists = importlib.import_module("mepo.command.checkout-if-exists") +mepo_pull_all = importlib.import_module("mepo.command.pull-all") + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) + + +class TestMepoCommands(unittest.TestCase): + + @classmethod + def __get_saved_output(cls, output_file): + with open(os.path.join(cls.output_dir, output_file), "r") as fin: + saved_output = fin.read() + return saved_output + + @classmethod + def __checkout_fixture(cls): + remote = f"https://github.com/GEOS-ESM/{cls.fixture}.git" + git_clone = "git clone " + if cls.tag: + git_clone += f"-b {cls.tag}" + cmd = f"{git_clone} {remote} {cls.fixture_dir}" + sp.run(shlex.split(cmd)) + + @classmethod + def __copy_config_file(cls): + src = os.path.join(cls.input_dir, "components.yaml") + dst = os.path.join(cls.fixture_dir) + shutil.copy(src, dst) + + @classmethod + def __mepo_clone(cls): + # mepo clone + args = SimpleNamespace( + style="prefix", + registry=None, + repo_url=None, + allrepos=None, + branch=None, + directory=None, + partial="blobless", + ) + mepo_clone.run(args) + print(flush=True) + + @classmethod + def setUpClass(cls): + cls.input_dir = os.path.join(TEST_DIR, "input") + cls.output_dir = os.path.join(TEST_DIR, "output") + cls.output_clone_status = cls.__get_saved_output("output_clone_status.txt") + cls.fixture = "GEOSfvdycore" + cls.tag = "v2.13.0" + cls.tmpdir = os.path.join(TEST_DIR, "tmp") + cls.fixture_dir = os.path.join(cls.tmpdir, cls.fixture) + if os.path.isdir(cls.fixture_dir): + shutil.rmtree(cls.fixture_dir) + cls.__checkout_fixture() + os.chdir(cls.fixture_dir) + cls.__mepo_clone() + + def setUp(self): + pass + + def __mepo_status(self, saved_output): + """saved_output is either a string or a filename""" + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + ignore_permissions=False, + nocolor=True, + hashes=False, + serial=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_status.run(args) + try: # assume saved_output is a file + saved_output_s = self.__class__.__get_saved_output(saved_output) + except FileNotFoundError: + saved_output_s = saved_output + self.assertEqual(output.getvalue(), saved_output_s) + + def __mepo_restore_state(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace(serial=False) + with contextlib.redirect_stdout(io.StringIO()) as _: + mepo_restore_state.run(args) + self.__mepo_status(self.__class__.output_clone_status) + + def test_list(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace(one_per_line=False) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_list.run(args) + saved_output = self.__class__.__get_saved_output("output_list.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_develop(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["env", "cmake", "fvdycore"], + quiet=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as _: + mepo_develop.run(args) + self.__mepo_status("output_develop_status.txt") + # Clean up + self.__mepo_restore_state() + + def test_checkout_compare(self): + os.chdir(self.__class__.fixture_dir) + # Checkout "develop" branch of MAPL and env + args = SimpleNamespace( + branch_name="develop", + comp_name=["MAPL"], + b=False, + quiet=False, + detach=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_checkout.run(args) + # Compare (default) + args_cmp = SimpleNamespace( + all=False, + nocolor=True, + wrap=True, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_compare.run(args_cmp) + saved_output = self.__class__.__get_saved_output("output_compare.txt") + self.assertEqual(output.getvalue(), saved_output) + # Compare (All) + args_cmp.all = True + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_compare.run(args_cmp) + saved_output = self.__class__.__get_saved_output("output_compare_all.txt") + self.assertEqual(output.getvalue(), saved_output) + # Clean up + self.__mepo_restore_state() + + def test_checkout_if_exists(self): + # Fixture component + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + ref_name="not-expected-to-exist", + quiet=True, + detach=False, + dry_run=False, + ) + mepo_checkout_if_exists.run(args) + # Since we do not expect this ref to exist, status should be that of clone + self.__mepo_status(self.__class__.output_clone_status) + + def test_branch_list(self): + os.chdir(self.__class__.fixture_dir) + # Not expecting new branches in this component (fingers crossed) + args = SimpleNamespace( + comp_name=["ecbuild"], + all=True, + nocolor=True, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_branch_list.run(args) + saved_output = self.__class__.__get_saved_output("output_branch_list.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_branch_create_delete(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["ecbuild"], + branch_name="the-best-branch-ever", + ) + # Create branch + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_branch_create.run(args) + saved_output = self.__class__.__get_saved_output("output_branch_create.txt") + self.assertEqual(output.getvalue(), saved_output) + # Delete the branch that was just created + args.force = False + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_branch_delete.run(args) + saved_output = self.__class__.__get_saved_output("output_branch_delete.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_tag_list(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace(comp_name=["env"]) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_tag_list.run(args) + self.assertTrue("cuda11.7.0-gcc11.2.0nvptx-openmpi4.0.6" in output.getvalue()) + + def test_tag_create_delete(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["FMS", "MAPL"], + tag_name="new-awesome-tag", + annotate=False, + message=None, + ) + # Create tag + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_tag_create.run(args) + saved_output = self.__class__.__get_saved_output("output_tag_create.txt") + self.assertEqual(output.getvalue(), saved_output) + # Delete the tag that was just created + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_tag_delete.run(args) + saved_output = self.__class__.__get_saved_output("output_tag_delete.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_fetch(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["FVdycoreCubed_GridComp"], + all=True, + prune=True, + tags=True, + force=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_fetch.run(args) + saved_output = "Fetching \x1b[33mFVdycoreCubed_GridComp\x1b[0m\n" + self.assertEqual(output.getvalue(), saved_output) + + def test_pull(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["FVdycoreCubed_GridComp"], + quiet=False, + ) + err_msg = "FVdycoreCubed_GridComp has detached head! Cannot pull." + with self.assertRaisesRegex(Exception, err_msg): + mepo_pull.run(args) + + def test_pull_all(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["FVdycoreCubed_GridComp"], + quiet=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_pull_all.run(args) + saved_output = self.__class__.__get_saved_output("output_pull_all.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_push(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=["FVdycoreCubed_GridComp"], + quiet=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + with self.assertRaises(sp.CalledProcessError): + mepo_push.run(args) + saved_output = self.__class__.__get_saved_output("output_push.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_diff(self): + os.chdir(self.__class__.fixture_dir) + os.chdir("./src/Components/@FVdycoreCubed_GridComp") + filename = "GEOS_FV3_Utilities.F90" + # Add a line + with open(filename, "w") as fout: + fout.write(" ") + args = SimpleNamespace( + comp_name=["FVdycoreCubed_GridComp"], + name_only=True, + name_status=False, + ignore_permissions=False, + staged=False, + ignore_space_change=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_diff.run(args) + saved_output = self.__class__.__get_saved_output("output_diff.txt") + # Ignore the last line of output (horizontal line + # with length that of the width of the terminal) + self.assertEqual(output.getvalue().split()[:-1], saved_output.split()[:-1]) + # Clean up + sp.run(f"git checkout {filename}".split(), stderr=sp.DEVNULL) + self.__mepo_status(self.__class__.output_clone_status) + + def test_whereis(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + comp_name=None, + ignore_case=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_whereis.run(args) + saved_output = self.__class__.__get_saved_output("output_whereis.txt") + self.assertEqual(output.getvalue(), saved_output) + + def test_reset(self): + os.chdir(self.__class__.fixture_dir) + args = SimpleNamespace( + force=True, + reclone=False, + dry_run=False, + ) + with contextlib.redirect_stdout(io.StringIO()) as output: + mepo_reset.run(args) + saved_output = self.__class__.__get_saved_output("output_reset.txt") + self.assertEqual(output.getvalue(), saved_output) + # Clean up - reclone (suppress output) + with contextlib.redirect_stdout(io.StringIO()) as output: + self.__class__.__mepo_clone() + + def tearDown(self): + pass + + @classmethod + def tearDownClass(cls): + os.chdir(TEST_DIR) + shutil.rmtree(cls.tmpdir) + + +if __name__ == "__main__": + unittest.main()