Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix, simplify, and improve certain aspects of the project. #24

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
105 changes: 41 additions & 64 deletions publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@
from git.cmd import Git
from github_release import gh_release_create

# Magical, always present, empty tree reference
# https://stackoverflow.com/questions/9765453/
# Magical, always present, empty tree reference. See https://stackoverflow.com/questions/9765453/
THE_NULL_COMMIT = Git().hash_object(os.devnull, t="tree")

ISSUE_NUMBER = re.compile(r"#(\d+)")
Expand All @@ -62,100 +61,75 @@ class Formatter(argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsH


class Repository(object):
RELEASE_TAG_PATTERN = re.compile(r"^v\d+(\.\d+){0,2}$")
RELEASE_TAG_PATTERN = re.compile(r"^v(0|[1-9]\d*)(\.(0|[1-9]\d*)){0,2}$")

def __init__(self, options):
self.repo = Repo(options.directory)
self._force = options.force
self._current_tag = None

@property
def current_tag(self):
if self._current_tag:
return self._current_tag
try:
current_name = self.repo.git.describe(all=True)
self._current_tag = self.repo.rev_parse(current_name)
return self._current_tag
except (BadName, GitCommandError):
return None

@property
def version(self):
tag = self.current_tag
current_name = self.repo.git.describe(all=True)
print(f"Current name: {current_name}", file=sys.stderr)
current_tag = self.repo.rev_parse(current_name)
print(f"Current tag id: {current_tag}", file=sys.stderr)
try:
return str(tag.tag)
except AttributeError:
return str(tag)
self.current_tag = current_tag.tag
print(f"Current tag: {self.current_tag}", file=sys.stderr)
except (AttributeError, BadName, GitCommandError):
print("Not an annotated tag!", file=sys.stderr)
exit(4)
Comment on lines +69 to +78
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since GitCommandError is caught here, I think you might've meant to include the git calls inside the try block.


def ready_for_release(self):
"""Return true if the current git checkout is suitable for release

To be suitable, it must not be dirty, must not have untracked files, must be an annotated tag,
and the tag must follow the naming convention v<major>.<minor>.<bugfix>
"""
if self._force:
return True
if self.repo.is_dirty():
print("Repository is dirty", file=sys.stderr)
return False
if self.repo.untracked_files:
file_list = "\n\t".join(self.repo.untracked_files)
print("Repository has untracked files:\n\t{}".format(file_list), file=sys.stderr)
return False
if not self._force:
if self.repo.is_dirty():
print("Repository is dirty", file=sys.stderr)
return False
if self.repo.untracked_files:
file_list = "\n\t".join(self.repo.untracked_files)
print(f"Repository has untracked files:\n\t{file_list}", file=sys.stderr)
return False
if not self.current_tag:
print("No tag found")
return False
try:
valid_tag = self.RELEASE_TAG_PATTERN.match(self.current_tag.tag) is not None
if not valid_tag:
print("Tag {} is not a valid release tag".format(self.current_tag.tag))
return valid_tag
except AttributeError:
print("Unable to read tag name")
return False
tag_is_valid = self.RELEASE_TAG_PATTERN.match(self.current_tag) is not None
if not tag_is_valid:
print(f"""Tag "{self.current_tag}" is not a valid release tag. Expected "vX.Y.Z" where X, Y, and Z are non-negative integers, formatted without leading zeros.""", file=sys.stderr)
return tag_is_valid

def generate_changelog(self):
"""Use the git log to create a changelog with all changes since the previous tag"""
try:
previous_name = self.repo.git.describe("{}^".format(self.current_tag), abbrev=0)
previous_name = self.repo.git.describe(f"{self.current_tag}^", abbrev=0)
previous_tag = self.repo.rev_parse(previous_name)
except GitCommandError:
previous_tag = THE_NULL_COMMIT
current = self._resolve_tag(self.current_tag)
previous = self._resolve_tag(previous_tag)
commit_range = "{}..{}".format(previous, current)
commit_range = f"{previous_tag}..{self.current_tag}"
return [(self._shorten(commit.hexsha), commit.summary) for commit in self.repo.iter_commits(commit_range)
if len(commit.parents) <= 1]

@staticmethod
def _resolve_tag(tag):
try:
current = tag.tag
except AttributeError:
current = tag
return current

def _shorten(self, sha):
return self.repo.git.rev_parse(sha, short=True)


class Uploader(object):
def __init__(self, options, version, changelog, artifacts):
self.dry_run = options.dry_run
self.repo = "{}/{}".format(options.organization, options.repository)
self.repo = f"{options.organization}/{options.repository}"
self.version = version
self.changelog = changelog
self.artifacts = artifacts

def _call(self, *args, **kwargs):
msg = kwargs.pop("msg", "")
if kwargs:
raise TypeError("Unexpected **kwargs: {!r}".format(kwargs))
raise TypeError(f"Unexpected **kwargs: {kwargs!r}")
try:
if self.dry_run:
cmd_line = " ".join(repr(x) for x in args)
print("Dry run. Would have called: {}".format(cmd_line))
print(f"Dry run. Would have called: {cmd_line}")
else:
subprocess.check_call(args)
except subprocess.CalledProcessError:
Expand All @@ -171,21 +145,19 @@ def github_release(self):

def pypi_release(self):
"""Create release in pypi.python.org, and upload artifacts and changelog"""
self._call("twine", "upload", *self.artifacts, msg="Failed to upload artifacts to PyPI")
self._call("twine", "upload", "--skip-existing", *self.artifacts, msg="Failed to upload artifacts to PyPI")


def format_rst_changelog(changelog, options):
output = CHANGELOG_HEADER.splitlines(False)
links = {}
for sha, summary in changelog:
links[sha] = ".. _{sha}: https://github.com/{org}/{repo}/commit/{sha}".format(
sha=sha, org=options.organization, repo=options.repository)
links[sha] = f".. _{sha}: https://github.com/{options.organization}/{options.repository}/commit/{sha}"
for match in ISSUE_NUMBER.finditer(summary):
issue_number = match.group(1)
links[issue_number] = ".. _#{num}: https://github.com/{org}/{repo}/issues/{num}".format(
num=issue_number, org=options.organization, repo=options.repository)
links[issue_number] = f".. _#{issue_number}: https://github.com/{options.organization}/{options.repository}/issues/{issue_number}"
summary = ISSUE_NUMBER.sub(r"`#\1`_", summary)
output.append("* `{sha}`_: {summary}".format(sha=sha, summary=summary))
output.append(f"* `{sha}`_: {summary}")
output.append("")
output.extend(links.values())
return "\n".join(output)
Expand All @@ -195,7 +167,7 @@ def format_gh_changelog(changelog):
output = CHANGELOG_HEADER.splitlines(False)
links = {}
for sha, summary in changelog:
output.append("* {sha}: {summary}".format(sha=sha, summary=summary))
output.append(f"* {sha}: {summary}")
output.append("")
output.extend(links.values())
return "\n".join(output)
Expand All @@ -215,7 +187,9 @@ def create_artifacts(changelog, options):
"egg_info", "--tag-build=",
"sdist",
"bdist_wheel", "--universal"
], env={"CHANGELOG_FILE": name})
],
env=( os.environ.copy() | {"CHANGELOG_FILE": name} ) # see https://stackoverflow.com/a/78652759
)
os.unlink(name)
return [os.path.abspath(os.path.join("dist", fname)) for fname in os.listdir("dist")]

