diff --git a/.gitignore b/.gitignore index e51e2901..2719e616 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,5 @@ docs/.doctrees/ docs/_website/ docs/_latex/ test/ +*.orig +.history/ diff --git a/.travis.yml b/.travis.yml index 84b7e06f..6055ce65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,8 @@ -# We set the language to c because python isn't supported on the MacOS X nodes -# on Travis. However, the language ends up being irrelevant anyway, since we -# install Python ourselves using conda. language: c os: - linux -# Setting sudo to false opts in to Travis-CI container-based builds. sudo: false env: @@ -14,8 +10,8 @@ env: - PYTHON_VERSION=3.6 SPHINX_VERSION=2.0 - PYTHON_VERSION=3.6 SPHINX_VERSION=2.1 - - PYTHON_VERSION=3.7 SPHINX_VERSION=2.0 - - PYTHON_VERSION=3.7 SPHINX_VERSION=2.1 + - PYTHON_VERSION=3.7 SPHINX_VERSION=2.2 + - PYTHON_VERSION=3.7 SPHINX_VERSION=dev global: - LOCALE=default diff --git a/ablog/__init__.py b/ablog/__init__.py index 3a70468e..6ce440fb 100755 --- a/ablog/__init__.py +++ b/ablog/__init__.py @@ -66,7 +66,7 @@ def setup(app): """ for args in CONFIG: - app.add_config_value(*args) + app.add_config_value(*args[:3]) app.add_directive("post", PostDirective) app.add_directive("postlist", PostListDirective) diff --git a/ablog/blog.py b/ablog/blog.py index 917cf158..a0c10264 100644 --- a/ablog/blog.py +++ b/ablog/blog.py @@ -5,10 +5,11 @@ import os import re -import sys import datetime as dtmod from datetime import datetime from unicodedata import normalize +from urllib.parse import urljoin +from collections.abc import Container from docutils import nodes from docutils.io import StringOutput @@ -16,22 +17,6 @@ from sphinx import addnodes from sphinx.util.osutil import relative_uri -try: - from urlparse import urljoin -except ImportError: - from urllib.parse import urljoin - - -if sys.version_info >= (3, 0): - text_type = str - re_flag = 0 -elif sys.version_info < (2, 7): - text_type = unicode - re_flag = None -else: - text_type = unicode - re_flag = re.UNICODE - __all__ = ["Blog", "Post", "Collection"] @@ -40,15 +25,9 @@ def slugify(string): Slugify *s*. """ - string = text_type(string) - string = normalize("NFKD", string) - - if re_flag is None: - string = re.sub(r"[^\w\s-]", "", string).strip().lower() - return re.sub(r"[-\s]+", "-", string) - else: - string = re.sub(r"[^\w\s-]", "", string, flags=re_flag).strip().lower() - return re.sub(r"[-\s]+", "-", string, flags=re_flag) + string = normalize("NFKD", str(string)) + string = re.sub(r"[^\w\s-]", "", string).strip().lower() + return re.sub(r"[-\s]+", "-", string) def os_path_join(path, *paths): @@ -56,32 +35,87 @@ def os_path_join(path, *paths): return os.path.join(path, *paths).replace(os.path.sep, "/") +def require_config_type(type_, is_optional=True): + def verify_fn(key, value, config): + if isinstance(value, type_) or (is_optional and value is None): + return value + # Historically, we're pretty sloppy on whether None or False is the default for omission + # so accept them both. + if value is False and is_optional: + return None + raise KeyError(key + " must be a " + type_.__name__ + (" or omitted" if is_optional else "")) + + return verify_fn + + +def require_config_str_or_list_lookup(lookup_config_key, is_optional=True): + """ + The default values can be a string or list of strings that match entries in a comprehensive + list -- for example, the default authors are one or more of all the authors. + """ + + def verify_fn(key, value, config): + if is_optional and value is None: + return value + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + raise KeyError(key + " must be a str or list") + + allowed_values = config[lookup_config_key] + for v in value: + if v not in allowed_values: + raise KeyError(str(v) + "must be a key of " + lookup_config_key) + return value + + return verify_fn + + +def require_config_full_name_link_dict(is_link_optional=True): + """ + The definition for authors and similar entries is to map a short name to a + (full name, link) tuple. + """ + + def verify_fn(key, value, config): + for full_name, link in value.values(): + if not isinstance(full_name, str): + raise KeyError(key + " must have full name entries that are strings") + is_link_valid = isinstance(link, str) or (is_link_optional and link is None) + if not is_link_valid: + raise KeyError(key + " links must be a string" + (" or omitted" if is_link_optional else "")) + return value + + return verify_fn + + DEBUG = True CONFIG = [ - # name, default, rebuild - ("blog_path", "blog", True), - ("blog_title", "Blog", True), - ("blog_baseurl", None, True), - ("blog_archive_titles", None, False), + # name, default, rebuild, verify_fn + # where verify_fn is (key, value, app.config) --> value, throwing a KeyError if the value isn't right + ("blog_path", "blog", True, require_config_type(str)), + ("blog_title", "Blog", True, require_config_type(str)), + ("blog_baseurl", "", True, require_config_type(str)), + ("blog_archive_titles", None, False, require_config_type(bool)), ("blog_feed_archives", False, True), ("blog_feed_fulltext", False, True), ("blog_feed_subtitle", None, True), ("blog_feed_titles", None, False), ("blog_feed_length", None, None), - ("blog_authors", {}, True), - ("blog_default_author", None, True), - ("blog_locations", {}, True), - ("blog_default_location", None, True), - ("blog_languages", {}, True), - ("blog_default_language", None, True), + ("blog_authors", {}, True, require_config_full_name_link_dict()), + ("blog_default_author", None, True, require_config_str_or_list_lookup("blog_authors")), + ("blog_locations", {}, True, require_config_full_name_link_dict()), + ("blog_default_location", None, True, require_config_str_or_list_lookup("blog_locations")), + ("blog_languages", {}, True, require_config_full_name_link_dict()), + ("blog_default_language", None, True, require_config_str_or_list_lookup("blog_languages")), ("fontawesome_link_cdn", None, True), - ("fontawesome_included", False, True), - ("fontawesome_css_file", "", True), - ("post_date_format", "%d %B %Y", True), - ("post_date_format_short", "%d %B", True), + ("fontawesome_included", False, True, require_config_type(bool)), + ("fontawesome_css_file", "", True, require_config_type(str)), + ("post_date_format", "%d %B %Y", True, require_config_type(str)), + ("post_date_format_short", "%d %B", True, require_config_type(str)), + ("post_auto_orphan", True, True, require_config_type(bool)), ("post_auto_image", 0, True), ("post_auto_excerpt", 1, True), - ("post_auto_orphan", True, True), ("post_redirect_refresh", 5, True), ("post_always_section", False, True), ("disqus_shortname", None, True), @@ -101,12 +135,6 @@ def revise_pending_xrefs(doctree, docname): node["refdoc"] = docname -try: - from collections.abc import Container -except ImportError: - from collections import Container - - def link_posts(posts): """ Link posts after sorting them post by published date. @@ -150,19 +178,17 @@ def _init(self, app): # get configuration from Sphinx app for opt in CONFIG: - self.config[opt[0]] = getattr(app.config, opt[0]) - - opt = self.config["blog_default_author"] - if opt is not None and not isinstance(opt, list): - self.config["blog_default_author"] = [opt] - - opt = self.config["blog_default_location"] - if opt is not None and not isinstance(opt, list): - self.config["blog_default_location"] = [opt] - - opt = self.config["blog_default_language"] - if opt is not None and not isinstance(opt, list): - self.config["blog_default_language"] = [opt] + try: + key, _, _, verify_fn = opt + except ValueError: + key, _, _ = opt + verify_fn = None + value = ( + verify_fn(key, getattr(app.config, key), app.config) + if verify_fn + else getattr(app.config, opt[0]) + ) + self.config[opt[0]] = value # blog catalog contains all posts self.blog = Catalog(self, "blog", "blog", None) @@ -203,10 +229,7 @@ def _init(self, app): # e.g. :ref:`blog-posts` refs["blog-posts"] = (os_path_join(self.config["blog_path"], "index"), "Posts") refs["blog-drafts"] = (os_path_join(self.config["blog_path"], "drafts", "index"), "Drafts") - refs["blog-feed"] = ( - os_path_join(self.config["blog_path"], "atom.xml"), - self.blog_title + " Feed", - ) + refs["blog-feed"] = (os_path_join(self.config["blog_path"], "atom.xml"), self.blog_title + " Feed") # set some internal configuration options self.config["fontawesome"] = ( @@ -328,7 +351,7 @@ def __str__(self): def __repr__(self): - return str(self) + " <" + text_type(self.docname) + ">" + return str(self) + " <" + str(self.docname) + ">" @property def blog(self): @@ -499,7 +522,7 @@ def __init__(self, blog, name, xref, path, reverse=False): def __str__(self): - return text_type(self.name) + return str(self.name) def __getitem__(self, name): @@ -577,7 +600,7 @@ def __init__(self, catalog, label, name=None, href=None, path=None, page=0): def __str__(self): - return text_type(self.name) + return str(self.name) def __len__(self): @@ -589,7 +612,7 @@ def __nonzero__(self): def __unicode__(self): - return text_type(self.name) + return str(self.name) def __iter__(self): diff --git a/ablog/commands.py b/ablog/commands.py index 4af7b909..b881e070 100644 --- a/ablog/commands.py +++ b/ablog/commands.py @@ -3,6 +3,13 @@ import glob import shutil import argparse +import webbrowser +import socketserver +from http import server + +from invoke import run +from watchdog.observers import Observer +from watchdog.tricks import ShellCommandTrick import ablog @@ -34,9 +41,7 @@ def parent(d): if isfile(conf) and "ablog" in open(conf).read(): return confdir else: - sys.exit( - "Current directory and its parents doesn't " "contain configuration file (conf.py)." - ) + sys.exit("Current directory and its parents doesn't " "contain configuration file (conf.py).") def read_conf(confdir): @@ -56,11 +61,7 @@ def read_conf(confdir): ) parser.add_argument( - "-v", - "--version", - help="print ABlog version and exit", - action="version", - version=ablog.__version__, + "-v", "--version", help="print ABlog version and exit", action="version", version=ablog.__version__ ) @@ -107,8 +108,7 @@ def arg_website(func): "-w", dest="website", type=str, - help="path for website, default is %s when `ablog_website` " - "is not set in conf.py" % BUILDDIR, + help="path for website, default is %s when `ablog_website` " "is not set in conf.py" % BUILDDIR, ) return func @@ -136,22 +136,10 @@ def arg_doctrees(func): @arg("-P", dest="runpdb", action="store_true", default=False, help="run pdb on exception") -@arg( - "-T", - dest="traceback", - action="store_true", - default=False, - help="show full traceback on exception", -) +@arg("-T", dest="traceback", action="store_true", default=False, help="show full traceback on exception") @arg("-W", dest="werror", action="store_true", default=False, help="turn warnings into errors") @arg("-N", dest="no_colors", action="store_true", default=False, help="do not emit colored output") -@arg( - "-Q", - dest="extra_quiet", - action="store_true", - default=False, - help="no output at all, not even warnings", -) +@arg("-Q", dest="extra_quiet", action="store_true", default=False, help="no output at all, not even warnings") @arg( "-q", dest="quiet", @@ -179,8 +167,7 @@ def arg_doctrees(func): @cmd( name="build", help="build your blog project", - description="Path options can be set in conf.py. " - "Default values of paths are relative to conf.py.", + description="Path options can be set in conf.py. " "Default values of paths are relative to conf.py.", ) def ablog_build( builder=None, @@ -240,8 +227,7 @@ def ablog_build( @cmd( name="clean", help="clean your blog build files", - description="Path options can be set in conf.py. " - "Default values of paths are relative to conf.py.", + description="Path options can be set in conf.py. " "Default values of paths are relative to conf.py.", ) def ablog_clean(website=None, doctrees=None, deep=False, **kwargs): @@ -275,38 +261,19 @@ def ablog_clean(website=None, doctrees=None, deep=False, **kwargs): default=False, help="rebuild when a file matching patterns change or get added", ) -@arg( - "-n", - dest="view", - action="store_false", - default=True, - help="do not open website in a new browser tab", -) +@arg("-n", dest="view", action="store_false", default=True, help="do not open website in a new browser tab") @arg("-p", dest="port", type=int, default=8000, help="port number for HTTP server; default is 8000") @arg_website @cmd( name="serve", help="serve and view your project", - description="Serve options can be set in conf.py. " - "Default values of paths are relative to conf.py.", + description="Serve options can be set in conf.py. " "Default values of paths are relative to conf.py.", ) -def ablog_serve( - website=None, port=8000, view=True, rebuild=False, patterns="*.rst;*.txt", **kwargs -): +def ablog_serve(website=None, port=8000, view=True, rebuild=False, patterns="*.rst;*.txt", **kwargs): confdir = find_confdir() conf = read_conf(confdir) - try: - import SimpleHTTPServer as server - except ImportError: - from http import server - import socketserver - else: - import SocketServer as socketserver - - import webbrowser - # to allow restarting the server in short succession socketserver.TCPServer.allow_reuse_address = True @@ -323,10 +290,6 @@ def ablog_serve( if rebuild: - # from watchdog.watchmedo import observe_with - from watchdog.observers import Observer - from watchdog.tricks import ShellCommandTrick - patterns = patterns.split(";") ignore_patterns = [os.path.join(website, "*")] handler = ShellCommandTrick( @@ -403,6 +366,7 @@ def ablog_post(filename, title=None, **kwargs): type=str, help="environment variable name storing GitHub access token", ) +@arg("--github-ssh", dest="github_is_http", action="store_true", help="use ssh when cloning website") @arg( "--push-quietly", dest="push_quietly", @@ -430,8 +394,7 @@ def ablog_post(filename, title=None, **kwargs): @cmd( name="deploy", help="deploy your website build files", - description="Path options can be set in conf.py. " - "Default values of paths are relative to conf.py.", + description="Path options can be set in conf.py. " "Default values of paths are relative to conf.py.", ) def ablog_deploy( website, @@ -440,6 +403,7 @@ def ablog_deploy( push_quietly=False, push_force=False, github_token=None, + github_is_http=True, repodir=None, **kwargs, ): @@ -456,11 +420,6 @@ def ablog_deploy( print("Nothing to deploy, build first.") return - try: - from invoke import run - except ImportError: - raise ImportError("invoke is required by deploy command, " "run `pip install invoke`") - if github_pages: if repodir is None: @@ -470,9 +429,9 @@ def ablog_deploy( run("git pull", echo=True) else: run( - "git clone https://github.com/{0}/{0}.github.io.git {1}".format( - github_pages, repodir - ), + "git clone " + + ("https://github.com/" if github_is_http else "git@github.com:") + + "{0}/{0}.github.io.git {1}".format(github_pages, repodir), echo=True, ) @@ -496,14 +455,17 @@ def ablog_deploy( os.chdir(repodir) - run( - "git add -f " + " ".join(['"{}"'.format(os.path.relpath(p)) for p in git_add]), - echo=True, - ) + run("git add -f " + " ".join(['"{}"'.format(os.path.relpath(p)) for p in git_add]), echo=True) if not os.path.isfile(".nojekyll"): open(".nojekyll", "w") run("git add -f .nojekyll") + # Check to see if anything has actually been committed + result = run("git diff --cached --name-status HEAD") + if not result.stdout: + print("Nothing changed from last deployment") + return + commit = 'git commit -m "{}"'.format(message or "Updates.") if push_force: commit += " --amend" diff --git a/ablog/post.py b/ablog/post.py index 9b04fafd..66f8066b 100644 --- a/ablog/post.py +++ b/ablog/post.py @@ -3,28 +3,22 @@ post and postlist directives. """ -import io import os -import sys from string import Formatter from datetime import datetime +from dateutil.parser import parse as date_parser from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.parsers.rst.directives.admonitions import BaseAdmonition from sphinx.locale import _ from sphinx.util.nodes import set_source_info +from werkzeug.contrib.atom import AtomFeed import ablog from .blog import Blog, os_path_join, revise_pending_xrefs, slugify -try: - from dateutil.parser import parse as date_parser -except ImportError: - date_parser = None - - text_type = str __all__ = [ @@ -48,31 +42,25 @@ class PostNode(nodes.Element): Represent ``post`` directive content and options in document tree. """ - pass - class PostList(nodes.General, nodes.Element): """ Represent ``postlist`` directive converted to a list of links. """ - pass - class UpdateNode(nodes.admonition): """ Represent ``update`` directive. """ - pass - class PostDirective(Directive): """ Handle ``post`` directives. """ - def _split(a): + def _split(a): # NOQA return [s.strip() for s in (a or "").split(",") if s.strip()] has_content = True @@ -130,7 +118,7 @@ class PostListDirective(Directive): Handle ``postlist`` directives. """ - def _split(a): + def _split(a): # NOQA return {s.strip() for s in a.split(",")} has_content = False @@ -215,9 +203,9 @@ def _get_update_dates(section, docname, post_date_format): raise ValueError("invalid post date in: " + docname) else: raise ValueError( - "invalid post date (%s) in " % (date) + f"invalid post date ({update_node['date']}) in " + docname - + ". Expected format: %s" % post_date_format + + f". Expected format: {post_date_format}" ) # Insert a new title element which contains the `Updated on {date}` logic. substitute = nodes.title("", "Updated on " + update.strftime(post_date_format)) @@ -382,19 +370,19 @@ def process_posts(app, doctree): # instantiate catalogs and collections here # so that references are created and no warnings are issued if app.builder.format == "html": - stdlabel = env.domains["std"].data["labels"] + stdlabel = env.domains["std"].data["labels"] # NOQA else: - stdlabel = env.intersphinx_inventory.setdefault("std:label", {}) - baseurl = getattr(env.config, "blog_baseurl").rstrip("/") + "/" - project, version = env.config.project, text_type(env.config.version) + stdlabel = env.intersphinx_inventory.setdefault("std:label", {}) # NOQA + baseurl = getattr(env.config, "blog_baseurl").rstrip("/") + "/" # NOQA + project, version = env.config.project, text_type(env.config.version) # NOQA for key in ["tags", "author", "category", "location", "language"]: catalog = blog.catalogs[key] for label in postinfo[key]: - coll = catalog[label] + coll = catalog[label] # NOQA if postinfo["date"]: - coll = blog.archive[postinfo["date"].year] + coll = blog.archive[postinfo["date"].year] # NOQA def process_postlist(app, doctree, docname): @@ -545,13 +533,7 @@ def generate_archive_pages(app): if not catalog: continue - context = { - "parents": [], - "title": title, - "header": header, - "catalog": catalog, - "summary": True, - } + context = {"parents": [], "title": title, "header": header, "catalog": catalog, "summary": True} if catalog.docname not in found_docs: yield (catalog.docname, context, "catalog.html") @@ -608,12 +590,6 @@ def generate_atom_feeds(app): if not url: raise StopIteration - try: - from werkzeug.contrib.atom import AtomFeed - except ImportError: - app.warn("werkzeug is not found, continue without atom feeds support.") - return - feed_path = os.path.join(app.builder.outdir, blog.blog_path, "atom.xml") feeds = [ diff --git a/ablog/start.py b/ablog/start.py index 4a95f2c0..96f7da78 100644 --- a/ablog/start.py +++ b/ablog/start.py @@ -6,7 +6,6 @@ from textwrap import wrap from docutils.utils import column_width -from pkg_resources import DistributionNotFound, get_distribution from sphinx.cmd.quickstart import do_prompt, ensuredir, is_path from sphinx.util import texescape from sphinx.util.console import bold, color_terminal, nocolor @@ -76,11 +75,10 @@ # 'Earth': ('The Blue Planet', 'https://en.wikipedia.org/wiki/Earth), #} - # -- Blog Post Related -------------------------------------------------------- -# post_date_format = '%%b %%d, %%Y' - +# Format date for a post. +#post_date_format = '%%b %%d, %%Y' # Number of paragraphs (default is ``1``) that will be displayed as an excerpt # from the post. Setting this ``0`` will result in displaying no post excerpt @@ -152,8 +150,8 @@ # is ``True`` # Link to `Font Awesome`_ at `Bootstrap CDN`_ and use icons in sidebars -# and post footers. Default: ``False`` -fontawesome_link_cdn = True +# and post footers. Default: ``None`` +#fontawesome_link_cdn = None # Sphinx_ theme already links to `Font Awesome`_. Default: ``False`` #fontawesome_included = False @@ -460,9 +458,7 @@ def generate(d, overwrite=True, silent=False): d["copyright"] = time.strftime("%Y") + ", " + d["author"] d["author_texescaped"] = str(d["author"]).translate(texescape.tex_escape_map) d["project_doc"] = d["project"] + " Documentation" - d["project_doc_texescaped"] = str(d["project"] + " Documentation").translate( - texescape.tex_escape_map - ) + d["project_doc_texescaped"] = str(d["project"] + " Documentation").translate(texescape.tex_escape_map) # escape backslashes and single quotes in strings that are put into # a Python string literal @@ -611,8 +607,7 @@ def ask_user(d): print("ablog-start will not overwrite the existing file.") print("") d["master"] = do_prompt( - w("Please enter a new file name, or rename the " "existing file and press Enter"), - d["master"], + w("Please enter a new file name, or rename the " "existing file and press Enter"), d["master"] ) if "blog_baseurl" not in d: diff --git a/docs/conf.py b/docs/conf.py index f99e9f39..eebb547a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,4 @@ -import os import re -import sys import alabaster from pkg_resources import get_distribution diff --git a/pyproject.toml b/pyproject.toml index eeea85ee..51018b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "setuptools_scm", "wheel"] build-backend = 'setuptools.build_meta' [tool.black] -line-length = 100 +line-length = 110 include = '\.pyi?$' exclude = ''' ( @@ -19,6 +19,7 @@ exclude = ''' | dist | astropy_helpers | docs + | .history )/ | ah_bootstrap.py ) diff --git a/setup.cfg b/setup.cfg index e7b9dd45..933bbf86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,30 +15,70 @@ packages = find: include_package_data = True setup_requires = setuptools_scm install_requires = - werkzeug - sphinx>=2.0 alabaster invoke python-dateutil sphinx-automodapi + sphinx>=2.0 + watchdog + werkzeug [options.extras_require] notebook = - nbsphinx ipython + nbsphinx [options.entry_points] console_scripts = ablog = ablog.commands:ablog_main -[tool:isort] -line_length = 100 -not_skip = __init__.py -sections = FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER +[pycodestyle] +max_line_length = 110 + +[flake8] +max-line-length = 110 + +[isort] default_section = THIRDPARTY +force_grid_wrap = 0 +include_trailing_comma = true known_first_party = ablog -multi_line_output = 3 -balanced_wrapping = True -include_trailing_comma = True length_sort = False length_sort_stdlib = True +line_length = 110 +multi_line_output = 3 +not_skip = __init__.py +skip = .history +sections = FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER + +[coverage:run] +omit = + */ablog/__init__* + */ablog/*/tests/* + */ablog/*setup* + */ablog/conftest.py + */ablog/cython_version* + */ablog/extern/* + */ablog/version* + ablog/__init__* + ablog/*/tests/* + ablog/*setup* + ablog/conftest.py + ablog/cython_version* + ablog/extern/* + ablog/version* + + +[coverage:report] +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + # Don't complain about packages we have installed + except ImportError + # Don't complain if tests don't hit assertions + raise AssertionError + raise NotImplementedError + # Don't complain about script hooks + def main\(.*\): + # Ignore branches that don't pertain to this version of Python + pragma: py{ignore_python_version}