diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f519bf..1f8b51b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,19 +9,20 @@ jobs: publish: runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # mandatory for trusted publishing steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: "3.14" - - run: pip install -U pip setuptools wheel twine tox - - run: tox -e py,docs,style - - run: python setup.py sdist bdist_wheel --universal - - - name: Publish sdist and wheel - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.MGIT_TOKEN }} - run: twine upload --non-interactive dist/* + - uses: astral-sh/setup-uv@v7 + - run: uv venv + - run: uv pip install -U tox-uv + - run: .venv/bin/tox -e py,style + - run: uv build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70a8ac6..1004478 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,29 +13,27 @@ jobs: strategy: matrix: - python-version: [3.6, 3.9] + python-version: ["3.10", "3.14"] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - run: pip install -U pip tox - run: tox -e py - - uses: codecov/codecov-action@v1 - with: - file: .tox/test-reports/coverage.xml + - uses: codecov/codecov-action@v5 linters: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: "3.14" - run: pip install -U pip tox - - run: tox -e docs,style + - run: tox -e style diff --git a/README.rst b/README.rst index 2046420..1151694 100644 --- a/README.rst +++ b/README.rst @@ -127,7 +127,8 @@ You can also compile from source:: git clone https://github.com/zsimic/mgit.git cd mgit - tox -e venv + uv venv + uv pip install -e . .venv/bin/mgit --help diff --git a/docs/contributing.rst b/docs/contributing.rst index 4bb16dc..a4a5d4f 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -10,7 +10,8 @@ To get going locally, simply do this:: git clone https://github.com/zsimic/mgit.git cd mgit - tox -e venv + uv venv + uv pip install -r tests/requirements.txt -e . # You have a venv now in ./.venv, use it, open it with pycharm etc source .venv/bin/activate @@ -24,12 +25,11 @@ To get going locally, simply do this:: Running the tests ================= -To run the tests, simply run ``tox``, this will run tests against all python versions you have locally installed. -You can use pyenv_ for example to get python installations. +To run the tests, simply run ``tox``. Run: -* ``tox -e py38`` (for example) to limit test run to only one python version. +* ``tox -e py314`` (for example) to limit test run to only one python version. * ``tox -e style`` to run style checks only diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..de83533 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,96 @@ +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +local_scheme = "dirty-tag" + +[project] +name = "mgit" +authors = [ + {name = "Zoran Simic", email = "zoran@simicweb.com"}, +] +description = "Fetch collections of git projects" +readme = "README.rst" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE.txt"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Utilities", +] +dependencies = [ + "click~=8.3", + "runez~=5.3", +] +dynamic = ["version"] + +[project.scripts] +mgit = "mgit.cli:main" + +[project.urls] +Source = "https://github.com/zsimic/mgit" + + +[tool.ruff] +cache-dir = ".tox/.ruff_cache" +line-length = 140 + +[tool.ruff.lint] +#ignore = ["RUF021", "RUF022", "RUF023", "S101"] +extend-select = [ +# "A", # flake8-builtins +# "ARG", # flake8-unused-arguments +# "B", # flake8-bugbear + "C4", # flake8-comprehensions + "C90", # mccabe +# "D", # pydocstyle + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "ERA", # eradicate + "EXE", # flake8-executable + "F", # pyflakes + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "INT", # flake8-gettext + "PGH", # pygrep-hooks + "PIE", # flake8-pie +# "PT", # flake8-pytest + "PYI", # flake8-pyi + "Q", # flake8-quotes + "RSE", # flake8-raise +# "RET", # flake8-return +# "RUF", # ruff-specific +# "S", #flake8-bandit +# "SIM", # flake8-simplify +# "SLF", # flake8-self + "SLOT", # flake8-slots + "T10", # flake8-debugger + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "TD", # flake8-todos +# "TRY", # tryceratops + "W", # pycodestyle warnings +] + +[tool.ruff.lint.isort] +order-by-type = false + +[tool.ruff.lint.mccabe] +max-complexity = 20 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3e0ad15..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# pinned -click==8.0.1 -runez==2.7.5 diff --git a/setup.py b/setup.py deleted file mode 100644 index b9fe080..0000000 --- a/setup.py +++ /dev/null @@ -1,35 +0,0 @@ -from setuptools import setup - - -if __name__ == "__main__": - setup( - name="mgit", - setup_requires="setupmeta", - versioning="dev", - author="Zoran Simic zoran@simicweb.com", - keywords='multiple, git, repos', - url="https://github.com/zsimic/mgit", - entry_points={ - "console_scripts": [ - "mgit = mgit.cli:main", - ], - }, - python_requires=">=3.6", - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "Operating System :: MacOS :: MacOS X", - "Operating System :: POSIX", - "Operating System :: Unix", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Software Development :: Build Tools", - "Topic :: Utilities" - ], - ) diff --git a/src/mgit/__init__.py b/src/mgit/__init__.py index f44058c..788608f 100644 --- a/src/mgit/__init__.py +++ b/src/mgit/__init__.py @@ -6,7 +6,6 @@ from mgit.git import GitDir, GitRunReport - LOG = logging.getLogger(__name__) @@ -55,31 +54,31 @@ def print_modified(items, color1, color2=None): for item in items: state = item[0:2] if color2: - state = "%s%s" % (color1(item[0]), color2(item[1])) + state = f"{color1(item[0])}{color2(item[1])}" elif color1: state = color1(state) - print(" %s %s" % (state, item[3:])) + print(f" {state} {item[3:]}") class MgitPreferences: """Various prefs""" - name_size = None # How many chars to align names when displaying list of checkouts - align = True # Whether to align names or not - verbose = False # Show verbose output - all = False # Show all entries, including missing/invalid checkout folders - fetch = False # Auto-fetch before showing status - pull = False # Auto-pull before showing status - inspect_remotes = False # Inspect remote branches to report cleanable (slower) + name_size = None # How many chars to align names when displaying list of checkouts + align = True # Whether to align names or not + verbose = False # Show verbose output + all = False # Show all entries, including missing/invalid checkout folders + fetch = False # Auto-fetch before showing status + pull = False # Auto-pull before showing status + inspect_remotes = False # Inspect remote branches to report cleanable (slower) def __init__(self, **kwargs): self.update(**kwargs) def __repr__(self): result = [self._value_representation(k) for k in sorted(self.__dict__)] - return ' '.join(s for s in result if s is not None) + return " ".join(s for s in result if s is not None) def _value_representation(self, name): value = getattr(self, name, None) @@ -92,7 +91,7 @@ def _value_representation(self, name): if value is False: return "!%s" % name - return "%s=%s" % (name, value) + return f"{name}={value}" def set_short(self, value): """ @@ -133,7 +132,7 @@ def __init__(self, url): self.name = url.repo or "unknown" def __repr__(self): - return "%s/%s" % (self.type, self.name) + return f"{self.type}/{self.name}" def __hash__(self): return hash(str(self)) @@ -213,7 +212,7 @@ def name(self): if not self.git.config.repo_name or not self.git.is_git_checkout or self.basename == self.git.config.repo_name: return self.basename - return "%s (%s)" % (self.basename, self.git.config.repo_name) + return f"{self.basename} ({self.git.config.repo_name})" @runez.cached_property def origin_project(self): @@ -301,13 +300,13 @@ def __init__(self, path, prefs=None): :param str path: Path to folder :param MgitPreferences|None prefs: Display prefs """ - self.path = path # Path to folder to examine - self.prefs = prefs or MgitPreferences() # Preferences on how to output result - self.checkouts = [] # Actual git checkouts in 'path' - self.projects = collections.defaultdict(set) # Seen remotes - self.predominant = None # Predominant remote, if any - self.additional = None # Additional projects (sorted by checkouts, descending) - self.stash_projects = {} # Corresponding projects from stash, when applicable + self.path = path # Path to folder to examine + self.prefs = prefs or MgitPreferences() # Preferences on how to output result + self.checkouts = [] # Actual git checkouts in 'path' + self.projects = collections.defaultdict(set) # Seen remotes + self.predominant = None # Predominant remote, if any + self.additional = None # Additional projects (sorted by checkouts, descending) + self.stash_projects = {} # Corresponding projects from stash, when applicable self.scan() def __repr__(self): @@ -375,16 +374,16 @@ def header(self): result = "%s:" % runez.purple(runez.short(self.path)) if not self.projects: - return "%s %s" % (result, runez.orange("no git folders")) + return "{} {}".format(result, runez.orange("no git folders")) if self.predominant: - result += runez.bold(" %s %s" % (len(self.projects[self.predominant]), self.predominant)) + result += runez.bold(f" {len(self.projects[self.predominant])} {self.predominant}") else: result += runez.orange(" no predominant project") if self.additional: - result += " (%s)" % runez.purple(", ".join("+%s %s" % (len(self.projects[project]), project) for project in self.additional)) + result += " (%s)" % runez.purple(", ".join(f"+{len(self.projects[project])} {project}" for project in self.additional)) return result diff --git a/src/mgit/cli.py b/src/mgit/cli.py index 054f0a7..06d89ad 100644 --- a/src/mgit/cli.py +++ b/src/mgit/cli.py @@ -18,7 +18,6 @@ from mgit import get_target, GitCheckout from mgit.git import GitRunReport - LOG = logging.getLogger(__name__) VALID_CLEAN_ACTIONS = "show local remote all reset".split() @@ -123,7 +122,7 @@ def clean_show(target): else: for branch in target.git.local_cleanable_branches: - print(" %s branch %s can be cleaned" % (runez.bold("local"), runez.bold(branch))) + print(" {} branch {} can be cleaned".format(runez.bold("local"), runez.bold(branch))) if not target.git.remote_cleanable_branches: print(" No remote branches can be cleaned") @@ -175,7 +174,7 @@ def handle_single_clean(target, what): print("%s cleaned" % runez.plural(cleaned, "remote branch")) else: - print("%s/%s remote branches cleaned" % (cleaned, total)) + print(f"{cleaned}/{total} remote branches cleaned") target.git.reset_cached_properties() if what == "all": @@ -206,7 +205,7 @@ def handle_single_clean(target, what): print(runez.bold("%s cleaned" % runez.plural(cleaned, "local branch"))) else: - print(runez.orange("%s/%s local branches cleaned" % (cleaned, total))) + print(runez.orange(f"{cleaned}/{total} local branches cleaned")) target.git.reset_cached_properties() diff --git a/src/mgit/git.py b/src/mgit/git.py index da560a8..18197ab 100644 --- a/src/mgit/git.py +++ b/src/mgit/git.py @@ -13,7 +13,6 @@ import runez - LOG = logging.getLogger(__name__) FETCH_AGE_FILES = ["FETCH_HEAD", "HEAD"] FRESHNESS_THRESHOLD = 12 * runez.date.SECONDS_IN_ONE_HOUR @@ -29,7 +28,7 @@ def is_valid_branch_name(name): :param str|None name: Branch name to validate :return bool: True if branch name appears valid, as per https://wincent.com/wiki/Legal_Git_branch_names """ - if not name or name[0] == "." or ".." in name or name.endswith("/") or name.endswith(".lock"): + if not name or name[0] == "." or ".." in name or name.endswith(("/", ".lock")): return False for char in name: @@ -79,7 +78,7 @@ def __init__(self, *args, **kwargs): self.add(*args, **kwargs) def __repr__(self): - return "%s problems, %s progress, %s notes" % (len(self._problem), len(self._progress), len(self._note)) + return f"{len(self._problem)} problems, {len(self._progress)} progress, {len(self._note)} notes" def __contains__(self, text): """ @@ -240,7 +239,7 @@ def set(self, url): if m: self.protocol = "ssh" self.hostname = m.group(1) or "unknown" - self.relative_path = "%s/%s" % (m.group(2), m.group(3)) + self.relative_path = f"{m.group(2)}/{m.group(3)}" self.username = "git" self._set_name(m.group(3)) self._set_repo(m.group(2)) @@ -333,7 +332,7 @@ def _git_command(self, args): args_represented = "git %s" % " ".join(args) else: - args_represented = "git -C %s %s" % (runez.short(self.path), " ".join(args)) + args_represented = "git -C {} {}".format(runez.short(self.path), " ".join(args)) cmd.extend(["-C", self.path]) cmd.extend(args) @@ -539,7 +538,7 @@ def local_cleanable_branches(self): """ :return set: Local branches that can be cleaned """ - result = set(name for name in self.orphan_branches if name not in self.special_branches) + result = {name for name in self.orphan_branches if name not in self.special_branches} for branch in self.remote_cleanable_branches: remote, _, name = branch.partition("/") tracking = self.config.tracking_remote.get(name) @@ -567,7 +566,7 @@ def remote_cleanable_branches(self): if not url or url.protocol != "ssh": continue - result.update(["%s/%s" % (remote, branch) for branch in branches if branch not in self.special_branches]) + result.update([f"{remote}/{branch}" for branch in branches if branch not in self.special_branches]) return result @@ -621,64 +620,64 @@ def _process_line(self, line): class GitBranches(GitAspect): """Branch info""" - _command = 'branch --list --all' - _remote_prefix = 'remotes/' + _command = "branch --list --all" + _remote_prefix = "remotes/" - current = '' # Current local branch - local = set() # Local branches - by_remote = collections.defaultdict(set) # Branches by remote (usually origin and optionally upstream) - default_branches = {} # Default branch per remote + current = "" # Current local branch + local = set() # Local branches + by_remote = collections.defaultdict(set) # Branches by remote (usually origin and optionally upstream) + default_branches = {} # Default branch per remote report = GitRunReport() @property def shortened_current_branch(self): - return str(self.current or 'HEAD').replace('feature/', 'f/').replace('bugfix/', 'b/') + return str(self.current or "HEAD").replace("feature/", "f/").replace("bugfix/", "b/") def _process_line(self, line): - if not line or len(line) <= 3 or line[0] not in ' *' or line[1] != ' ': + if not line or len(line) <= 3 or line[0] not in " *" or line[1] != " ": LOG.warning("Internal error: malformed branch --list line: %s", line) return name = line[2:] if name.startswith(self._remote_prefix): - name = name[len(self._remote_prefix):] + name = name[len(self._remote_prefix) :] default = None try: - i = name.index(' -> ') + i = name.index(" -> ") first = name[:i] - if first.endswith('/HEAD'): - default = name = name[i + 4:] + if first.endswith("/HEAD"): + default = name = name[i + 4 :] except ValueError: pass - remote, _, name = name.partition('/') + remote, _, name = name.partition("/") self.by_remote[remote].add(name) if default: self.default_branches[remote] = name return - if name.startswith('('): + if name.startswith("("): name = name[1:] - if name.endswith(')'): + if name.endswith(")"): name = name[:-1] - name, _, problem = name.partition(' ') - self.report.add(note='%s %s' % (name, problem)) + name, _, problem = name.partition(" ") + self.report.add(note=f"{name} {problem}") self.local.add(name) - if line[0] == '*': + if line[0] == "*": self.current = name class GitConfig(GitAspect): """Remote info""" - _command = 'config --list' - origin = GitURL() # URL to remote called 'origin' - remotes = {} # GitURL by remote name map - tracking_remote = {} # Remotes that each local branch is tracking + _command = "config --list" + origin = GitURL() # URL to remote called 'origin' + remotes = {} # GitURL by remote name map + tracking_remote = {} # Remotes that each local branch is tracking content = {} @runez.cached_property @@ -790,13 +789,13 @@ def _report_sorter(enum): :return int: Value to use for sorting messages in this report """ index, message = enum - if message[0] == '<': - return -enum[0] # '<' makes message sort towards front, but keeping order with other such prefixed messages + if message[0] == "<": + return -enum[0] # '<' makes message sort towards front, but keeping order with other such prefixed messages - if message[0] == '>': - return 1000000 + enum[0] # '>' makes message sort towards end + if message[0] == ">": + return 1000000 + enum[0] # '>' makes message sort towards end - return enum[0] # Non-prefixed message stay where they were + return enum[0] # Non-prefixed message stay where they were def _add_sorted(result, target, color, n, max_chars): diff --git a/tests/conftest.py b/tests/conftest.py index 0b65d91..0de2ce2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,5 +2,4 @@ from mgit.cli import main - cli.default_main = main diff --git a/tests/test_git.py b/tests/test_git.py index 7d8b695..965fe80 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -18,8 +18,8 @@ def test_invalid_branch_names(): def check_url(url, protocol, hostname, username, relative_path, repo, name): u = GitURL() u.set(url) - assert str(u) == (url or '') - assert u.url == (url or '') + assert str(u) == (url or "") + assert u.url == (url or "") assert u.protocol == protocol assert u.hostname == hostname assert u.username == username @@ -35,13 +35,7 @@ def test_git_urls(): check_url("/some/repo/foo", "file", "local", None, "/some/repo/foo", "repo", "foo") check_url( - "ssh://git@stash.corp.foo.com:7999/myproject/bin.git", - "ssh", - "stash.corp.foo.com", - "git", - "/myproject/bin.git", - "myproject", - "bin" + "ssh://git@stash.corp.foo.com:7999/myproject/bin.git", "ssh", "stash.corp.foo.com", "git", "/myproject/bin.git", "myproject", "bin" ) check_url( "https://user@stash.corp.foo.com/scm/myproject/bin.git", @@ -50,7 +44,7 @@ def test_git_urls(): "user", "/scm/myproject/bin.git", "myproject", - "bin" + "bin", ) check_url("git@github.com:foo/vmaf.git", "ssh", "github.com", "git", "foo/vmaf.git", "foo", "vmaf") diff --git a/tests/test_reporting.py b/tests/test_reporting.py index ca3c1e2..55c5f94 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -15,12 +15,12 @@ def test_reporting(): assert GitRunReport.not_git().has_problems # Sorting - check_sorting("a b c", "a; b; c") # Messages stay in order they were provided - check_sorting("a b c b a", "a; b; c") # No dupes - check_sorting("a c d", "b; a; d; c") # > pushed message to back - check_sorting("a c e f", "d; b; a; f; c; e") # > ordered pushing + check_sorting("a b c", "a; b; c") # Messages stay in order they were provided + check_sorting("a b c b a", "a; b; c") # No dupes + check_sorting("a c d", "b; a; d; c") # > pushed message to back + check_sorting("a c e f", "d; b; a; f; c; e") # > ordered pushing # Typical issues check_sorting(GitRunReport.not_git(), "not a git checkout") @@ -47,18 +47,11 @@ def test_reporting(): check_sorting(GitRunReport(r1).add(problem=r2), "p1; p2; n1") # Problems come ahead of notes - check_sorting(GitRunReport().add(problem="p1").add(problem='p2').add(problem="p3"), "p1; p2; p3") - check_sorting(GitRunReport().add(problem="p1", note='n1').add(problem="p2"), "p1; p2; n1") + check_sorting(GitRunReport().add(problem="p1").add(problem="p2").add(problem="p3"), "p1; p2; p3") + check_sorting(GitRunReport().add(problem="p1", note="n1").add(problem="p2"), "p1; p2; n1") # Progress comes ahead of notes, but after problems - check_sorting( - GitRunReport().add( - note="n1