Expand All @@ -227,7 +201,7 @@ def publish(options):
return 1
changelog = repo.generate_changelog()
artifacts = create_artifacts(changelog, options)
uploader = Uploader(options, repo.version, changelog, artifacts)
uploader = Uploader(options, repo.current_tag, changelog, artifacts)
return_code = 0
for i, release in enumerate((uploader.github_release, uploader.pypi_release)):
try:
Expand All @@ -243,8 +217,11 @@ def main():
parser.add_argument("-f", "--force", action="store_true", help="Make a release even if the repo is unclean")
parser.add_argument("-n", "--dry-run", action="store_true", help="Do everything, except upload to GH/PyPI")
parser.add_argument("organization", help="Github organization")
parser.add_argument("repository", help="The repository")
parser.add_argument("repository", help="The name of the repository on GitHub")
options = parser.parse_args()
if "GITHUB_TOKEN" not in os.environ:
print("Publish requires the GITHUB_TOKEN environment variable to be set. Before you run this command, try `export GITHUB_TOKEN=your-gh-token-blah-blah-blah-whatever` (on Unix-likes) or `set GITHUB_TOKEN=your-gh-token-blah-blah-blah-whatever` (on Windows)\n\nPublish also requires the TWINE_USERNAME and TWINE_PASSWORD environment variables to be set in the same way; but, unlike GITHUB_TOKEN, you will be prompted to provide them later if they aren't in the environment, so setting them beforehand is not mandatory.")
sys.exit(3)
sys.exit(publish(options))


Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

GENERIC_REQ = [
'GitPython==3.1.41',
"twine==4.0.2",
"twine==5.1.1",
"githubrelease==1.5.9",
]

Expand Down