proj-flow 0.9.3__py3-none-any.whl → 0.9.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. proj_flow/__init__.py +1 -1
  2. proj_flow/api/env.py +7 -2
  3. proj_flow/api/makefile.py +1 -1
  4. proj_flow/base/plugins.py +1 -36
  5. proj_flow/base/registry.py +2 -1
  6. proj_flow/cli/__init__.py +1 -3
  7. proj_flow/cli/argument.py +1 -1
  8. proj_flow/ext/cplusplus/__init__.py +10 -0
  9. proj_flow/ext/cplusplus/cmake/__init__.py +12 -0
  10. proj_flow/{plugins → ext/cplusplus}/cmake/context.py +2 -2
  11. proj_flow/{plugins → ext/cplusplus}/cmake/parser.py +6 -28
  12. proj_flow/ext/cplusplus/cmake/steps.py +142 -0
  13. proj_flow/ext/cplusplus/cmake/version.py +35 -0
  14. proj_flow/{plugins → ext/cplusplus}/conan/__init__.py +2 -1
  15. proj_flow/{plugins → ext/cplusplus}/conan/_conan.py +7 -2
  16. proj_flow/ext/github/__init__.py +2 -2
  17. proj_flow/{plugins/github.py → ext/github/switches.py} +1 -1
  18. proj_flow/ext/{markdown_changelist.py → markdown_changelog.py} +2 -1
  19. proj_flow/ext/python/rtdocs.py +1 -1
  20. proj_flow/ext/python/version.py +1 -2
  21. proj_flow/ext/{re_structured_changelist.py → re_structured_changelog.py} +3 -1
  22. proj_flow/{plugins → ext}/sign/__init__.py +64 -44
  23. proj_flow/ext/sign/api.py +83 -0
  24. proj_flow/ext/sign/win32.py +152 -0
  25. proj_flow/{plugins/store/store_packages.py → ext/store.py} +51 -9
  26. proj_flow/log/release.py +1 -1
  27. proj_flow/log/rich_text/markdown.py +1 -1
  28. proj_flow/log/rich_text/re_structured_text.py +1 -1
  29. proj_flow/minimal/__init__.py +2 -2
  30. proj_flow/{plugins → minimal}/base.py +2 -2
  31. proj_flow/{plugins/commands → minimal}/init.py +1 -1
  32. {proj_flow-0.9.3.dist-info → proj_flow-0.9.4.dist-info}/METADATA +2 -1
  33. {proj_flow-0.9.3.dist-info → proj_flow-0.9.4.dist-info}/RECORD +37 -46
  34. proj_flow/plugins/__init__.py +0 -8
  35. proj_flow/plugins/cmake/__init__.py +0 -11
  36. proj_flow/plugins/cmake/build.py +0 -29
  37. proj_flow/plugins/cmake/config.py +0 -59
  38. proj_flow/plugins/cmake/pack.py +0 -37
  39. proj_flow/plugins/cmake/test.py +0 -29
  40. proj_flow/plugins/commands/__init__.py +0 -12
  41. proj_flow/plugins/commands/ci/__init__.py +0 -17
  42. proj_flow/plugins/commands/ci/changelog.py +0 -47
  43. proj_flow/plugins/commands/ci/matrix.py +0 -46
  44. proj_flow/plugins/commands/ci/release.py +0 -116
  45. proj_flow/plugins/sign/win32.py +0 -191
  46. proj_flow/plugins/store/__init__.py +0 -11
  47. proj_flow/plugins/store/store_both.py +0 -22
  48. proj_flow/plugins/store/store_tests.py +0 -21
  49. /proj_flow/{plugins → ext/cplusplus}/cmake/__version__.py +0 -0
  50. {proj_flow-0.9.3.dist-info → proj_flow-0.9.4.dist-info}/WHEEL +0 -0
  51. {proj_flow-0.9.3.dist-info → proj_flow-0.9.4.dist-info}/entry_points.txt +0 -0
  52. {proj_flow-0.9.3.dist-info → proj_flow-0.9.4.dist-info}/licenses/LICENSE +0 -0
