autopub 0.3.0__tar.gz → 1.0.0a1__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,20 +1,19 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: autopub
3
- Version: 0.3.0
3
+ Version: 1.0.0a1
4
4
  Summary: Automatic package release upon pull request merge
5
5
  Home-page: https://github.com/autopub/autopub
6
6
  License: AGPL-3.0
7
7
  Keywords: automatic,packaging,publish,release,version
8
8
  Author: Justin Mayer
9
9
  Author-email: entroP@gmail.com
10
- Requires-Python: >=3.7,<4.0
10
+ Requires-Python: >=3.8,<4.0
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: GNU Affero General Public License v3
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.7
18
17
  Classifier: Programming Language :: Python :: 3.8
19
18
  Classifier: Programming Language :: Python :: 3.9
20
19
  Classifier: Programming Language :: Python :: 3.10
@@ -27,8 +26,12 @@ Requires-Dist: build (>=0.10.0,<0.11.0)
27
26
  Requires-Dist: dunamai (>=1.17.0,<2.0.0)
28
27
  Requires-Dist: githubrelease (>=1.5.9,<2.0.0) ; extra == "github"
29
28
  Requires-Dist: httpx (==0.16.1) ; extra == "github"
29
+ Requires-Dist: python-frontmatter (>=1.0.0,<2.0.0)
30
+ Requires-Dist: rich (>=12.5.1,<13.0.0)
31
+ Requires-Dist: time-machine (>=2.13.0,<3.0.0)
30
32
  Requires-Dist: tomlkit (>=0.5,<2.0)
31
33
  Requires-Dist: twine (>=4.0.2,<5.0.0)
34
+ Requires-Dist: typer (>=0.9.0,<0.10.0)
32
35
  Project-URL: Issue Tracker, https://github.com/autopub/autopub/issues
33
36
  Project-URL: Repository, https://github.com/autopub/autopub
34
37
  Description-Content-Type: text/markdown
@@ -87,3 +90,4 @@ For systems such as Travis CI in which only one deployment step is permitted, th
87
90
  [Travis CI]: https://travis-ci.org
88
91
  [build]: https://pypa-build.readthedocs.io
89
92
  [Twine]: https://twine.readthedocs.io/
93
+
@@ -51,4 +51,4 @@ For systems such as Travis CI in which only one deployment step is permitted, th
51
51
  [CircleCI]: https://circleci.com
52
52
  [Travis CI]: https://travis-ci.org
53
53
  [build]: https://pypa-build.readthedocs.io
