stouputils 1.12.1__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 (138) hide show
  1. stouputils/__init__.py +40 -0
  2. stouputils/__init__.pyi +14 -0
  3. stouputils/__main__.py +81 -0
  4. stouputils/_deprecated.py +37 -0
  5. stouputils/_deprecated.pyi +12 -0
  6. stouputils/all_doctests.py +160 -0
  7. stouputils/all_doctests.pyi +46 -0
  8. stouputils/applications/__init__.py +22 -0
  9. stouputils/applications/__init__.pyi +2 -0
  10. stouputils/applications/automatic_docs.py +634 -0
  11. stouputils/applications/automatic_docs.pyi +106 -0
  12. stouputils/applications/upscaler/__init__.py +39 -0
  13. stouputils/applications/upscaler/__init__.pyi +3 -0
  14. stouputils/applications/upscaler/config.py +128 -0
  15. stouputils/applications/upscaler/config.pyi +18 -0
  16. stouputils/applications/upscaler/image.py +247 -0
  17. stouputils/applications/upscaler/image.pyi +109 -0
  18. stouputils/applications/upscaler/video.py +287 -0
  19. stouputils/applications/upscaler/video.pyi +60 -0
  20. stouputils/archive.py +344 -0
  21. stouputils/archive.pyi +67 -0
  22. stouputils/backup.py +488 -0
  23. stouputils/backup.pyi +109 -0
  24. stouputils/collections.py +244 -0
  25. stouputils/collections.pyi +86 -0
  26. stouputils/continuous_delivery/__init__.py +27 -0
  27. stouputils/continuous_delivery/__init__.pyi +5 -0
  28. stouputils/continuous_delivery/cd_utils.py +243 -0
  29. stouputils/continuous_delivery/cd_utils.pyi +129 -0
  30. stouputils/continuous_delivery/github.py +522 -0
  31. stouputils/continuous_delivery/github.pyi +162 -0
  32. stouputils/continuous_delivery/pypi.py +91 -0
  33. stouputils/continuous_delivery/pypi.pyi +43 -0
  34. stouputils/continuous_delivery/pyproject.py +147 -0
  35. stouputils/continuous_delivery/pyproject.pyi +67 -0
  36. stouputils/continuous_delivery/stubs.py +86 -0
  37. stouputils/continuous_delivery/stubs.pyi +39 -0
  38. stouputils/ctx.py +408 -0
  39. stouputils/ctx.pyi +211 -0
  40. stouputils/data_science/config/get.py +51 -0
  41. stouputils/data_science/config/set.py +125 -0
  42. stouputils/data_science/data_processing/image/__init__.py +66 -0
  43. stouputils/data_science/data_processing/image/auto_contrast.py +79 -0
  44. stouputils/data_science/data_processing/image/axis_flip.py +58 -0
  45. stouputils/data_science/data_processing/image/bias_field_correction.py +74 -0
  46. stouputils/data_science/data_processing/image/binary_threshold.py +73 -0
  47. stouputils/data_science/data_processing/image/blur.py +59 -0
  48. stouputils/data_science/data_processing/image/brightness.py +54 -0
  49. stouputils/data_science/data_processing/image/canny.py +110 -0
  50. stouputils/data_science/data_processing/image/clahe.py +92 -0
  51. stouputils/data_science/data_processing/image/common.py +30 -0
  52. stouputils/data_science/data_processing/image/contrast.py +53 -0
  53. stouputils/data_science/data_processing/image/curvature_flow_filter.py +74 -0
  54. stouputils/data_science/data_processing/image/denoise.py +378 -0
  55. stouputils/data_science/data_processing/image/histogram_equalization.py +123 -0
  56. stouputils/data_science/data_processing/image/invert.py +64 -0
  57. stouputils/data_science/data_processing/image/laplacian.py +60 -0
  58. stouputils/data_science/data_processing/image/median_blur.py +52 -0
  59. stouputils/data_science/data_processing/image/noise.py +59 -0
  60. stouputils/data_science/data_processing/image/normalize.py +65 -0
  61. stouputils/data_science/data_processing/image/random_erase.py +66 -0
  62. stouputils/data_science/data_processing/image/resize.py +69 -0
  63. stouputils/data_science/data_processing/image/rotation.py +80 -0
  64. stouputils/data_science/data_processing/image/salt_pepper.py +68 -0
  65. stouputils/data_science/data_processing/image/sharpening.py +55 -0
  66. stouputils/data_science/data_processing/image/shearing.py +64 -0
  67. stouputils/data_science/data_processing/image/threshold.py +64 -0
  68. stouputils/data_science/data_processing/image/translation.py +71 -0
  69. stouputils/data_science/data_processing/image/zoom.py +83 -0
  70. stouputils/data_science/data_processing/image_augmentation.py +118 -0
  71. stouputils/data_science/data_processing/image_preprocess.py +183 -0
  72. stouputils/data_science/data_processing/prosthesis_detection.py +359 -0
  73. stouputils/data_science/data_processing/technique.py +481 -0
  74. stouputils/data_science/dataset/__init__.py +45 -0
  75. stouputils/data_science/dataset/dataset.py +292 -0
  76. stouputils/data_science/dataset/dataset_loader.py +135 -0
  77. stouputils/data_science/dataset/grouping_strategy.py +296 -0
  78. stouputils/data_science/dataset/image_loader.py +100 -0
  79. stouputils/data_science/dataset/xy_tuple.py +696 -0
  80. stouputils/data_science/metric_dictionnary.py +106 -0
  81. stouputils/data_science/metric_utils.py +847 -0
  82. stouputils/data_science/mlflow_utils.py +206 -0
  83. stouputils/data_science/models/abstract_model.py +149 -0
  84. stouputils/data_science/models/all.py +85 -0
  85. stouputils/data_science/models/base_keras.py +765 -0
  86. stouputils/data_science/models/keras/all.py +38 -0
  87. stouputils/data_science/models/keras/convnext.py +62 -0
  88. stouputils/data_science/models/keras/densenet.py +50 -0
  89. stouputils/data_science/models/keras/efficientnet.py +60 -0
  90. stouputils/data_science/models/keras/mobilenet.py +56 -0
  91. stouputils/data_science/models/keras/resnet.py +52 -0
  92. stouputils/data_science/models/keras/squeezenet.py +233 -0
  93. stouputils/data_science/models/keras/vgg.py +42 -0
  94. stouputils/data_science/models/keras/xception.py +38 -0
  95. stouputils/data_science/models/keras_utils/callbacks/__init__.py +20 -0
  96. stouputils/data_science/models/keras_utils/callbacks/colored_progress_bar.py +219 -0
  97. stouputils/data_science/models/keras_utils/callbacks/learning_rate_finder.py +148 -0
  98. stouputils/data_science/models/keras_utils/callbacks/model_checkpoint_v2.py +31 -0
  99. stouputils/data_science/models/keras_utils/callbacks/progressive_unfreezing.py +249 -0
  100. stouputils/data_science/models/keras_utils/callbacks/warmup_scheduler.py +66 -0
  101. stouputils/data_science/models/keras_utils/losses/__init__.py +12 -0
  102. stouputils/data_science/models/keras_utils/losses/next_generation_loss.py +56 -0
  103. stouputils/data_science/models/keras_utils/visualizations.py +416 -0
  104. stouputils/data_science/models/model_interface.py +939 -0
  105. stouputils/data_science/models/sandbox.py +116 -0
  106. stouputils/data_science/range_tuple.py +234 -0
  107. stouputils/data_science/scripts/augment_dataset.py +77 -0
  108. stouputils/data_science/scripts/exhaustive_process.py +133 -0
  109. stouputils/data_science/scripts/preprocess_dataset.py +70 -0
  110. stouputils/data_science/scripts/routine.py +168 -0
  111. stouputils/data_science/utils.py +285 -0
  112. stouputils/decorators.py +595 -0
  113. stouputils/decorators.pyi +242 -0
  114. stouputils/image.py +441 -0
  115. stouputils/image.pyi +172 -0
  116. stouputils/installer/__init__.py +18 -0
  117. stouputils/installer/__init__.pyi +5 -0
  118. stouputils/installer/common.py +67 -0
  119. stouputils/installer/common.pyi +39 -0
  120. stouputils/installer/downloader.py +101 -0
  121. stouputils/installer/downloader.pyi +24 -0
  122. stouputils/installer/linux.py +144 -0
  123. stouputils/installer/linux.pyi +39 -0
  124. stouputils/installer/main.py +223 -0
  125. stouputils/installer/main.pyi +57 -0
  126. stouputils/installer/windows.py +136 -0
  127. stouputils/installer/windows.pyi +31 -0
  128. stouputils/io.py +486 -0
  129. stouputils/io.pyi +213 -0
  130. stouputils/parallel.py +453 -0
  131. stouputils/parallel.pyi +211 -0
  132. stouputils/print.py +527 -0
  133. stouputils/print.pyi +146 -0
  134. stouputils/py.typed +1 -0
  135. stouputils-1.12.1.dist-info/METADATA +179 -0
  136. stouputils-1.12.1.dist-info/RECORD +138 -0
  137. stouputils-1.12.1.dist-info/WHEEL +4 -0
  138. stouputils-1.12.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,129 @@
