aerospike-async 0.3.0a18__tar.gz

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 (134) hide show
  1. aerospike_async-0.3.0a18/.github/scripts/generate_release_notes.py +376 -0
  2. aerospike_async-0.3.0a18/.github/workflows/build-test.yml +530 -0
  3. aerospike_async-0.3.0a18/.github/workflows/release.yml +165 -0
  4. aerospike_async-0.3.0a18/.gitignore +86 -0
  5. aerospike_async-0.3.0a18/.pre-commit-config.yaml +10 -0
  6. aerospike_async-0.3.0a18/Cargo.lock +1645 -0
  7. aerospike_async-0.3.0a18/Cargo.toml +42 -0
  8. aerospike_async-0.3.0a18/LICENSE +220 -0
  9. aerospike_async-0.3.0a18/Makefile +85 -0
  10. aerospike_async-0.3.0a18/PKG-INFO +9 -0
  11. aerospike_async-0.3.0a18/README.md +332 -0
  12. aerospike_async-0.3.0a18/aerospike.env.example +44 -0
  13. aerospike_async-0.3.0a18/benchmarks/__init__.py +4 -0
  14. aerospike_async-0.3.0a18/benchmarks/_env.py +59 -0
  15. aerospike_async-0.3.0a18/benchmarks/benchmark.py +386 -0
  16. aerospike_async-0.3.0a18/bin/get-version +37 -0
  17. aerospike_async-0.3.0a18/pyproject.toml +28 -0
  18. aerospike_async-0.3.0a18/python/aerospike_async/__init__.py +47 -0
  19. aerospike_async-0.3.0a18/python/aerospike_async/__init__.pyi +4 -0
  20. aerospike_async-0.3.0a18/python/aerospike_async/_aerospike_async_native.pyi +3503 -0
  21. aerospike_async-0.3.0a18/python/aerospike_async/exceptions/__init__.py +109 -0
  22. aerospike_async-0.3.0a18/python/aerospike_async/exceptions/__init__.pyi +161 -0
  23. aerospike_async-0.3.0a18/python/benchmarks/README.md +42 -0
  24. aerospike_async-0.3.0a18/python/benchmarks/benchmarks.py +54 -0
  25. aerospike_async-0.3.0a18/python/clean_caches.sh +18 -0
  26. aerospike_async-0.3.0a18/python/conftest.py +126 -0
  27. aerospike_async-0.3.0a18/python/examples/README.md +142 -0
  28. aerospike_async-0.3.0a18/python/examples/async_demo.py +320 -0
  29. aerospike_async-0.3.0a18/python/examples/basic_examples.py +107 -0
  30. aerospike_async-0.3.0a18/python/examples/create_index.py +255 -0
  31. aerospike_async-0.3.0a18/python/examples/create_index_simple.py +151 -0
  32. aerospike_async-0.3.0a18/python/examples/geo_query_bug_demo.py +296 -0
  33. aerospike_async-0.3.0a18/python/examples/geospatial.py +235 -0
  34. aerospike_async-0.3.0a18/python/examples/privilege.py +275 -0
  35. aerospike_async-0.3.0a18/python/examples/privilege_simple.py +145 -0
  36. aerospike_async-0.3.0a18/python/examples/role_management.py +312 -0
  37. aerospike_async-0.3.0a18/python/examples/statement_simple.py +232 -0
  38. aerospike_async-0.3.0a18/python/examples/tls_example.py +178 -0
  39. aerospike_async-0.3.0a18/python/examples/user_management.py +248 -0
  40. aerospike_async-0.3.0a18/python/postprocess_stubs.py +1973 -0
  41. aerospike_async-0.3.0a18/python/tests/__init__.py +0 -0
  42. aerospike_async-0.3.0a18/python/tests/integration/__init__.py +0 -0
  43. aerospike_async-0.3.0a18/python/tests/integration/add_test.py +48 -0
  44. aerospike_async-0.3.0a18/python/tests/integration/append_test.py +43 -0
  45. aerospike_async-0.3.0a18/python/tests/integration/batch_test.py +1021 -0
  46. aerospike_async-0.3.0a18/python/tests/integration/bit_exp_test.py +482 -0
  47. aerospike_async-0.3.0a18/python/tests/integration/cdt_ordering_test.py +456 -0
  48. aerospike_async-0.3.0a18/python/tests/integration/cdt_path_test.py +1065 -0
  49. aerospike_async-0.3.0a18/python/tests/integration/client_test.py +70 -0
  50. aerospike_async-0.3.0a18/python/tests/integration/conftest.py +63 -0
  51. aerospike_async-0.3.0a18/python/tests/integration/create_index_expression_test.py +187 -0
  52. aerospike_async-0.3.0a18/python/tests/integration/delete_bin_test.py +182 -0
  53. aerospike_async-0.3.0a18/python/tests/integration/delete_test.py +47 -0
  54. aerospike_async-0.3.0a18/python/tests/integration/exists_test.py +82 -0
  55. aerospike_async-0.3.0a18/python/tests/integration/exp_operation_test.py +251 -0
  56. aerospike_async-0.3.0a18/python/tests/integration/expiration_test.py +211 -0
  57. aerospike_async-0.3.0a18/python/tests/integration/filter_expr_test.py +205 -0
  58. aerospike_async-0.3.0a18/python/tests/integration/fixtures.py +106 -0
  59. aerospike_async-0.3.0a18/python/tests/integration/generation_test.py +233 -0
  60. aerospike_async-0.3.0a18/python/tests/integration/geo_query_test.py +142 -0
  61. aerospike_async-0.3.0a18/python/tests/integration/get_bins_test.py +414 -0
  62. aerospike_async-0.3.0a18/python/tests/integration/get_node_test.py +325 -0
  63. aerospike_async-0.3.0a18/python/tests/integration/get_test.py +89 -0
  64. aerospike_async-0.3.0a18/python/tests/integration/hll_exp_test.py +227 -0
  65. aerospike_async-0.3.0a18/python/tests/integration/index_test.py +191 -0
  66. aerospike_async-0.3.0a18/python/tests/integration/info_test.py +126 -0
  67. aerospike_async-0.3.0a18/python/tests/integration/list_exp_test.py +196 -0
  68. aerospike_async-0.3.0a18/python/tests/integration/map_exp_test.py +87 -0
  69. aerospike_async-0.3.0a18/python/tests/integration/mrt_test.py +265 -0
  70. aerospike_async-0.3.0a18/python/tests/integration/operate_bit_test.py +1210 -0
  71. aerospike_async-0.3.0a18/python/tests/integration/operate_hll_test.py +584 -0
  72. aerospike_async-0.3.0a18/python/tests/integration/operate_list_test.py +1566 -0
  73. aerospike_async-0.3.0a18/python/tests/integration/operate_map_test.py +1971 -0
  74. aerospike_async-0.3.0a18/python/tests/integration/operate_test.py +413 -0
  75. aerospike_async-0.3.0a18/python/tests/integration/partition_filter_test.py +468 -0
  76. aerospike_async-0.3.0a18/python/tests/integration/prepend_test.py +52 -0
  77. aerospike_async-0.3.0a18/python/tests/integration/put_test.py +359 -0
  78. aerospike_async-0.3.0a18/python/tests/integration/query_aggregate_test.py +196 -0
  79. aerospike_async-0.3.0a18/python/tests/integration/query_background_test.py +164 -0
  80. aerospike_async-0.3.0a18/python/tests/integration/query_test.py +257 -0
  81. aerospike_async-0.3.0a18/python/tests/integration/record_exists_action_test.py +265 -0
  82. aerospike_async-0.3.0a18/python/tests/integration/security_test.py +697 -0
  83. aerospike_async-0.3.0a18/python/tests/integration/set_xdr_filter_test.py +124 -0
  84. aerospike_async-0.3.0a18/python/tests/integration/special_value_test.py +245 -0
  85. aerospike_async-0.3.0a18/python/tests/integration/timeout_test.py +113 -0
  86. aerospike_async-0.3.0a18/python/tests/integration/tls_test.py +220 -0
  87. aerospike_async-0.3.0a18/python/tests/integration/touch_test.py +43 -0
  88. aerospike_async-0.3.0a18/python/tests/integration/truncate_test.py +48 -0
  89. aerospike_async-0.3.0a18/python/tests/integration/udf/record_example.lua +91 -0
  90. aerospike_async-0.3.0a18/python/tests/integration/udf/sleep_example.lua +17 -0
  91. aerospike_async-0.3.0a18/python/tests/integration/udf/sum_example.lua +17 -0
  92. aerospike_async-0.3.0a18/python/tests/integration/udf_batch_test.py +176 -0
  93. aerospike_async-0.3.0a18/python/tests/integration/udf_execute_test.py +243 -0
  94. aerospike_async-0.3.0a18/python/tests/integration/udf_register_test.py +141 -0
  95. aerospike_async-0.3.0a18/python/tests/integration/udf_remove_test.py +110 -0
  96. aerospike_async-0.3.0a18/python/tests/unit/__init__.py +0 -0
  97. aerospike_async-0.3.0a18/python/tests/unit/batch_policy_test.py +282 -0
  98. aerospike_async-0.3.0a18/python/tests/unit/cdt_path_types_test.py +240 -0
  99. aerospike_async-0.3.0a18/python/tests/unit/cdt_policy_test.py +221 -0
  100. aerospike_async-0.3.0a18/python/tests/unit/client_error_test.py +177 -0
  101. aerospike_async-0.3.0a18/python/tests/unit/client_policy_test.py +78 -0
  102. aerospike_async-0.3.0a18/python/tests/unit/ctx_test.py +97 -0
  103. aerospike_async-0.3.0a18/python/tests/unit/enum_test.py +234 -0
  104. aerospike_async-0.3.0a18/python/tests/unit/exception_test.py +224 -0
  105. aerospike_async-0.3.0a18/python/tests/unit/exp_operation_test.py +65 -0
  106. aerospike_async-0.3.0a18/python/tests/unit/filter_expr_test.py +272 -0
  107. aerospike_async-0.3.0a18/python/tests/unit/filter_test.py +132 -0
  108. aerospike_async-0.3.0a18/python/tests/unit/flag_input_uniformity_test.py +389 -0
  109. aerospike_async-0.3.0a18/python/tests/unit/key_test.py +251 -0
  110. aerospike_async-0.3.0a18/python/tests/unit/mrt_test.py +180 -0
  111. aerospike_async-0.3.0a18/python/tests/unit/operation_test.py +395 -0
  112. aerospike_async-0.3.0a18/python/tests/unit/partition_filter_test.py +155 -0
  113. aerospike_async-0.3.0a18/python/tests/unit/partition_status_test.py +258 -0
  114. aerospike_async-0.3.0a18/python/tests/unit/policies_test.py +760 -0
  115. aerospike_async-0.3.0a18/python/tests/unit/query_test.py +60 -0
  116. aerospike_async-0.3.0a18/python/tests/unit/return_type_test.py +286 -0
  117. aerospike_async-0.3.0a18/python/tests/unit/tls_test.py +121 -0
  118. aerospike_async-0.3.0a18/python/tests/unit/user_test.py +116 -0
  119. aerospike_async-0.3.0a18/python/tests/unit/value_test.py +551 -0
  120. aerospike_async-0.3.0a18/requirements.txt +4 -0
  121. aerospike_async-0.3.0a18/src/bin/stub_gen.rs +62 -0
  122. aerospike_async-0.3.0a18/src/cdt.rs +1710 -0
  123. aerospike_async-0.3.0a18/src/cluster.rs +487 -0
  124. aerospike_async-0.3.0a18/src/completion.rs +163 -0
  125. aerospike_async-0.3.0a18/src/enums.rs +1157 -0
  126. aerospike_async-0.3.0a18/src/errors.rs +181 -0
  127. aerospike_async-0.3.0a18/src/expressions.rs +3151 -0
  128. aerospike_async-0.3.0a18/src/filter.rs +877 -0
  129. aerospike_async-0.3.0a18/src/lib.rs +2150 -0
  130. aerospike_async-0.3.0a18/src/operations.rs +2970 -0
  131. aerospike_async-0.3.0a18/src/policies.rs +2045 -0
  132. aerospike_async-0.3.0a18/src/record.rs +1338 -0
  133. aerospike_async-0.3.0a18/src/tasks.rs +321 -0
  134. aerospike_async-0.3.0a18/src/tls.rs +143 -0
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2023-2026 Aerospike, Inc.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ """
15
+ Transform GitHub-generated release notes into a custom format:
16
+ - Release date at top
17
+ - Sections: Breaking Changes, New Features, Improvements, Bug Fixes, Security (omitted if empty)
18
+ - Style normalization: sentence case start, trailing period, expanded contractions
19
+ - JIRA/ticket identifiers (e.g. [CLIENT-1234] or CLIENT-1234:) moved to the end of each line (after PR link)
20
+ - PR links preserved as ([#N](url))
21
+ - Internal-only items (CI, tests-only, etc.) filtered out
22
+ - Full Changelog link at bottom
23
+
24
+ Reads raw markdown from stdin; writes formatted markdown to stdout.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import re
31
+ import sys
32
+
33
+
34
+ TICKET_RE = re.compile(r"\[([A-Z][A-Z0-9]*-[0-9]+)\]")
35
+ COLON_TICKET_PREFIX_RE = re.compile(
36
+ r"^([A-Z][A-Z0-9]*-[0-9]+)\s*:\s*",
37
+ )
38
+ PR_SUFFIX_RE = re.compile(
39
+ r"\s+by\s+@[\w-]+\s+in\s+(https?://\S+)", re.IGNORECASE
40
+ )
41
+
42
+ # Longer phrases first where relevant; word-boundary case-insensitive replacement.
43
+ _CONTRACTION_PAIRS: tuple[tuple[str, str], ...] = (
44
+ (r"shouldn't\b", "should not"),
45
+ (r"couldn't\b", "could not"),
46
+ (r"wouldn't\b", "would not"),
47
+ (r"doesn't\b", "does not"),
48
+ (r"haven't\b", "have not"),
49
+ (r"hasn't\b", "has not"),
50
+ (r"weren't\b", "were not"),
51
+ (r"wasn't\b", "was not"),
52
+ (r"aren't\b", "are not"),
53
+ (r"isn't\b", "is not"),
54
+ (r"won't\b", "will not"),
55
+ (r"can't\b", "cannot"),
56
+ (r"don't\b", "do not"),
57
+ (r"there's\b", "there is"),
58
+ (r"that's\b", "that is"),
59
+ (r"it's\b", "it is"),
60
+ )
61
+
62
+ INTERNAL_PATTERNS: tuple[re.Pattern[str], ...] = (
63
+ re.compile(r"\bbump\b.*\btest\b", re.IGNORECASE),
64
+ re.compile(r"\bskip\b.*\btest\b", re.IGNORECASE),
65
+ re.compile(r"\bstub\s+pipeline\b", re.IGNORECASE),
66
+ re.compile(r"\brefactor\b", re.IGNORECASE),
67
+ re.compile(r"\bversion\s+bump\b", re.IGNORECASE),
68
+ re.compile(r"\bCI\b", re.IGNORECASE),
69
+ re.compile(r"\bpipeline\b", re.IGNORECASE),
70
+ re.compile(r"\btest(?:s)?\s+(?:only|fix|update|cleanup)\b", re.IGNORECASE),
71
+ )
72
+
73
+
74
+ def normalize_summary(desc: str) -> str:
75
+ """Capitalize first letter, ensure trailing sentence end, expand common contractions."""
76
+ s = desc.strip()
77
+ for pattern, repl in _CONTRACTION_PAIRS:
78
+ s = re.sub(pattern, repl, s, flags=re.IGNORECASE)
79
+ s = s.strip()
80
+ if not s:
81
+ return s
82
+ for i, c in enumerate(s):
83
+ if c.isalpha():
84
+ s = s[:i] + c.upper() + s[i + 1 :]
85
+ break
86
+ if s[-1] not in ".!?":
87
+ s = f"{s}."
88
+ return s
89
+
90
+
91
+ def is_internal(desc: str) -> bool:
92
+ """True if the item should be omitted from user-facing release notes."""
93
+ for pat in INTERNAL_PATTERNS:
94
+ if pat.search(desc):
95
+ return True
96
+ return False
97
+
98
+
99
+ def is_bug_fix(title: str) -> bool:
100
+ t = title.strip().lower()
101
+ return (
102
+ t.startswith("fix ")
103
+ or t.startswith("fixes ")
104
+ or t.startswith("bug")
105
+ or t.startswith("fix:")
106
+ )
107
+
108
+
109
+ def is_security(desc: str) -> bool:
110
+ d = desc.lower()
111
+ return "cve-" in d or "security" in d or "vulnerability" in d
112
+
113
+
114
+ def is_breaking(desc: str) -> bool:
115
+ d = desc.lower()
116
+ return (
117
+ "breaking change" in d
118
+ or bool(re.search(r"\bbreaking\b", d))
119
+ or "drop support" in d
120
+ or "remove deprecated" in d
121
+ or "incompatible" in d
122
+ )
123
+
124
+
125
+ def is_new_feature(desc: str) -> bool:
126
+ if is_bug_fix(desc):
127
+ return False
128
+ d = desc.strip().lower()
129
+ return (
130
+ d.startswith("add ")
131
+ or d.startswith("new ")
132
+ or d.startswith("implement ")
133
+ or d.startswith("introduce ")
134
+ )
135
+
136
+
137
+ _BREAKING_PREFIX_RE = re.compile(
138
+ r"^(?:breaking\s*(?:change\s*)?[:=-]+\s*|breaking\s+)", re.IGNORECASE
139
+ )
140
+ _SECURITY_PREFIX_RE = re.compile(
141
+ r"^(?:security\s*[:=-]+\s*)", re.IGNORECASE
142
+ )
143
+
144
+
145
+ def strip_section_prefix(desc: str, section: str) -> str:
146
+ """Remove redundant leading keywords that duplicate the section header."""
147
+ if section == "breaking":
148
+ return _BREAKING_PREFIX_RE.sub("", desc).strip()
149
+ if section == "security":
150
+ return _SECURITY_PREFIX_RE.sub("", desc).strip()
151
+ return desc
152
+
153
+
154
+ def classify_section(desc: str) -> str:
155
+ """Return bucket key: breaking, feature, improvement, bug, security."""
156
+ if is_security(desc):
157
+ return "security"
158
+ if is_breaking(desc):
159
+ return "breaking"
160
+ if is_bug_fix(desc):
161
+ return "bug"
162
+ if is_new_feature(desc):
163
+ return "feature"
164
+ return "improvement"
165
+
166
+
167
+ def warn_if_compound_summary(desc: str) -> None:
168
+ if " + " in desc:
169
+ print(
170
+ f'WARNING: Compound summary detected, consider splitting: "{desc}"',
171
+ file=sys.stderr,
172
+ )
173
+ return
174
+ if desc.count(",") > 2:
175
+ print(
176
+ f'WARNING: Compound summary detected, consider splitting: "{desc}"',
177
+ file=sys.stderr,
178
+ )
179
+
180
+
181
+ def split_description_and_tickets(text: str) -> tuple[str, list[str]]:
182
+ """Strip ticket brackets from text; return (clean description, ticket keys)."""
183
+ tickets = TICKET_RE.findall(text)
184
+ rest = TICKET_RE.sub("", text).strip()
185
+ rest = re.sub(r" +", " ", rest).strip()
186
+ return rest, tickets
187
+
188
+
189
+ def extract_leading_colon_ticket(text: str) -> tuple[str, list[str]]:
190
+ """If text starts with CLIENT-1234:, strip it and return extra ticket keys."""
191
+ m = COLON_TICKET_PREFIX_RE.match(text)
192
+ if not m:
193
+ return text, []
194
+ return text[m.end() :].strip(), [m.group(1)]
195
+
196
+
197
+ def pr_link_markdown(url: str) -> str:
198
+ m = re.search(r"/pull/(\d+)", url, re.IGNORECASE)
199
+ if m:
200
+ return f"([#{m.group(1)}]({url}))"
201
+ return f"([pull]({url}))"
202
+
203
+
204
+ def parse_bullet(line: str) -> tuple[str, str] | None:
205
+ """
206
+ Parse a line like '* Fix something by @user in https://.../pull/99'.
207
+
208
+ Returns (formatted_bullet, description_for_classification) or None.
209
+ """
210
+ line = line.strip()
211
+ if not line.startswith("* ") and not line.startswith("- "):
212
+ return None
213
+ text = line[2:].strip()
214
+ pr_url = ""
215
+ by_match = PR_SUFFIX_RE.search(text)
216
+ if by_match:
217
+ pr_url = by_match.group(1).rstrip(").,;")
218
+ text = text[: by_match.start()].strip()
219
+ if not text:
220
+ return None
221
+
222
+ text, colon_tickets = extract_leading_colon_ticket(text)
223
+ desc, bracket_tickets = split_description_and_tickets(text)
224
+ tickets = colon_tickets + bracket_tickets
225
+
226
+ if not desc:
227
+ return None
228
+
229
+ warn_if_compound_summary(desc)
230
+
231
+ if is_internal(desc):
232
+ return None
233
+
234
+ desc_norm = normalize_summary(desc)
235
+ if not desc_norm:
236
+ return None
237
+
238
+ parts = [desc_norm]
239
+ if pr_url:
240
+ parts.append(pr_link_markdown(pr_url))
241
+ if tickets:
242
+ parts.append(f"[{tickets[0]}]")
243
+ return " ".join(parts), desc_norm
244
+
245
+
246
+ def run_self_tests() -> None:
247
+ # normalize_summary: capitalize, period, contractions
248
+ assert normalize_summary("fix the bug") == "Fix the bug."
249
+ assert normalize_summary("already done.") == "Already done."
250
+ assert normalize_summary("doesn't work") == "Does not work."
251
+ assert normalize_summary("don't panic!") == "Do not panic!"
252
+
253
+ # is_internal: broadened patterns
254
+ assert is_internal("Bump test dependencies")
255
+ assert is_internal("skip test on windows")
256
+ assert is_internal("Bump timeout on large put test")
257
+ assert is_internal("Skip timeout test in case the timeout does not happen")
258
+ assert is_internal("Refactor lib.rs - split into submodules")
259
+ assert is_internal("Tests only fix for flaky suite")
260
+ assert is_internal("test cleanup for CI")
261
+ assert not is_internal("Fix query timeout for users")
262
+ assert not is_internal("Add batch API support")
263
+
264
+ # extract_leading_colon_ticket: Jira colon format
265
+ t2, k2 = extract_leading_colon_ticket("CLIENT-99: hello [CLIENT-100]")
266
+ desc2, bracket_keys = split_description_and_tickets(t2)
267
+ assert desc2 == "hello" and k2 == ["CLIENT-99"] and bracket_keys == ["CLIENT-100"]
268
+ t, keys = extract_leading_colon_ticket("CLIENT-99: hello world")
269
+ assert t == "hello world" and keys == ["CLIENT-99"]
270
+
271
+ # classify_section
272
+ assert classify_section("Security patch for CVE-2024-1") == "security"
273
+ assert classify_section("Breaking change: drop Python 3.8") == "breaking"
274
+ assert classify_section("Fix null pointer in client") == "bug"
275
+ assert classify_section("Add batch API support") == "feature"
276
+ assert classify_section("Polish error messages") == "improvement"
277
+
278
+ # strip_section_prefix: remove redundant keywords
279
+ assert strip_section_prefix("BREAKING drop Python 3.8.", "breaking") == "drop Python 3.8."
280
+ assert strip_section_prefix("Breaking change: drop Python 3.8.", "breaking") == "drop Python 3.8."
281
+ assert strip_section_prefix("Security patch for CVE-2024-1.", "security") == "Security patch for CVE-2024-1."
282
+ assert strip_section_prefix("Security: fix for CVE-2024-1.", "security") == "fix for CVE-2024-1."
283
+ assert strip_section_prefix("Add batch API support.", "feature") == "Add batch API support."
284
+
285
+ # parse_bullet: bracket ticket preserved at end
286
+ out = parse_bullet("* fix foo by @x in https://github.com/o/r/pull/1")
287
+ assert out is not None
288
+ assert out[0].startswith("Fix foo.")
289
+
290
+ # parse_bullet: internal items filtered
291
+ assert parse_bullet("* Bump timeout on large put test by @x in https://github.com/o/r/pull/1") is None
292
+ assert parse_bullet("* Refactor lib.rs by @x in https://github.com/o/r/pull/2") is None
293
+
294
+ print("generate_release_notes self-tests passed.", file=sys.stderr)
295
+
296
+
297
+ def main() -> None:
298
+ parser = argparse.ArgumentParser(description="Format release notes.")
299
+ parser.add_argument("--date", default="", help="Release date (e.g. 'March 17, 2026')")
300
+ parser.add_argument(
301
+ "--changelog-url",
302
+ default="",
303
+ help="Full Changelog URL (optional; may be parsed from input)",
304
+ )
305
+ parser.add_argument(
306
+ "--test",
307
+ action="store_true",
308
+ help="Run inline self-tests and exit",
309
+ )
310
+ args = parser.parse_args()
311
+
312
+ if args.test:
313
+ run_self_tests()
314
+ return
315
+
316
+ if not args.date:
317
+ parser.error("--date is required unless --test is set")
318
+
319
+ raw = sys.stdin.read()
320
+ breaking: list[str] = []
321
+ features: list[str] = []
322
+ improvements: list[str] = []
323
+ bug_fixes: list[str] = []
324
+ security: list[str] = []
325
+
326
+ changelog_url = args.changelog_url
327
+ for line in raw.splitlines():
328
+ if "Full Changelog" in line or "full changelog" in line.lower():
329
+ url_match = re.search(r"https://[^\s\)]+", line)
330
+ if url_match and not changelog_url:
331
+ changelog_url = url_match.group(0)
332
+ continue
333
+ parsed = parse_bullet(line)
334
+ if parsed is None:
335
+ continue
336
+ formatted, desc_for_category = parsed
337
+ bucket = classify_section(desc_for_category)
338
+ stripped = strip_section_prefix(desc_for_category, bucket)
339
+ if stripped and stripped != desc_for_category:
340
+ stripped = normalize_summary(stripped)
341
+ formatted = formatted.replace(desc_for_category, stripped, 1)
342
+ if bucket == "security":
343
+ security.append(formatted)
344
+ elif bucket == "breaking":
345
+ breaking.append(formatted)
346
+ elif bucket == "bug":
347
+ bug_fixes.append(formatted)
348
+ elif bucket == "feature":
349
+ features.append(formatted)
350
+ else:
351
+ improvements.append(formatted)
352
+
353
+ out = [f"Release Date: {args.date}", ""]
354
+ section_blocks: list[tuple[str, list[str]]] = [
355
+ ("## Breaking Changes", breaking),
356
+ ("## New Features", features),
357
+ ("## Improvements", improvements),
358
+ ("## Bug Fixes", bug_fixes),
359
+ ("## Security", security),
360
+ ]
361
+ for heading, items in section_blocks:
362
+ if items:
363
+ out.append(heading)
364
+ for item in items:
365
+ out.append(f"- {item}")
366
+ out.append("")
367
+ if changelog_url:
368
+ out.append(f"**Full Changelog**: {changelog_url}")
369
+
370
+ sys.stdout.write("\n".join(out))
371
+ if out and not out[-1].endswith("\n"):
372
+ sys.stdout.write("\n")
373
+
374
+
375
+ if __name__ == "__main__":
376
+ main()