54
- [Twine]: https://twine.readthedocs.io/
54
+ [Twine]: https://twine.readthedocs.io/
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ import frontmatter
9
+
10
+ from autopub.exceptions import (
11
+ ArtifactHashMismatch,
12
+ ArtifactNotFound,
13
+ AutopubException,
14
+ NoPackageManagerPluginFound,
15
+ ReleaseFileEmpty,
16
+ ReleaseFileNotFound,
17
+ ReleaseNotesEmpty,
18
+ ReleaseTypeInvalid,
19
+ ReleaseTypeMissing,
20
+ )
21
+ from autopub.plugins import AutopubPackageManagerPlugin, AutopubPlugin
22
+ from autopub.types import ReleaseInfo
23
+
24
+
25
+ class Autopub:
26
+ RELEASE_FILE_PATH = "RELEASE.md"
27
+
28
+ def __init__(self, plugins: Iterable[type[AutopubPlugin]] = ()) -> None:
29
+ self.plugins = [plugin_class() for plugin_class in plugins]
30
+
31
+ @property
32
+ def release_file(self) -> Path:
33
+ return Path.cwd() / self.RELEASE_FILE_PATH
34
+
35
+ @property
36
+ def release_notes(self) -> str:
37
+ return self.release_file.read_text()
38
+
39
+ @property
40
+ def release_file_hash(self) -> str:
41
+ return hashlib.sha256(self.release_notes.encode("utf-8")).hexdigest()
42
+
43
+ @property
44
+ def release_data_file(self) -> Path:
45
+ return Path(".autopub") / "release_data.json"
46
+
47
+ # TODO: typed dict
48
+ @property
49
+ def release_data(self) -> ReleaseInfo:
50
+ if not self.release_data_file.exists():
51
+ raise ArtifactNotFound()
52
+
53
+ release_data = json.loads(self.release_data_file.read_text())
54
+
55
+ if release_data["hash"] != self.release_file_hash:
56
+ raise ArtifactHashMismatch()
57
+
58
+ return ReleaseInfo(
59
+ release_type=release_data["release_type"],
60
+ release_notes=release_data["release_notes"],
61
+ additional_info=release_data["plugin_data"],
62
+ )
63
+
64
+ def check(self) -> ReleaseInfo:
65
+ release_file = Path(self.RELEASE_FILE_PATH)
66
+
67
+ if not release_file.exists():
68
+ raise ReleaseFileNotFound()
69
+
70
+ try:
71
+ release_info = self._validate_release_notes(self.release_notes)
72
+ except AutopubException as e:
73
+ for plugin in self.plugins:
74
+ plugin.on_release_notes_invalid(e)
75
+ raise
76
+
77
+ for plugin in self.plugins:
78
+ plugin.on_release_notes_valid(release_info)
79
+
80
+ self._write_artifact(release_info)
81
+
82
+ return release_info
83
+
84
+ def build(self) -> None:
85
+ if not any(
86
+ isinstance(plugin, AutopubPackageManagerPlugin) for plugin in self.plugins
87
+ ):
88
+ raise NoPackageManagerPluginFound()
89
+
90
+ for plugin in self.plugins:
91
+ if isinstance(plugin, AutopubPackageManagerPlugin):
92
+ plugin.build()
93
+
94
+ def prepare(self) -> None:
95
+ for plugin in self.plugins:
96
+ plugin.prepare(self.release_data)
97
+
98
+ def publish(self, repository: str | None = None) -> None:
99
+ # TODO: shall we put this in a function, to make it
100
+ # clear that we are triggering the logic to check the release file?
101
+ self.release_data
102
+
103
+ for plugin in self.plugins:
104
+ if isinstance(plugin, AutopubPackageManagerPlugin):
105
+ plugin.publish(repository=repository)
106
+
107
+ def _write_artifact(self, release_info: ReleaseInfo) -> None:
108
+ data = {
109
+ "hash": self.release_file_hash,
110
+ "release_type": release_info.release_type,
111
+ "release_notes": release_info.release_notes,
112
+ "plugin_data": {
113
+ key: value
114
+ for plugin in self.plugins
115
+ for key, value in plugin.data.items()
116
+ },
117
+ }
118
+
119
+ self.release_data_file.parent.mkdir(exist_ok=True)
120
+ self.release_data_file.write_text(json.dumps(data))
121
+
122
+ def _deprecated_load(self, release_notes: str) -> ReleaseInfo:
123
+ # supports loading of old release notes format, which is
124
+ # deprecated and will be removed in a future release
125
+ # and looks like this:
126
+ # Release type: patch
127
+ # release notes here.
128
+
129
+ try:
130
+ release_info, release_notes = release_notes.split("\n", 1)
131
+ except ValueError as e:
132
+ raise ReleaseTypeMissing() from e
133
+
134
+ release_info = release_info.lower()
135
+
136
+ if not release_info.startswith("release type:"):
137
+ raise ReleaseTypeMissing()
138
+
139
+ release_type = release_info.split(":", 1)[1].strip().lower()
140
+
141
+ return ReleaseInfo(
142
+ release_type=release_type, release_notes=release_notes.strip()
143
+ )
144
+
145
+ def _load_from_frontmatter(self, release_notes: str) -> ReleaseInfo:
146
+ # supports loading of new release notes format, which looks like this:
147
+ # ---
148
+ # release type: patch
149
+ # ---
150
+ # release notes here.
151
+
152
+ post = frontmatter.loads(release_notes)
153
+
154
+ data: dict[str, str] = post.to_dict()
155
+
156
+ release_type = data.pop("release type").lower()
157
+
158
+ if release_type not in ("major", "minor", "patch"):
159
+ raise ReleaseTypeInvalid(release_type)
160
+
161
+ if post.content.strip() == "":
162
+ raise ReleaseNotesEmpty()
163
+
164
+ return ReleaseInfo(
165
+ release_type=release_type,
166
+ release_notes=post.content,
167
+ additional_info=data,
168
+ )
169
+
170
+ def _validate_release_notes(self, release_notes: str) -> ReleaseInfo:
171
+ if not release_notes:
172
+ raise ReleaseFileEmpty()
173
+
174
+ try:
175
+ release_info = self._load_from_frontmatter(release_notes)
176
+ except KeyError:
177
+ release_info = self._deprecated_load(release_notes)
178
+
179
+ for plugin in self.plugins:
180
+ plugin.validate_release_notes(release_info)
181
+
182
+ return release_info
@@ -0,0 +1,121 @@
1
+ from typing import Optional, TypedDict
2
+
3
+ import rich
4
+ import typer
5
+ from rich.console import Group
6
+ from rich.markdown import Markdown
7
+ from rich.padding import Padding
8
+ from rich.panel import Panel
9
+ from typing_extensions import Annotated
10
+
11
+ from autopub import Autopub
12
+ from autopub.cli.plugins import find_plugins
13
+ from autopub.exceptions import AutopubException
14
+
15
+ app = typer.Typer()
16
+
17
+
18
+ class State(TypedDict):
19
+ plugins: list[str]
20
+
21
+
22
+ state: State = {"plugins": []}
23
+
24
+
25
+ @app.command()
26
+ def check():
27
+ """This commands checks if the current PR has a valid release file."""
28
+
29
+ autopub = Autopub(plugins=find_plugins(state["plugins"]))
30
+
31
+ try:
32
+ release_info = autopub.check()
33
+ except AutopubException as e:
34
+ rich.print(Panel.fit(f"[red]{e.message}"))
35
+
36
+ raise typer.Exit(1) from e
37
+ else:
38
+ rich.print(
39
+ Padding(
40
+ Group(
41
+ (
42
+ "[bold on bright_magenta] Release type: [/] "
43
+ f"[yellow italic underline]{release_info.release_type}[/]\n"
44
+ ),
45
+ "[bold on bright_magenta] Release notes: [/]\n",
46
+ Markdown(release_info.release_notes),
47
+ "\n---\n\n[green bold]Release file is valid![/] 🚀",
48
+ ),
49
+ (1, 1),
50
+ )
51
+ )
52
+
53
+
54
+ @app.command()
55
+ def build():
56
+ autopub = Autopub(plugins=find_plugins(state["plugins"]))
57
+
58
+ try:
59
+ autopub.build()
60
+ except AutopubException as e:
61
+ rich.print(Panel.fit(f"[red]{e.message}"))
62
+
63
+ raise typer.Exit(1) from e
64
+ else:
65
+ rich.print(Panel.fit("[green]Build succeeded"))
66
+
67
+
68
+ @app.command()
69
+ def prepare():
70
+ autopub = Autopub(plugins=find_plugins(state["plugins"]))
71
+
72
+ try:
73
+ autopub.prepare()
74
+ except AutopubException as e:
75
+ rich.print(Panel.fit(f"[red]{e.message}"))
76
+
77
+ raise typer.Exit(1) from e
78
+ else:
79
+ rich.print(Panel.fit("[green]Preparation succeeded"))
80
+
81
+
82
+ @app.command()
83
+ def publish(
84
+ repository: Annotated[
85
+ Optional[str],
86
+ typer.Option("--repository", "-r", help="Repository to publish to"),
87
+ ] = None,
88
+ ):
89
+ autopub = Autopub(plugins=find_plugins(state["plugins"]))
90
+
91
+ try:
92
+ autopub.publish(repository=repository)
93
+ except AutopubException as e:
94
+ rich.print(Panel.fit(f"[red]{e.message}"))
95
+
96
+ raise typer.Exit(1) from e
97
+ else:
98
+ rich.print(Panel.fit("[green]Publishing succeeded"))
99
+
100
+
101
+ @app.callback(invoke_without_command=True)
102
+ def main(
103
+ plugins: list[str] = typer.Option(
104
+ [],
105
+ "--plugin",
106
+ "-p",
107
+ help="List of plugins to use",
108
+ ),
109
+ should_show_version: Annotated[
110
+ Optional[bool], typer.Option("--version", is_eager=True)
111
+ ] = None,
112
+ ):
113
+ state["plugins"] = plugins
114
+ state["plugins"].extend(["bump_version"])
115
+
116
+ if should_show_version:
117
+ from importlib.metadata import version
118
+
119
+ print(version("autopub"))
120
+
121
+ raise typer.Exit()
@@ -0,0 +1,37 @@
1
+ from importlib import import_module
2
+
3
+ from autopub.plugins import AutopubPlugin
4
+
5
+
6
+ def _find_plugin(module: object) -> type[AutopubPlugin] | None:
7
+ for obj in module.__dict__.values():
8
+ if (
9
+ isinstance(obj, type)
10
+ and issubclass(obj, AutopubPlugin)
11
+ and obj is not AutopubPlugin
12
+ ):
13
+ return obj
14
+
15
+ return None
16
+
17
+
18
+ def find_plugins(names: list[str]) -> list[type[AutopubPlugin]]:
19
+ plugins: list[type] = []
20
+
21
+ for plugin_name in names:
22
+ try:
23
+ # TODO: find plugins outside the autopub namespace
24
+ plugin_module = import_module(f"autopub.plugins.{plugin_name}")
25
+
26
+ plugin_class = _find_plugin(plugin_module)
27
+
28
+ if plugin_class is None:
29
+ print(f"Could not find plugin {plugin_name}")
30
+ # TODO: raise
31
+ continue
32
+
33
+ plugins.append(plugin_class)
34
+ except ImportError as e:
35
+ print(f"Error importing plugin {plugin_name}: {e}")
36
+
37
+ return plugins
@@ -0,0 +1,47 @@
1
+ class AutopubException(Exception):
2
+ message: str
3
+
4
+ def __init__(self) -> None:
5
+ super().__init__(self.message)
6
+
7
+
8
+ class ReleaseFileNotFound(AutopubException):
9
+ message = "Release file not found"
10
+
11
+
12
+ class ReleaseFileEmpty(AutopubException):
13
+ message = "Release file is empty"
14
+
15
+
16
+ class ReleaseNotesEmpty(AutopubException):
17
+ message = "Release notes are empty"
18
+
19
+
20
+ class ReleaseTypeMissing(AutopubException):
21
+ message: str = "Release note is missing release type"
22
+
23
+
24
+ class ReleaseTypeInvalid(AutopubException):
25
+ def __init__(self, release_type: str):
26
+ self.message = f"Release type {release_type} is invalid"
27
+ super().__init__()
28
+
29
+
30
+ class NoPackageManagerPluginFound(AutopubException):
31
+ message = "No package manager plugin found"
32
+
33
+
34
+ class ArtifactNotFound(AutopubException):
35
+ message = "Artifact not found, did you run `autopub check`?"
36
+
37
+
38
+ class ArtifactHashMismatch(AutopubException):
39
+ message = "Artifact hash mismatch, did you run `autopub check`?"
40
+
41
+
42
+ class CommandFailed(AutopubException):
43
+ def __init__(self, command: list[str], returncode: int) -> None:
44
+ self.message = (
45
+ f"Command {' '.join(command)} failed with return code {returncode}"
46
+ )
47
+ super().__init__()
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+ from autopub.exceptions import AutopubException, CommandFailed
7
+ from autopub.types import ReleaseInfo
8
+
9
+
10
+ class AutopubPlugin:
11
+ data: dict[str, object] = {}
12
+
13
+ def run_command(self, command: list[str]) -> None:
14
+ try:
15
+ subprocess.run(command, check=True)
16
+ except subprocess.CalledProcessError as e:
17
+ raise CommandFailed(command=command, returncode=e.returncode) from e
18
+
19
+ def prepare(self, release_info: ReleaseInfo) -> None: # pragma: no cover
20
+ ...
21
+
22
+ def validate_release_notes(self, release_info: ReleaseInfo): # pragma: no cover
23
+ ...
24
+
25
+ def on_release_notes_valid(self, release_info: ReleaseInfo): # pragma: no cover
26
+ ...
27
+
28
+ def on_release_notes_invalid(self, exception: AutopubException): # pragma: no cover
29
+ ...
30
+
31
+
32
+ @runtime_checkable
33
+ class AutopubPackageManagerPlugin(Protocol):
34
+ def build(self) -> None: # pragma: no cover
35
+ ...
36
+
37
+ def publish(
38
+ self, repository: str | None = None, **kwargs: Any
39
+ ) -> None: # pragma: no cover
40
+ ...
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ import tomlkit
6
+ from dunamai import Version
7
+
8
+ from autopub.plugins import AutopubPlugin
9
+ from autopub.types import ReleaseInfo
10
+
11
+
12
+ class BumpVersionPlugin(AutopubPlugin):
13
+ @property
14
+ def pyproject_config(self) -> tomlkit.TOMLDocument:
15
+ content = pathlib.Path("pyproject.toml").read_text()
16
+
17
+ return tomlkit.parse(content)
18
+
19
+ def _get_version(self, config: tomlkit.TOMLDocument) -> str:
20
+ try:
21
+ return config["tool"]["poetry"]["version"] # type: ignore
22
+ except KeyError:
23
+ return config["project"]["version"] # type: ignore
24
+
25
+ def _update_version(self, config: tomlkit.TOMLDocument, new_version: str) -> None:
26
+ try:
27
+ config["tool"]["poetry"]["version"] = new_version # type: ignore
28
+ except KeyError:
29
+ config["project"]["version"] = new_version # type: ignore
30
+
31
+ def prepare(self, release_info: ReleaseInfo) -> None:
32
+ config = self.pyproject_config
33
+
34
+ version = Version(self._get_version(config))
35
+
36
+ bump_type = {"major": 0, "minor": 1, "patch": 2}[release_info.release_type]
37
+ new_version = version.bump(bump_type).serialize()
38
+
39
+ self._update_version(config, new_version)
40
+
41
+ pathlib.Path("pyproject.toml").write_text(tomlkit.dumps(config)) # type: ignore
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from autopub.plugins import AutopubPackageManagerPlugin, AutopubPlugin
6
+
7
+
8
+ class PDMPlugin(AutopubPlugin, AutopubPackageManagerPlugin):
9
+ def build(self) -> None:
10
+ self.run_command(["pdm", "build"])
11
+
12
+ def publish(self, repository: str | None = None, **kwargs: Any) -> None:
13
+ additional_args: list[str] = []
14
+
15
+ if repository:
16
+ additional_args += ["--repository", repository]
17
+
18
+ self.run_command(["pdm", "publish", *additional_args])
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from autopub.plugins import AutopubPackageManagerPlugin, AutopubPlugin
4
+
5
+
6
+ class PoetryPlugin(AutopubPlugin, AutopubPackageManagerPlugin):
7
+ def build(self) -> None:
8
+ self.run_command(["poetry", "build"])
9
+
10
+ def publish(self, repository: str | None = None, **kwargs: str) -> None:
11
+ additional_args: list[str] = []
12
+
13
+ if repository:
14
+ additional_args += ["--repository", repository]
15
+
16
+ self.run_command(["poetry", "publish", *additional_args])
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from autopub.plugins import AutopubPlugin
7
+ from autopub.types import ReleaseInfo
8
+
9
+ # TODO: from config
10
+ CHANGELOG_HEADER = "========="
11
+ VERSION_HEADER = "-"
12
+
13
+
14
+ class UpdateChangelogPlugin(AutopubPlugin):
15
+ @property
16
+ def changelog_file(self) -> Path:
17
+ return Path("CHANGELOG.md")
18
+
19
+ def post_prepare(self, release_info: ReleaseInfo) -> None:
20
+ assert release_info.version is not None
21
+
22
+ if not self.changelog_file.exists():
23
+ self.changelog_file.write_text(f"CHANGELOG\n{CHANGELOG_HEADER}\n\n")
24
+
25
+ current_date = date.today().strftime("%Y-%m-%d")
26
+
27
+ old_changelog_data = ""
28
+ header = ""
29
+
30
+ lines = self.changelog_file.read_text().splitlines()
31
+
32
+ for index, line in enumerate(lines):
33
+ if CHANGELOG_HEADER != line.strip():
34
+ continue
35
+
36
+ old_changelog_data = lines[index + 1 :]
37
+ header = lines[: index + 1]
38
+ break
39
+
40
+ with self.changelog_file.open("w") as f:
41
+ f.write("\n".join(header))
42
+ f.write("\n")
43
+
44
+ new_version_header = f"{release_info.version} - {current_date}"
45
+
46
+ f.write(f"\n{new_version_header}\n")
47
+ f.write(f"{VERSION_HEADER * len(new_version_header)}\n\n")
48
+ f.write(release_info.release_notes)
49
+ f.write("\n")
50
+ f.write("\n".join(old_changelog_data))
@@ -0,0 +1,11 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class ReleaseInfo:
6
+ """Release information."""
7
+
8
+ release_type: str
9
+ release_notes: str
10
+ additional_info: dict[str, str] = dataclasses.field(default_factory=dict)
11
+ version: str | None = None
@@ -1,8 +1,11 @@
1
+ [project]
2
+ requires-python = ">=3.8"
3
+
1
4
  [tool.poetry]
