Compare commits

..
1 Commits
26 changed files with 1001 additions and 1298 deletions
-2
View File
@@ -2,5 +2,3 @@
remote = origin
branches = main
initialized = true
version = 2
remote-type = gitea
-38
View File
@@ -1,38 +0,0 @@
name: Publish to PyPI
on:
push:
branches:
- main
jobs:
Publish-to-PyPI:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Configure git
run: |
git config --global user.name "gitea-actions"
git config --global user.email "actions@git.jonathanteran.dev"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: "Set up Python"
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
- name: Bump version
run: |
uv run git-flow tag --token="${{ secrets.GITEA_TOKEN }}"
- name: Build and publish
run: |
uv build
uv publish --token="${{ secrets.PYPI_TOKEN }}"
-3
View File
@@ -9,6 +9,3 @@ dist/
build/
**/__pycache__/
**/*.egg-info/
# MacOS
.DS_Store
+1 -1
View File
@@ -1 +1 @@
3.14
3.10
+2 -132
View File
@@ -1,136 +1,6 @@
## v0.0.1-main - 2026-07-18
## v1.13.2 - 2026-02-11
- **bugfix**: add fetch tags to checkout step [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f9fb5a6)
---
## v0.1.0-main - 2026-07-18
- **feature**: print current tag command output [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0cdc012)
---
## v0.1.0-main - 2026-07-18
- **style**: organize imports [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (a08666c)
- **style**: format and organize imports [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (726df21)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (07d4ff9)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (919e680)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (add4cd2)
- **style**: run black formatter on merge.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e4833c9)
- **style**: run black formatter on commit.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7315681)
- **style**: run black formatter on new.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ba1c9bd)
- **style**: run black formatter [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1a7ae33)
- **refactor**: use new base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0120faa)
- **refactor**: use unsafe_ask in favor of global error catch [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (febd18c)
- **style**: run black formatter [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (069c33b)
- **refactor**: reword current branch check, move if around so it's better placed [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1d186e1)
- **feature**: change unicode status icons [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c0f7642)
---
## v1.17.6 - 2026-07-09
- **bugfix**: actualizo versiones de checkout, setup-python, clonar depth completo [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (4666503)
---
## v0.1.0-main - 2026-07-09
- **bugfix**: usar https por defecto como esquema si se detecta ssh [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (5989699)
- **feature**: usar esquema de remoto para host de api gitea [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ecfe058)
- **bugfix**: agregar setup previo a ejecucion de git tag [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1cc2de0)
- **bugfix**: arreglo acceso a secretos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d456565)
- **bugfix**: agrego fetch-depth a checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ad8984c)
- **bugfix**: agrego log de git luego del checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf6a0e8)
- **bugfix**: cambio version de action setup-uv [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (cb74242)
- **feature**: agrego workflow para subir paquete a pypi [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e94ee2c)
---
## v0.1.0-main - 2026-07-09
- **bugfix**: agregar setup previo a ejecucion de git tag [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1cc2de0)
- **bugfix**: arreglo acceso a secretos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d456565)
- **bugfix**: agrego fetch-depth a checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ad8984c)
- **bugfix**: agrego log de git luego del checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf6a0e8)
- **bugfix**: cambio version de action setup-uv [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (cb74242)
- **feature**: agrego workflow para subir paquete a pypi [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e94ee2c)
---
## v1.17.5 - 2026-06-03
- **bugfix**: allow commits not compliant with conventional commits in changelog generation [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (3ff9f76)
---
## v1.17.4 - 2026-05-24
- **bugfix**: use rest of branch name instead of ticket for pr title generation [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (094bc1a)
---
## v1.17.3 - 2026-05-22
- **bugfix**: dont close branches on merges between environments [[jteran](mailto:jteran@renatre.org.ar)] (8ee0129)
---
## v1.17.2 - 2026-05-22
- **bugfix**: change pr title on merges between environments [[jteran](mailto:jteran@renatre.org.ar)] (de124ba)
---
## v1.17.1 - 2026-05-20
- **bugfix**: dont push environment branches since they are already created on remote [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (04c9a4b)
---
## v1.17.0 - 2026-05-20
- **feature**: allow merges between environments [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (5806aea)
---
## v1.16.1 - 2026-05-20
- **bugfix**: arreglo split de branch para armar correctamente el titulo del PR [[jteran](mailto:jteran@renatre.org.ar)] (a0d3028)
---
## v1.16.0 - 2026-05-20
- **feature**: agrego soporte para especificar tickets en nombre e rama [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f389118)
---
## v1.15.0 - 2026-05-17
- **feature**: include revlist count in version tag when skipping bump [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d275ac0)
---
## v1.14.0 - 2026-02-11
- **feature**: actualizar nombre de proyecto en uv.lock [[jteran](mailto:jteran@renatre.org.ar)] (9cd0b25)
- **bugfix**: test pipeline change [[jteran](mailto:jteran@renatre.org.ar)] (f2eb5af)
---
+1 -2
View File
@@ -47,8 +47,7 @@ El comando solicitará 2 opciones:
automáticamente. Por el momento, se soportan los siguientes remotos:
- [x] Bitbucket
- [x] Github
- [x] Gitea
- [ ] Github: planeado
La configuración del remoto requiere tener un Access Token para poder crear los PRs automáticamente.
El mismo se puede crear desde el repositorio al que se quiere dar acceso: *"Repository settings" >
+3 -1
View File
@@ -1,4 +1,4 @@
image: python:3.14
image: python:3.10
pipelines:
branches:
@@ -10,5 +10,7 @@ pipelines:
script:
- pip install uv
- uv run git-flow tag --token=$BEARER
- git status
- git log --oneline -5
- uv build
- uv publish --token=$PYPI_TOKEN
+2 -2
View File
@@ -6,9 +6,9 @@ build-backend = "setuptools.build_meta"
name = "git-flow-envs"
description = "Git workflow automation to follow best practices when working with multiple environments"
dynamic = ["version"]
requires-python = ">= 3.14"
requires-python = ">= 3.10"
dependencies = [
"requests", "typer", "rich", "questionary"
"requests"
]
authors = [
{name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"},
+11 -1
View File
@@ -50,4 +50,14 @@ SUPPORTED_REMOTE_APIS = [
REPOSITORY_TOKEN_FILENAME = ".repository-token"
FLOWCONFIG_VERSION = 2
COLOR_BLACK = "\033[30m"
COLOR_RED = "\033[31m"
COLOR_GREEN = "\033[32m"
COLOR_YELLOW = "\033[33m"
COLOR_BLUE = "\033[34m"
COLOR_5 = "\033[35m"
COLOR_6 = "\033[36m"
COLOR_7 = "\033[37m"
COLOR_BLACK_BOLD = "\033[1;30m"
COLOR_WHITE_BOLD = "\033[1;37m"
COLOR_RESET = "\033[0m"
+1 -5
View File
@@ -28,15 +28,11 @@ class Changelog:
message = Git("show", commit, patch=False, format="%s").firstline()
email = Git("show", commit, patch=False, format="%ae").firstline()
username = email[: email.index("@")]
description = message
if ":" in message:
index = message.index(":")
commit_type = message[:index]
commit_message = message[index + 1 :]
description = f"**{commit_type}**: {commit_message}"
content += f"- {description} [[{username}](mailto:{email})] ({commit})\n"
content += f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})\n"
return content
+171 -155
View File
@@ -1,31 +1,127 @@
from argparse import Namespace, ArgumentParser
from abc import ABC, abstractmethod
from os.path import isfile
from git_flow import (
BRANCH_TYPES,
COLOR_BLUE,
COLOR_GREEN,
COLOR_RED,
COLOR_RESET,
COLOR_YELLOW,
REPOSITORY_TOKEN_FILENAME,
FLOWCONFIG_VERSION,
COMMIT_TYPES,
GitFlowError,
)
from git_flow.git import Git
from git_flow.git import FLOWCONFIG_FILE, Git
from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.github import GithubRemoteAPI
from git_flow.remote.gitea import GiteaRemoteAPI
from git_flow.io import *
from os.path import isfile
from typing import Optional
import rich
import rich.panel
import questionary
TYPE_SUCCESS = 0
TYPE_WARNING = 1
TYPE_ERROR = 2
TYPE_INFO = 3
flowconfig = Git.get_config()
environments = (
flowconfig["flow.branches"].split(",") if "flow.branches" in flowconfig else []
class Command(ABC):
flowconfig: dict[str, str]
@abstractmethod
def name(self) -> str:
pass
@abstractmethod
def description(self) -> str:
pass
@abstractmethod
def run(self, args: Namespace = Namespace()):
pass
def init(self):
self.flowconfig = (
Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {}
)
def success(self, msg: str):
self._print(TYPE_SUCCESS, msg)
def ensure_initialized():
initialized = flowconfig["flow.initialized"] if flowconfig else None
def warning(self, msg: str):
self._print(TYPE_WARNING, msg)
def error(self, msg: str):
self._print(TYPE_ERROR, msg)
def info(self, msg: str):
self._print(TYPE_INFO, msg)
def prompt(
self,
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
if default:
prompt += f" [{default}]"
prompt += ": "
result = input(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
self.error("Debe ingresar un valor no vacío.")
else:
return result
def confirm(self, question: str, default: bool = True):
suffix = " [Y/n]: " if default else " [y/N]: "
answer = input(question + suffix)
return default if len(answer) == 0 else answer.startswith("y")
def choice(self, prompt: str, options: list[str]):
print(prompt)
for i, option in enumerate(options):
print(f"\t{i+1}. {option}")
selection = None
while selection is None:
answer = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
if answer.isdigit():
answer = int(answer)
if 1 <= answer and answer <= len(options):
selection = options[answer - 1]
if not self.confirm(
f"Seleccionó la opción {answer} ({selection}), ¿es correcto?"
):
selection = None
else:
self.error(f"La opción {answer} está fuera del rango permitido.")
elif answer in options:
selection = answer
else:
self.error(f"La opción '{answer}' es inválida.")
return selection
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
return parser
def ensure_initialized(self):
initialized = self.flowconfig["flow.initialized"] if self.flowconfig else None
if not initialized:
raise GitFlowError(
@@ -36,28 +132,45 @@ def ensure_initialized():
f"El valor de `flow.initialized` ({initialized}) es inválido."
)
version = int(flowconfig.get("flow.version", "1"))
if version != FLOWCONFIG_VERSION:
raise GitFlowError(
f"La versión del flowconfig ({version}) no es compatible con esta versión de git-flow ({FLOWCONFIG_VERSION}). "
f"Debe reinicializar el repositorio."
)
def ensure_right_branch():
def ensure_right_branch(self):
branch = Git.get_current_branch()
if branch == "HEAD":
raise GitFlowError("No se encuentra parado sobre una rama.")
elif io_confirm(f"Rama actual: {branch}. ¿Es correcto?"):
elif self.confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
return branch
else:
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
def get_branch_env_and_type(branch: str) -> tuple[str, str]:
with open(REPOSITORY_TOKEN_FILENAME) as f:
return f.readline().strip()
def ensure_clean_worktree(self, has_remote: bool):
status = Git.status()
if not status:
return
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
if not has_remote:
self.warning("Existen cambios en tu entorno de trabajo sin commitear.")
else:
raise GitFlowError("No se puede continuar con cambios pendientes.")
if not self.confirm("¿Desea continuar?", False):
raise GitFlowError("Ejecución abortada")
def get_branch_env_and_type(self, branch: str) -> tuple[str, str]:
components = branch.split("/")
target_branches = self.flowconfig["flow.branches"].split(",")
if len(components) == 2:
if components[0] not in BRANCH_TYPES:
@@ -65,13 +178,13 @@ def get_branch_env_and_type(branch: str) -> tuple[str, str]:
f"Branch inválida, el tipo '{components[0]}' no es válido."
)
return (environments[0], components[0])
return (target_branches[0], components[0])
elif len(components) == 4:
valid_environments = environments[1:]
target_branches = target_branches[1:]
if (
components[0] != "release"
or components[1] not in valid_environments
or components[1] not in target_branches
or components[2] not in BRANCH_TYPES
):
raise GitFlowError(
@@ -83,138 +196,41 @@ def get_branch_env_and_type(branch: str) -> tuple[str, str]:
else:
raise GitFlowError("Branch inválido: " + branch)
def ensure_repository_token():
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
with open(REPOSITORY_TOKEN_FILENAME) as f:
return f.readline().strip()
def ensure_clean_worktree(has_remote: bool):
status = Git.status()
if not status:
return
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
if not has_remote:
io_warning("Existen cambios en tu entorno de trabajo sin commitear.")
if not io_confirm("¿Desea continuar?", False):
raise GitFlowError("Ejecución abortada")
else:
raise GitFlowError("No se puede continuar con cambios pendientes.")
def get_remote_api(token: str) -> RemoteAPI:
if "flow.remote" not in flowconfig:
def get_remote_api(self, token: str) -> RemoteAPI:
if "flow.remote" not in self.flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.")
remote_type = flowconfig.get("flow.remote-type", "").lower()
[schema, host, repository] = RemoteAPI.parse(flowconfig["flow.remote"])
[host, repository] = RemoteAPI.parse(self.flowconfig["flow.remote"])
if remote_type == "gitea":
remote_schema = "https://" if schema == "ssh://" else schema
return GiteaRemoteAPI(remote_schema + host, repository, token)
elif remote_type == "bitbucket":
if host == "bitbucket.org":
return BitbucketRemoteAPI(repository, token)
elif remote_type == "github":
elif host == "github.com":
return GithubRemoteAPI(repository, token)
else:
raise GitFlowError("El tipo del remoto es inválido")
raise GitFlowError("El host del repositorio remoto es inválido")
def _print(self, type: int, msg: str):
print(self._get_color(type) + self._get_tag(type) + " " + msg + COLOR_RESET)
def is_valid_ticket(ticket: str) -> bool:
"""Check if the specified string is a valid ticket number (ABC-123)"""
components = ticket.split("-")
def _get_color(self, type: int) -> str:
if type == TYPE_SUCCESS:
return COLOR_GREEN
elif type == TYPE_WARNING:
return COLOR_YELLOW
elif type == TYPE_ERROR:
return COLOR_RED
elif type == TYPE_INFO:
return COLOR_BLUE
return ""
if len(components) != 2:
return False
def _get_tag(self, type: int) -> str:
if type == TYPE_SUCCESS:
return "[success]"
elif type == TYPE_WARNING:
return "[warning]"
elif type == TYPE_ERROR:
return "[error]"
elif type == TYPE_INFO:
return "[info]"
ticket_project = components[0]
ticket_number = components[1]
return (
ticket_project.isalpha()
and ticket_project.isupper()
and ticket_number.isnumeric()
)
def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
commit_type = io_choice("Tipo de commit", commit_types)
commit_message = io_prompt(
"Mensaje de commit",
validator=lambda s: 0 < len(s) and len(s) < 100,
instruction="100 caracteres máximo",
)
return commit_type + ": " + commit_message
def io_error(message: str, title: Optional[str] = None):
_io_status(message, "✖ Error: ", "red", title)
def io_warning(message: str, title: Optional[str] = None):
_io_status(message, "⚠ Warning: ", "yellow", title)
def io_info(message: str, title: Optional[str] = None):
_io_status(message, " Info: ", "blue", title)
def io_success(message: str, title: Optional[str] = None):
_io_status(message, "✔ Success: ", "green", title)
def _io_status(message: str, prefix: str, style: str, title: Optional[str] = None):
if title:
rich.print(
rich.panel.Panel(
message,
title=prefix + title,
style=style,
expand=False,
title_align="left",
)
)
else:
rich.print(f"[{style}]{prefix}{message}[/{style}]")
def io_confirm(question: str, default: bool = True) -> bool:
return questionary.confirm(question, default=default, auto_enter=False).unsafe_ask()
def io_choice(prompt: str, options: list[str]) -> str:
return questionary.select(
prompt,
options,
use_search_filter=True,
use_jk_keys=False,
instruction="Tipear o usar flechas",
).unsafe_ask()
def io_prompt(
message: str,
default: str = "",
persistent: bool = False,
strip: bool = False,
validator=None,
instruction: str | None = None,
) -> str:
if persistent and not validator:
validator = lambda s: len(s.strip()) > 0
value = questionary.text(
message, default, validate=validator, instruction=instruction
).unsafe_ask()
return value.strip() if strip else value
return ""
+80 -109
View File
@@ -1,146 +1,117 @@
from typing import Annotated
import typer
from rich.console import Console
import git_flow.command.base as base
from git_flow import (
TRASH_BRANCH_PREFIX,
WIP_BRANCH_PREFIX,
GitFlowError,
)
from argparse import ArgumentParser, Namespace
from git_flow import COLOR_WHITE_BOLD, COLOR_GREEN, COLOR_RED, COLOR_RESET, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)"
app = typer.Typer()
console = Console()
class BranchCommand(Command):
def name(self) -> str:
return "branch"
@app.command()
def branch(
environment: Annotated[
str | None,
typer.Argument(
help="Entorno de las ramas a listar. Por defecto, es el entorno actual."
),
] = None,
trash: Annotated[
bool,
typer.Option(
def description(self) -> str:
return """Lista ramas del repositorio, agrupandolas por entorno objetivo"""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument(
"environment",
nargs="?",
help="Entorno de las ramas a listar. Por defecto, es el entorno actual.",
)
parser.add_argument(
"--trash",
action="store_true",
help=f"Listar ramas en la 'papelera de reciclaje' (ramas que empiezan con '{TRASH_BRANCH_PREFIX}')"
),
] = False,
wip: Annotated[
bool,
typer.Option(
)
parser.add_argument(
"--wip",
action="store_true",
help=f"Listar ramas 'en progreso' (ramas que empiezan con '{WIP_BRANCH_PREFIX}')"
),
] = False,
all: Annotated[
bool, typer.Option(help="Listar ramas de todos los entornos")
] = False,
):
"""Lista ramas del repositorio, agrupandolas por entorno objetivo"""
base.ensure_initialized()
)
current_branch = Git.get_current_branch()
parser.add_argument(
"--all",
action="store_true",
help=f"Listar ramas de todos los entornos"
)
if current_branch in base.environments:
current_environment = current_branch
return parser
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
self.current_branch = Git.get_current_branch()
self.environments = self.flowconfig["flow.branches"].split(",")
if self.current_branch in self.environments:
current_environment = self.current_branch
else:
current_environment, _ = base.get_branch_env_and_type(current_branch)
(current_environment, _) = self.get_branch_env_and_type(self.current_branch)
if trash:
show_branches(TRASH_BRANCH_PREFIX, current_branch)
elif wip:
show_branches(WIP_BRANCH_PREFIX, current_branch)
elif all:
show_envs(base.environments, current_branch)
show_all_branches(base.environments, current_branch)
elif environment is None:
show_envs(base.environments, current_branch)
show_env_branches(current_environment, base.environments, current_branch)
elif environment in base.environments:
show_envs(base.environments, current_branch)
show_env_branches(environment, base.environments, current_branch)
if args.trash:
self.show_branches(TRASH_BRANCH_PREFIX)
elif args.wip:
self.show_branches(WIP_BRANCH_PREFIX)
elif args.all:
self.show_envs()
self.show_all_branches()
elif args.environment is None:
self.show_envs()
self.show_env_branches(current_environment)
elif args.environment in self.environments:
self.show_envs()
self.show_env_branches(args.environment)
else:
raise GitFlowError(f"'{environment}' no es un entorno válido.")
raise GitFlowError(f"'{args.environment}' no es un entorno válido.")
def show_branches(branch_prefix: str, current_branch: str):
branches = get_branches(branch_prefix)
def show_branches(self, prefix: str):
branches = self.get_branches(prefix)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
prefix = (COLOR_RED + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
def get_branches(self, prefix: str):
return Git("for-each-ref", "refs/heads/" + prefix, format=BRANCH_FORMAT).lines()
def get_branches(branch_prefix: str):
return Git(
"for-each-ref", "refs/heads/" + branch_prefix, format=BRANCH_FORMAT
).lines()
def show_all_branches(self):
for environment in self.environments:
self.show_env_branches(environment, len(self.environments) > 1)
def show_all_branches(environments: list[str], current_branch: str):
for environment in environments:
show_env_branches(
environment, environments, current_branch, len(environments) > 1
)
trash = len(get_branches(TRASH_BRANCH_PREFIX))
trash = len(self.get_branches(TRASH_BRANCH_PREFIX))
if trash:
print()
console.print(
"[bold]Papelera:[/bold] "
+ str(trash)
+ " rama"
+ ("" if trash == 1 else "s")
)
print(COLOR_WHITE_BOLD + "Papelera: " + COLOR_RESET + str(trash) + " rama" + ("" if trash == 1 else "s"))
def show_envs(environments: list[str], current_branch: str):
console.print("Entornos", style="bold")
for environment in environments:
is_current = environment == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + environment, style="green" if is_current else None)
def show_envs(self):
print(COLOR_WHITE_BOLD + "Entornos" + COLOR_RESET)
for environment in self.environments:
prefix = (COLOR_GREEN + "*") if environment == self.current_branch else " "
print(prefix + " " + environment + COLOR_RESET)
print()
def show_env_branches(
environment: str,
environments: list[str],
current_branch: str,
show_env: bool = False,
):
is_first_env = environment == environments[0]
pattern = (
"refs/heads/" if is_first_env else "refs/heads/release/" + environment + "/"
)
def show_env_branches(self, environment: str, show_env: bool = False):
is_first_env = environment == self.environments[0]
pattern = "refs/heads/" if is_first_env else "refs/heads/release/" + environment + "/"
pattern += "*/*"
exclude = (
["refs/heads/" + TRASH_BRANCH_PREFIX, "refs/heads/release/"]
if is_first_env
else []
)
branches = Git(
"for-each-ref", pattern, exclude=exclude, format=BRANCH_FORMAT
).lines()
exclude = ["refs/heads/" + TRASH_BRANCH_PREFIX, "refs/heads/release/"] if is_first_env else []
branches = Git("for-each-ref", pattern, exclude=exclude, format=BRANCH_FORMAT).lines()
console.print(environment if show_env else "Ramas", style="bold")
print(COLOR_WHITE_BOLD + (environment if show_env else "Ramas") + COLOR_RESET)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
prefix = (COLOR_GREEN + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
+27 -28
View File
@@ -1,29 +1,35 @@
from argparse import Namespace
from datetime import datetime
import typer
import git_flow.command.base as base
from git_flow import TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow import COMMIT_TYPES, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
app = typer.Typer()
class CommitCommand(Command):
def name(self) -> str:
return "commit"
@app.command()
def commit():
"""Crea un commit siguiendo el formato de Conventional Commits"""
base.ensure_initialized()
branch = base.ensure_right_branch()
def description(self) -> str:
return """Crea un commit siguiendo el formato de Conventional Commits"""
if not _has_files_staged():
base.io_warning("No hay cambios en el indice para commitear.")
if base.io_confirm("¿Desea agregar la carpeta actual?"):
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
if not self._has_files_staged():
self.warning("No hay cambios en el indice para commitear.")
if self.confirm("¿Desea agregar la carpeta actual?"):
Git("add", ".").exec(print="Agregando cambios")
else:
raise GitFlowError("Debe agregar algún cambio al indice para continuar.")
raise GitFlowError(
"Debe agregar algún cambio al indice para continuar."
)
message = base.get_commit_message()
commit_type = message[: message.index(":")]
commit_type = self.choice("Tipo de commit", COMMIT_TYPES)
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
if branch.startswith(WIP_BRANCH_PREFIX):
original_branch = branch.removeprefix(WIP_BRANCH_PREFIX)
@@ -32,23 +38,16 @@ def commit():
if commit_type != "wip":
suffix = datetime.now().strftime("%Y%m%dT%H%M")
Git("switch", original_branch).exec(print="Volviendo a rama original")
Git("merge", branch, squash=True).exec(
print="Squasheando commits WIP en uno solo"
)
Git("merge", branch, squash=True).exec(print="Squasheando commits WIP en uno solo")
Git("commit", m=message).exec(print="Creando commit final de rama WIP")
Git(
"branch", branch, TRASH_BRANCH_PREFIX + branch + "/" + suffix, move=True
).exec(print="Backup de rama WIP")
Git("branch", branch, TRASH_BRANCH_PREFIX + branch + "/" + suffix, move=True).exec(print="Backup de rama WIP")
else:
if commit_type == "wip":
Git("switch", WIP_BRANCH_PREFIX + branch, create=True).exec(
print="Creando nueva rama WIP"
)
Git("switch", WIP_BRANCH_PREFIX + branch, create=True).exec(print="Creando nueva rama WIP")
Git("commit", m=message).exec(print="Creando commit")
def _has_files_staged():
def _has_files_staged(self):
status = Git.status()
if not status:
+48 -59
View File
@@ -1,66 +1,65 @@
import typer
import git_flow.command.base as base
from git_flow import FLOWCONFIG_VERSION, GitFlowError
from git_flow.git import Git
app = typer.Typer()
from argparse import Namespace
from git_flow import GitFlowError
from git_flow.command.base import Command
from git_flow.git import FLOWCONFIG_FILE, Git
@app.command()
def init():
"""Inicializa el repositorio para utilizar git-flow"""
class InitCommand(Command):
def name(self) -> str:
return "init"
_ensure_is_repository()
_ensure_not_already_initialized()
def description(self) -> str:
return """Inicializa el repositorio para utilizar git-flow"""
branches = _setup_flow_branches()
remote, remote_type = _setup_flow_remote()
def run(self, args: Namespace = Namespace()):
self._ensure_is_repository()
self._ensure_not_already_initialized()
branches = self._setup_flow_branches()
remote = self._setup_flow_remote()
flowconfig = {
"flow.version": str(FLOWCONFIG_VERSION),
"flow.initialized": "true",
"flow.branches": ",".join(branches),
}
if remote and remote_type:
if remote:
flowconfig["flow.remote"] = remote
flowconfig["flow.remote-type"] = remote_type
Git.set_config(flowconfig)
Git.set_config(flowconfig, FLOWCONFIG_FILE)
Git("add", FLOWCONFIG_FILE).exec()
Git("commit", message="feature: initialize git-flow").exec()
_ensure_all_flow_branches_exist(branches, remote)
self._ensure_all_flow_branches_exist(branches, remote)
def _ensure_is_repository():
def _ensure_is_repository(self):
if not Git.is_repository():
base.io_warning("El directorio actual no es un repositorio.")
self.warning("El directorio actual no es un repositorio.")
if not base.io_confirm("¿Desea inicializarlo?"):
raise GitFlowError("No se puede continuar sin inicializar el repositorio")
if not self.confirm("¿Desea inicializarlo?"):
raise GitFlowError(
"No se puede continuar sin inicializar el repositorio"
)
Git("init").exec()
def _ensure_not_already_initialized():
if not base.flowconfig:
def _ensure_not_already_initialized(self):
if not self.flowconfig:
return
elif base.flowconfig.get("flow.initialized") == "true":
elif self.flowconfig.get("flow.initialized") == "true":
raise GitFlowError("El repositorio ya fue inicializado para usar git-flow")
else:
raise GitFlowError("Valor de 'flow.initialized' es inválido")
def _setup_flow_branches():
base.io_info(
"""Ingrese las ramas que representan los entornos de deploy del proyecto
en orden creciente de cercanía al entorno productivo, y separados por
coma. Por ejemplo: "dev, test, prod".""",
title="Configuración de entornos",
def _setup_flow_branches(self):
self.info(
"""Ingrese las ramas que representan los entornos de deploy del proyecto en
orden creciente de cercanía al entorno productivo, y separados por coma.
Por ejemplo: "dev,test,prod"."""
)
branches = list(
map(lambda b: b.strip(), base.io_prompt("Ramas", "main").split(","))
map(lambda b: b.strip(), self.prompt("Ramas", "main").split(","))
)
if not all(branches):
@@ -68,25 +67,20 @@ coma. Por ejemplo: "dev, test, prod".""",
return branches
def _setup_flow_remote():
base.io_info(
"""Configurar un repositorio remoto le permite generar PRs automáticamente para el
entorno correspondiente. Deberá seleccionar uno de los remotos actuales, o crear
uno nuevo.""",
title="Configuración de remoto",
def _setup_flow_remote(self):
self.info(
"""Puede elegir o crear un repositorio remoto para generar automáticamente PRs"""
)
remote = None
remote_type = None
if base.io_confirm("¿Configurar repositorio remoto?"):
if self.confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes:
base.io_info("No tiene ningún repositorio remoto, se creará uno.")
self.info("No tiene ningún repositorio remoto, se creará uno.")
url = base.io_prompt(
url = self.prompt(
"Ingrese la URL del repositorio (e.g. https://github.com/user/repo.git)",
persistent=True,
)
@@ -95,24 +89,19 @@ uno nuevo.""",
elif len(remotes) == 1:
remote = remotes[0]
else:
base.io_info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = base.io_choice("Remoto", remotes)
self.info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = self.choice("Remoto", remotes)
remote_type = base.io_choice(
"Tipo de remoto:", ["bitbucket", "github", "gitea"]
)
return remote
return (remote, remote_type)
def _ensure_all_flow_branches_exist(branches: list[str], remote: str | None):
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str | None):
existing_branches = Git.get_branches()
if not all(map(lambda b: b in existing_branches, branches)):
base.io_warning("Algunas de las ramas de entornos expecificadas no existen.")
base.io_info("Debe indicar sobre que rama se crearán las ramas de entorno.")
self.warning("Algunas de las ramas de entornos expecificadas no existen.")
self.info("Debe indicar sobre que rama se crearán las ramas de entorno.")
target_branch = base.io_choice("Rama", existing_branches)
target_branch = self.choice("Rama", existing_branches)
for branch in branches:
if branch not in existing_branches:
+50 -96
View File
@@ -1,74 +1,57 @@
import typer
import git_flow.command.base as base
from argparse import Namespace
from git_flow import GitFlowError
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
app = typer.Typer()
class MergeCommand(Command):
def name(self) -> str:
return "merge"
@app.command()
def merge():
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
base.ensure_initialized()
def description(self) -> str:
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
branch = base.ensure_right_branch()
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
if branch in base.environments:
if branch == base.environments[-1]:
raise GitFlowError("No se puede hacer un merge del último entorno.")
branch = self.ensure_right_branch()
(target, _) = self.get_branch_env_and_type(branch)
base.io_info(
"""Está por hacer un merge de 2 entornos que puede implicar muchos cambios en el proyecto.""",
"Merge de entornos",
)
if not base.io_confirm("¿Desea continuar?"):
raise GitFlowError("Ejecución abortada")
target = base.environments[base.environments.index(branch) + 1]
if "flow.remote" in self.flowconfig:
remote = self.flowconfig["flow.remote"]
token = self.ensure_repository_token()
self.run_remote(remote, token, branch, target)
else:
target, _ = base.get_branch_env_and_type(branch)
self.run_local(branch, target)
if "flow.remote" in base.flowconfig:
remote = base.flowconfig["flow.remote"]
token = base.ensure_repository_token()
run_remote(remote, token, branch, target)
else:
run_local(branch, target)
def run_remote(remote: str, token: str, branch: str, target: str):
base.ensure_clean_worktree(True)
def run_remote(self, remote: str, token: str, branch: str, target: str):
self.ensure_clean_worktree(True)
Git("switch", target).exec(print="Cambiando a rama destino")
Git("pull").exec(print="Obteniendo ultimos cambios del remoto")
Git("switch", "-").exec(print="Volviendo a rama a mergear")
check_merge_conflicts(target)
show_commits_to_merge(target)
self.check_merge_conflicts(target)
self.show_commits_to_merge(target)
if base.io_confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
if branch not in base.environments:
if self.confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
Git("push", remote, branch, set_upstream=True).exec(
print="Subiendo rama al remoto para crear PR"
)
create_pull_request(token, branch, target)
self.create_pull_request(token, branch, target)
def run_local(self, branch: str, target: str):
self.ensure_clean_worktree(False)
self.check_merge_conflicts(target)
self.show_commits_to_merge(target)
def run_local(branch: str, target: str):
base.ensure_clean_worktree(False)
check_merge_conflicts(target)
show_commits_to_merge(target)
if base.io_confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
if self.confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
Git("switch", target).exec(print="Cambiando a rama destino")
Git("merge", branch, ff=False).exec(print="Mergeando")
def show_commits_to_merge(target):
def show_commits_to_merge(self, target):
commits = Git("log", target + "..", format="%s").lines()
if not commits:
@@ -79,8 +62,7 @@ def show_commits_to_merge(target):
for commit in commits:
print("- " + commit)
def check_merge_conflicts(target: str):
def check_merge_conflicts(self, target: str):
merge_conflicts = Git("merge", target, ff=False, commit=False).code(
print="Realizando merge de prueba para verificar conflictos"
)
@@ -88,70 +70,42 @@ def check_merge_conflicts(target: str):
Git("merge", abort=True).exec(print="Abortando merge de prueba", check=False)
if merge_conflicts:
base.io_error(
f"La rama actual tiene conflictos con {target}.\n"
f"Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente,\n"
"y ejecutar nuevamente este comando",
title="Conflictos de merge",
self.error(f"La rama actual tiene conflictos con {target}")
self.info(
f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando"""
)
raise GitFlowError("Ejecución abortada.")
else:
base.io_success("No se detectaron merge conflicts.")
self.success("No se detectaron merge conflicts.")
def create_pull_request(token: str, branch: str, target: str):
def create_pull_request(self, token: str, branch: str, target: str):
changelog = Changelog()
commits = Git("log", target + "..", format="%s").lines()
title = commits[0] if len(commits) == 1 else _get_pr_title_from_branch(branch)
base.panel("Título del PR", title)
if len(commits) == 1:
title = commits[0]
else:
title = branch.replace("/", ": ").replace("-", " ")
if base.io_confirm("¿Desea cambiar el título del PR?", False):
title = base.io_prompt("Título del PR", persistent=True)
if title.startswith("release: "):
title = title[title.index(' ', title.index(' ') + 1) + 1:]
message = base.get_remote_api(token).create_pull_request(
self.info("Título del PR por defecto: " + title)
opt_title = self.prompt("[Opcional] Ingrese otro titulo para el PR")
if opt_title:
title = opt_title
message = self.get_remote_api(token).create_pull_request(
branch,
target,
title,
changelog.generate_content(target, branch),
branch not in base.environments,
)
base.io_success(message, "Rama creada")
self.success(message)
if base.io_confirm("¿Desea cambiar a la rama objetivo y bajar los cambios?"):
if self.confirm("¿Desea cambiar a la rama objetivo y bajar los cambios?"):
Git("switch", target).exec(print="Cambiando a rama objetivo")
Git("pull").exec(print="Obteniendo cambios")
def _get_pr_title_from_branch(branch: str) -> str:
if branch in base.environments:
target = base.environments[base.environments.index(branch) + 1]
return f"Sincronización de entorno {branch} a {target}"
components = branch.split("/") # <type>/<desc> or release/<env>/<type>/<desc>
branch_type = components[-2]
branch_desc = components[-1]
# detect if branch description has ticket as prefix: <desc> = ABC-123-my-branch-name
branch_ticket = None
first_dash = branch_desc.find("-")
second_dash = branch_desc.find("-", first_dash + 1)
if first_dash >= 0 and second_dash >= 0:
maybe_ticket = branch_desc[:second_dash]
if base.is_valid_ticket(maybe_ticket):
branch_ticket = maybe_ticket
branch_desc = branch_desc[second_dash + 1 :]
return " ".join(
filter(
None,
[
f"[Pasaje a {components[1]}]" if len(components) == 4 else None,
branch_type + ":",
branch_ticket,
branch_desc.replace("-", " "),
],
)
)
+29 -42
View File
@@ -1,67 +1,54 @@
from typing import Optional
import typer
import git_flow.command.base as base
from argparse import Namespace
from git_flow import BRANCH_TYPES
from git_flow.command.base import Command
from git_flow.git import Git
app = typer.Typer()
class NewCommand(Command):
def name(self) -> str:
return "new"
@app.command()
def new():
"""Crea una nueva rama siguiendo Conventional Branches"""
def description(self) -> str:
return """Crea una nueva rama siguiendo conventional branches"""
base.ensure_initialized()
branch = base.ensure_right_branch()
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
if branch not in base.environments:
base.io_warning("La rama actual no corresponde a un entorno configurado.")
if branch not in envs:
self.warning("La rama actual no corresponde a un entorno configurado.")
base.io_info(
"Se creará una nueva rama de trabajo sobre la rama actual.\n"
"Debe seleccionar el tipo de cambio a realizar.",
title="Tipo de cambio",
)
self.info("Se creará una nueva rama de trabajo sobre la rama actual.")
self.info("Debe seleccionar el tipo de cambio a realizar.")
branch_type = base.io_choice("Tipos de cambio", BRANCH_TYPES)
ticket = _get_optional_ticket()
new_branch = _get_unique_branch_name(branch_type, ticket)
branch_type = self.choice("Tipos de cambio", BRANCH_TYPES)
new_branch = self._get_unique_branch_name(branch_type)
if "flow.remote" in base.flowconfig and Git.get_tracking_branch(branch):
if "flow.remote" in self.flowconfig and Git.get_tracking_branch(branch):
Git("pull").exec(print="Actualizando rama actual")
if base.io_confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
def _get_optional_ticket() -> Optional[str]:
return base.io_prompt(
"Ticket que respalda el cambio (en formato ABC-123)",
validator=lambda x: len(x.strip()) == 0 or base.is_valid_ticket(x),
)
def _get_unique_branch_name(branch_type: str, ticket: Optional[str]) -> str:
base.io_info(
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'.\n"
"Las mismas se utilizaran para generar el nombre de la rama.",
title="Nombre de rama",
def _get_unique_branch_name(self, branch_type: str) -> str:
self.info(
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'."
)
branch = None
branches = Git.get_branches()
while branch is None:
keywords = base.io_prompt("Palabras clave", persistent=True)
keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
branch = (
branch_type + "/" + (ticket + "-" if ticket else "") + "-".join(keywords)
keywords = self.prompt(
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True,
)
keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
branch = branch_type + "/" + "-".join(keywords)
if branch in branches:
base.io_error(
self.error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
)
branch = None
+51 -34
View File
@@ -1,40 +1,50 @@
from typing import Optional
import typer
import git_flow.command.base as base
from argparse import ArgumentParser, Namespace
from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
app = typer.Typer()
class ReleaseCommand(Command):
def name(self) -> str:
return "release"
@app.command()
def release(group: Optional[str] = None):
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
base.ensure_initialized()
def description(self) -> str:
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
branch = base.ensure_right_branch()
env, _ = base.get_branch_env_and_type(branch)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument(
"--group",
nargs="?",
help="Agrupar release",
)
if env == base.environments[-1]:
return parser
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
(env, _) = self.get_branch_env_and_type(branch)
if env == envs[-1]:
raise GitFlowError(
"No se puede hacer release de una rama en el ultimo entorno."
)
has_remote = "flow.remote" in base.flowconfig
has_remote = "flow.remote" in self.flowconfig
base.ensure_clean_worktree(has_remote)
self.ensure_clean_worktree(has_remote)
next_env = base.environments[base.environments.index(env) + 1]
next_env = envs[envs.index(env) + 1]
next_branch = (
branch.replace(f"/{env}/", f"/{next_env}/")
if branch.startswith("release/")
else f"release/{next_env}/{branch}"
)
fork_point = Git.get_first_fork_point(branch, env)
commits = Git("log", fork_point + "..", format="%s").lines()
base = Git.get_first_fork_point(branch, env)
commits = Git("log", base + ".." , format="%s").lines()
if not commits:
raise GitFlowError("No hay cambios a mergear.")
@@ -44,14 +54,13 @@ def release(group: Optional[str] = None):
for commit in commits:
print("- " + commit)
group = args.group
grouping = group is not None
if not group:
if base.io_confirm("¿Desea agrupar este release con otra rama?", False):
if not grouping:
if self.confirm("¿Desea agrupar este release con otra rama?", False):
grouping = True
group = base.io_choice(
"Grupo release: ", Git.get_branches("release/" + next_env + "/")
)
group = self.choice("Grupo release: ", Git.get_branches("release/" + next_env + "/"))
else:
group = next_env
@@ -60,29 +69,37 @@ def release(group: Optional[str] = None):
Git("pull").exec(print="Sincronizando cambios la rama objetivo")
Git("switch", "-").exec(print="Volviendo a la rama original")
if len(commits) > 1 and base.io_confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?", False
if len(commits) > 1 and self.confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?",
False
):
base.io_info("Debe ingresar el mensaje del commit a crear.")
message = base.get_commit_message(COMMIT_TYPES[:-1])
self.info("Debe ingresar el mensaje del commit a crear.")
Git("switch", next_branch, fork_point, create=True).exec(
commit_type = self.choice("Tipo de commit", COMMIT_TYPES[:-1])
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
Git("switch", next_branch, base, create=True).exec(
print="Creando rama release en base"
)
Git("merge", branch, squash=True).exec(print="Squasheando commits en uno solo")
Git("merge", branch, squash=True).exec(
print="Squasheando commits en uno solo"
)
Git("commit", m=message).exec(print="Creando commit único")
else:
Git("switch", next_branch, create=True).exec(print="Creando rama release")
status = Git("rebase", fork_point, next_branch, onto=group).code(
status = Git("rebase", base, next_branch, onto=group).code(
print="Moviendo cambios hacia el siguiente ambiente"
)
if status:
Git("rebase", abort=True).exec(print="Deshaciendo cambios por conflictos")
Git("rebase", abort=True).exec(
print="Deshaciendo cambios por conflictos"
)
raise GitFlowError("Error al realizar pasaje de cambios a rama objetivo")
elif grouping:
Git("switch", group).exec(print="Cambiando a rama objetivo")
Git("merge", next_branch, ff_only=True).exec(
print="Mergeando cambios de la nueva rama"
)
Git("merge", next_branch, ff_only=True).exec(print="Mergeando cambios de la nueva rama")
+33 -37
View File
@@ -1,8 +1,4 @@
from typing import Optional
import typer
import git_flow.command.base as base
from argparse import ArgumentParser, Namespace
from git_flow import (
COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR,
@@ -12,61 +8,63 @@ from git_flow import (
GitFlowError,
)
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
app = typer.Typer()
class TagCommand(Command):
def name(self) -> str:
return "tag"
@app.command()
def tag(token: Optional[str] = None):
"""Crea un nuevo tag para el último merge."""
base.ensure_initialized()
def description(self) -> str:
return """Crea un nuevo tag para el último merge."""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
envs = self.flowconfig["flow.branches"].split(",")
target = Git.get_current_branch()
if target not in base.environments:
if target not in envs:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
branch = Git(
"show", get_last_merge_commit() + "^2", patch=False, format="%h"
).firstline()
fork_point = Git.get_first_fork_point(branch, target)
commits = Git("log", fork_point + ".." + branch, format="%s").lines()
next_tag = get_next_tag_from_commits(commits, target)
branch = Git("show", self.get_last_merge_commit() + "^2", patch=False, format="%h").firstline()
base = Git.get_first_fork_point(branch, target)
commits = Git("log", base + ".." + branch, format="%s").lines()
next_tag = self.get_next_tag_from_commits(commits, target)
if not next_tag:
base.io_info(
self.info(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
return
token, ci = get_token_and_ci(token)
if not token:
raise GitFlowError("No hay token disponible")
(token, ci) = self.get_token_and_ci(args)
changelog = Changelog()
changelog.update(next_tag, fork_point, branch)
changelog.update(next_tag, base, branch)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline()
if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo")
base.io_success(base.get_remote_api(token).create_tag(next_tag, tag_commit))
self.success(self.get_remote_api(token).create_tag(next_tag, tag_commit))
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(token: Optional[str]):
def get_token_and_ci(self, args: Namespace):
try:
return (base.ensure_repository_token(), False)
return (self.ensure_repository_token(), False)
except GitFlowError:
return (token, True)
return (args.token, True)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument("--token")
def get_last_merge_commit():
return parser
def get_last_merge_commit(self):
commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).firstline()
@@ -76,19 +74,19 @@ def get_last_merge_commit():
return commit
def check_not_tagged(commit: str):
def check_not_tagged(self, commit: str):
tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if tag:
raise GitFlowError("El commit a taggear ya tiene tag: " + tag)
def get_next_tag_from_commits(commits: list[str], branch: str) -> str | None:
def get_next_tag_from_commits(self, commits: list[str], branch: str) -> str | None:
increment = SEMVER_SKIP
for commit in commits:
commit_type = commit[: commit.index(":")]
increment = max(increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP))
increment = max(
increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP)
)
tag = Git.get_current_tag() or f"v0.0.0-{branch}"
@@ -105,7 +103,5 @@ def get_next_tag_from_commits(commits: list[str], branch: str) -> str | None:
patch = 0
elif increment == SEMVER_PATCH:
patch += 1
elif increment == SEMVER_SKIP:
suffix = "-r" + Git("rev-list", "HEAD", count=True).firstline()
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}"
+31 -12
View File
@@ -1,8 +1,16 @@
import subprocess
from git_flow import GitFlowError
from git_flow.io import *
FLOWCONFIG_FILE = ".flowconfig"
CHANGELOG_FILE = "CHANGELOG.md"
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]"
class Git:
class Status:
worktree: str
@@ -32,7 +40,11 @@ class Git:
@staticmethod
def set_config(config: dict[str, str], file: str | None = None):
for key, value in config.items():
Git("config", key, value, file=file).exec(print="Updating config")
Git("config", key, value, file=file).exec()
@staticmethod
def flow_config(*args, **kwargs):
return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs)
@staticmethod
def get_current_branch():
@@ -48,7 +60,7 @@ class Git:
@staticmethod
def get_current_tag():
return Git("describe", abbrev="0", tags=True).firstline(check=False, print="Obteniendo tag actual")
return Git("describe", abbrev="0", tags=True).firstline(check=False)
@staticmethod
def get_first_fork_point(branch: str, env: str):
@@ -76,9 +88,7 @@ class Git:
except subprocess.CalledProcessError:
return None
def __init__(
self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None
) -> None:
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None) -> None:
self.command = ["git", subcommand]
for option, value in kwargs.items():
@@ -112,7 +122,7 @@ class Git:
return lines if not strip else list(map(lambda l: l.strip(), lines))
def firstline(self, **kwargs):
def firstline(self, **kwargs: bool):
lines = self.lines(**kwargs)
return lines[0] if lines else ""
@@ -134,8 +144,17 @@ class Git:
return process
def _run(self, print: str|None = None, check: bool = True):
return self._get(print, check)
process = subprocess.run(self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if print is not None:
self._print_process("", "", title=print)
if check:
process.check_returncode()
return process
def _print_process(self, stdout: str, stderr: str, title: str):
command = []
@@ -149,12 +168,12 @@ class Git:
command.append(arg)
text = f"[green]$ {" ".join(command)}[/green]"
title = title or "Ejecutando"
print(COLOR_YELLOW + "| " + title + COLOR_RESET)
print(COLOR_YELLOW + "| $ " + " ".join(command) + COLOR_RESET)
for line in stdout.splitlines():
text += "\n" + line
print(COLOR_YELLOW + "| " + COLOR_RESET + "[out] " + line)
for line in stderr.splitlines():
text += "\n[red]" + line + "[/red]"
panel(title or "Ejecutando", text)
print(COLOR_YELLOW + "| " + COLOR_RED + "[err] " + line + COLOR_RESET)
+42 -41
View File
@@ -1,55 +1,56 @@
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.panel import Panel
import sys
console = Console(highlight=False)
from git_flow import COLOR_BLACK_BOLD, COLOR_BLUE, COLOR_GREEN, COLOR_RED, COLOR_RESET, COLOR_YELLOW
def confirm(prompt, default: bool = True):
user_input = input(prompt + (" [Y/n]: " if default else " [y/N]: "))
return default if len(user_input) == 0 else user_input.startswith("y")
def success(msg: str):
console.print(f":white_check_mark: [green]{msg}[/green]")
def choose(prompt: str, options: list[str]):
print(prompt)
for i, option in enumerate(options):
print(f"\t{i+1}. {option}")
def warning(msg: str):
console.print(f":warning: [yellow]{msg}[/yellow]")
selection = None
while selection is None:
user_input = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
def error(msg: str):
console.print(f":x: [red]{msg}[/red]")
if user_input.isdigit():
user_input = int(user_input)
if 1 <= user_input and user_input <= len(options):
selection = options[user_input - 1]
def info(msg: str):
console.print(f":information: [blue]{msg}[/blue]")
def panel(title: str, text: str):
console.print(Panel(text, title=title, expand=False, title_align="left"))
def prompt(
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
result = Prompt.ask(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
error("Debe ingresar un valor no vacío.")
if not confirm(
f"Seleccionó la opción {user_input} ({selection}), ¿es correcto?"
):
selection = None
else:
return result
print_error(f"La opción {user_input} está fuera del rango permitido.")
elif user_input in options:
selection = user_input
else:
print_error(f"La opción '{user_input}' es inválida.")
return selection
def confirm(question: str, default: bool = True):
return Confirm.ask(question, default=default)
def print_error(message: str):
print(f"{COLOR_RED}[err] {message}{COLOR_RESET}", file=sys.stderr)
def choice(prompt: str, options: list[str]):
return Prompt.ask(prompt, choices=options)
def print_warning(message: str):
print(f"{COLOR_YELLOW}[wrn] {message}{COLOR_RESET}")
def print_success(message: str):
print(f"{COLOR_GREEN}[ok!] {message}{COLOR_RESET}")
def print_info(message: str):
print(f"{COLOR_BLUE}[inf] {message}{COLOR_RESET}")
def print_debug(message: str):
print(f"{COLOR_BLACK_BOLD}[dbg] {message}{COLOR_RESET}")
+74 -16
View File
@@ -1,32 +1,90 @@
#!/usr/bin/env python3
import typer
import locale
from argparse import Namespace, ArgumentParser
from git_flow import GitFlowError
import git_flow.command.base as base
from git_flow.command import init, new, commit, merge, tag, release, branch
from git_flow.command.base import Command
from git_flow.command.branch import BranchCommand
from git_flow.command.commit import CommitCommand
from git_flow.command.init import InitCommand
from git_flow.command.merge import MergeCommand
from git_flow.command.new import NewCommand
from git_flow.command.release import ReleaseCommand
from git_flow.command.tag import TagCommand
from git_flow.io import *
REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8"
app = typer.Typer()
app.add_typer(init.app)
app.add_typer(new.app)
app.add_typer(commit.app)
app.add_typer(merge.app)
app.add_typer(tag.app)
app.add_typer(release.app)
app.add_typer(branch.app)
class GitFlowCommand(Command):
commands: tuple[Command, ...]
def __init__(self, *args: Command) -> None:
self.commands = args
def name(self) -> str:
return "git-flow"
def description(self) -> str:
return """Git flow es una herramienta para automatizar un workflow siguiendo conventional commits, conventional branch y semver."""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
subparsers = parser.add_subparsers(
title="comandos", dest="command", required=True
)
for command in self.commands:
subparser = subparsers.add_parser(
command.name(), description=command.description()
)
command.setup_parser(subparser)
return parser
def get_parser(self) -> ArgumentParser:
parser = ArgumentParser(self.name(), description=self.description())
return self.setup_parser(parser)
def run(self, args: Namespace = Namespace()):
args = self.get_parser().parse_args()
for command in self.commands:
if command.name() == args.command:
command.init()
return command.run(args)
def main():
try:
app()
print()
locale.setlocale(locale.LC_ALL, LOCALE)
except locale.Error as e:
print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}")
command = GitFlowCommand(
InitCommand(),
NewCommand(),
CommitCommand(),
MergeCommand(),
TagCommand(),
ReleaseCommand(),
BranchCommand()
)
try:
command.run()
except GitFlowError as e:
base.io_error(str(e), title="Git Flow")
command.error(str(e))
except Exception as e:
base.io_error(str(e), title="Inesperado")
command.error("Ocurrió un error inesperado: " + str(e))
except KeyboardInterrupt as e:
base.io_error("Ejecución abortada")
print()
command.error("Ejecución abortada")
return
if __name__ == "__main__":
main()
+28 -11
View File
@@ -12,24 +12,42 @@ class RemoteAPI(ABC):
@staticmethod
def parse(remote: str):
url = Git("remote", "get-url", remote).firstline()
invalid_url_error = GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
url_type = None
if url.startswith("git@"):
url_type = "ssh"
elif url.startswith("http://") or url.startswith("https://"):
url_type = "https"
else:
raise invalid_url_error
if url_type == "ssh":
# git@host:user/repo[.git]
at_pos = url.index("@")
colon_pos = url.index(":")
schema = "ssh://"
host = url[at_pos+1:colon_pos]
repository = url[colon_pos+1:]
elif url.startswith("http://") or url.startswith("https://"):
start_pos = url.index("://") + 3
uri_start = url.index("/", start_pos)
schema = url[:start_pos]
host = url[start_pos:uri_start]
repository = url[uri_start+1:]
else:
raise GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
# https://host/user/repo[.git]
host_start = url.index("://") + 3
uri_start = url.index("/", host_start)
host = url[host_start:uri_start]
repository = url[uri_start+1:]
# Esto permite configurar hosts "imaginarios" para permitir el uso de multiples claves ssh
if host.endswith(".bitbucket.org"):
host = "bitbucket.org"
elif host.endswith(".github.com"):
host = "github.com"
if host not in SUPPORTED_REMOTE_APIS:
raise GitFlowError("El host del repositorio remoto es inválido")
repository.removesuffix(".git")
return [host, repository]
repository = repository.removesuffix(".git")
return [schema, host, repository]
@abstractmethod
def create_pull_request(
@@ -38,7 +56,6 @@ class RemoteAPI(ABC):
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
pass
+1 -2
View File
@@ -12,7 +12,6 @@ class BitbucketRemoteAPI(RemoteAPI):
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
):
request = requests.post(
self.get_endpoint("/pullrequests"),
@@ -22,7 +21,7 @@ class BitbucketRemoteAPI(RemoteAPI):
"description": description,
"source": {"branch": {"name": source}},
"destination": {"branch": {"name": destination}},
"close_source_branch": close_source_branch,
"close_source_branch": True,
},
)
-55
View File
@@ -1,55 +0,0 @@
import requests
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class GiteaRemoteAPI(RemoteAPI):
def __init__(self, base_url: str, repository: str, token: str) -> None:
super().__init__(repository, token)
self.base_url = base_url
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
response = requests.post(
self.get_endpoint("/pulls"),
headers=self.get_headers(),
json={
"title": title,
"body": description,
"head": source,
"base": destination,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["html_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
def create_tag(self, tag: str, commit: str) -> str:
response = requests.post(
self.get_endpoint("/tags"),
headers=self.get_headers(),
json={"tag_name": tag, "target": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_endpoint(self, resource: str) -> str:
return f"{self.base_url}/api/v1/repos/{self.repository}{resource}"
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "token " + self.token,
}
+4 -29
View File
@@ -1,10 +1,9 @@
import requests
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class GithubRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://api.github.com/repos"
API_ENDPOINT = "https://github.com"
def create_pull_request(
self,
@@ -12,35 +11,11 @@ class GithubRemoteAPI(RemoteAPI):
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
response = requests.post(
self.get_endpoint("/pulls"),
headers=self.get_headers(),
json={
"title": title,
"body": description,
"head": source,
"base": destination,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["html_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
raise GitFlowError("not implemented yet!")
def create_tag(self, tag: str, commit: str) -> str:
response = requests.post(
self.get_endpoint("/git/refs"),
headers=self.get_headers(),
json={"ref": f"refs/tags/{tag}", "sha": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
raise GitFlowError("not implemented yet!")
def get_endpoint(self, resource: str) -> str:
return f"{GithubRemoteAPI.API_ENDPOINT}/{self.repository}{resource}"
return GithubRemoteAPI.API_ENDPOINT
Generated
+67 -141
View File
@@ -1,15 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
requires-python = ">=3.10"
[[package]]
name = "certifi"
@@ -26,6 +17,70 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
@@ -46,43 +101,14 @@ wheels = [
]
[[package]]
name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "git-flow-envs"
name = "git-flow"
source = { editable = "." }
dependencies = [
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
{ name = "typer" },
]
[package.metadata]
requires-dist = [
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
{ name = "typer" },
]
requires-dist = [{ name = "requests" }]
[[package]]
name = "idna"
@@ -93,60 +119,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@@ -162,43 +134,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -207,12 +142,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "wcwidth"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
]