Modernize extension

- update dependencies
- use tox for testing
- use type hints
- use pathlib and ipaddress from standard library instead of path and
  ipcalc
- fix Sphinx deprecation warnings
This commit is contained in:
Jan Dittberner 2023-01-28 17:43:04 +01:00
parent c721d1bf9c
commit 7c675a6fdb
13 changed files with 801 additions and 465 deletions

2
.gitignore vendored
View file

@ -5,6 +5,8 @@
.coverage .coverage
.idea/ .idea/
.ropeproject/ .ropeproject/
/.*_cache/
/.python-version
/.tox/ /.tox/
__pycache__/ __pycache__/
_build/ _build/

View file

@ -5,6 +5,9 @@ unreleased
---------- ----------
* add development documentation in development.rst * add development documentation in development.rst
* use tox as test runner
* add type annotations
* fix Sphinx 6 deprecation warnings
0.5.1 - 2021-09-04 0.5.1 - 2021-09-04
------------------ ------------------

View file

@ -11,7 +11,7 @@ Running test
To install all dependencies and run the tests use:: To install all dependencies and run the tests use::
pipenv install --dev pipenv install --dev
pipenv run pytest pipenv run tox
Release a new version Release a new version
--------------------- ---------------------
@ -31,7 +31,7 @@ Start by deciding the new release number and perform the following steps:
shortlog <previous_tag>..HEAD`` could help to create a good release tag shortlog <previous_tag>..HEAD`` could help to create a good release tag
message) :: message) ::
git tag -s -a 0.5.1 git tag -s -a <version>
* build the release artifacts :: * build the release artifacts ::

View file

@ -6,12 +6,11 @@ verify_ssl = true
[dev-packages] [dev-packages]
coverage = "*" coverage = "*"
twine = "*" twine = "*"
path = "*"
pytest = "*" pytest = "*"
tox = "*"
black = "*"
[packages] [packages]
jandd-sphinxext-ip = { path = ".", editable = true } jandd-sphinxext-ip = { path = ".", editable = true }
Sphinx = ">=4" Sphinx = ">=5"
docutils = "*" docutils = "*"
six = "*"
ipcalc = ">=1.99"

1027
Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
__import__("pkg_resources").declare_namespace(__name__)

View file

@ -1 +0,0 @@
__import__("pkg_resources").declare_namespace(__name__)

View file