2
5
  name = "autopub"
3
- version = "0.3.0"
6
+ version = "1.0.0-alpha.1"
4
7
  description = "Automatic package release upon pull request merge"
5
- authors = ["Justin Mayer <entroP@gmail.com>"]
8
+ authors = ["Justin Mayer <entroP@gmail.com>", "Patrick Arminio <patrick.arminio@gmail.com>"]
6
9
  license = "AGPL-3.0"
7
10
  readme = "README.md"
8
11
  keywords = ["automatic", "packaging", "publish", "release", "version"]
@@ -18,17 +21,22 @@ classifiers = [
18
21
  "Topic :: System :: Systems Administration",
19
22
  ]
20
23
 
24
+
21
25
  [tool.poetry.urls]
22
26
  "Issue Tracker" = "https://github.com/autopub/autopub/issues"
23
27
 
24
28
  [tool.poetry.dependencies]
25
- python = "^3.7"
29
+ python = "^3.8"
26
30
  tomlkit = ">= 0.5, < 2.0"
27
31
  githubrelease = {version = "^1.5.9", optional = true}
28
32
  httpx = {version = "=0.16.1", optional = true}
33
+ typer = "^0.9.0"
34
+ rich = "^12.5.1"
35
+ python-frontmatter = "^1.0.0"
29
36
  build = "^0.10.0"
