summaryrefslogtreecommitdiff
path: root/debian/blurb/blurb.py
diff options
context:
space:
mode:
Diffstat (limited to 'debian/blurb/blurb.py')
-rwxr-xr-xdebian/blurb/blurb.py1602
1 files changed, 1602 insertions, 0 deletions
diff --git a/debian/blurb/blurb.py b/debian/blurb/blurb.py
new file mode 100755
index 0000000..a35260a
--- /dev/null
+++ b/debian/blurb/blurb.py
@@ -0,0 +1,1602 @@
+#!/usr/bin/env python3
+"""Command-line tool to manage CPython Misc/NEWS.d entries."""
+__version__ = "1.0.4"
+
+##
+## blurb version 1.0
+## Part of the blurb package.
+## Copyright 2015-2017 by Larry Hastings
+##
+## Redistribution and use in source and binary forms, with or without
+## modification, are permitted provided that the following conditions are
+## met:
+##
+## 1. Redistributions of source code must retain the above copyright
+## notice, this list of conditions and the following disclaimer.
+##
+## 2. Redistributions in binary form must reproduce the above copyright
+## notice, this list of conditions and the following disclaimer in the
+## documentation and/or other materials provided with the distribution.
+##
+## 3. Neither the name of the copyright holder nor the names of its
+## contributors may be used to endorse or promote products derived from
+## this software without specific prior written permission.
+##
+## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+## IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+## TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+## PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+## HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+## TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+## PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+## LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+## NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+## SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+##
+##
+## Licensed to the Python Software Foundation under a contributor agreement.
+##
+
+
+# TODO
+#
+# automatic git adds and removes
+
+import atexit
+import base64
+import builtins
+from collections import OrderedDict
+import glob
+import hashlib
+import inspect
+import itertools
+import math
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import textwrap
+import time
+import types
+import unittest
+import uuid
+
+
+#
+# This template is the canonical list of acceptable section names!
+# It's parsed internally into the "sections" set.
+#
+
+template = """
+
+#
+# Please enter the relevant bugs.python.org issue number here:
+#
+.. bpo:
+
+#
+# Uncomment one of these "section:" lines to specify which section
+# this entry should go in in Misc/NEWS.
+#
+#.. section: Security
+#.. section: Core and Builtins
+#.. section: Library
+#.. section: Documentation
+#.. section: Tests
+#.. section: Build
+#.. section: Windows
+#.. section: macOS
+#.. section: IDLE
+#.. section: Tools/Demos
+#.. section: C API
+
+# Write your Misc/NEWS entry below. It should be a simple ReST paragraph.
+# Don't start with "- Issue #<n>: " or "- bpo-<n>: " or that sort of stuff.
+###########################################################################
+
+
+""".lstrip()
+
+root = None
+original_dir = None
+sections = []
+
+for line in template.split('\n'):
+ line = line.strip()
+ prefix, found, section = line.partition("#.. section: ")
+ if found and not prefix:
+ sections.append(section.strip())
+
+
+def sanitize_section(section):
+ """
+Cleans up a section string, making it viable as a directory name.
+ """
+ return section.replace("/", "-")
+
+_unsanitize_section = {
+ "Tools-Demos": "Tools/Demos",
+ }
+
+def unsanitize_section(section):
+ return _unsanitize_section.get(section, section)
+
+
+def textwrap_body(body, *, subsequent_indent=''):
+ """
+ Accepts either a string or an iterable of strings.
+ (Iterable is assumed to be individual lines.)
+ Returns a string.
+ """
+ if isinstance(body, str):
+ text = body
+ else:
+ text = "\n".join(body).rstrip()
+
+ # textwrap merges paragraphs, ARGH
+
+ # step 1: remove trailing whitespace from individual lines
+ # (this means that empty lines will just have \n, no invisible whitespace)
+ lines = []
+ for line in text.split("\n"):
+ lines.append(line.rstrip())
+ text = "\n".join(lines)
+ # step 2: break into paragraphs and wrap those
+ paragraphs = text.split("\n\n")
+ paragraphs2 = []
+ kwargs = {}
+ if subsequent_indent:
+ kwargs['subsequent_indent'] = subsequent_indent
+ dont_reflow = False
+ for paragraph in paragraphs:
+ # don't reflow bulleted / numbered lists
+ dont_reflow = dont_reflow or paragraph.startswith(("* ", "1. ", "#. "))
+ if dont_reflow:
+ initial = kwargs.get("initial_indent", "")
+ subsequent = kwargs.get("subsequent_indent", "")
+ if initial or subsequent:
+ lines = [line.rstrip() for line in paragraph.split("\n")]
+ indents = itertools.chain(
+ itertools.repeat(initial, 1),
+ itertools.repeat(subsequent),
+ )
+ lines = [indent + line for indent, line in zip(indents, lines)]
+ paragraph = "\n".join(lines)
+ paragraphs2.append(paragraph)
+ else:
+ paragraph = "\n".join(textwrap.wrap(paragraph.strip(), width=76, **kwargs)).rstrip()
+ paragraphs2.append(paragraph)
+ # don't reflow literal code blocks (I hope)
+ dont_reflow = paragraph.endswith("::")
+ if subsequent_indent:
+ kwargs['initial_indent'] = subsequent_indent
+ text = "\n\n".join(paragraphs2).rstrip()
+ if not text.endswith("\n"):
+ text += "\n"
+ return text
+
+
+def current_date():
+ return time.strftime("%Y-%m-%d", time.localtime())
+
+def sortable_datetime():
+ return time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime())
+
+
+def prompt(prompt):
+ return input("[{}> ".format(prompt))
+
+def require_ok(prompt):
+ prompt = "[{}> ".format(prompt)
+ while True:
+ s = input(prompt).strip()
+ if s == 'ok':
+ return s
+
+class pushd:
+ def __init__(self, path):
+ self.path = path
+
+ def __enter__(self):
+ self.previous_cwd = os.getcwd()
+ os.chdir(self.path)
+
+ def __exit__(self, *args):
+ os.chdir(self.previous_cwd)
+
+
+def safe_mkdir(path):
+ if not os.path.exists(path):
+ os.makedirs(path)
+
+
+def which(cmd, path="PATH"):
+ """Find cmd on PATH."""
+ if os.path.exists(cmd):
+ return cmd
+ if cmd[0] == '/':
+ return None
+ for segment in os.getenv(path).split(":"):
+ program = os.path.normpath(os.path.join(segment, cmd))
+ if os.path.exists(program):
+ return program
+ return None
+
+
+def strip_whitespace_lines(lines):
+ # strip from head
+ while lines:
+ if lines[0]:
+ break
+ lines.pop(0)
+
+ # strip from tail
+ while lines:
+ if lines[-1]:
+ return
+ lines.pop()
+
+
+def longest_line(lines):
+ longest = 0
+ for line in lines:
+ longest = max(longest, len(line))
+ return longest
+
+
+def version_key(element):
+ fields = list(element.split("."))
+ if len(fields) == 1:
+ return element
+
+ # in sorted order,
+ # 3.5.0a1 < 3.5.0b1 < 3.5.0rc1 < 3.5.0
+ # so for sorting purposes we transform
+ # "3.5." and "3.5.0" into "3.5.0zz0"
+ last = fields.pop()
+ for s in ("a", "b", "rc"):
+ if s in last:
+ last, stage, stage_version = last.partition(s)
+ break
+ else:
+ stage = 'zz'
+ stage_version = "0"
+
+ fields.append(last)
+ while len(fields) < 3:
+ fields.append("0")
+
+ fields.extend([stage, stage_version])
+ fields = [s.rjust(6, "0") for s in fields]
+
+ return ".".join(fields)
+
+
+def nonceify(body):
+ digest = hashlib.md5(body.encode("utf-8")).digest()
+ return base64.urlsafe_b64encode(digest)[0:6].decode('ascii')
+
+
+def glob_versions():
+ with pushd("Misc/NEWS.d"):
+ versions = []
+ for wildcard in ("2.*.rst", "3.*.rst", "next"):
+ files = [x.partition(".rst")[0] for x in glob.glob(wildcard)]
+ versions.extend(files)
+ xform = [version_key(x) for x in versions]
+ xform.sort(reverse=True)
+ versions = sorted(versions, key=version_key, reverse=True)
+ return versions
+
+
+def glob_blurbs(version):
+ filenames = []
+ base = os.path.join("Misc", "NEWS.d", version)
+ if version != "next":
+ wildcard = base + ".rst"
+ filenames.extend(glob.glob(wildcard))
+ else:
+ for section in sections:
+ wildcard = os.path.join(base, sanitize_section(section), "*.rst")
+ entries = glob.glob(wildcard)
+ entries.sort(reverse=True)
+ deletables = [x for x in entries if x.endswith("/README.rst")]
+ for filename in deletables:
+ entries.remove(filename)
+ filenames.extend(entries)
+ return filenames
+
+
+def printable_version(version):
+ if version == "next":
+ return version
+ if "a" in version:
+ return version.replace("a", " alpha ")
+ if "b" in version:
+ return version.replace("b", " beta ")
+ if "rc" in version:
+ return version.replace("rc", " release candidate ")
+ return version + " final"
+
+
+class BlurbError(RuntimeError):
+ pass
+
+"""
+
+The format of a blurb file:
+
+ ENTRY
+ [ENTRY2
+ ENTRY3
+ ...]
+
+In other words, you may have one or more ENTRYs (entries) in a blurb file.
+
+The format of an ENTRY:
+
+ METADATA
+ BODY
+
+The METADATA section is optional.
+The BODY section is mandatory and must be non-empty.
+
+Format of the METADATA section:
+
+ * Lines starting with ".." are metadata lines of the format:
+ .. name: value
+ * Lines starting with "#" are comments:
+ # comment line
+ * Empty and whitespace-only lines are ignored.
+ * Trailing whitespace is removed. Leading whitespace is not removed
+ or ignored.
+
+The first nonblank line that doesn't start with ".." or "#" automatically
+terminates the METADATA section and is the first line of the BODY.
+
+Format of the BODY section:
+
+ * The BODY section should be a single paragraph of English text
+ in ReST format. It should not use the following ReST markup
+ features:
+ * section headers
+ * comments
+ * directives, citations, or footnotes
+ * Any features that require significant line breaks,
+ like lists, definition lists, quoted paragraphs, line blocks,
+ literal code blocks, and tables.
+ Note that this is not (currently) enforced.
+ * Trailing whitespace is stripped. Leading whitespace is preserved.
+ * Empty lines between non-empty lines are preserved.
+ Trailing empty lines are stripped.
+ * The BODY mustn't start with "Issue #", "bpo-", or "- ".
+ (This formatting will be inserted when rendering the final output.)
+ * Lines longer than 76 characters will be wordwrapped.
+ * In the final output, the first line will have
+ "- bpo-<bpo-number>: " inserted at the front,
+ and subsequent lines will have two spaces inserted
+ at the front.
+
+To terminate an ENTRY, specify a line containing only "..". End of file
+also terminates the last ENTRY.
+
+-----------------------------------------------------------------------------
+
+The format of a "next" file is exactly the same, except that we're storing
+four pieces of metadata in the filename instead of in the metadata section.
+Those four pieces of metadata are: section, bpo, date, and nonce.
+
+-----------------------------------------------------------------------------
+
+In addition to the four conventional metadata (section, bpo, date, and nonce),
+there are two additional metadata used per-version: "release date" and
+"no changes". These may only be present in the metadata block in the *first*
+blurb in a blurb file.
+ * "release date" is the day a particular version of Python was released.
+ * "no changes", if present, notes that there were no actual changes
+ for this version. When used, there are two more things that must be
+ true about the the blurb file:
+ * There should only be one entry inside the blurb file.
+ * That entry's bpo number must be 0.
+
+"""
+
+class Blurbs(list):
+
+ def parse(self, text, *, metadata=None, filename="input"):
+ """
+ Parses a string. Appends a list of blurb ENTRIES to self, as tuples:
+ (metadata, body)
+ metadata is a dict. body is a string.
+ """
+
+ metadata = metadata or {}
+ body = []
+ in_metadata = True
+
+ line_number = None
+
+ def throw(s):
+ raise BlurbError("Error in {}:{}:\n{}".format(filename, line_number, s))
+
+ def finish_entry():
+ nonlocal body
+ nonlocal in_metadata
+ nonlocal metadata
+ nonlocal self
+
+ if not body:
+ throw("Blurb 'body' text must not be empty!")
+ text = textwrap_body(body)
+ for naughty_prefix in ("- ", "Issue #", "bpo-"):
+ if text.startswith(naughty_prefix):
+ throw("Blurb 'body' can't start with " + repr(naughty_prefix) + "!")
+
+ no_changes = metadata.get('no changes')
+ section = metadata.get('section')
+
+ if not no_changes:
+ if not section:
+ throw("No 'section' specified. You must provide one!")
+ elif section not in sections:
+ throw("Invalid 'section'! You must use one of the predefined sections.")
+
+ bpo = None
+ try:
+ bpo = int(metadata.get('bpo'))
+ except (TypeError, ValueError):
+ throw("Invalid bpo issue number! (" + repr(bpo) + ")")
+
+ self.append((metadata, text))
+ metadata = {}
+ body = []
+ in_metadata = True
+
+ for line_number, line in enumerate(text.split("\n")):
+ line = line.rstrip()
+ if in_metadata:
+ if line.startswith('..'):
+ line = line[2:].strip()
+ name, colon, value = line.partition(":")
+ assert colon
+ name = name.strip()
+ value = value.strip()
+ if name in metadata:
+ throw("Blurb metadata sets " + repr(name) + " twice!")
+ metadata[name] = value
+ continue
+ if line.startswith("#") or not line:
+ continue
+ in_metadata = False
+
+ if line == "..":
+ finish_entry()
+ continue
+ body.append(line)
+
+ finish_entry()
+
+ def load(self, filename, *, metadata=None):
+ """
+Read a blurb file.
+
+Broadly equivalent to blurb.parse(open(filename).read()).
+ """
+ with open(filename, "rt", encoding="utf-8") as f:
+ text = f.read()
+ self.parse(text, metadata=metadata, filename=filename)
+
+ def __str__(self):
+ output = []
+ add = output.append
+ add_separator = False
+ for metadata, body in self:
+ if add_separator:
+ add("\n..\n\n")
+ else:
+ add_separator = True
+ if metadata:
+ for name, value in sorted(metadata.items()):
+ add(".. {}: {}\n".format(name, value))
+ add("\n")
+ add(textwrap_body(body))
+ return "".join(output)
+
+ def save(self, path):
+ dirname = os.path.dirname(path)
+ safe_mkdir(dirname)
+
+ text = str(self)
+ with open(path, "wt", encoding="utf-8") as f:
+ f.write(text)
+
+ @staticmethod
+ def _parse_next_filename(filename):
+ """
+Parses a "next" filename into its equivalent blurb metadata.
+Returns a dict.
+ """
+ components = filename.split("/")
+ section, filename = components[-2:]
+ section = unsanitize_section(section)
+ assert section in sections, "Unknown section {}".format(section)
+
+ fields = [x.strip() for x in filename.split(".")]
+ assert len(fields) >= 4, "Can't parse 'next' filename! filename {!r} fields {}".format(filename, fields)
+ assert fields[-1] == "rst"
+
+ metadata = {"date": fields[0], "nonce": fields[-2], "section": section}
+
+ for field in fields[1:-2]:
+ for name in ("bpo",):
+ _, got, value = field.partition(name + "-")
+ if got:
+ metadata[name] = value.strip()
+ break
+ else:
+ assert False, "Found unparsable field in 'next' filename: " + repr(field)
+
+ return metadata
+
+ def load_next(self, filename):
+ metadata = self._parse_next_filename(filename)
+ o = type(self)()
+ o.load(filename, metadata=metadata)
+ assert len(o) == 1
+ self.extend(o)
+
+ def ensure_metadata(self):
+ metadata, body = self[-1]
+ assert 'section' in metadata
+ for name, default in (
+ ("bpo", "0"),
+ ("date", sortable_datetime()),
+ ("nonce", nonceify(body)),
+ ):
+ if name not in metadata:
+ metadata[name] = default
+
+ def _extract_next_filename(self):
+ """
+ changes metadata!
+ """
+ self.ensure_metadata()
+ metadata, body = self[-1]
+ metadata['section'] = sanitize_section(metadata['section'])
+ metadata['root'] = root
+ path = "{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst".format_map(metadata)
+ for name in "root section date bpo nonce".split():
+ del metadata[name]
+ return path
+
+
+ def save_next(self):
+ assert len(self) == 1
+ blurb = type(self)()
+ metadata, body = self[0]
+ metadata = dict(metadata)
+ blurb.append((metadata, body))
+ filename = blurb._extract_next_filename()
+ blurb.save(filename)
+ return filename
+
+ def save_split_next(self):
+ """
+ Save out blurbs created from "blurb split".
+ They don't have dates, so we have to get creative.
+ """
+ filenames = []
+ # the "date" MUST have a leading zero.
+ # this ensures these files sort after all
+ # newly created blurbs.
+ width = int(math.ceil(math.log(len(self), 10))) + 1
+ i = 1
+ blurb = Blurbs()
+ while self:
+ metadata, body = self.pop()
+ metadata['date'] = str(i).rjust(width, '0')
+ if 'release date' in metadata:
+ del metadata['release date']
+ blurb.append((metadata, body))
+ filename = blurb._extract_next_filename()
+ blurb.save(filename)
+ blurb.clear()
+ filenames.append(filename)
+ i += 1
+ return filenames
+
+
+tests_run = 0
+
+class TestParserPasses(unittest.TestCase):
+ directory = "tests/pass"
+
+ def filename_test(self, filename):
+ b = Blurbs()
+ b.load(filename)
+ self.assertTrue(b)
+
+ def test_files(self):
+ global tests_run
+ with pushd(self.directory):
+ for filename in glob.glob("*"):
+ self.filename_test(filename)
+ print(".", end="")
+ sys.stdout.flush()
+ tests_run += 1
+
+
+class TestParserFailures(TestParserPasses):
+ directory = "tests/fail"
+
+ def filename_test(self, filename):
+ b = Blurbs()
+ with self.assertRaises(Exception):
+ b.read(filename)
+
+
+
+def run(s):
+ process = subprocess.run(s.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ process.check_returncode()
+ return process.stdout.decode('ascii')
+
+
+readme_re = re.compile(r"This is Python version [23]\.\d").match
+
+def chdir_to_repo_root():
+ global root
+
+ # find the root of the local CPython repo
+ # note that we can't ask git, because we might
+ # be in an exported directory tree!
+
+ # we intentionally start in a (probably nonexistant) subtree
+ # the first thing the while loop does is .., basically
+ path = os.path.abspath("garglemox")
+ while True:
+ next_path = os.path.dirname(path)
+ if next_path == path:
+ sys.exit('You\'re not inside a CPython repo right now!')
+ path = next_path
+
+ os.chdir(path)
+
+ def test_first_line(filename, test):
+ if not os.path.exists(filename):
+ return False
+ with open(filename, "rt") as f:
+ lines = f.read().split('\n')
+ if not (lines and test(lines[0])):
+ return False
+ return True
+
+ if not (test_first_line("README", readme_re)
+ or test_first_line("README.rst", readme_re)):
+ continue
+
+ if not test_first_line("LICENSE", "A. HISTORY OF THE SOFTWARE".__eq__):
+ continue
+ if not os.path.exists("Include/Python.h"):
+ continue
+ if not os.path.exists("Python/ceval.c"):
+ continue
+
+ break
+
+ root = path
+ return root
+
+
+def error(*a):
+ s = " ".join(str(x) for x in a)
+ sys.exit("Error: " + s)
+
+
+subcommands = {}
+
+def subcommand(fn):
+ global subcommands
+ name = fn.__name__
+ subcommands[name] = fn
+ return fn
+
+def get_subcommand(subcommand):
+ fn = subcommands.get(subcommand)
+ if not fn:
+ error("Unknown subcommand: {}".format(subcommand))
+ return fn
+
+
+
+@subcommand
+def help(subcommand=None):
+ """
+Print help for subcommands.
+
+Prints the help text for the specified subcommand.
+If subcommand is not specified, prints one-line summaries for every command.
+ """
+
+ if not subcommand:
+ print("blurb version", __version__)
+ print()
+ print("Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.")
+ print()
+ print("Usage:")
+ print(" blurb [subcommand] [options...]")
+ print()
+
+ # print list of subcommands
+ summaries = []
+ longest_name_len = -1
+ for name, fn in subcommands.items():
+ longest_name_len = max(longest_name_len, len(name))
+ if not fn.__doc__:
+ error("help is broken, no docstring for " + fn.__name__)
+ fields = fn.__doc__.lstrip().split("\n")
+ if not fields:
+ first_line = "(no help available)"
+ else:
+ first_line = fields[0]
+ summaries.append((name, first_line))
+ summaries.sort()
+
+ print("Available subcommands:")
+ print()
+ for name, summary in summaries:
+ print(" ", name.ljust(longest_name_len), " ", summary)
+
+ print()
+ print("If blurb is run without any arguments, this is equivalent to 'blurb add'.")
+
+ sys.exit(0)
+
+ fn = get_subcommand(subcommand)
+ doc = fn.__doc__.strip()
+ if not doc:
+ error("help is broken, no docstring for " + subcommand)
+
+ options = []
+ positionals = []
+
+ nesting = 0
+ for name, p in inspect.signature(fn).parameters.items():
+ if p.kind == inspect.Parameter.KEYWORD_ONLY:
+ short_option = name[0]
+ options.append(" [-{}|--{}]".format(short_option, name))
+ elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
+ positionals.append(" ")
+ has_default = (p.default != inspect._empty)
+ if has_default:
+ positionals.append("[")
+ nesting += 1
+ positionals.append("<{}>".format(name))
+ positionals.append("]" * nesting)
+
+
+ parameters = "".join(options + positionals)
+ print("blurb {}{}".format(subcommand, parameters))
+ print()
+ print(doc)
+ sys.exit(0)
+
+
+@subcommand
+def test(*args):
+ """
+Run unit tests. Only works inside source repo, not when installed.
+ """
+ # unittest.main doesn't work because this isn't a module
+ # so we'll do it ourselves
+
+ print("-" * 79)
+
+ for clsname, cls in sorted(globals().items()):
+ if clsname.startswith("Test") and isinstance(cls, type):
+ o = cls()
+ for fnname in sorted(dir(o)):
+ if fnname.startswith("test"):
+ fn = getattr(o, fnname)
+ if callable(fn):
+ fn()
+ print()
+ print(tests_run, "tests passed.")
+
+
+def find_editor():
+ for var in 'GIT_EDITOR', 'EDITOR':
+ editor = os.environ.get(var)
+ if editor is not None:
+ return editor
+ if sys.platform == 'win32':
+ fallbacks = ['notepad.exe']
+ else:
+ fallbacks = ['/etc/alternatives/editor', 'nano']
+ for fallback in fallbacks:
+ if os.path.isabs(fallback):
+ found_path = fallback
+ else:
+ found_path = shutil.which(fallback)
+ if found_path and os.path.exists(found_path):
+ return found_path
+ error('Could not find an editor! Set the EDITOR environment variable.')
+
+
+@subcommand
+def add():
+ """
+Add a blurb (a Misc/NEWS entry) to the current CPython repo.
+ """
+
+ editor = find_editor()
+
+ handle, tmp_path = tempfile.mkstemp(".rst")
+ os.close(handle)
+ atexit.register(lambda : os.unlink(tmp_path))
+
+ def init_tmp_with_template():
+ with open(tmp_path, "wt", encoding="utf-8") as f:
+ # hack:
+ # my editor likes to strip trailing whitespace from lines.
+ # normally this is a good idea. but in the case of the template
+ # it's unhelpful.
+ # so, manually ensure there's a space at the end of the bpo line.
+ text = template
+
+ bpo_line = ".. bpo:"
+ without_space = "\n" + bpo_line + "\n"
+ with_space = "\n" + bpo_line + " \n"
+ if without_space not in text:
+ sys.exit("Can't find BPO line to ensure there's a space on the end!")
+ text = text.replace(without_space, with_space)
+ f.write(text)
+
+ init_tmp_with_template()
+
+ while True:
+ subprocess.run([editor, tmp_path])
+
+ failure = None
+ blurb = Blurbs()
+ try:
+ blurb.load(tmp_path)
+ except BlurbError as e:
+ failure = str(e)
+
+ if not failure:
+ assert len(blurb) # if parse_blurb succeeds, we should always have a body
+ if len(blurb) > 1:
+ failure = "Too many entries! Don't specify '..' on a line by itself."
+
+ if failure:
+ print()
+ print("Error: {}".format(failure))
+ print()
+ try:
+ prompt("Hit return to retry (or Ctrl-C to abort)")
+ except KeyboardInterrupt:
+ print()
+ return
+ print()
+ continue
+ break
+
+ path = blurb.save_next()
+ git_add_files.append(path)
+ flush_git_add_files()
+ print("Ready for commit.")
+
+
+
+@subcommand
+def release(version):
+ """
+Move all new blurbs to a single blurb file for the release.
+
+This is used by the release manager when cutting a new release.
+ """
+ if version == ".":
+ # harvest version number from dirname of repo
+ # I remind you, we're in the Misc subdir right now
+ version = os.path.basename(root)
+
+ existing_filenames = glob_blurbs(version)
+ if existing_filenames:
+ error("Sorry, can't handle appending 'next' files to an existing version (yet).")
+
+ output = "Misc/NEWS.d/{}.rst".format(version)
+ filenames = glob_blurbs("next")
+ blurbs = Blurbs()
+ date = current_date()
+
+ if not filenames:
+ print("No blurbs found. Setting {} as having no changes.".format(version))
+ body = "There were no new changes in version {}.\n".format(version)
+ metadata = {"no changes": "True", "bpo": "0", "section": "Library", "date": date, "nonce": nonceify(body)}
+ blurbs.append((metadata, body))
+ else:
+ no_changes = None
+ count = len(filenames)
+ print('Merging {} blurbs to "{}".'.format(count, output))
+
+ for filename in filenames:
+ if not filename.endswith(".rst"):
+ continue
+ blurbs.load_next(filename)
+
+ metadata = blurbs[0][0]
+
+ metadata['release date'] = date
+ print("Saving.")
+
+ blurbs.save(output)
+ git_add_files.append(output)
+ flush_git_add_files()
+
+ # sanity check: ensuring that saving/reloading the merged blurb file works.
+ blurbs2 = Blurbs()
+ blurbs2.load(output)
+ assert blurbs2 == blurbs, "Reloading {} isn't reproducable?!".format(output)
+
+ print("Removing {} 'next' files from git.".format(len(filenames)))
+ git_rm_files.extend(filenames)
+ flush_git_rm_files()
+
+ print()
+ print("Ready for commit.")
+
+
+
+@subcommand
+def merge(output=None, *, forced=False):
+ """
+Merge all blurbs together into a single Misc/NEWS file.
+
+Optional output argument specifies where to write to.
+Default is <cpython-root>/Misc/NEWS.
+
+If overwriting, blurb merge will prompt you to make sure it's okay.
+To force it to overwrite, use -f.
+ """
+ if output:
+ output = os.path.join(original_dir, output)
+ else:
+ output = "Misc/NEWS"
+
+ versions = glob_versions()
+ if not versions:
+ sys.exit("You literally don't have ANY blurbs to merge together!")
+
+ if os.path.exists(output) and not forced:
+ builtins.print("You already have a", repr(output), "file.")
+ require_ok("Type ok to overwrite")
+
+ news = open(output, "wt", encoding="utf-8")
+
+ def print(*a, sep=" "):
+ s = sep.join(str(x) for x in a)
+ return builtins.print(s, file=news)
+
+ print ("""
++++++++++++
+Python News
++++++++++++
+
+""".strip())
+
+ for version in versions:
+ filenames = glob_blurbs(version)
+
+ blurbs = Blurbs()
+ if version == "next":
+ for filename in filenames:
+ if os.path.basename(filename) == "README.rst":
+ continue
+ blurbs.load_next(filename)
+ if not blurbs:
+ continue
+ metadata = blurbs[0][0]
+ metadata['release date'] = "XXXX-XX-XX"
+ else:
+ assert len(filenames) == 1
+ blurbs.load(filenames[0])
+
+ header = "What's New in Python " + printable_version(version) + "?"
+ print()
+ print(header)
+ print("=" * len(header))
+ print()
+
+
+ metadata, body = blurbs[0]
+ release_date = metadata["release date"]
+
+ print("*Release date: {}*".format(release_date))
+ print()
+
+ if "no changes" in metadata:
+ print(body)
+ print()
+ continue
+
+ last_section = None
+ for metadata, body in blurbs:
+ section = metadata['section']
+ if last_section != section:
+ last_section = section
+ print(section)
+ print("-" * len(section))
+ print()
+
+ bpo = metadata['bpo']
+ if int(bpo):
+ body = "bpo-" + bpo + ": " + body
+ body = "- " + body
+ text = textwrap_body(body, subsequent_indent=' ')
+ print(text)
+ print()
+ print("**(For information about older versions, consult the HISTORY file.)**")
+ news.close()
+
+
+git_add_files = []
+def flush_git_add_files():
+ if git_add_files:
+ subprocess.run(["git", "add", *git_add_files], stdout=subprocess.PIPE, stderr=subprocess.PIPE).check_returncode()
+ git_add_files.clear()
+
+git_rm_files = []
+def flush_git_rm_files():
+ if git_rm_files:
+ try:
+ subprocess.run(["git", "rm", "-f", *git_rm_files], stdout=subprocess.PIPE, stderr=subprocess.PIPE).check_returncode()
+ except subprocess.CalledProcessError:
+ pass
+
+ # clean up
+ for path in git_rm_files:
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+
+ git_rm_files.clear()
+
+
+# @subcommand
+# def noop():
+# "Do-nothing command. Used for blurb smoke-testing."
+# pass
+
+
+@subcommand
+def populate():
+ """
+Creates and populates the Misc/NEWS.d directory tree.
+ """
+ os.chdir("Misc")
+ safe_mkdir("NEWS.d/next")
+
+ for section in sections:
+ dir_name = sanitize_section(section)
+ dir_path = "NEWS.d/next/{}".format(dir_name)
+ safe_mkdir(dir_path)
+ readme_path = "NEWS.d/next/{}/README.rst".format(dir_name)
+ with open(readme_path, "wt", encoding="utf-8") as f:
+ f.write("Put news entry ``blurb`` files for the *{}* section in this directory.\n".format(section))
+ git_add_files.append(dir_path)
+ git_add_files.append(readme_path)
+ flush_git_add_files()
+
+
+@subcommand
+def export():
+ """
+Removes blurb data files, for building release tarballs/installers.
+ """
+ os.chdir("Misc")
+ shutil.rmtree("NEWS.d", ignore_errors=True)
+
+
+
+# @subcommand
+# def arg(*, boolean=False, option=True):
+# """
+# Test function for blurb command-line processing.
+# """
+# print("arg: boolean {} option {}".format(boolean, option))
+
+
+@subcommand
+def split(*, released=False):
+ """
+Split the current Misc/NEWS into a zillion little blurb files.
+
+Assumes that the newest version section in Misc/NEWS is under
+development, and splits those entries into the "next" subdirectory.
+If the current version has actually been released, use the
+--released flag.
+
+Also runs "blurb populate" for you.
+ """
+
+ # note: populate also does chdir $python_root/Misc for you
+ populate()
+
+ if not os.path.isfile("NEWS"):
+ error("You don't have a Misc/NEWS file!")
+
+ def global_sections():
+ global sections
+ return sections
+
+ sections = set(global_sections())
+ release_date_marker = "Release date:"
+ whats_new_marker = "What's New in Python "
+
+ blurbs = Blurbs()
+
+ accumulator = []
+ bpo = "0"
+ serial_number = 9999
+ version = None
+ release_date = None
+ section = None
+ see_also = None
+ no_changes = None
+ security = None
+ blurb_count = 0
+ version_count = 0
+
+
+ def flush_blurb():
+ nonlocal accumulator
+ nonlocal serial_number
+ nonlocal bpo
+ nonlocal release_date
+ nonlocal see_also
+ nonlocal no_changes
+ nonlocal blurb_count
+ nonlocal security
+
+ if accumulator:
+ if version:
+ # strip trailing blank lines
+ while accumulator:
+ line = accumulator.pop()
+ if not line.rstrip():
+ continue
+ accumulator.append(line)
+ break
+ if see_also:
+ fields = []
+ see_also = see_also.replace(" and ", ",")
+ for field in see_also.split(","):
+ field = field.strip()
+ if not field:
+ continue
+ if field.startswith("and "):
+ field = field[5:].lstrip()
+ if field.lower().startswith("issue"):
+ field = field[5:].strip()
+ if field.startswith("#"):
+ field = field[1:]
+ try:
+ int(field)
+ field = "bpo-" + field
+ except ValueError:
+ pass
+ fields.append(field)
+ see_also = ", ".join(fields)
+ # print("see_also: ", repr(see_also))
+ accumulator.append("(See also: {})".format(see_also))
+ see_also = None
+ if not accumulator:
+ return
+ if not (section or no_changes):
+ error("No section for line " + str(line_number) + "!")
+
+ body = "\n".join(accumulator) + "\n"
+ metadata = {}
+ metadata["bpo"] = bpo
+ metadata["date"] = str(serial_number)
+ if section:
+ metadata["section"] = section
+ else:
+ assert no_changes
+ metadata["nonce"] = nonceify(body)
+ if security:
+ # retroactively change section to "Security"
+ assert section
+ metadata["original section"] = metadata["section"]
+ metadata["section"] = "Security"
+
+ if release_date is not None:
+ assert not len(blurbs)
+ metadata["release date"] = release_date
+ release_date = None
+ if no_changes is not None:
+ assert not len(blurbs)
+ metadata["no changes"] = "True"
+ no_changes = None
+ blurbs.append((metadata, body))
+ blurb_count += 1
+
+ bpo = "0"
+ serial_number -= 1
+ accumulator.clear()
+
+ def flush_version():
+ global git_add_files
+ nonlocal released
+ nonlocal version_count
+
+ flush_blurb()
+ if version is None:
+ assert not blurbs, "version should only be None initially, we shouldn't have blurbs yet"
+ return
+ assert blurbs, "No blurbs defined when flushing version {}!".format(version)
+ output = "NEWS.d/{}.rst".format(version)
+
+ if released:
+ # saving merged blurb file for version, e.g. Misc/NEWS.d/3.7.0a1.rst
+ blurbs.save(output)
+ git_add_files.append(output)
+ else:
+ # saving a million old-school blurb next files
+ # with serial numbers instead of dates
+ # e.g. Misc/NEWS.d/next/IDLE/094.bpo-25514.882pXa.rst
+ filenames = blurbs.save_split_next()
+ git_add_files.extend(filenames)
+ released = True
+ blurbs.clear()
+ version_count += 1
+
+ with open("NEWS", "rt", encoding="utf-8") as f:
+ for line_number, line in enumerate(f):
+ line = line.rstrip()
+
+ if line.startswith("\ufeff"):
+ line = line[1:]
+
+ # clean the slightly dirty data:
+ # 1. inconsistent names for sections, etc
+ for old, new in (
+ ("C-API", "C API"),
+ ("Core and builtins", "Core and Builtins"),
+ ("Tools", "Tools/Demos"),
+ ("Tools / Demos", "Tools/Demos"),
+ ("Misc", "Windows"), # only used twice, both were really Windows
+ ("Mac", "macOS"),
+ ("Mac OS X", "macOS"),
+ ("Extension Modules", "Library"),
+ ("Whats' New in Python 2.7.6?", "What's New in Python 2.7.6?"),
+ ):
+ if line == old:
+ line = new
+ # 2. unusual indenting
+ _line = line.lstrip()
+ if _line.startswith(("- Issue #", "- bpo-")):
+ line = _line
+ if _line == ".characters() and ignorableWhitespace() methods. Original patch by Sebastian":
+ line = " " + line
+ # 3. fix version for What's New
+ if line.startswith(whats_new_marker):
+ flush_version()
+ version = line[len(whats_new_marker):].strip().lower()
+ for old, new in (
+ ("?", ""),
+ (" alpha ", "a"),
+ (" beta ", "b"),
+ (" release candidate ", "rc"),
+ (" final", ""),
+ ("3.5a", "3.5.0a"),
+ ):
+ version = version.replace(old, new)
+ section = None
+ continue
+ # 3.a. fix specific precious little snowflakes
+ # who can't be bothered to follow our stifling style conventions
+ # and like, did their own *thing*, man.
+ if line.startswith("- Issue #27181 remove statistics.geometric_mean"):
+ line = line.replace(" remove", ": remove")
+ elif line.startswith("* bpo-30357: test_thread: setUp()"):
+ line = line.replace("* bpo-30357", "- bpo-30357")
+ elif line.startswith("- Issue #25262. Added support for BINBYTES8"):
+ line = line.replace("#25262.", "#25262:")
+ elif line.startswith("- Issue #21032. Fixed socket leak if"):
+ line = line.replace("#21032.", "#21032:")
+ elif line.startswith("- Issue ##665194: Update "):
+ line = line.replace("##665194", "#665194")
+ elif line.startswith("- Issue #13449 sched.scheduler.run()"):
+ line = line.replace("#13449 sched", "#13449: sched")
+ elif line.startswith("- Issue #8684 sched.scheduler class"):
+ line = line.replace("#8684 sched", "#8684: sched")
+ elif line.startswith(" bpo-29243: Prevent unnecessary rebuilding"):
+ line = line.replace(" bpo-29243:", "- bpo-29243:")
+ elif line.startswith((
+ "- Issue #11603 (again): Setting",
+ "- Issue #15801 (again): With string",
+ )):
+ line = line.replace(" (again):", ":")
+ elif line.startswith("- Issue #1665206 (partially): "):
+ line = line.replace(" (partially):", ":")
+ elif line.startswith("- Issue #2885 (partial): The"):
+ line = line.replace(" (partial):", ":")
+ elif line.startswith("- Issue #2885 (partial): The"):
+ line = line.replace(" (partial):", ":")
+ elif line.startswith("- Issue #1797 (partial fix):"):
+ line = line.replace(" (partial fix):", ":")
+ elif line.startswith("- Issue #5828 (Invalid behavior of unicode.lower): Fixed bogus logic in"):
+ line = line.replace(" (Invalid behavior of unicode.lower):", ":")
+ elif line.startswith("- Issue #4512 (part 2): Promote ``ZipImporter._get_filename()`` to be a public"):
+ line = line.replace(" (part 2):", ":")
+ elif line.startswith("- Revert bpo-26293 for zipfile breakage. See also bpo-29094."):
+ line = "- bpo-26293, bpo-29094: Change resulted because of zipfile breakage."
+ elif line.startswith("- Revert a37cc3d926ec (Issue #5322)."):
+ line = "- Issue #5322: Revert a37cc3d926ec."
+ elif line.startswith("- Patch #1970 by Antoine Pitrou: Speedup unicode whitespace and"):
+ line = "- Issue #1970: Speedup unicode whitespace and"
+ elif line.startswith(" linebreak detection"):
+ line = " linebreak detection. (Patch by Antoine Pitrou.)"
+ elif line.startswith("- Patch #1182394 from Shane Holloway: speed up HMAC.hexdigest."):
+ line = "- Issue #1182394: Speed up ``HMAC.hexdigest``. (Patch by Shane Holloway.)"
+ elif line.startswith("- Variant of patch #697613: don't exit the interpreter on a SystemExit"):
+ line = "- Issue #697613: Don't exit the interpreter on a SystemExit"
+ elif line.startswith("- Bugs #1668596/#1720897: distutils now copies data files even if"):
+ line = "- Issue #1668596, #1720897: distutils now copies data files even if"
+ elif line.startswith("- Reverted patch #1504333 to sgmllib because it introduced an infinite"):
+ line = "- Issue #1504333: Reverted change to sgmllib because it introduced an infinite"
+ elif line.startswith("- PEP 465 and Issue #21176: Add the '@' operator for matrix multiplication."):
+ line = "- Issue #21176: PEP 465: Add the '@' operator for matrix multiplication."
+ elif line.startswith("- Issue: #15138: base64.urlsafe_{en,de}code() are now 3-4x faster."):
+ line = "- Issue #15138: base64.urlsafe_{en,de}code() are now 3-4x faster."
+ elif line.startswith("- Issue #9516: Issue #9516: avoid errors in sysconfig when MACOSX_DEPLOYMENT_TARGET"):
+ line = "- Issue #9516 and Issue #9516: avoid errors in sysconfig when MACOSX_DEPLOYMENT_TARGET"
+ elif line.title().startswith(("- Request #", "- Bug #", "- Patch #", "- Patches #")):
+ # print("FIXING LINE {}: {!r}".format(line_number), line)
+ line = "- Issue #" + line.partition('#')[2]
+ # print("FIXED LINE {!r}".format(line))
+ # else:
+ # print("NOT FIXING LINE {}: {!r}".format(line_number, line))
+
+
+ # 4. determine the actual content of the line
+
+ # 4.1 section declaration
+ if line in sections:
+ flush_blurb()
+ section = line
+ continue
+
+ # 4.2 heading ReST marker
+ if line.startswith((
+ "===",
+ "---",
+ "---",
+ "+++",
+ "Python News",
+ "**(For information about older versions, consult the HISTORY file.)**",
+ )):
+ continue
+
+ # 4.3 version release date declaration
+ if line.startswith(release_date_marker) or (
+ line.startswith("*") and release_date_marker in line):
+ while line.startswith("*"):
+ line = line[1:]
+ while line.endswith("*"):
+ line = line[:-1]
+ release_date = line[len(release_date_marker):].strip()
+ continue
+
+ # 4.4 no changes declaration
+ if line.strip() in (
+ '- No changes since release candidate 2',
+ 'No changes from release candidate 2.',
+ 'There were no code changes between 3.5.3rc1 and 3.5.3 final.',
+ 'There were no changes between 3.4.6rc1 and 3.4.6 final.',
+ ):
+ no_changes = True
+ if line.startswith("- "):
+ line = line[2:]
+ accumulator.append(line)
+ continue
+
+ # 4.5 start of new blurb
+ if line.startswith("- "):
+ flush_blurb()
+ line = line[2:]
+ security = line.startswith("[Security]")
+ if security:
+ line = line[10:].lstrip()
+
+ if line.startswith("Issue"):
+ line = line[5:].lstrip()
+ if line.startswith("s"):
+ line = line[1:]
+ line = line.lstrip()
+ if line.startswith("#"):
+ line = line[1:].lstrip()
+ parse_bpo = True
+ elif line.startswith("bpo-"):
+ line = line[4:]
+ parse_bpo = True
+ else:
+ # print("[[{:8} no bpo]] {}".format(line_number, line))
+ parse_bpo = False
+ if parse_bpo:
+ # GAAAH
+ if line == "17500, and https://github.com/python/pythondotorg/issues/945: Remove":
+ line = "Remove"
+ bpo = "17500"
+ see_also = "https://github.com/python/pythondotorg/issues/945"
+ else:
+ bpo, colon, line = line.partition(":")
+ line = line.lstrip()
+ bpo, comma, see_also = bpo.partition(",")
+ if comma:
+ see_also = see_also.strip()
+ # if it's just an integer, add bpo- to the front
+ try:
+ int(see_also)
+ see_also = "bpo-" + see_also
+ except ValueError:
+ pass
+ else:
+ # - Issue #21529 (CVE-2014-4616)
+ bpo, space_paren, see_also = bpo.partition(" (")
+ if space_paren:
+ see_also = see_also.rstrip(")")
+ else:
+ # - Issue #19544 and Issue #1180:
+ bpo, space_and, see_also = bpo.partition(" and ")
+ if not space_and:
+ bpo, space_and, see_also = bpo.partition(" & ")
+ if space_and:
+ see_also = see_also.replace("Issue #", "bpo-").strip()
+ else:
+ # - Issue #5258/#10642: if site.py
+ bpo, slash, see_also = bpo.partition("/")
+ if space_and:
+ see_also = see_also.replace("#", "bpo-").strip()
+ try:
+ int(bpo) # this will throw if it's not a legal int
+ except ValueError:
+ sys.exit("Couldn't convert bpo number to int on line {}! ".format(line_number) + repr(bpo))
+ if see_also == "partially":
+ sys.exit("What the hell on line {}! ".format(line_number) + repr(bpo))
+
+ # 4.6.1 continuation of blurb
+ elif line.startswith(" "):
+ line = line[2:]
+ # 4.6.2 continuation of blurb
+ elif line.startswith(" * "):
+ line = line[3:]
+ elif line:
+ sys.exit("Didn't recognize line {}! ".format(line_number) + repr(line))
+ # only add blank lines if we have an initial line in the accumulator
+ if line or accumulator:
+ accumulator.append(line)
+
+ flush_version()
+
+ assert git_add_files
+ flush_git_add_files()
+ git_rm_files.append("NEWS")
+ flush_git_rm_files()
+
+ print("Wrote {} news items across {} versions.".format(blurb_count, version_count))
+ print()
+ print("Ready for commit.")
+
+
+
+def main():
+ global original_dir
+
+ args = sys.argv[1:]
+
+ if not args:
+ args = ["add"]
+ elif args[0] == "-h":
+ # slight hack
+ args[0] = "help"
+
+ subcommand = args[0]
+ args = args[1:]
+
+ fn = get_subcommand(subcommand)
+
+ # hack
+ if fn in (test, help):
+ sys.exit(fn(*args))
+
+ try:
+ original_dir = os.getcwd()
+ chdir_to_repo_root()
+
+ # map keyword arguments to options
+ # we only handle boolean options
+ # and they must have default values
+ short_options = {}
+ long_options = {}
+ kwargs = {}
+ for name, p in inspect.signature(fn).parameters.items():
+ if p.kind == inspect.Parameter.KEYWORD_ONLY:
+ assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options"
+ kwargs[name] = p.default
+ short_options[name[0]] = name
+ long_options[name] = name
+
+ filtered_args = []
+ done_with_options = False
+
+ def handle_option(s, dict):
+ name = dict.get(s, None)
+ if not name:
+ sys.exit('blurb: Unknown option for {}: "{}"'.format(subcommand, s))
+ kwargs[name] = not kwargs[name]
+
+ # print("short_options {} long_options {}".format(short_options, long_options))
+ for a in args:
+ if done_with_options:
+ filtered_args.append(a)
+ continue
+ if a.startswith('-'):
+ if a == "--":
+ done_with_options = True
+ elif a.startswith("--"):
+ handle_option(a[2:], long_options)
+ else:
+ for s in a[1:]:
+ handle_option(s, short_options)
+ continue
+ filtered_args.append(a)
+
+
+ sys.exit(fn(*filtered_args, **kwargs))
+ except TypeError as e:
+ # almost certainly wrong number of arguments.
+ # count arguments of function and print appropriate error message.
+ specified = len(args)
+ required = optional = 0
+ for p in inspect.signature(fn).parameters.values():
+ if p.default == inspect._empty:
+ required += 1
+ else:
+ optional += 1
+ total = required + optional
+
+ if required <= specified <= total:
+ # whoops, must be a real type error, reraise
+ raise e
+
+ how_many = "{} argument".format(specified)
+ if specified != 1:
+ how_many += "s"
+
+ if total == 0:
+ middle = "accepts no arguments"
+ else:
+ if total == required:
+ middle = "requires"
+ else:
+ plural = "" if required == 1 else "s"
+ middle = "requires at least {} argument{} and at most".format(required, plural)
+ middle += " {} argument".format(total)
+ if total != 1:
+ middle += "s"
+
+ print('Error: Wrong number of arguments!\n\nblurb {} {},\nand you specified {}.'.format(subcommand, middle, how_many))
+ print()
+ print("usage: ", end="")
+ help(subcommand)
+
+
+if __name__ == '__main__':
+ main()