@@ -2,7 +2,7 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.plugins.sign** provides the ``"Sign"`` and ``"SignPackages"``
5
+ The **proj_flow.ext.sign** provides the ``"Sign"`` and ``"SignPackages"``
6
6
  steps.
7
7
  """
8
8
 
@@ -14,23 +14,7 @@ from typing import List, cast
14
14
 
15
15
  from proj_flow.api import env, init, step
16
16
 
17
- if sys.platform == "win32":
18
- from . import win32
19
-
20
- else:
21
-
22
- class win32:
23
- @staticmethod
24
- def is_active(*args):
25
- return False
26
-
27
- @staticmethod
28
- def sign(*args):
29
- return 0
30
-
31
- @staticmethod
32
- def is_pe_exec(arg):
33
- return False
17
+ from . import api, win32
34
18
 
35
19
 
36
20
  def should_exclude(filename: str, exclude: List[str], config_os: str):
@@ -44,34 +28,69 @@ def should_exclude(filename: str, exclude: List[str], config_os: str):
44
28
 
45
29
 
46
30
  class SignBase(step.Step):
31
+ _name: str
32
+ _runs_after: List[str] = []
33
+ _runs_before: List[str] = []
34
+
35
+ _active_tools: List[api.SigningTool] = []
36
+
37
+ @property
38
+ def name(self):
39
+ return self._name
40
+
41
+ @property
42
+ def runs_after(self):
43
+ return self._runs_after
44
+
45
+ @property
46
+ def runs_before(self):
47
+ return self._runs_before
48
+
49
+ def __init__(
50
+ self, name: str, runs_after: List[str] = [], runs_before: List[str] = []
51
+ ):
52
+ super().__init__()
53
+ self._name = name
54
+ self._runs_after = runs_after
55
+ self._runs_before = runs_before
56
+
47
57
  def is_active(self, config: env.Config, rt: env.Runtime) -> int:
48
- return win32.is_active(config.os, rt)
58
+ self._active_tools = [
59
+ tool for tool in api.signing_tool.get() if tool.is_active(config, rt)
60
+ ]
61
+ return len(self._active_tools) > 0
49
62
 
50
63
  @abstractmethod
51
- def get_files(self, config: env.Config, rt: env.Runtime) -> List[str]: ...
64
+ def get_files(
65
+ self, tool: api.SigningTool, config: env.Config, rt: env.Runtime
66
+ ) -> List[str]: ...
52
67
 
53
68
  def run(self, config: env.Config, rt: env.Runtime) -> int:
54
- files = [file.replace(os.sep, "/") for file in self.get_files(config, rt)]
55
- if len(files) == 0:
56
- return 0
69
+ for tool in self._active_tools:
70
+ files = [
71
+ file.replace(os.sep, "/") for file in self.get_files(tool, config, rt)
72
+ ]
57
73
 
58
- rt.print("signtool", *(os.path.basename(file) for file in files))
74
+ if len(files) == 0:
75
+ continue
59
76
 
60
- if rt.dry_run:
61
- return 0
77
+ result = tool.sign(config, rt, files)
78
+ if result:
79
+ return result
62
80
 
63
- return win32.sign(files, rt)
81
+ return 0
64
82
 
65
83
 
66
84
  @step.register
67
85
  class SignFiles(SignBase):
68
86
  """*(Windows)* Signs executable files in build directory"""
69
87
 
70
- name = "Sign"
71
- runs_after = ["Build"]
72
- runs_before = ["Pack"]
88
+ def __init__(self):
89
+ super().__init__(name="Sign", runs_after=["Build"], runs_before=["Pack"])
73
90
 
74
- def get_files(self, config: env.Config, rt: env.Runtime) -> List[str]:
91
+ def get_files(
92
+ self, tool: api.SigningTool, config: env.Config, rt: env.Runtime
93
+ ) -> List[str]:
75
94
  cfg = cast(dict, rt._cfg.get("sign", {}))
76
95
  roots = cfg.get("directories", ["bin", "lib", "libexec", "share"])
77
96
  exclude = cfg.get("exclude", ["*-test"])
@@ -85,10 +104,8 @@ class SignFiles(SignBase):
85
104
  continue
86
105
 
87
106
  full_path = os.path.join(curr_dir, filename)
88
- if not win32.is_pe_exec(full_path):
89
- continue
90
-
91
- result.append(full_path)
107
+ if tool.is_executable(full_path, as_package=False):
108
+ result.append(full_path)
92
109
  return result
93
110
 
94
111
 
@@ -96,26 +113,29 @@ class SignFiles(SignBase):
96
113
  class SignMsi(SignBase):
97
114
  """*(Windows)* Signs MSI installers in build directory"""
98
115
 
99
- name = "SignPackages"
100
- runs_after = ["Pack"]
101
- runs_before = ["StorePackages", "Store"]
116
+ def __init__(self):
117
+ super().__init__(
118
+ name="SignPackages",
119
+ runs_after=["Pack"],
120
+ runs_before=["StorePackages", "Store"],
121
+ )
102
122
 
103
123
  def is_active(self, config: env.Config, rt: env.Runtime) -> int:
104
124
  return super().is_active(config, rt) and "WIX" in config.items.get(
105
125
  "cpack_generator", []
106
126
  )
107
127
 
108
- def get_files(self, config: env.Config, rt: env.Runtime) -> List[str]:
128
+ def get_files(
129
+ self, tool: api.SigningTool, config: env.Config, rt: env.Runtime
130
+ ) -> List[str]:
109
131
  result: List[str] = []
110
132
  pkg_dir = os.path.join(config.build_dir, "packages")
111
133
  for curr_dir, dirnames, filenames in os.walk(pkg_dir):
112
134
  dirnames[:] = []
113
135
  for filename in filenames:
114
- _, ext = os.path.splitext(filename)
115
- if ext.lower() != ".msi":
116
- continue
117
-
118
- result.append(os.path.join(curr_dir, filename))
136
+ full_path = os.path.join(curr_dir, filename)
137
+ if tool.is_executable(full_path, as_package=False):
138
+ result.append(full_path)
119
139
 
120
140
  return result
121
141
 
@@ -0,0 +1,83 @@
1
+ # Copyright (c) 2025 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
3
+
4
+ """
5
+ The **proj_flow.ext.sign.api** defines an extension point for per-platform
6
+ sign tools.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ from abc import ABC, abstractmethod
13
+ from typing import List, NamedTuple, Optional
14
+
15
+ from proj_flow import base
16
+ from proj_flow.api import env
17
+
18
+ ENV_KEY = "SIGN_TOKEN"
19
+
20
+
21
+ class Key(NamedTuple):
22
+ token: str
23
+ secret: bytes
24
+
25
+
26
+ def _get_key_from_contents(key: str, rt: env.Runtime):
27
+ try:
28
+ obj = json.loads(key)
29
+ except json.decoder.JSONDecodeError:
30
+ rt.message("sign: the signature is not a valid JSON document")
31
+ return None
32
+
33
+ if not isinstance(obj, dict):
34
+ rt.message("sign: the signature is missing required fields")
35
+ return None
36
+
37
+ token = obj.get("token")
38
+ secret = obj.get("secret")
39
+ if not isinstance(token, str) or not isinstance(secret, str):
40
+ rt.message("sign: the signature is missing required fields")
41
+ return None
42
+
43
+ return Key(
44
+ base64.b64decode(token).decode("UTF-8"),
45
+ base64.b64decode(secret),
46
+ )
47
+
48
+
49
+ def get_key(rt: env.Runtime) -> Optional[Key]:
50
+ rt.message(f"sign: trying ${ENV_KEY}")
51
+ env = os.environ.get(ENV_KEY)
52
+ if env:
53
+ key = _get_key_from_contents(env, rt)
54
+ if key is not None:
55
+ return key
56
+ local_signature = os.path.join(".", "signature.key")
57
+ home_signature = os.path.join(os.path.expanduser("~"), "signature.key")
58
+ for filename in [local_signature, home_signature]:
59
+ rt.message(f"sign: trying {filename}")
60
+ if os.path.isfile(filename):
61
+ with open(filename, encoding="UTF-8") as file:
62
+ result = file.read().strip()
63
+ key = _get_key_from_contents(result, rt)
64
+ if key is not None:
65
+ return key
66
+
67
+ rt.message("sign: no key set up")
68
+
69
+ return None
70
+
71
+
72
+ class SigningTool(ABC):
73
+ @abstractmethod
74
+ def is_active(self, config: env.Config, rt: env.Runtime): ...
75
+
76
+ @abstractmethod
77
+ def sign(self, config: env.Config, rt: env.Runtime, files: List[str]): ...
78
+
79
+ @abstractmethod
80
+ def is_executable(self, filename: str, as_package: bool): ...
81
+
82
+
83
+ signing_tool = base.registry.Registry[SigningTool]("SigningTool")
@@ -0,0 +1,152 @@
1
+ # Copyright (c) 2025 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
3
+
4
+ """
5
+ The **proj_flow.ext.sign.win32** provides code signing with SignTool
6
+ from Windows SDKs.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import platform
13
+ import struct
14
+ import subprocess
15
+ import sys
16
+ from typing import Iterable, List, Optional, Tuple
17
+
18
+ from proj_flow.api.env import Config, Msg, Runtime
19
+
20
+ from .api import ENV_KEY, SigningTool, get_key, signing_tool
21
+
22
+ if sys.platform == "win32":
23
+ import winreg
24
+
25
+ @signing_tool.add
26
+ class Win32SigningTool(SigningTool):
27
+ def is_active(self, config: Config, rt: Runtime):
28
+ return _is_active(config.os, rt)
29
+
30
+ def sign(self, config: Config, rt: Runtime, files: List[str]):
31
+ rt.print("signtool", *(os.path.basename(file) for file in files))
32
+ return _sign(files, rt)
33
+
34
+ def is_signable(self, filename: str, as_package: bool):
35
+ if as_package:
36
+ _, ext = os.path.splitext(filename)
37
+ return ext.lower() == ".msi"
38
+ return _is_pe_exec(filename)
39
+
40
+ Version = Tuple[int, int, int]
41
+
42
+ machine = {"ARM64": "arm64", "AMD64": "x64", "X86": "x86"}.get(
43
+ platform.machine(), "x86"
44
+ )
45
+
46
+ def _find_sign_tool(rt: Runtime) -> Optional[str]:
47
+ with winreg.OpenKeyEx( # type: ignore
48
+ winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows Kits\Installed Roots" # type: ignore
49
+ ) as kits:
50
+ try:
51
+ kits_root = winreg.QueryValueEx(kits, "KitsRoot10")[0] # type: ignore
52
+ except FileNotFoundError:
53
+ rt.message("sign/win32: No KitsRoot10 value")
54
+ return None
55
+
56
+ versions: List[Tuple[Version, str]] = []
57
+ try:
58
+ index = 0
59
+ while True:
60
+ ver_str = winreg.EnumKey(kits, index) # type: ignore
61
+ ver = tuple(int(chunk) for chunk in ver_str.split("."))
62
+ index += 1
63
+ versions.append((ver, ver_str)) # type: ignore
64
+ except OSError:
65
+ pass
66
+ versions.sort()
67
+ versions.reverse()
68
+ rt.message(
69
+ "sign/win32: Regarding versions:",
70
+ ", ".join(version[1] for version in versions),
71
+ )
72
+ for _, version in versions:
73
+ # C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe
74
+ sign_tool = os.path.join(kits_root, "bin", version, machine, "signtool.exe")
75
+ if os.path.isfile(sign_tool):
76
+ rt.message("sign/win32: using:", sign_tool)
77
+ return sign_tool
78
+ return None
79
+
80
+ def _is_active(os_name: str, rt: Runtime):
81
+ if os_name != "windows":
82
+ return False
83
+ key = get_key(rt)
84
+ return (
85
+ key is not None
86
+ and key.token is not None
87
+ and key.secret is not None
88
+ and _find_sign_tool(rt) is not None
89
+ )
90
+
91
+ _IMAGE_DOS_HEADER = "HHHHHHHHHHHHHH8sHH20sI"
92
+ _IMAGE_NT_HEADERS_Signature = "H"
93
+ _IMAGE_DOS_HEADER_size = struct.calcsize(_IMAGE_DOS_HEADER)
94
+ _IMAGE_NT_HEADERS_Signature_size = struct.calcsize(_IMAGE_NT_HEADERS_Signature)
95
+ _MZ = 23117
96
+ _PE = 17744
97
+
98
+ def _is_pe_exec(path: str):
99
+ with open(path, "rb") as exe:
100
+ mz_header = exe.read(_IMAGE_DOS_HEADER_size)
101
+ dos_header = struct.unpack(_IMAGE_DOS_HEADER, mz_header)
102
+ if dos_header[0] != _MZ:
103
+ return False
104
+
105
+ PE_offset = dos_header[-1]
106
+ if PE_offset < _IMAGE_DOS_HEADER_size:
107
+ return False
108
+
109
+ if PE_offset > _IMAGE_DOS_HEADER_size:
110
+ exe.read(PE_offset - _IMAGE_DOS_HEADER_size)
111
+
112
+ pe_header = exe.read(_IMAGE_NT_HEADERS_Signature_size)
113
+ signature = struct.unpack(_IMAGE_NT_HEADERS_Signature, pe_header)[0]
114
+ return signature == _PE
115
+
116
+ def _sign(files: Iterable[str], rt: Runtime):
117
+ key = get_key(rt)
118
+
119
+ if key is None or key.token is None or key.secret is None:
120
+ rt.fatal("sign: the key is missing")
121
+
122
+ sign_tool = _find_sign_tool(rt)
123
+ if sign_tool is None:
124
+ rt.message("proj-flow: sign: signtool.exe not found", level=Msg.ALWAYS)
125
+ return 0
126
+
127
+ with open("temp.pfx", "wb") as pfx:
128
+ pfx.write(key.secret)
129
+
130
+ args = [
131
+ sign_tool,
132
+ "sign",
133
+ "/f",
134
+ "temp.pfx",
135
+ "/p",
136
+ key.token,
137
+ "/tr",
138
+ "http://timestamp.digicert.com",
139
+ "/fd",
140
+ "sha256",
141
+ "/td",
142
+ "sha256",
143
+ *files,
144
+ ]
145
+
146
+ result = 1
147
+ try:
148
+ result = subprocess.run(args, shell=False).returncode
149
+ finally:
150
+ os.remove("temp.pfx")
151
+
152
+ return result
@@ -2,18 +2,17 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.plugins.store** provides ``"StorePackages"`` step.
5
+ The **proj_flow.ext.store** provides ``"Store"``, ``"StoreTests"`` and
6
+ ``"StorePackages"`` steps.
6
7
  """