30
37
  twine = "^4.0.2"
31
38
  dunamai = "^1.17.0"
39
+ time-machine = "^2.13.0"
32
40
 
33
41
  [tool.poetry.dev-dependencies]
34
42
  githubrelease = "^1.5"
@@ -43,18 +51,19 @@ myst-parser = "^0.15"
43
51
  Sphinx = "^4.2"
44
52
 
45
53
  # Linting
46
- black = {version = "^19.3b0", allow-prereleases = true}
47
- flake8 = "^3.8"
48
- flake8-black = "^0.2"
49
- flake8-bugbear = "^20.0"
50
- flake8-fixme = "^1.1"
51
- flake8-markdown = "^0.2.0"
54
+ black = "23.3.0"
55
+ mypy = "^0.971"
56
+ pytest = "^7.1.2"
57
+ pytest-httpserver = "^1.0.8"
52
58
 
53
59
  [tool.poetry.extras]
54
60
  github = ["githubrelease", "httpx"]
55
61
 
56
62
  [tool.poetry.scripts]
57
- autopub = "autopub.autopub:main"
63
+ autopub = "autopub.cli:app"
64
+
65
+ [tool.poetry.group.dev.dependencies]
66
+ pytest-cov = "^4.1.0"
58
67
 
59
68
  [tool.autopub]
