|
63 | 63 |
|
64 | 64 | #
|
65 | 65 | # This template is the canonical list of acceptable section names!
|
66 |
| -# It's parsed internally into the "sections" set. |
| 66 | +# It is parsed internally into SECTIONS. String replacement on the |
| 67 | +# formatted comments is done later. Beware when editing. |
67 | 68 | #
|
68 | 69 |
|
69 | 70 | template = """
|
|
98 | 99 |
|
99 | 100 | root = None
|
100 | 101 | original_dir = None
|
101 |
| -sections = [] |
| 102 | +SECTIONS = [] |
102 | 103 |
|
103 | 104 | for line in template.split('\n'):
|
104 | 105 | line = line.strip()
|
105 | 106 | prefix, found, section = line.partition("#.. section: ")
|
106 | 107 | if found and not prefix:
|
107 |
| - sections.append(section.strip()) |
| 108 | + SECTIONS.append(section.strip()) |
| 109 | + SECTIONS = sorted(SECTIONS) |
108 | 110 |
|
109 | 111 |
|
110 | 112 | _sanitize_section = {
|
@@ -319,8 +321,8 @@ def glob_blurbs(version):
|
319 | 321 | filenames.extend(glob.glob(wildcard))
|
320 | 322 | else:
|
321 | 323 | sanitized_sections = (
|
322 |
| - {sanitize_section(section) for section in sections} | |
323 |
| - {sanitize_section_legacy(section) for section in sections} |
| 324 | + {sanitize_section(section) for section in SECTIONS} | |
| 325 | + {sanitize_section_legacy(section) for section in SECTIONS} |
324 | 326 | )
|
325 | 327 | for section in sanitized_sections:
|
326 | 328 | wildcard = os.path.join(base, section, "*.rst")
|
@@ -487,7 +489,7 @@ def finish_entry():
|
487 | 489 | if key == "section":
|
488 | 490 | if no_changes:
|
489 | 491 | continue
|
490 |
| - if value not in sections: |
| 492 | + if value not in SECTIONS: |
491 | 493 | throw(f"Invalid section {value!r}! You must use one of the predefined sections.")
|
492 | 494 |
|
493 | 495 | if "gh-issue" not in metadata and "bpo" not in metadata:
|
@@ -568,7 +570,7 @@ def _parse_next_filename(filename):
|
568 | 570 | components = filename.split(os.sep)
|
569 | 571 | section, filename = components[-2:]
|
570 | 572 | section = unsanitize_section(section)
|
571 |
| - assert section in sections, f"Unknown section {section}" |
| 573 | + assert section in SECTIONS, f"Unknown section {section}" |
572 | 574 |
|
573 | 575 | fields = [x.strip() for x in filename.split(".")]
|
574 | 576 | assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}"
|
@@ -817,82 +819,135 @@ def find_editor():
|
817 | 819 | error('Could not find an editor! Set the EDITOR environment variable.')
|
818 | 820 |
|
819 | 821 |
|
820 |
| -@subcommand |
821 |
| -def add(): |
822 |
| - """ |
823 |
| -Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. |
824 |
| - """ |
| 822 | +def validate_add_parameters(section, gh_issue, rst_on_stdin): |
| 823 | + """Validate parameters for the add command.""" |
| 824 | + if section and section not in SECTIONS: |
| 825 | + error(f"--section must be one of {SECTIONS} not {section!r}") |
825 | 826 |
|
826 |
| - editor = find_editor() |
| 827 | + if gh_issue < 0: |
| 828 | + error(f"--gh_issue must be a positive integer not {gh_issue!r}") |
827 | 829 |
|
828 |
| - handle, tmp_path = tempfile.mkstemp(".rst") |
829 |
| - os.close(handle) |
830 |
| - atexit.register(lambda : os.unlink(tmp_path)) |
831 |
| - |
832 |
| - def init_tmp_with_template(): |
833 |
| - with open(tmp_path, "wt", encoding="utf-8") as file: |
834 |
| - # hack: |
835 |
| - # my editor likes to strip trailing whitespace from lines. |
836 |
| - # normally this is a good idea. but in the case of the template |
837 |
| - # it's unhelpful. |
838 |
| - # so, manually ensure there's a space at the end of the gh-issue line. |
839 |
| - text = template |
840 |
| - |
841 |
| - issue_line = ".. gh-issue:" |
842 |
| - without_space = "\n" + issue_line + "\n" |
843 |
| - with_space = "\n" + issue_line + " \n" |
844 |
| - if without_space not in text: |
845 |
| - sys.exit("Can't find gh-issue line to ensure there's a space on the end!") |
846 |
| - text = text.replace(without_space, with_space) |
847 |
| - file.write(text) |
| 830 | + if rst_on_stdin and (gh_issue <= 0 or not section): |
| 831 | + error("--gh_issue and --section required with --rst_on_stdin") |
848 | 832 |
|
849 |
| - init_tmp_with_template() |
| 833 | + return True |
850 | 834 |
|
851 |
| - # We need to be clever about EDITOR. |
852 |
| - # On the one hand, it might be a legitimate path to an |
853 |
| - # executable containing spaces. |
854 |
| - # On the other hand, it might be a partial command-line |
855 |
| - # with options. |
| 835 | + |
| 836 | +def prepare_template(tmp_path, gh_issue, section, rst_content): |
| 837 | + """Write the template file with substitutions.""" |
| 838 | + text = template |
| 839 | + |
| 840 | + # Ensure gh-issue line ends with space |
| 841 | + issue_line = ".. gh-issue:" |
| 842 | + text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n") |
| 843 | + |
| 844 | + # Apply substitutions |
| 845 | + if gh_issue > 0: |
| 846 | + text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n") |
| 847 | + if section: |
| 848 | + text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n") |
| 849 | + if rst_content: |
| 850 | + marker = "#################\n\n" |
| 851 | + text = text.replace(marker, f"{marker}{rst_content}\n") |
| 852 | + |
| 853 | + with open(tmp_path, "wt", encoding="utf-8") as file: |
| 854 | + file.write(text) |
| 855 | + |
| 856 | + |
| 857 | +def get_editor_args(editor, tmp_path): |
| 858 | + """Prepare editor command arguments.""" |
856 | 859 | if shutil.which(editor):
|
857 | 860 | args = [editor]
|
858 | 861 | else:
|
859 | 862 | args = list(shlex.split(editor))
|
860 | 863 | if not shutil.which(args[0]):
|
861 | 864 | sys.exit(f"Invalid GIT_EDITOR / EDITOR value: {editor}")
|
862 | 865 | args.append(tmp_path)
|
| 866 | + return args |
| 867 | + |
| 868 | + |
| 869 | +def edit_until_valid(editor, tmp_path): |
| 870 | + """Run editor until we get a valid blurb.""" |
| 871 | + args = get_editor_args(editor, tmp_path) |
863 | 872 |
|
864 | 873 | while True:
|
865 | 874 | subprocess.run(args)
|
866 | 875 |
|
867 |
| - failure = None |
868 | 876 | blurb = Blurbs()
|
869 | 877 | try:
|
870 | 878 | blurb.load(tmp_path)
|
871 |
| - except BlurbError as e: |
872 |
| - failure = str(e) |
873 |
| - |
874 |
| - if not failure: |
875 |
| - assert len(blurb) # if parse_blurb succeeds, we should always have a body |
876 | 879 | if len(blurb) > 1:
|
877 |
| - failure = "Too many entries! Don't specify '..' on a line by itself." |
878 |
| - |
879 |
| - if failure: |
880 |
| - print() |
881 |
| - print(f"Error: {failure}") |
882 |
| - print() |
| 880 | + raise BlurbError("Too many entries! Don't specify '..' on a line by itself.") |
| 881 | + return blurb |
| 882 | + except BlurbError as e: |
| 883 | + print(f"\nError: {e}\n") |
883 | 884 | try:
|
884 | 885 | prompt("Hit return to retry (or Ctrl-C to abort)")
|
885 | 886 | except KeyboardInterrupt:
|
886 | 887 | print()
|
887 |
| - return |
| 888 | + return None |
888 | 889 | print()
|
889 |
| - continue |
890 |
| - break |
891 | 890 |
|
| 891 | + |
| 892 | +@subcommand |
| 893 | +def add(*, help=False, gh_issue: int = 0, section: str = "", rst_on_stdin=False): |
| 894 | + """ |
| 895 | +Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. |
| 896 | +
|
| 897 | +Optional arguments, useful for automation: |
| 898 | + --gh_issue - The GitHub issue number to associate the NEWS entry with. |
| 899 | + --section - The NEWS section name. One of {SECTIONS} |
| 900 | + --rst_on_stdin - Pipe your ReStructured Text news entry via stdin instead of opening an editor. |
| 901 | +
|
| 902 | +When using --rst_on_stdin, both --gh_issue and --section are required. |
| 903 | + """ |
| 904 | + if help: |
| 905 | + print(add.__doc__) |
| 906 | + sys.exit(0) |
| 907 | + |
| 908 | + # Validate parameters |
| 909 | + if not validate_add_parameters(section, gh_issue, rst_on_stdin): |
| 910 | + return 1 |
| 911 | + |
| 912 | + # Prepare content source |
| 913 | + if rst_on_stdin: |
| 914 | + rst_content = sys.stdin.read().strip() |
| 915 | + if not rst_content: |
| 916 | + error("No content provided on stdin") |
| 917 | + editor = None |
| 918 | + else: |
| 919 | + rst_content = None |
| 920 | + editor = find_editor() |
| 921 | + |
| 922 | + # Create temp file |
| 923 | + handle, tmp_path = tempfile.mkstemp(".rst") |
| 924 | + os.close(handle) |
| 925 | + atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path)) |
| 926 | + |
| 927 | + # Prepare template |
| 928 | + prepare_template(tmp_path, gh_issue, section, rst_content) |
| 929 | + |
| 930 | + # Get blurb content |
| 931 | + if editor: |
| 932 | + blurb = edit_until_valid(editor, tmp_path) |
| 933 | + if not blurb: |
| 934 | + return 1 |
| 935 | + else: |
| 936 | + blurb = Blurbs() |
| 937 | + try: |
| 938 | + blurb.load(tmp_path) |
| 939 | + except BlurbError as e: |
| 940 | + error(str(e)) |
| 941 | + |
| 942 | + # Save and commit |
892 | 943 | path = blurb.save_next()
|
893 | 944 | git_add_files.append(path)
|
894 | 945 | flush_git_add_files()
|
895 |
| - print("Ready for commit.") |
| 946 | + print(f"Ready for commit. {path!r} created and git added.") |
| 947 | + |
| 948 | + |
| 949 | +# Format the docstring with the actual SECTIONS list |
| 950 | +add.__doc__ = add.__doc__.format(SECTIONS=SECTIONS) |
896 | 951 |
|
897 | 952 |
|
898 | 953 |
|
@@ -1107,7 +1162,7 @@ def populate():
|
1107 | 1162 | os.chdir("Misc")
|
1108 | 1163 | os.makedirs("NEWS.d/next", exist_ok=True)
|
1109 | 1164 |
|
1110 |
| - for section in sections: |
| 1165 | + for section in SECTIONS: |
1111 | 1166 | dir_name = sanitize_section(section)
|
1112 | 1167 | dir_path = f"NEWS.d/next/{dir_name}"
|
1113 | 1168 | os.makedirs(dir_path, exist_ok=True)
|
@@ -1161,43 +1216,76 @@ def main():
|
1161 | 1216 | original_dir = os.getcwd()
|
1162 | 1217 | chdir_to_repo_root()
|
1163 | 1218 |
|
1164 |
| - # map keyword arguments to options |
1165 |
| - # we only handle boolean options |
1166 |
| - # and they must have default values |
1167 |
| - short_options = {} |
1168 |
| - long_options = {} |
1169 |
| - kwargs = {} |
1170 |
| - for name, p in inspect.signature(fn).parameters.items(): |
1171 |
| - if p.kind == inspect.Parameter.KEYWORD_ONLY: |
1172 |
| - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" |
1173 |
| - kwargs[name] = p.default |
1174 |
| - short_options[name[0]] = name |
1175 |
| - long_options[name] = name |
1176 |
| - |
1177 |
| - filtered_args = [] |
1178 |
| - done_with_options = False |
1179 |
| - |
1180 |
| - def handle_option(s, dict): |
1181 |
| - name = dict.get(s, None) |
1182 |
| - if not name: |
1183 |
| - sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') |
1184 |
| - kwargs[name] = not kwargs[name] |
1185 |
| - |
1186 |
| - # print(f"short_options {short_options} long_options {long_options}") |
1187 |
| - for a in args: |
1188 |
| - if done_with_options: |
1189 |
| - filtered_args.append(a) |
1190 |
| - continue |
1191 |
| - if a.startswith('-'): |
1192 |
| - if a == "--": |
1193 |
| - done_with_options = True |
1194 |
| - elif a.startswith("--"): |
1195 |
| - handle_option(a[2:], long_options) |
| 1219 | + # Special handling for 'add' command with non-boolean options |
| 1220 | + if subcommand == "add": |
| 1221 | + kwargs = {"help": False, "gh_issue": 0, "section": "", "rst_on_stdin": False} |
| 1222 | + filtered_args = [] |
| 1223 | + i = 0 |
| 1224 | + while i < len(args): |
| 1225 | + arg = args[i] |
| 1226 | + if arg in ("-h", "--help"): |
| 1227 | + kwargs["help"] = True |
| 1228 | + elif arg == "--gh_issue": |
| 1229 | + if i + 1 < len(args): |
| 1230 | + try: |
| 1231 | + kwargs["gh_issue"] = int(args[i + 1]) |
| 1232 | + i += 1 |
| 1233 | + except ValueError: |
| 1234 | + sys.exit(f"blurb: --gh_issue requires an integer value, got '{args[i + 1]}'") |
| 1235 | + else: |
| 1236 | + sys.exit("blurb: --gh_issue requires a value") |
| 1237 | + elif arg == "--section": |
| 1238 | + if i + 1 < len(args): |
| 1239 | + kwargs["section"] = args[i + 1] |
| 1240 | + i += 1 |
| 1241 | + else: |
| 1242 | + sys.exit("blurb: --section requires a value") |
| 1243 | + elif arg in ("-r", "--rst_on_stdin"): |
| 1244 | + kwargs["rst_on_stdin"] = True |
| 1245 | + elif arg.startswith("-"): |
| 1246 | + sys.exit(f'blurb: Unknown option for add: "{arg}"') |
1196 | 1247 | else:
|
1197 |
| - for s in a[1:]: |
1198 |
| - handle_option(s, short_options) |
1199 |
| - continue |
1200 |
| - filtered_args.append(a) |
| 1248 | + filtered_args.append(arg) |
| 1249 | + i += 1 |
| 1250 | + else: |
| 1251 | + # Original code for other commands |
| 1252 | + # map keyword arguments to options |
| 1253 | + # we only handle boolean options |
| 1254 | + # and they must have default values |
| 1255 | + short_options = {} |
| 1256 | + long_options = {} |
| 1257 | + kwargs = {} |
| 1258 | + for name, p in inspect.signature(fn).parameters.items(): |
| 1259 | + if p.kind == inspect.Parameter.KEYWORD_ONLY: |
| 1260 | + assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" |
| 1261 | + kwargs[name] = p.default |
| 1262 | + short_options[name[0]] = name |
| 1263 | + long_options[name] = name |
| 1264 | + |
| 1265 | + filtered_args = [] |
| 1266 | + done_with_options = False |
| 1267 | + |
| 1268 | + def handle_option(s, dict): |
| 1269 | + name = dict.get(s, None) |
| 1270 | + if not name: |
| 1271 | + sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') |
| 1272 | + kwargs[name] = not kwargs[name] |
| 1273 | + |
| 1274 | + # print(f"short_options {short_options} long_options {long_options}") |
| 1275 | + for a in args: |
| 1276 | + if done_with_options: |
| 1277 | + filtered_args.append(a) |
| 1278 | + continue |
| 1279 | + if a.startswith('-'): |
| 1280 | + if a == "--": |
| 1281 | + done_with_options = True |
| 1282 | + elif a.startswith("--"): |
| 1283 | + handle_option(a[2:], long_options) |
| 1284 | + else: |
| 1285 | + for s in a[1:]: |
| 1286 | + handle_option(s, short_options) |
| 1287 | + continue |
| 1288 | + filtered_args.append(a) |
1201 | 1289 |
|
1202 | 1290 |
|
1203 | 1291 | sys.exit(fn(*filtered_args, **kwargs))
|
|
0 commit comments