7
8
 
8
9
  import os
9
10
  import shutil
10
11
  from typing import List, cast
11
12
 
12
- from proj_flow.api import env, step
13
+ from proj_flow.api import env, release, step
13
14
  from proj_flow.base.uname import uname
14
15
 
15
- from ..cmake.parser import get_project
16
-
17
16
  _system, _version, _arch = uname()
18
17
  _version = "" if _version is None else f"-{_version}"
19
18
  _project_pkg = None
@@ -26,12 +25,24 @@ def _package_name(config: env.Config, pkg: str, group: str):
26
25
  return f"{pkg}-{_system}{_version}-{_arch}{debug}{suffix}"
27
26
 
28
27
 
28
+ def _get_project(rt: env.Runtime):
29
+ def wrap(suite: release.ProjectSuite):
30
+ return suite.get_project(rt)
31
+
32
+ return wrap
33
+
34
+
29
35
  @step.register
30
- class StorePackages:
36
+ class StorePackages(step.Step):
31
37
  """Stores archives and installers build for ``preset`` config value."""
32
38
 
33
- name = "StorePackages"
34
- runs_after = ["Pack"]
39
+ @property
40
+ def name(self):
41
+ return "StorePackages"
42
+
43
+ @property
44
+ def runs_after(self):
45
+ return ["Pack"]
35
46
 