1
+ import requests
2
+ from ..decorators import handle_error as handle_error
3
+ from ..io import clean_path as clean_path, json_load as json_load
4
+ from ..print import warning as warning
5
+ from typing import Any
6
+
7
+ def load_credentials(credentials_path: str) -> dict[str, Any]:
8
+ ''' Load credentials from a JSON or YAML file into a dictionary.
9
+
10
+ \tLoads credentials from either a JSON or YAML file and returns them as a dictionary.
11
+ \tThe file must contain the required credentials in the appropriate format.
12
+
13
+ \tArgs:
14
+ \t\tcredentials_path (str): Path to the credentials file (.json or .yml)
15
+ \tReturns:
16
+ \t\tdict[str, Any]: Dictionary containing the credentials
17
+
18
+ \tExample JSON format:
19
+
20
+ \t.. code-block:: json
21
+
22
+ \t\t{
23
+ \t\t\t"github": {
24
+ \t\t\t\t"username": "Stoupy51",
25
+ \t\t\t\t"api_key": "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXX"
26
+ \t\t\t}
27
+ \t\t}
28
+
29
+ \tExample YAML format:
30
+
31
+ \t.. code-block:: yaml
32
+
33
+ \t\tgithub:
34
+ \t\t\tusername: "Stoupy51"
35
+ \t\t\tapi_key: "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXX"
36
+ \t'''
37
+ def handle_response(response: requests.Response, error_message: str) -> None:
38
+ """ Handle a response from the API by raising an error if the response is not successful (status code not in 200-299).
39
+
40
+ \tArgs:
41
+ \t\tresponse\t\t(requests.Response): The response from the API
42
+ \t\terror_message\t(str): The error message to raise if the response is not successful
43
+ \t"""
44
+ def clean_version(version: str, keep: str = '') -> str:
45
+ ''' Clean a version string
46
+
47
+ \tArgs:
48
+ \t\tversion\t(str): The version string to clean
49
+ \t\tkeep\t(str): The characters to keep in the version string
50
+ \tReturns:
51
+ \t\tstr: The cleaned version string
52
+
53
+ \t>>> clean_version("v1.e0.zfezf0.1.2.3zefz")
54
+ \t\'1.0.0.1.2.3\'
55
+ \t>>> clean_version("v1.e0.zfezf0.1.2.3zefz", keep="v")
56
+ \t\'v1.0.0.1.2.3\'
57
+ \t>>> clean_version("v1.2.3b", keep="ab")
58
+ \t\'1.2.3b\'
59
+ \t'''
60
+ def version_to_float(version: str, error: bool = True) -> Any:
61
+ ''' Converts a version string into a float for comparison purposes.
62
+ \tThe version string is expected to follow the format of major.minor.patch.something_else....,
63
+ \twhere each part is separated by a dot and can be extended indefinitely.
64
+ \tSupports pre-release suffixes with numbers: devN/dN (dev), aN (alpha), bN (beta), rcN/cN (release candidate).
65
+ \tOrdering: 1.0.0 > 1.0.0rc2 > 1.0.0rc1 > 1.0.0b2 > 1.0.0b1 > 1.0.0a2 > 1.0.0a1 > 1.0.0dev1
66
+
67
+ \tArgs:
68
+ \t\tversion (str): The version string to convert. (e.g. "v1.0.0.1.2.3", "v2.0.0b2", "v1.0.0rc1")
69
+ \t\terror (bool): Return None on error instead of raising an exception
70
+ \tReturns:
71
+ \t\tfloat: The float representation of the version. (e.g. 0)
72
+
73
+ \t>>> version_to_float("v1.0.0")
74
+ \t1.0
75
+ \t>>> version_to_float("v1.0.0.1")
76
+ \t1.000000001
77
+ \t>>> version_to_float("v2.3.7")
78
+ \t2.003007
79
+ \t>>> version_to_float("v1.0.0.1.2.3")
80
+ \t1.0000000010020031
81
+ \t>>> version_to_float("v2.0") > version_to_float("v1.0.0.1")
82
+ \tTrue
83
+ \t>>> version_to_float("v2.0.0") > version_to_float("v2.0.0rc") > version_to_float("v2.0.0b") > version_to_float("v2.0.0a") > version_to_float("v2.0.0dev")
84
+ \tTrue
85
+ \t>>> version_to_float("v1.0.0b") > version_to_float("v1.0.0a")
86
+ \tTrue
87
+ \t>>> version_to_float("v1.0.0") > version_to_float("v1.0.0b")
88
+ \tTrue
89
+ \t>>> version_to_float("v3.0.0a") > version_to_float("v2.9.9")
90
+ \tTrue
91
+ \t>>> version_to_float("v1.2.3b") < version_to_float("v1.2.3")
92
+ \tTrue
93
+ \t>>> version_to_float("1.0.0") == version_to_float("v1.0.0")
94
+ \tTrue
95
+ \t>>> version_to_float("2.0.0.0.0.0.1b") > version_to_float("2.0.0.0.0.0.1a")
96
+ \tTrue
97
+ \t>>> version_to_float("2.0.0.0.0.0.1") > version_to_float("2.0.0.0.0.0.1b")
98
+ \tTrue
99
+ \t>>> version_to_float("v1.0.0rc") == version_to_float("v1.0.0c")
100
+ \tTrue
101
+ \t>>> version_to_float("v1.0.0c") > version_to_float("v1.0.0b")
102
+ \tTrue
103
+ \t>>> version_to_float("v1.0.0d") < version_to_float("v1.0.0a")
104
+ \tTrue
105
+ \t>>> version_to_float("v1.0.0dev") < version_to_float("v1.0.0a")
106
+ \tTrue
107
+ \t>>> version_to_float("v1.0.0dev") == version_to_float("v1.0.0d")
108
+ \tTrue
109
+ \t>>> version_to_float("v1.0.0rc2") > version_to_float("v1.0.0rc1")
110
+ \tTrue
111
+ \t>>> version_to_float("v1.0.0b2") > version_to_float("v1.0.0b1")
112
+ \tTrue
113
+ \t>>> version_to_float("v1.0.0a2") > version_to_float("v1.0.0a1")
114
+ \tTrue
115
+ \t>>> version_to_float("v1.0.0dev2") > version_to_float("v1.0.0dev1")
116
+ \tTrue
117
+ \t>>> version_to_float("v1.0.0") > version_to_float("v1.0.0rc2") > version_to_float("v1.0.0rc1")
118
+ \tTrue
119
+ \t>>> version_to_float("v1.0.0rc1") > version_to_float("v1.0.0b2")
120
+ \tTrue
121
+ \t>>> version_to_float("v1.0.0b1") > version_to_float("v1.0.0a2")
122
+ \tTrue
123
+ \t>>> version_to_float("v1.0.0a1") > version_to_float("v1.0.0dev2")
124
+ \tTrue
125
+ \t>>> versions = ["v1.0.0", "v1.0.0rc2", "v1.0.0rc1", "v1.0.0b2", "v1.0.0b1", "v1.0.0a2", "v1.0.0a1", "v1.0.0dev2", "v1.0.0dev1"]
126
+ \t>>> sorted_versions = sorted(versions, key=version_to_float, reverse=True)
127
+ \t>>> sorted_versions == versions
128
+ \tTrue
129
+ \t'''
@@ -0,0 +1,522 @@
1
+ """ This module contains utilities for continuous delivery on GitHub.
2
+
3
+ - upload_to_github: Upload the project to GitHub using the credentials and the configuration
4
+ (make a release and upload the assets, handle existing tag, generate changelog, etc.)
5
+
6
+ .. image:: https://raw.githubusercontent.com/Stoupy51/stouputils/refs/heads/main/assets/continuous_delivery/github_module.gif
7
+ :alt: stouputils upload_to_github examples
8
+ """
9
+
10
+ # Imports
11
+ import os
12
+ from typing import Any
13
+
14
+ from ..decorators import handle_error, measure_time
15
+ from ..io import clean_path
16
+ from ..print import info, progress, warning
17
+ from .cd_utils import clean_version, handle_response, version_to_float
18
+
19
+ # Constants
20
+ GITHUB_API_URL: str = "https://api.github.com"
21
+ PROJECT_ENDPOINT: str = f"{GITHUB_API_URL}/repos"
22
+ COMMIT_TYPES: dict[str, str] = {
23
+ "feat": "Features",
24
+ "fix": "Bug Fixes",
25
+ "docs": "Documentation",
26
+ "style": "Style",
27
+ "refactor": "Code Refactoring",
28
+ "perf": "Performance Improvements",
29
+ "test": "Tests",
30
+ "build": "Build System",
31
+ "ci": "CI/CD",
32
+ "chore": "Chores",
33
+ "revert": "Reverts",
34
+ "uwu": "UwU ༼ つ ◕_◕ ༽つ",
35
+ }
36
+
37
+ def validate_credentials(credentials: dict[str, dict[str, str]]) -> tuple[str, dict[str, str]]:
38
+ """ Get and validate GitHub credentials
39
+
40
+ Args:
41
+ credentials (dict[str, dict[str, str]]): Credentials for the GitHub API
42
+ Returns:
43
+ tuple[str, dict[str, str]]:
44
+ str: Owner (the username of the account to use)
45
+
46
+ dict[str, str]: Headers (for the requests to the GitHub API)
47
+ """
48
+ if "github" not in credentials:
49
+ raise ValueError(
50
+ "The credentials file must contain a 'github' key, which is a dictionary containing a 'api_key' key"
51
+ "(a PAT for the GitHub API: https://github.com/settings/tokens) "
52
+ "and a 'username' key (the username of the account to use)"
53
+ )
54
+ if "api_key" not in credentials["github"]:
55
+ raise ValueError(
56
+ "The credentials file must contain a 'github' key, which is a dictionary containing a 'api_key' key"
57
+ "(a PAT for the GitHub API: https://github.com/settings/tokens) "
58
+ "and a 'username' key (the username of the account to use)"
59
+ )
60
+ if "username" not in credentials["github"]:
61
+ raise ValueError(
62
+ "The credentials file must contain a 'github' key, which is a dictionary containing a 'api_key' key"
63
+ "(a PAT for the GitHub API: https://github.com/settings/tokens) "
64
+ "and a 'username' key (the username of the account to use)"
65
+ )
66
+
67
+ api_key: str = credentials["github"]["api_key"]
68
+ owner: str = credentials["github"]["username"]
69
+ headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
70
+ return owner, headers
71
+
72
+ def validate_config(github_config: dict[str, Any]) -> tuple[str, str, str, list[str]]:
73
+ """ Validate GitHub configuration
74
+
75
+ Args:
76
+ github_config (dict[str, str]): Configuration for the GitHub project
77
+ Returns:
78
+ tuple[str, str, str, list[str]]:
79
+ str: Project name on GitHub
80
+
81
+ str: Version of the project
82
+
83
+ str: Build folder path containing zip files to upload to the release
84
+
85
+ list[str]: List of zip files to upload to the release
86
+ """
87
+ if "project_name" not in github_config:
88
+ raise ValueError(
89
+ "The github_config file must contain a 'project_name' key, "
90
+ "which is the name of the project on GitHub"
91
+ )
92
+ if "version" not in github_config:
93
+ raise ValueError(
94
+ "The github_config file must contain a 'version' key, "
95
+ "which is the version of the project"
96
+ )
97
+ if "build_folder" not in github_config:
98
+ raise ValueError(
99
+ "The github_config file must contain a 'build_folder' key, "
100
+ "which is the folder containing the build of the project "
101
+ "(datapack and resourcepack zip files)"
102
+ )
103
+
104
+ project_name: str = github_config["project_name"]
105
+ version: str = github_config["version"]
106
+ build_folder: str = github_config["build_folder"]
107
+ endswith: list[str] = github_config.get("endswith", [])
108
+
109
+ return project_name, version, build_folder, endswith
110
+
111
+ def handle_existing_tag(owner: str, project_name: str, version: str, headers: dict[str, str]) -> bool:
112
+ """ Check if tag exists and handle deletion if needed
113
+
114
+ Args:
115
+ owner (str): GitHub username
116
+ project_name (str): Name of the GitHub repository
117
+ version (str): Version to check for existing tag
118
+ headers (dict[str, str]): Headers for GitHub API requests
119
+ Returns:
120
+ bool: True if the tag was deleted or if it was not found, False otherwise
121
+ """
122
+ # Get the tag URL and check if it exists
123
+ import requests
124
+ tag_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/git/refs/tags/v{version}"
125
+ response: requests.Response = requests.get(tag_url, headers=headers)
126
+
127
+ # If the tag exists, ask the user if they want to delete it
128
+ if response.status_code == 200:
129
+ warning(f"A tag v{version} already exists. Do you want to delete it? (y/N): ")
130
+ if input().lower() == "y":
131
+ delete_existing_release(owner, project_name, version, headers)
132
+ delete_existing_tag(tag_url, headers)
133
+ return True
134
+ else:
135
+ return False
136
+ return True
137
+
138
+ def delete_existing_release(owner: str, project_name: str, version: str, headers: dict[str, str]) -> None:
139
+ """ Delete existing release for a version
140
+
141
+ Args:
142
+ owner (str): GitHub username
143
+ project_name (str): Name of the GitHub repository
144
+ version (str): Version of the release to delete
145
+ headers (dict[str, str]): Headers for GitHub API requests
146
+ """
147
+ # Get the release URL and check if it exists
148
+ import requests
149
+ releases_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/releases/tags/v{version}"
150
+ release_response: requests.Response = requests.get(releases_url, headers=headers)
151
+
152
+ # If the release exists, delete it
153
+ if release_response.status_code == 200:
154
+ release_id: int = release_response.json()["id"]
155
+ delete_release: requests.Response = requests.delete(
156
+ f"{PROJECT_ENDPOINT}/{owner}/{project_name}/releases/{release_id}",
157
+ headers=headers
158
+ )
159
+ handle_response(delete_release, "Failed to delete existing release")
160
+ info(f"Deleted existing release for v{version}")
161
+
162
+ def delete_existing_tag(tag_url: str, headers: dict[str, str]) -> None:
163
+ """ Delete existing tag
164
+
165
+ Args:
166
+ tag_url (str): URL of the tag to delete
167
+ headers (dict[str, str]): Headers for GitHub API requests
168
+ """
169
+ import requests
170
+ delete_response: requests.Response = requests.delete(tag_url, headers=headers)
171
+ handle_response(delete_response, "Failed to delete existing tag")
172
+ info("Deleted existing tag")
173
+
174
+ def get_latest_tag(
175
+ owner: str, project_name: str, version: str, headers: dict[str, str]
176
+ ) -> tuple[str, str] | tuple[None, None]:
177
+ """ Get latest tag information
178
+
179
+ Args:
180
+ owner (str): GitHub username
181
+ project_name (str): Name of the GitHub repository
182
+ version (str): Version to remove from the list of tags
183
+ headers (dict[str, str]): Headers for GitHub API requests
184
+ Returns:
185
+ str|None: SHA of the latest tag commit, None if no tags exist
186
+ str|None: Version number of the latest tag, None if no tags exist
187
+ """
188
+ # Get the tags list
189
+ import requests
190
+ tags_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/tags"
191
+ response = requests.get(tags_url, headers=headers)
192
+ handle_response(response, "Failed to get tags")
193
+ tags: list[dict[str, Any]] = response.json()
194
+
195
+ # Remove the version from the list of tags and sort the tags by their float values
196
+ tags = [tag for tag in tags if tag["name"] != f"v{version}"]
197
+ tags.sort(key=lambda x: version_to_float(x.get("name", "0")), reverse=True)
198
+
199
+ # If there are no tags, return None
200
+ if len(tags) == 0:
201
+ return None, None
202
+ else:
203
+ return tags[0]["commit"]["sha"], clean_version(tags[0]["name"], keep="ab")
204
+
205
+ def get_commits_since_tag(
206
+ owner: str, project_name: str, latest_tag_sha: str|None, headers: dict[str, str]
207
+ ) -> list[dict[str, Any]]:
208
+ """ Get commits since last tag
209
+
210
+ Args:
211
+ owner (str): GitHub username
212
+ project_name (str): Name of the GitHub repository
213
+ latest_tag_sha (str|None): SHA of the latest tag commit
214
+ headers (dict[str, str]): Headers for GitHub API requests
215
+ Returns:
216
+ list[dict]: List of commits since the last tag
217
+ """
218
+ # Get the commits URL and parameters
219
+ import requests
220
+ commits_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/commits"
221
+ commits_params: dict[str, str] = {"per_page": "100"}
222
+
223
+ # Initialize tag_date as None
224
+ tag_date: str|None = None # type: ignore
225
+
226
+ # If there is a latest tag, use it to get the commits since the tag date
227
+ if latest_tag_sha:
228
+
229
+ # Get the date of the latest tag
230
+ tag_commit_url = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/commits/{latest_tag_sha}"
231
+ tag_response = requests.get(tag_commit_url, headers=headers)
232
+ handle_response(tag_response, "Failed to get tag commit")
233
+ tag_date: str = tag_response.json()["commit"]["committer"]["date"]
234
+
235
+ # Use the date as the 'since' parameter to get all commits after that date
236
+ commits_params["since"] = tag_date
237
+
238
+ # Paginate through all commits
239
+ commits: list[dict[str, Any]] = []
240
+ page = 1
241
+ while True:
242
+ params = commits_params.copy()
243
+ params["page"] = str(page)
244
+ response = requests.get(commits_url, headers=headers, params=params)
245
+ handle_response(response, "Failed to get commits")
246
+ page_commits = response.json()
247
+ if not page_commits:
248
+ break
249
+ commits.extend(page_commits)
250
+ if len(page_commits) < 100:
251
+ break
252
+ page += 1
253
+
254
+ # Filter commits only if we have a tag_date
255
+ if tag_date:
256
+ commits = [c for c in commits if c["commit"]["committer"]["date"] != tag_date]
257
+ return commits
258
+
259
+ def generate_changelog(
260
+ commits: list[dict[str, Any]], owner: str, project_name: str, latest_tag_version: str|None, version: str
261
+ ) -> str:
262
+ """ Generate changelog from commits. They must follow the conventional commits convention.
263
+
264
+ Convention format: <type>: <description> or <type>(<sub-category>): <description>
265
+
266
+ Args:
267
+ commits (list[dict]): List of commits to generate changelog from
268
+ owner (str): GitHub username
269
+ project_name (str): Name of the GitHub repository
270
+ latest_tag_version (str|None): Version number of the latest tag
271
+ version (str): Current version being released
272
+ Returns:
273
+ str: Generated changelog text
274
+ Source:
275
+ https://www.conventionalcommits.org/en/v1.0.0/
276
+ """
277
+ # Initialize the commit groups
278
+ commit_groups: dict[str, list[tuple[str, str, str | None]]] = {}
279
+
280
+ # Iterate over the commits
281
+ for commit in commits:
282
+ message: str = commit["commit"]["message"].split("\n")[0]
283
+ sha: str = commit["sha"]
284
+
285
+ # If the message contains a colon, split the message into a type and a description
286
+ if ":" in message:
287
+ commit_type_part, desc = message.split(":", 1)
288
+
289
+ # Check for breaking change indicator (!)
290
+ is_breaking: bool = False
291
+ if "!" in commit_type_part:
292
+ is_breaking = True
293
+ commit_type_part = commit_type_part.replace("!", "")
294
+
295
+ # Extract sub-category if present (e.g., 'feat(Project)' -> 'feat', 'Project')
296
+ sub_category: str|None = None
297
+ if "(" in commit_type_part and ")" in commit_type_part:
298
+ # Extract the base type (before parentheses)
299
+ commit_type: str = commit_type_part.split('(')[0].split('/')[0]
300
+ # Extract the sub-category (between parentheses)
301
+ sub_category = commit_type_part.split('(')[1].split(')')[0]
302
+ else:
303
+ # No sub-category, just clean the type
304
+ commit_type: str = commit_type_part.split('/')[0]
305
+
306
+ # Clean the type to only keep letters
307
+ commit_type = "".join(c for c in commit_type.lower().strip() if c in "abcdefghijklmnopqrstuvwxyz")
308
+ commit_type = COMMIT_TYPES.get(commit_type, commit_type.title())
309
+
310
+ # Prepend emoji if breaking change
311
+ formatted_desc = f"🚨 {desc.strip()}" if is_breaking else desc.strip()
312
+
313
+ # Add the commit to the commit groups
314
+ if commit_type not in commit_groups:
315
+ commit_groups[commit_type] = []
316
+ commit_groups[commit_type].append((formatted_desc, sha, sub_category))
317
+
318
+ # Initialize the changelog
319
+ changelog: str = "## Changelog\n\n"
320
+
321
+ # Iterate over the commit groups
322
+ for commit_type in sorted(commit_groups.keys()):
323
+ changelog += f"### {commit_type}\n"
324
+
325
+ # Group commits by sub-category
326
+ sub_category_groups: dict[str|None, list[tuple[str, str, str|None]]] = {}
327
+ for desc, sha, sub_category in commit_groups[commit_type]:
328
+ if sub_category not in sub_category_groups:
329
+ sub_category_groups[sub_category] = []
330
+ sub_category_groups[sub_category].append((desc, sha, sub_category))
331
+
332
+ # Sort sub-categories (None comes first, then alphabetical)
333
+ sorted_sub_categories = sorted(
334
+ sub_category_groups.keys(),
335
+ key=lambda x: (x is None, x or "")
336
+ )
337
+
338
+ # Iterate over sub-categories
339
+ for sub_category in sorted_sub_categories:
340
+
341
+ # Add commits for this sub-category
342
+ for desc, sha, _ in reversed(sub_category_groups[sub_category]):
343
+
344
+ # Prepend sub-category to description if present
345
+ if sub_category:
346
+ words: list[str] = [
347
+ word[0].upper() + word[1:] # We don't use title() because we don't want to lowercase any letter
348
+ for word in sub_category.replace('_', ' ').split()
349
+ ]
350
+ formatted_sub_category: str = ' '.join(words)
351
+ formatted_desc = f"[{formatted_sub_category}] {desc}"
352
+ else:
353
+ formatted_desc = desc
354
+ changelog += f"- {formatted_desc} ([{sha[:7]}](https://github.com/{owner}/{project_name}/commit/{sha}))\n"
355
+
356
+ changelog += "\n"
357
+
358
+ # Add the full changelog link if there is a latest tag and return the changelog
359
+ if latest_tag_version:
360
+ changelog += f"**Full Changelog**: https://github.com/{owner}/{project_name}/compare/v{latest_tag_version}...v{version}\n"
361
+ return changelog
362
+
363
+ def create_tag(owner: str, project_name: str, version: str, headers: dict[str, str]) -> None:
364
+ """ Create a new tag
365
+
366
+ Args:
367
+ owner (str): GitHub username
368
+ project_name (str): Name of the GitHub repository
369
+ version (str): Version for the new tag
370
+ headers (dict[str, str]): Headers for GitHub API requests
371
+ """
372
+ # Message and prepare urls
373
+ import requests
374
+ progress(f"Creating tag v{version}")
375
+ create_tag_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/git/refs"
376
+ latest_commit_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/git/refs/heads/main"
377
+
378
+ # Get the latest commit SHA
379
+ commit_response: requests.Response = requests.get(latest_commit_url, headers=headers)
380
+ handle_response(commit_response, "Failed to get latest commit")
381
+ commit_sha: str = commit_response.json()["object"]["sha"]
382
+
383
+ # Create the tag
384
+ tag_data: dict[str, str] = {
385
+ "ref": f"refs/tags/v{version}",
386
+ "sha": commit_sha
387
+ }
388
+ response: requests.Response = requests.post(create_tag_url, headers=headers, json=tag_data)
389
+ handle_response(response, "Failed to create tag")
390
+
391
+ def create_release(owner: str, project_name: str, version: str, changelog: str, headers: dict[str, str]) -> int:
392
+ """ Create a new release
393
+
394
+ Args:
395
+ owner (str): GitHub username
396
+ project_name (str): Name of the GitHub repository
397
+ version (str): Version for the new release
398
+ changelog (str): Changelog text for the release
399
+ headers (dict[str, str]): Headers for GitHub API requests
400
+ Returns:
401
+ int: ID of the created release
402
+ """
403
+ # Message and prepare urls
404
+ import requests
405
+ progress(f"Creating release v{version}")
406
+ release_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/releases"
407
+ release_data: dict[str, str|bool] = {
408
+ "tag_name": f"v{version}",
409
+ "name": f"{project_name} [v{version}]",
410
+ "body": changelog,
411
+ "draft": False,
412
+ "prerelease": False
413
+ }
414
+
415
+ # Create the release and return the release ID
416
+ response: requests.Response = requests.post(release_url, headers=headers, json=release_data)
417
+ handle_response(response, "Failed to create release")
418
+ return response.json()["id"]
419
+
420
+ def upload_assets(
421
+ owner: str, project_name: str, release_id: int, build_folder: str, headers: dict[str, str], endswith: list[str]
422
+ ) -> None:
423
+ """ Upload release assets
424
+
425
+ Args:
426
+ owner (str): GitHub username
427
+ project_name (str): Name of the GitHub repository
428
+ release_id (int): ID of the release to upload assets to
429
+ build_folder (str): Folder containing assets to upload
430
+ headers (dict[str, str]): Headers for GitHub API requests
431
+ endswith (list[str]): List of files to upload to the release
432
+ (every file ending with one of these strings will be uploaded)
433
+ """
434
+ endswith_tuple: tuple[str, ...] = tuple(endswith)
435
+
436
+ # If there is no build folder, return
437
+ if not build_folder:
438
+ return
439
+ progress("Uploading assets")
440
+
441
+ # Get the release details
442
+ import requests
443
+ release_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/releases/{release_id}"
444
+ response: requests.Response = requests.get(release_url, headers=headers)
445
+ handle_response(response, "Failed to get release details")
446
+ upload_url_template: str = response.json()["upload_url"]
447
+ upload_url_base: str = upload_url_template.split("{", maxsplit=1)[0]
448
+
449
+ # Iterate over the files in the build folder
450
+ for file in os.listdir(build_folder):
451
+ if file.endswith(endswith_tuple):
452
+ file_path: str = f"{clean_path(build_folder)}/{file}"
453
+ with open(file_path, "rb") as f:
454
+
455
+ # Prepare the headers and params
456
+ headers_with_content: dict[str, str] = {
457
+ **headers,
458
+ "Content-Type": "application/zip"
459
+ }
460
+ params: dict[str, str] = {"name": file}
461
+
462
+ # Upload the file
463
+ response: requests.Response = requests.post(
464
+ upload_url_base,
465
+ headers=headers_with_content,
466
+ params=params,
467
+ data=f.read()
468
+ )
469
+ handle_response(response, f"Failed to upload {file}")
470
+ progress(f"Uploaded {file}")
471
+
472
+ @measure_time(message="Uploading to GitHub took")
473
+ @handle_error()
474
+ def upload_to_github(credentials: dict[str, Any], github_config: dict[str, Any]) -> str:
475
+ """ Upload the project to GitHub using the credentials and the configuration
476
+
477
+ Args:
478
+ credentials (dict[str, Any]): Credentials for the GitHub API
479
+ github_config (dict[str, Any]): Configuration for the GitHub project
480
+ Returns:
481
+ str: Generated changelog text
482
+ Examples:
483
+
484
+ .. code-block:: python
485
+
486
+ > upload_to_github(
487
+ credentials={
488
+ "github": {
489
+ "api_key": "ghp_...",
490
+ "username": "Stoupy"
491
+ }
492
+ },
493
+ github_config={
494
+ "project_name": "stouputils",
495
+ "version": "1.0.0",
496
+ "build_folder": "build",
497
+ "endswith": [".zip"]
498
+ }
499
+ )
500
+ """
501
+ import requests # type: ignore # noqa: F401
502
+
503
+ # Validate credentials and configuration
504
+ owner, headers = validate_credentials(credentials)
505
+ project_name, version, build_folder, endswith = validate_config(github_config)
506
+
507
+ # Handle existing tag
508
+ can_create: bool = handle_existing_tag(owner, project_name, version, headers)
509
+
510
+ # Get the latest tag and commits since the tag
511
+ latest_tag_sha, latest_tag_version = get_latest_tag(owner, project_name, version, headers)
512
+ commits: list[dict[str, Any]] = get_commits_since_tag(owner, project_name, latest_tag_sha, headers)
513
+ changelog: str = generate_changelog(commits, owner, project_name, latest_tag_version, version)
514
+
515
+ # Create the tag and release if needed
516
+ if can_create:
517
+ create_tag(owner, project_name, version, headers)
518
+ release_id: int = create_release(owner, project_name, version, changelog, headers)
519
+ upload_assets(owner, project_name, release_id, build_folder, headers, endswith)
520
+ info(f"Project '{project_name}' updated on GitHub!")
521
+ return changelog
522
+