60
69
  project-name = "AutoPub"
@@ -62,6 +71,18 @@ git-username = "botpub"
62
71
  git-email = "botpub@autopub.rocks"
63
72
  append-github-contributor = true
64
73
 
74
+
75
+ [tool.ruff]
76
+ select = [
77
+ "I",
78
+ "E",
79
+ "F",
80
+ "UP",
81
+ ]
82
+ exclude = ["typings"]
83
+ force-exclude = true
84
+
85
+
65
86
  [build-system]
66
87
  requires = ["poetry-core>=1.0.0"]
67
88
  build-backend = "poetry.core.masonry.api"
File without changes
@@ -1,83 +0,0 @@
1
- import argparse
2
- import os
3
- import sys
4
-
5
- sys.path.append(os.path.dirname(__file__)) # noqa
6
-
7
- from build_release import build_release
8
- from check_release import check_release
9
- from commit_release import git_commit_and_push
10
- from create_github_release import create_github_release
11
- from deploy_release import deploy_release
12
- from prepare_release import prepare_release
13
- from publish_release import publish_release
14
-
15
-
16
- def check(arguments):
17
- check_release()
18
-
19
-
20
- def prepare(arguments):
21
- prepare_release()
22
-
23
-
24
- def build(arguments):
25
- build_release()
26
-
27
-
28
- def commit(arguments):
29
- git_commit_and_push()
30
-
31
-
32
- def githubrelease(arguments):
33
- create_github_release()
34
-
35
-
36
- def publish(arguments):
37
- publish_release()
38
-
39
-
40
- def deploy(arguments):
41
- deploy_release()
42
-
43
-
44
- def parse_arguments():
45
- try:
46
- version = __import__("pkg_resources").get_distribution("autopub").version
47
- except Exception:
48
- version = "unknown"
49
-
50
- parser = argparse.ArgumentParser()
51
- parser.add_argument("--version", action="version", version=version)
52
-
53
- subparsers = parser.add_subparsers()
54
-
55
- check_parser = subparsers.add_parser("check")
56
- check_parser.set_defaults(func=check)
57
-
58
- prepare_parser = subparsers.add_parser("prepare")
59
- prepare_parser.set_defaults(func=prepare)
60
-
61
- build_parser = subparsers.add_parser("build")
62
- build_parser.set_defaults(func=build)
63
-
64
- commit_parser = subparsers.add_parser("commit")
65
- commit_parser.set_defaults(func=commit)
66
-
67
- githubrelease_parser = subparsers.add_parser("githubrelease")
68
- githubrelease_parser.set_defaults(func=githubrelease)
69
-
70
- publish_parser = subparsers.add_parser("publish")
71
- publish_parser.set_defaults(func=publish)
72
-
73
- deploy_parser = subparsers.add_parser("deploy")
74
- deploy_parser.set_defaults(func=deploy)
75
-
76
- arguments = parser.parse_args()
77
-
78
- return arguments
79
-
80
-
81
- def main():
82
- arguments = parse_arguments()
83
- arguments.func(arguments)
@@ -1,160 +0,0 @@
1
- import os
2
- import re
3
- import subprocess
4
- import sys
5
- from pathlib import Path
6
-
7
- from tomlkit import parse
8
-
9
-
10
- def dict_get(_dict, keys, default=None):
11
- """Query nested dictionary with list of keys, returning None if not found."""
12
- for key in keys:
13
- if isinstance(_dict, dict):
14
- _dict = _dict.get(key, default)
15
- else:
16
- return default
17
- return _dict
18
-
19
-
20
- # Determine CI/CD environment
21
-
22
- if os.environ.get("CIRCLECI"):
23
- CI_SYSTEM = "circleci"
24
- CIRCLE_PROJECT_USERNAME = os.environ.get("CIRCLE_PROJECT_USERNAME")
25
- CIRCLE_PROJECT_REPONAME = os.environ.get("CIRCLE_PROJECT_REPONAME")
26
- REPO_SLUG = f"{CIRCLE_PROJECT_USERNAME}/{CIRCLE_PROJECT_REPONAME}"
27
- elif os.environ.get("GITHUB_ACTIONS") == "true":
28
- CI_SYSTEM = "github"
29
- REPO_SLUG = os.environ.get("GITHUB_REPOSITORY")
30
- elif os.environ.get("TRAVIS"):
31
- CI_SYSTEM = "travis"
32
- REPO_SLUG = os.environ.get("TRAVIS_REPO_SLUG")
33
- else:
34
- CI_SYSTEM = os.environ.get("CI_SYSTEM", None)
35
- REPO_SLUG = os.environ.get("REPO_SLUG", None)
36
-
37
- # Project root and file name configuration
38
-
39
- PROJECT_ROOT = os.environ.get("PROJECT_ROOT")
40
- PYPROJECT_FILE_NAME = os.environ.get("PYPROJECT_FILE_NAME", "pyproject.toml")
41
-
42
- if PROJECT_ROOT:
43
- ROOT = Path(PROJECT_ROOT)
44
- else:
45
- ROOT = Path(
46
- subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
47
- .decode("ascii")
48
- .strip()
49
- )
50
-
51
- PYPROJECT_FILE = ROOT / PYPROJECT_FILE_NAME
52
-
53
- # Retrieve configuration from pyproject file
54
-
55
- if os.path.exists(PYPROJECT_FILE):
56
- config = parse(open(PYPROJECT_FILE).read())
57
- else:
58
- print(f"Could not find pyproject file at: {PYPROJECT_FILE}")
59
- sys.exit(1)
60
-
61
- PROJECT_NAME = dict_get(config, ["tool", "autopub", "project-name"])
62
- if not PROJECT_NAME:
63
- PROJECT_NAME = dict_get(config, ["tool", "poetry", "name"])
64
- if not PROJECT_NAME:
65
- PROJECT_NAME = dict_get(config, ["project", "name"])
66
- if not PROJECT_NAME:
67
- print(
68
- "Could not determine project name. Under the pyproject file's "
69
- '[tool.autopub] header, add:\nproject-name = "YourProjectName"'
70
- )
71
- sys.exit(1)
72
-
73
- RELEASE_FILE_NAME = dict_get(
74
- config, ["tool", "autopub", "release-file"], default="RELEASE.md"
75
- )
76
- RELEASE_FILE = ROOT / RELEASE_FILE_NAME
77
-
78
- CHANGELOG_FILE_NAME = dict_get(
79
- config, ["tool", "autopub", "changelog-file"], default="CHANGELOG.md"
80
- )
81
- CHANGELOG_FILE = ROOT / CHANGELOG_FILE_NAME
82
-
83
- CHANGELOG_HEADER = dict_get(
84
- config, ["tool", "autopub", "changelog-header"], default="========="
85
- )
86
-
87
- VERSION_HEADER = dict_get(config, ["tool", "autopub", "version-header"], default="-")
88
- VERSION_STRINGS = dict_get(config, ["tool", "autopub", "version-strings"], default=[])
89
-
90
- TAG_PREFIX = dict_get(config, ["tool", "autopub", "tag-prefix"], default="")
91
-
92
- PYPI_URL = dict_get(config, ["tool", "autopub", "pypi-url"])
93
-
94
- # Git configuration
95
-
96
- GIT_USERNAME = dict_get(config, ["tool", "autopub", "git-username"])
97
- GIT_EMAIL = dict_get(config, ["tool", "autopub", "git-email"])
98
-
99
- # GitHub
100
-
101
- GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", None)
102
- APPEND_GITHUB_CONTRIBUTOR = dict_get(
103
- config, ["tool", "autopub", "append-github-contributor"], False
104
- )
105
-
106
-
107
- def run_process(popenargs, encoding="utf-8", env=None):
108
- if env is not None:
109
- env = {**os.environ, **env}
110
- try:
111
- return subprocess.check_output(popenargs, encoding=encoding, env=env).strip()
112
- except subprocess.CalledProcessError as e:
113
- print(e.output, file=sys.stderr)
114
- sys.exit(1)
115
-
116
-
117
- def git(popenargs):
118
- # Do not decode ASCII for commit messages so emoji are preserved
119
- return subprocess.check_output(["git", *popenargs])
120
-
121
-
122
- def check_exit_code(popenargs):
123
- return subprocess.call(popenargs, shell=True)
124
-
125
-
126
- def get_project_version():
127
- # Backwards compat: Try poetry first and then fall "back" to standards
128
- version = dict_get(config, ["tool", "poetry", "version"])
129
- if version is None:
130
- return dict_get(config, ["project", "version"])
131
- else:
132
- return version
133
-
134
-
135
- def get_release_info():
136
- RELEASE_TYPE_REGEX = re.compile(r"^[Rr]elease [Tt]ype: (major|minor|patch)$")
137
-
138
- with open(RELEASE_FILE, "r") as f:
139
- line = f.readline()
140
- match = RELEASE_TYPE_REGEX.match(line)
141
-
142
- if not match:
143
- print(
144
- "The RELEASE file should start with 'Release type' and "
145
- "specify one of the following values: major, minor, or patch."
146
- )
147
- sys.exit(1)
148
-
149
- type_ = match.group(1)
150
- changelog = "".join([l for l in f.readlines()]).strip()
151
-
152
- return type_, changelog
153
-
154
-
155
- def configure_git():
156
- if not GIT_USERNAME or not GIT_EMAIL:
157
- print("git-username and git-email must be defined in the pyproject file")
158
- sys.exit(1)
159
- git(["config", "user.name", GIT_USERNAME])
160
- git(["config", "user.email", GIT_EMAIL])
@@ -1,14 +0,0 @@
1
- import os
2
- import sys
3
-
4
- sys.path.append(os.path.dirname(__file__)) # noqa
5
-
6
- from base import git, run_process
7
-
8
-
9
- def build_release():
10
- env = None
11
- if "SOURCE_DATE_EPOCH" not in os.environ:
12
- ctime = git(["log", "-1", "--pretty=%ct"]).decode().strip()
13
- env = {"SOURCE_DATE_EPOCH": ctime}
14
- run_process([sys.executable, "-m", "build"], env=env)
@@ -1,19 +0,0 @@
1
- import os
2
- import sys
3
-
4
- sys.path.append(os.path.dirname(__file__)) # noqa
5
-
6
- from base import CI_SYSTEM, RELEASE_FILE, run_process
7
-
8
-
9
- def check_release():
10
- needs_release = os.path.exists(RELEASE_FILE)
11
- if not needs_release:
12
- print("Not releasing a new version because there is no RELEASE file.")
13
- if CI_SYSTEM == "circleci":
14
- run_process(["circleci", "step", "halt"])
15
- elif CI_SYSTEM == "travis":
16
- sys.exit(1)
17
- if CI_SYSTEM == "github":
18
- with open(os.path.expandvars("$GITHUB_OUTPUT"), "a") as f:
19
- f.write("autopub_release={}\n".format("true" if needs_release else "false"))
@@ -1,33 +0,0 @@
1
- import os
2
- import sys
3
-
4
- sys.path.append(os.path.dirname(__file__)) # noqa
5
-
6
- from base import (
7
- get_project_version,
8
- git,
9
- configure_git,
10
- PROJECT_NAME,
11
- PYPROJECT_FILE_NAME,
12
- CHANGELOG_FILE_NAME,
13
- RELEASE_FILE_NAME,
14
- VERSION_STRINGS,
15
- )
16
-
17
-
18
- def git_commit_and_push():
19
- configure_git()
20
-
21
- version = get_project_version()
22
-
23
- git(["add", PYPROJECT_FILE_NAME])
24
- git(["add", CHANGELOG_FILE_NAME])
25
-
26
- if VERSION_STRINGS:
27
- for version_file in VERSION_STRINGS:
28
- git(["add", version_file])
29
-
30
- git(["rm", "--cached", RELEASE_FILE_NAME])
31
-
32
- git(["commit", "-m", f"Release {PROJECT_NAME} {version}"])
33
- git(["push", "origin", "HEAD"])
@@ -1,66 +0,0 @@
1
- import os
2
- import sys
3
- import time
4
-
5
- sys.path.append(os.path.dirname(__file__)) # noqa
6
-
7
- from base import (
8
- run_process,
9
- check_exit_code,
10
- get_project_version,
11
- configure_git,
12
- PROJECT_NAME,
13
- REPO_SLUG,
14
- TAG_PREFIX,
15
- get_release_info,
16
- )
17
-
18
-
19
- def create_github_release():
20
- try:
21
- from github_release import gh_release_create, gh_asset_upload
22
- except ModuleNotFoundError:
23
- print("Cannot create GitHub release due to missing dependency: github_release")
24
- sys.exit(1)
25
-
26
- configure_git()
27
- version = get_project_version()
28
- tag = f"{TAG_PREFIX}{version}"
29
-
30
- if not version:
31
- print("Unable to determine the current version")
32
- sys.exit(1)
33
-
34
- tag_exists = (
35
- check_exit_code([f'git show-ref --tags --quiet --verify -- "refs/tags/{tag}"'])
36
- == 0
37
- )
38
-
39
- if not tag_exists:
40
- run_process(["git", "tag", tag])
41
- run_process(["git", "push", "--tags"])
42
-
43
- _, changelog = get_release_info()
44
-
45
- gh_release_create(
46
- REPO_SLUG,
47
- tag,
48
- publish=True,
49
- name=f"{PROJECT_NAME} {version}",
50
- body=changelog,
51
- )
52
-
53
- # give some time to the API to get updated
54
- # not sure if this is the proper way to fix the issue
55
- # ideally the githubrelease package shouldn't need
56
- # to do another API call to get the release since it
57
- # should be returned by the create API.
58
- # anyway, this fix might be good enough for the time being
59
-
60
- time.sleep(2)
61
-
62
- gh_asset_upload(
63
- REPO_SLUG,
64
- tag,
65
- pattern="dist/*",
66
- )
@@ -1,18 +0,0 @@
1
- import os
2
- import sys
3
-
4
- sys.path.append(os.path.dirname(__file__)) # noqa
5
-
6
- from build_release import build_release
7
- from create_github_release import create_github_release
8
- from commit_release import git_commit_and_push
9
- from prepare_release import prepare_release
10
- from publish_release import publish_release
11
-
12
-
13
- def deploy_release():
14
- prepare_release()
15
- build_release()
16
- git_commit_and_push()
17
- create_github_release()
18
- publish_release()
@@ -1,83 +0,0 @@
1
- import os
2
- import subprocess
3
- import sys
4
-
5
- sys.path.append(os.path.dirname(__file__)) # noqa
6
-
7
- from base import GITHUB_TOKEN, REPO_SLUG, APPEND_GITHUB_CONTRIBUTOR
8
-
9
-
10
- def append_github_contributor(file):
11
- if not APPEND_GITHUB_CONTRIBUTOR:
12
- return
13
-
14
- try:
15
- import httpx
16
- except ModuleNotFoundError:
17
- print("Cannot append the GitHub contributor due to missing dependency: httpx")
18
- sys.exit(1)
19
-
20
- org, repo = REPO_SLUG.split("/")
21
- current_commit = (
22
- subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ascii").strip()
23
- )
24
-
25
- response = httpx.post(
26
- "https://api.github.com/graphql",
27
- json={
28
- "query": """query Contributor(
29
- $owner: String!
30
- $name: String!
31
- $commit: GitObjectID!
32
- ) {
33
- repository(owner: $owner, name: $name) {
34
- object(oid: $commit) {
35
- __typename
36
- ... on Commit {
37
- associatedPullRequests(first: 1) {
38
- nodes {
39
- number
40
- author {
41
- __typename
42
- login
43
- ... on User {
44
- name
45
- }
46
- }
47
- }
48
- }
49
- }
50
- }
51
- }
52
- }""",
53
- "variables": {"owner": org, "name": repo, "commit": current_commit},
54
- },
55
- headers={
56
- "Content-Type": "application/json",
57
- "Authorization": f"Bearer {GITHUB_TOKEN}",
58
- },
59
- )
60
-
61
- payload = response.json()
62
- commit = payload["data"]["repository"]["object"]
63
-
64
- if not commit:
65
- return
66
-
67
- prs = commit["associatedPullRequests"]["nodes"]
68
-
69
- if not prs:
70
- return
71
-
72
- pr = prs[0]
73
-
74
- pr_number = pr["number"]
75
- pr_author_username = pr["author"]["login"]
76
- pr_author_fullname = pr["author"].get("name", "")
77
-
78
- file.write("\n")
79
- file.write("\n")
80
- file.write(
81
- f"Contributed by [{pr_author_fullname or pr_author_username}](https://github.com/{pr_author_username}) via [PR #{pr_number}](https://github.com/{REPO_SLUG}/pull/{pr_number}/)"
82
- )
83
- file.write("\n")
@@ -1,98 +0,0 @@
1
- import os
2
- import re
3
- import sys
4
-
5
- sys.path.append(os.path.dirname(__file__)) # noqa
6
-
7
- from datetime import datetime
8
-
9
- import tomlkit
10
- from base import (
11
- CHANGELOG_FILE,
12
- CHANGELOG_HEADER,
13
- PYPROJECT_FILE,
14
- ROOT,
15
- VERSION_HEADER,
16
- VERSION_STRINGS,
17
- configure_git,
18
- dict_get,
19
- get_project_version,
20
- get_release_info,
21
- )
22
- from dunamai import Version
23
- from github_contributor import append_github_contributor
24
-
25
-
26
- def update_version_strings(file_path, new_version):
27
- version_regex = re.compile(r"(^_*?version_*?\s*=\s*['\"])(\d+\.\d+\.\d+)", re.M)
28
- with open(file_path, "r+") as f:
29
- content = f.read()
30
- f.seek(0)
31
- f.write(
32
- re.sub(
33
- version_regex,
34
- lambda match: "{}{}".format(match.group(1), new_version),
35
- content,
36
- )
37
- )
38
- f.truncate()
39
-
40
-
41
- def prepare_release():
42
- configure_git()
43
-
44
- type_, release_changelog = get_release_info()
45
-
46
- version = Version(get_project_version())
47
- new_version = version.bump({"major": 0, "minor": 1, "patch": 2}[type_]).serialize()
48
-
49
- with open(PYPROJECT_FILE, "r") as f:
50
- config = tomlkit.load(f)
51
-
52
- poetry = dict_get(config, ["tool", "poetry", "version"])
53
- if poetry:
54
- config["tool"]["poetry"]["version"] = new_version
55
- else:
56
- config["project"]["version"] = new_version
57
-
58
- with open(PYPROJECT_FILE, "w") as f:
59
- config = tomlkit.dump(config, f)
60
-
61
- if VERSION_STRINGS:
62
- for version_file in VERSION_STRINGS:
63
- file_path = ROOT / version_file
64
- update_version_strings(file_path, new_version)
65
-
66
- current_date = datetime.utcnow().strftime("%Y-%m-%d")
67
-
68
- old_changelog_data = ""
69
- header = ""
70
-
71
- if not CHANGELOG_FILE.is_file():
72
- with open(CHANGELOG_FILE, "a+") as f:
73
- f.write(f"CHANGELOG\n{CHANGELOG_HEADER}\n\n")
74
-
75
- with open(CHANGELOG_FILE, "r") as f:
76
- lines = f.readlines()
77
-
78
- for index, line in enumerate(lines):
79
- if CHANGELOG_HEADER != line.strip():
80
- continue
81
-
82
- old_changelog_data = lines[index + 1 :]
83
- header = lines[: index + 1]
84
- break
85
-
86
- with open(CHANGELOG_FILE, "w") as f:
87
- f.write("".join(header))
88
-
89
- new_version_header = f"{new_version} - {current_date}"
90
-
91
- f.write(f"\n{new_version_header}\n")
92
- f.write(f"{VERSION_HEADER * len(new_version_header)}\n\n")
93
-
94
- f.write(release_changelog)
95
- append_github_contributor(f)
96
- f.write("\n")
97
-
98
- f.write("".join(old_changelog_data))
@@ -1,17 +0,0 @@
1
- import glob
2
- import os
3
- import sys
4
-
5
- sys.path.append(os.path.dirname(__file__)) # noqa
6
-
7
- from base import PYPI_URL, run_process
8
-
9
-
10
- def publish_release():
11
- env = None
12
- if PYPI_URL:
13
- env = {"TWINE_REPOSITORY_URL": PYPI_URL}
14
- dists = glob.glob("dist/*")
15
- run_process(
16
- [sys.executable, "-m", "twine", "upload", "--non-interactive", *dists], env=env
17
- )
File without changes