36
47
  def run(self, config: env.Config, rt: env.Runtime) -> int:
37
48
  if not rt.dry_run:
@@ -41,10 +52,10 @@ class StorePackages:
41
52
 
42
53
  global _project_pkg
43
54
  if _project_pkg is None:
44
- project = get_project("")
55
+ _, project = release.project_suites.find(_get_project(rt))
45
56
  if project is None:
46
57
  rt.fatal(f"Cannot get project information from {rt.root}")
47
- _project_pkg = project.pkg
58
+ _project_pkg = project.archive_name
48
59
 
49
60
  main_group = cast(str, rt._cfg.get("package", {}).get("main-group"))
50
61
  if main_group is not None and not rt.dry_run:
@@ -77,3 +88,34 @@ class StorePackages:
77
88
  "build/artifacts/packages",
78
89
  f"^{_project_pkg}-.*$",
79
90
  )
91
+
92
+
93
+ @step.register
94
+ class StoreTests(step.Step):
95
+ """Stores test results gathered during tests for ``preset`` config value."""
96
+
97
+ @property
98
+ def name(self):
99
+ return "StoreTests"
100
+
101
+ @property
102
+ def runs_after(self):
103
+ return ["Test"]
104
+
105
+ def run(self, config: env.Config, rt: env.Runtime) -> int:
106
+ return rt.cp(
107
+ f"build/{config.preset}/test-results", "build/artifacts/test-results"
108
+ )
109
+
110
+
111
+ @step.register
112
+ class StoreBoth(step.SerialStep):
113
+ """Stores all artifacts created for ``preset`` config value."""
114
+
115
+ @property
116
+ def name(self):
117
+ return "Store"
118
+
119
+ def __init__(self):
120
+ super().__init__()
121
+ self.children = [StoreTests(), StorePackages()]
proj_flow/log/release.py CHANGED
@@ -134,4 +134,4 @@ def add_release(
134
134
  if draft_url:
135
135
  rt.message("Visit draft at", draft_url, level=env.Msg.ALWAYS)
136
136
 
137
- return setup.curr_tag
137
+ return setup.curr_tag
@@ -2,7 +2,7 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.log.rich.markdown** provides details of making Markdown
5
+ The **proj_flow.log.rich_text.markdown** provides details of making Markdown
6
6
  changelogs.
7
7
  """
8
8
 
@@ -2,7 +2,7 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.log.rich.re_structured_text** provides details of making
5
+ The **proj_flow.log.rich_text.re_structured_text** provides details of making
6
6
  reStructuredText changelogs.
7
7
  """
8
8
 
@@ -6,6 +6,6 @@ The **proj_flow.minimal** defines minimal extension package: ``bootstrap``
6
6
  and ``run`` commands, with basic set of steps.
7
7
  """
8
8
 
9
- from . import bootstrap, list, run, system
9
+ from . import bootstrap, init, list, run, system
10
10
 
11
- __all__ = ["bootstrap", "list", "run", "system"]
11
+ __all__ = ["bootstrap", "init", "list", "run", "system"]
@@ -2,8 +2,8 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.plugins.base** provides basic initialization setup for new
6
- projects.
5
+ The **proj_flow.minimal.base** provides basic initialization setup for all
6
+ new projects.
7
7
  """
8
8
 
9
9
  from proj_flow import __version__, api
@@ -2,7 +2,7 @@
2
2
  # This code is licensed under MIT license (see LICENSE for details)
3
3
 
4
4
  """
5
- The **proj_flow.plugins.commands.init** implements ``proj-flow init`` command.
5
+ The **proj_flow.minimal.init** implements ``proj-flow init`` command.
6
6
  """
7
7
 
8
8
  import json
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proj-flow
3
- Version: 0.9.3
3
+ Version: 0.9.4
4
4
  Summary: C++ project maintenance, automated
5
+ Project-URL: Changelog, https://github.com/mzdun/proj-flow/blob/main/CHANGELOG.rst
5
6
  Project-URL: Documentation, https://proj-flow.readthedocs.io/en/latest/
6
7
  Project-URL: Homepage, https://pypi.org/project/proj-flow/
7
8
  Project-URL: Source Code, https://github.com/mzdun/proj-flow