@ -10,13 +10,15 @@
""" """
__version__ = "0.5.1" __version__ = "0.5.1"
import ipaddress
from collections import defaultdict from collections import defaultdict
from typing import Iterable, List, Optional, Tuple from typing import Iterable, List, Optional, Tuple, Any, cast
from docutils import nodes from docutils import nodes
from ipcalc import IP, Network from docutils.nodes import Element
from sphinx import addnodes from sphinx import addnodes
from sphinx.addnodes import desc_signature, pending_xref from sphinx.addnodes import desc_signature, pending_xref
from sphinx.application import Sphinx
from sphinx.builders import Builder from sphinx.builders import Builder
from sphinx.directives import ObjectDescription, T from sphinx.directives import ObjectDescription, T
from sphinx.domains import Domain, ObjType from sphinx.domains import Domain, ObjType
@ -39,32 +41,32 @@ class IPRangeDirective(ObjectDescription):
has_content = True has_content = True
required_arguments = 1 required_arguments = 1
title_prefix = None title_prefix: str = ""
range_spec = None range_spec: str = ""
def get_title_prefix(self) -> str: def get_title_prefix(self) -> str:
if self.title_prefix is None:
raise NotImplemented("subclasses must set title_prefix")
return self.title_prefix return self.title_prefix
def handle_signature(self, sig: str, signode: desc_signature) -> T: def handle_signature(self, sig: str, sig_node: desc_signature) -> str:
signode += addnodes.desc_name(text="{} {}".format(self.get_title_prefix(), sig)) sig_node += addnodes.desc_name(
text="{} {}".format(self.get_title_prefix(), sig)
)
self.range_spec = sig self.range_spec = sig
return sig return sig
def transform_content(self, contentnode: addnodes.desc_content) -> None: def transform_content(self, content_node: addnodes.desc_content) -> None:
ip_range_node = ip_range() ip_range_node = ip_range()
ip_range_node["range_spec"] = self.range_spec ip_range_node["range_spec"] = self.range_spec
contentnode += ip_range_node content_node += ip_range_node
class IPV4RangeDirective(IPRangeDirective): class IPV4RangeDirective(IPRangeDirective):
title_prefix = _("IPv4 range") title_prefix = _("IPv4 range")
def add_target_and_index(self, name: T, sig: str, signode: desc_signature) -> None: def add_target_and_index(self, name: T, sig: str, sig_node: desc_signature) -> None:
anchor = "ip-ipv4range-{0}".format(sig) anchor = "ip-ipv4range-{0}".format(sig)
signode["ids"].append(anchor) sig_node["ids"].append(anchor)
ips = self.env.get_domain("ip") ips = cast(IPDomain, self.env.get_domain("ip"))
ips.add_ip4_range(sig) ips.add_ip4_range(sig)
idx_text = "{}; {}".format(self.title_prefix, name) idx_text = "{}; {}".format(self.title_prefix, name)
self.indexnode["entries"] = [ self.indexnode["entries"] = [
@ -78,7 +80,7 @@ class IPV6RangeDirective(IPRangeDirective):
def add_target_and_index(self, name: T, sig: str, signode: desc_signature) -> None: def add_target_and_index(self, name: T, sig: str, signode: desc_signature) -> None:
anchor = "ip-ipv6range-{0}".format(sig) anchor = "ip-ipv6range-{0}".format(sig)
signode["ids"].append(anchor) signode["ids"].append(anchor)
ips = self.env.get_domain("ip") ips = cast(IPDomain, self.env.get_domain("ip"))
ips.add_ip6_range(sig) ips.add_ip6_range(sig)
idx_text = "{}; {}".format(self.title_prefix, name) idx_text = "{}; {}".format(self.title_prefix, name)
self.indexnode["entries"] = [ self.indexnode["entries"] = [
@ -101,7 +103,7 @@ class IPXRefRole(XRefRole):
) -> Tuple[str, str]: ) -> Tuple[str, str]:
refnode.attributes.update(env.ref_context) refnode.attributes.update(env.ref_context)
ips = env.get_domain("ip") ips = cast(IPDomain, env.get_domain("ip"))
if refnode["reftype"] == "v4": if refnode["reftype"] == "v4":
ips.add_ip4_address_reference(target) ips.add_ip4_address_reference(target)
elif refnode["reftype"] == "v6": elif refnode["reftype"] == "v6":
@ -122,7 +124,7 @@ class IPXRefRole(XRefRole):
) -> Tuple[List[nodes.Node], List[nodes.system_message]]: ) -> Tuple[List[nodes.Node], List[nodes.system_message]]:
node_list, message = super().result_nodes(document, env, node, is_ref) node_list, message = super().result_nodes(document, env, node, is_ref)
ip = env.get_domain("ip") ip = cast(IPDomain, env.get_domain("ip"))
if self.reftype in ["v4", "v6"] and self.target not in ip.data["ip_dict"]: if self.reftype in ["v4", "v6"] and self.target not in ip.data["ip_dict"]:
return node_list, message return node_list, message
if ( if (
@ -138,7 +140,7 @@ class IPXRefRole(XRefRole):
ip.add_ip_address_anchor(self.target, env.docname, target_id) ip.add_ip_address_anchor(self.target, env.docname, target_id)
target_node = nodes.target("", "", ids=[target_id]) target_node = nodes.target("", "", ids=[target_id])
doc_title = next(d for d in document.traverse(nodes.title)).astext() doc_title = next(d for d in document.findall(nodes.title)).astext()
node_text = node.astext() node_text = node.astext()
@ -157,6 +159,25 @@ class IPXRefRole(XRefRole):
class IPDomain(Domain): class IPDomain(Domain):
"""Custom domain for IP addresses and ranges.""" """Custom domain for IP addresses and ranges."""
def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
# TODO: implement merge_domaindata
print(
f"merge_domaindata called for docnames: {docnames} with otherdata: {otherdata}"
)
def resolve_any_xref(
self,
env: BuildEnvironment,
fromdocname: str,
builder: Builder,
target: str,
node: pending_xref,
contnode: Element,
) -> list[tuple[str, Element]]:
# TODO: implement resolve_any_xref
print("resolve_any_xref called")
return []
name = "ip" name = "ip"
label = "IP addresses and ranges." label = "IP addresses and ranges."
@ -194,23 +215,23 @@ class IPDomain(Domain):
for obj in self.data["range_nodes"]: for obj in self.data["range_nodes"]:
yield obj yield obj
def add_ip4_range(self, sig: desc_signature): def add_ip4_range(self, sig: str):
logger.debug("add_ip4_range: %s", sig) logger.debug("add_ip4_range: %s", sig)
self._add_ip_range("v4", sig) self._add_ip_range("v4", sig)
def add_ip6_range(self, sig: desc_signature): def add_ip6_range(self, sig: str):
logger.debug("add_ip6_range: %s", sig) logger.debug("add_ip6_range: %s", sig)
self._add_ip_range("v6", sig) self._add_ip_range("v6", sig)
def _add_ip_range(self, family: str, sig: desc_signature): def _add_ip_range(self, family: str, sig: str):
name = "ip{}range.{}".format(family, sig) name = "ip{}range.{}".format(family, sig)
anchor = "ip-ip{}range-{}".format(family, sig) anchor = "ip-ip{}range-{}".format(family, sig)
try: try:
ip_range = Network(sig) new_ip_range = ipaddress.ip_network(sig)
self.data["range_nodes"].append( self.data["range_nodes"].append(
(name, family, sig, self.env.docname, anchor, 0) (name, family, sig, self.env.docname, anchor, 0)
) )
self.data["ranges"][sig].append((ip_range, self.env.docname, anchor)) self.data["ranges"][sig].append((new_ip_range, self.env.docname, anchor))
except ValueError as e: except ValueError as e:
logger.error("invalid ip range address '%s': %s", sig, e) logger.error("invalid ip range address '%s': %s", sig, e)
@ -224,13 +245,13 @@ class IPDomain(Domain):
def _add_ip_address_reference(self, family, sig): def _add_ip_address_reference(self, family, sig):
try: try:
self.data["ip_dict"][sig] = IP(sig) self.data["ip_dict"][sig] = ipaddress.ip_address(sig)
except ValueError as e: except ValueError as e:
logger.error("invalid ip address '%s': %s", sig, e) logger.error("invalid ip address '%s': %s", sig, e)
def add_ip_address_anchor(self, sig, docname, anchor): def add_ip_address_anchor(self, sig, docname, anchor):
try: try:
ip = IP(sig) ip = ipaddress.ip_address(sig)
self.data["ip_refs"][sig].append((ip, docname, anchor)) self.data["ip_refs"][sig].append((ip, docname, anchor))
except ValueError as e: except ValueError as e:
logger.error("invalid ip address '%s': %s", sig, e) logger.error("invalid ip address '%s': %s", sig, e)
@ -247,7 +268,7 @@ class IPDomain(Domain):
name = "iprange{}.{}".format(family, sig) name = "iprange{}.{}".format(family, sig)
anchor = "ip-iprange{}-{}".format(family, sig) anchor = "ip-iprange{}-{}".format(family, sig)
try: try:
ip_range = Network(sig) ip_range = ipaddress.ip_network(sig)
self.data["range_refs"].append( self.data["range_refs"].append(
( (
name, name,
@ -274,15 +295,11 @@ class IPDomain(Domain):
) -> Optional[nodes.Element]: ) -> Optional[nodes.Element]:
match = [] match = []
def address_tuple(docname, anchor, ip_range) -> Tuple[str, str, str]: def address_tuple(docname, anchor, ip_range: Any) -> Tuple[str, str, str]:
return ( return (
docname, docname,
anchor, anchor,
_( _("IPv{0} range {1}".format(ip_range.version, ip_range.compressed)),
"IPv{0} range {1}".format(
ip_range.version(), ip_range.to_compressed()
)
),
) )
if typ in ("v4", "v6"): if typ in ("v4", "v6"):
@ -318,26 +335,26 @@ class IPDomain(Domain):
return None return None
def process_ip_nodes(app, doctree, fromdocname): def process_ip_nodes(app: Sphinx, doctree: nodes.Node, fromdocname: str):
env = app.builder.env env = app.builder.env
ips = env.get_domain(IPDomain.name) ips = env.get_domain(IPDomain.name)
header = (_("IP address"), _("Used by")) header = (_("IP address"), _("Used by"))
column_widths = (2, 5) column_widths = (2, 5)
for node in doctree.traverse(ip_range): for node in doctree.findall(ip_range):
content = [] content = []
net = Network(node["range_spec"]) net = ipaddress.ip_network(node["range_spec"], strict=False)
addresses = defaultdict(list) addresses = defaultdict(list)
for ip_address_sig, refs in ips.data["ip_refs"].items(): for ip_address_sig, refs in ips.data["ip_refs"].items():
for ip_address, todocname, anchor in refs: for ip_address, to_doc_name, anchor in refs:
if ip_address in net: if ip_address in net:
addresses[ip_address_sig].append((ip_address, todocname, anchor)) addresses[ip_address_sig].append((ip_address, to_doc_name, anchor))
logger.debug( logger.debug(
"found %s in network %s on %s", "found %s in network %s on %s",
ip_address_sig, ip_address_sig,
net.to_compressed(), net.compressed,
todocname, to_doc_name,
) )
if addresses: if addresses:
table = nodes.table() table = nodes.table()
@ -360,30 +377,30 @@ def process_ip_nodes(app, doctree, fromdocname):
(key, addresses[key]) for key in sorted(addresses, key=sort_by_ip) (key, addresses[key]) for key in sorted(addresses, key=sort_by_ip)
]: ]:
para = nodes.paragraph() para = nodes.paragraph()
para += nodes.literal("", ip_info[0][0].to_compressed()) para += nodes.literal("", ip_info[0][0].compressed)
ref_node = nodes.paragraph() ref_node = nodes.paragraph()
ref_nodes = [] ref_nodes = []
referenced_docs = set() referenced_docs = set()
for item in ip_info: for item in ip_info:
ip_address, todocname, anchor = item ip_address, to_doc_name, anchor = item
if todocname in referenced_docs: if to_doc_name in referenced_docs:
continue continue
referenced_docs.add(todocname) referenced_docs.add(to_doc_name)
title = env.titles[todocname] title = env.titles[to_doc_name]
innernode = nodes.Text(title.astext()) inner_node = nodes.Text(title.astext())
newnode = make_refnode( new_node = make_refnode(
app.builder, app.builder,
fromdocname, fromdocname,
todocname, to_doc_name,
anchor, anchor,
innernode, inner_node,
title.astext(), title.astext(),
) )
ref_nodes.append(newnode) ref_nodes.append(new_node)
for count in range(len(ref_nodes)): for count in range(len(ref_nodes)):
ref_node.append(ref_nodes[count]) ref_node.append(ref_nodes[count])
if count < len(ref_nodes) - 1: if count < len(ref_nodes) - 1:
@ -407,10 +424,10 @@ def create_table_row(rowdata):
def sort_by_ip(item): def sort_by_ip(item):
return IP(item).ip return ipaddress.ip_address(item)
def setup(app): def setup(app: Sphinx):
app.add_domain(IPDomain) app.add_domain(IPDomain)
app.connect("doctree-resolved", process_ip_nodes) app.connect("doctree-resolved", process_ip_nodes)
return { return {

View file

@ -15,7 +15,7 @@ author = Jan Dittberner
author_email = jan@dittberner.info author_email = jan@dittberner.info
keywords = sphinx, extension, IP keywords = sphinx, extension, IP
license = GPLv3+ license = GPLv3+
license_file = COPYING license_files = COPYING
platforms = any platforms = any
version = 0.5.1 version = 0.5.1
classifiers = classifiers =
@ -31,8 +31,4 @@ zip_safe = False
include_package_data = True include_package_data = True
packages = find: packages = find:
install_requires = install_requires =
Sphinx >= 4 Sphinx >= 5
ipcalc >= 1.99
namespace_packages =
jandd
jandd.sphinxext

View file

@ -12,14 +12,14 @@
# #
# All configuration values have a default; values that are commented out # All configuration values have a default; values that are commented out
# serve to show the default. # serve to show the default.
import os
# import sys import sys
# import os from typing import Dict
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
# sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath(os.path.join("..", "..")))
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
@ -47,7 +47,7 @@ master_doc = "index"
# General information about the project. # General information about the project.
project = "Sphinxext IP Tests" project = "Sphinxext IP Tests"
copyright = "2016-2021, Jan Dittberner" copyright = "2016-2023, Jan Dittberner"
author = "Jan Dittberner" author = "Jan Dittberner"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
@ -104,7 +104,6 @@ pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing. # If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False todo_include_todos = False
# -- Options for HTML output ---------------------------------------------- # -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
@ -207,15 +206,15 @@ htmlhelp_basename = "SphinxextIPTestsdoc"
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements: Dict[str, str] = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper', # 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt'). # The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt', # 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble. # Additional stuff for the LaTeX preamble.
#'preamble': '', # 'preamble': '',
# Latex figure (float) alignment # Latex figure (float) alignment
#'figure_align': 'htbp', # 'figure_align': 'htbp',
} }
# Grouping the document tree into LaTeX files. List of tuples # Grouping the document tree into LaTeX files. List of tuples

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import shutil
import unittest import unittest
from io import StringIO from io import StringIO
@ -23,7 +23,7 @@ class TestIPExtension(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.app.cleanup() self.app.cleanup()
(test_root / "_build").rmtree(True) shutil.rmtree((test_root / "_build"))
def test_ip_domaindata(self): def test_ip_domaindata(self):
self.assertIn("ip", self.app.env.domaindata) self.assertIn("ip", self.app.env.domaindata)

View file

@ -12,8 +12,8 @@ import shutil
import sys import sys
import tempfile import tempfile
from functools import wraps from functools import wraps
from pathlib import Path
from path import Path
from sphinx import application from sphinx import application
__all__ = [ __all__ = [
@ -25,14 +25,13 @@ __all__ = [
"SphinxTestApplication", "SphinxTestApplication",
"with_app", "with_app",
"gen_with_app", "gen_with_app",
"Path",
"with_tempdir", "with_tempdir",
"write_file", "write_file",
"sprint", "sprint",
] ]
test_root = Path(__file__).parent.joinpath("root").abspath() test_root = Path(__file__).parent.joinpath("root").absolute()
def _excstr(exc): def _excstr(exc):
@ -96,9 +95,9 @@ class SphinxTestApplication(application.Sphinx):
def __init__( def __init__(
self, self,
srcdir=None, src_dir=None,
confdir=None, confdir=None,
outdir=None, out_dir=None,
doctreedir=None, doctreedir=None,
buildername="html", buildername="html",
confoverrides=None, confoverrides=None,
@ -115,26 +114,26 @@ class SphinxTestApplication(application.Sphinx):
self.cleanup_trees = [test_root / "generated"] self.cleanup_trees = [test_root / "generated"]
if srcdir is None: if src_dir is None:
srcdir = test_root src_dir = test_root
if srcdir == "(temp)": if src_dir == "(temp)":
tempdir = Path(tempfile.mkdtemp()) tempdir = Path(tempfile.mkdtemp())
self.cleanup_trees.append(tempdir) self.cleanup_trees.append(tempdir)
temproot = tempdir / "root" temp_root = tempdir / "root"
test_root.copytree(temproot) shutil.copytree(test_root.resolve(), temp_root.resolve())
srcdir = temproot src_dir = temp_root
else: else:
srcdir = Path(srcdir) src_dir = Path(src_dir)
self.builddir = srcdir.joinpath("_build") self.builddir = src_dir.joinpath("_build")
if confdir is None: if confdir is None:
confdir = srcdir confdir = src_dir
if outdir is None: if out_dir is None:
outdir = srcdir.joinpath(self.builddir, buildername) out_dir = src_dir.joinpath(self.builddir, buildername)
if not outdir.isdir(): if not out_dir.is_dir():
outdir.makedirs() out_dir.mkdir(parents=True)
self.cleanup_trees.insert(0, outdir) self.cleanup_trees.insert(0, out_dir)
if doctreedir is None: if doctreedir is None:
doctreedir = srcdir.joinpath(srcdir, self.builddir, "doctrees") doctreedir = src_dir.joinpath(src_dir, self.builddir, "doctrees")
if cleanenv: if cleanenv:
self.cleanup_trees.insert(0, doctreedir) self.cleanup_trees.insert(0, doctreedir)
if confoverrides is None: if confoverrides is None:
@ -150,9 +149,9 @@ class SphinxTestApplication(application.Sphinx):
application.Sphinx.__init__( application.Sphinx.__init__(
self, self,
srcdir, str(src_dir),
confdir, confdir,
outdir, out_dir,
doctreedir, doctreedir,
buildername, buildername,
confoverrides, confoverrides,
@ -211,7 +210,7 @@ def with_tempdir(func):
def new_func(): def new_func():
tempdir = Path(tempfile.mkdtemp()) tempdir = Path(tempfile.mkdtemp())
func(tempdir) func(tempdir)
tempdir.rmtree() tempdir.rmdir()
new_func.__name__ = func.__name__ new_func.__name__ = func.__name__
return new_func return new_func

26
tox.ini Normal file
View file

@ -0,0 +1,26 @@
[tox]
requires =
tox>=4
env_list = lint, type, py{39,310,311,312}
[testenv]
description = run unit tests
deps =
pytest>=7
pytest-sugar
commands =
pytest {posargs:tests}
[testenv:lint]
description = run linters
skip_install = true
deps =
black==22.12
commands = black {posargs:.}
[testenv:type]
description = run type checks
deps =
mypy>=0.991
commands =
mypy {posargs:--install-types --non-interactive jandd tests}