Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ docs/_build
coverage.xml
_build
uv.lock
*.egg-info
__pycache__/*
*.pyc
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@
inv_target = "http://docs.pyinvoke.org/en/latest/"
# Put them all together, + Python core
intersphinx_mapping = {
"python": ("http://docs.python.org/", None),
"invoke": (inv_target, None),
"python": ("https://docs.python.org/3/", None),
"invoke": ("https://docs.pyinvoke.org/en/stable/", None),
}
133 changes: 79 additions & 54 deletions invocations/autodoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,76 +6,101 @@
allows Sphinx's `autodoc`_ functionality to see and document
Invoke tasks and similar Invoke objects.

.. note::
This functionality is mostly useful for redistributable/reusable tasks
which have been defined as importable members of some Python package or
module, as opposed to "local-only" tasks that live in a single project's
``tasks.py``.

However, it will work for any tasks that Sphinx autodoc can import, so in a
pinch you could for example tweak ``sys.path`` in your Sphinx ``conf.py``
to get it loading up a "local" tasks file for import.

To use:
- Add ``"invocations.autodoc"`` to your Sphinx ``conf.py``'s ``extensions``.
- Use ``.. automodule:: myproject.tasks`` with ``:members:``.
"""

- Add ``"invocations.autodoc"`` to your Sphinx ``conf.py``'s ``extensions``
list.
- Use Sphinx autodoc's ``automodule`` directive normally, aiming it at your
tasks module(s), e.g. ``.. automodule:: myproject.tasks`` in some ``.rst``
document of your choosing.

- As noted above, this only works for modules that are importable, like any
other Sphinx autodoc use case.
- Unless you want to opt-in which module members get documented, use
``:members:`` or add ``"members": True`` to your ``conf.py``'s
``autodoc_default_options``.
- By default, only tasks with docstrings will be picked up, unless you also
give the ``:undoc-members:`` flag or add ``:undoc-members:`` / add
``"undoc-members": True`` to ``autodoc_default_options``.
- Please see the `autodoc`_ docs for details on these settings and more!

- Build your docs, and you should see your tasks showing up as documented
functions in the result.
import inspect
from sphinx.ext import autodoc

# Compatibility layer for signature stringification
try:
from sphinx.util import inspect as sphinx_inspect

.. _autodoc: http://www.sphinx-doc.org/en/master/ext/autodoc.html
"""
stringify = sphinx_inspect.stringify_signature
except (ImportError, AttributeError):
try:
stringify = autodoc.stringify_signature
except AttributeError:

import inspect
def stringify(x):
return str(x)

from invoke import Task

# For sane mock patching. Meh.
from sphinx.ext import autodoc
class TaskDocumenter(autodoc.ModuleLevelDocumenter):
"""
Custom autodoc documenter for Invoke Task objects.

Inherits from ModuleLevelDocumenter to ensure tasks are discovered in
modules without triggering DataDocumenter's ':value:' header logic,
which causes errors in standard function directives.
"""

class TaskDocumenter(
autodoc.DocstringSignatureMixin, autodoc.ModuleLevelDocumenter
):
objtype = "task"
directivetype = "function"
priority = 50

@classmethod
def can_document_member(cls, member, membername, isattr, parent):
return isinstance(member, Task)

def format_args(self):
function = self.object.body
# TODO: consider extending (or adding a sibling to) Task.argspec so it
# preserves more of the full argspec tuple.
# TODO: whether to preserve the initial context argument is an open
# question. For now, it will appear, but only pending invoke#170 -
# after which point "call tasks as raw functions" may be less common.
# TODO: also, it may become moot-ish if we turn this all into emission
# of custom domain objects and/or make the CLI arguments the focus
return autodoc.stringify_signature(inspect.signature(function))

def document_members(self, all_members=False):
# Neuter this so superclass bits don't introspect & spit out autodoc
# directives for task attributes. Most of that's not useful.
pass
from invoke import Task

# Identify Invoke tasks by their characteristic attributes or type
return (
isinstance(member, Task)
or hasattr(member, "body")
and hasattr(member, "argspec")
)

def import_object(self, **kwargs):
# Import the Task instance,
# then store the wrapped function for inspection
success = super().import_object(**kwargs)
if success and hasattr(self.object, "body"):
self.wrapped_function = self.object.body
return success

def get_object_members(self, want_all):
# Tasks are atomic; they have no child members to document
return False, []

def format_args(self, **kwargs):
try:
sig = inspect.signature(self.object.body)
return stringify(sig)
except Exception:
return None

def format_signature(self, **kwargs):
# Extract signature from the wrapped function body
try:
sig = inspect.signature(self.wrapped_function)
return stringify(sig)
except Exception:
return ""

def add_directive_header(self, sig):
# Write the standard header (.. py:function:: name(args))
# Note: 3.10+ adds :value: which is incompatible with py:function
super().add_directive_header(sig)

def add_content(self, more_content, no_docstring=False):
# Manually inject the docstring from the wrapped function
sourcename = self.get_sourcename()
docstring = inspect.getdoc(self.wrapped_function)

if docstring:
for line in docstring.splitlines():
self.add_line(line, sourcename)

# Call parent with no_docstring=True to avoid redundant lookup attempts
try:
super().add_content(more_content, no_docstring=True)
except TypeError:
super().add_content(more_content)


def setup(app):
app.setup_extension("sphinx.ext.autodoc")
app.add_autodocumenter(TaskDocumenter)
return {"version": "1.0", "parallel_read_safe": True}
5 changes: 3 additions & 2 deletions invocations/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ def sudo_run(c, command):
"""
Run some command under CI-oriented sudo subshell/virtualenv.

:param str command:
Command string to run, e.g. ``inv coverage``, ``inv integration``, etc.
:param str command: Command string to run,
e.g. ``inv coverage``, ``inv integration``, etc.
(Does not necessarily need to be an Invoke task, but...)

"""
# NOTE: due to circle sudoers config, circleci user can't do "sudo -u" w/o
# password prompt. However, 'sudo su' seems to work just as well...
Expand Down
12 changes: 12 additions & 0 deletions invocations/packaging/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,10 @@ def all_(c, dry_run=False):
.. versionchanged:: 2.1
Expanded functionality to run ``publish`` and ``push`` as well as
``prepare``.

.. versionchanged:: 2.1
Added the ``dry_run`` flag.

"""
prepare(c, dry_run=dry_run)
publish(c, dry_run=dry_run)
Expand All @@ -309,8 +311,10 @@ def prepare(c, dry_run=False):

.. versionchanged:: 2.1
Added the ``dry_run`` parameter.

.. versionchanged:: 2.1
Generate annotated git tags instead of lightweight ones.

"""
# Print dry-run/status/actions-to-take data & grab programmatic result
# TODO: maybe expand the enum-based stuff to have values that split up
Expand Down Expand Up @@ -607,16 +611,21 @@ def build(
.. versionchanged:: 2.0
``clean`` now defaults to False instead of True, cleans both dist and
build dirs when True, and honors configuration.

.. versionchanged:: 2.0
``wheel`` now defaults to True instead of False.

.. versionchanged:: 4.0
Switched to using ``pypa/build`` and made related changes to args
(eg, ``directory`` now only controls dist output location).

.. versionchanged:: 4.1
Added the ``opts`` argument.

.. versionchanged:: 4.1
Updated ``--clean`` to additionally remove any ``build/`` directories
within the source root.

"""
# Config hooks
config = c.config.get("packaging", {})
Expand Down Expand Up @@ -715,6 +724,7 @@ def publish(

Defaults to a temporary directory which is cleaned up after the run
finishes.

"""
# Don't hide by default, this step likes to be verbose most of the time.
c.config.run.hide = False
Expand Down Expand Up @@ -774,6 +784,7 @@ def test_install(c, directory, verbose=False, skip_import=False):
:param bool skip_import:
If True, don't try importing the installed module or checking it for
type hints.

"""
# TODO: wants contextmanager or similar for only altering a setting within
# a given scope or block - this may pollute subsequent subroutine calls
Expand Down Expand Up @@ -852,6 +863,7 @@ def upload(c, directory, index=None, sign=False, dry_run=False):

This also prevents cleanup of the temporary build/dist directories, so
you can examine the build artifacts.

"""
archives = get_archives(directory)
# Sign each archive in turn
Expand Down
1 change: 0 additions & 1 deletion invocations/packaging/semantic_version_monkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
or distributing our own fork.
"""


from semantic_version import Version


Expand Down
1 change: 1 addition & 0 deletions invocations/packaging/vendorize.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Tasks for importing external code into a vendor subdirectory.
"""

from os import chdir
from pathlib import Path
from shutil import copy, copytree, rmtree
Expand Down
2 changes: 2 additions & 0 deletions invocations/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def test(
will be given. Default: ``True``.

.. versionadded:: 2.0

"""
# TODO: really need better tooling around these patterns
# TODO: especially the problem of wanting to be configurable, but
Expand Down Expand Up @@ -152,6 +153,7 @@ def coverage(

.. versionchanged:: 2.4
Added the ``additional_testers`` argument.

"""
my_opts = "--cov --no-cov-on-fail --cov-report={}".format(report)
if opts:
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "invocations"
requires-python = ">=3.9"
requires-python = ">=3.10"
version = "4.1.0"
description = "Common/best-practice Invoke tasks and collections"
readme = "README.rst"
Expand All @@ -19,7 +19,7 @@ dependencies = [
"blessings>=1.6",
"build>=1.3",
# For envs that don't actually have pip - we use some of its tooling atm.
"pip>=25.1",
"pip>=26.1",
"releases>=1.6",
"semantic_version>=2.4,<2.7",
"tabulate>=0.7.5",
Expand All @@ -40,7 +40,6 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand Down Expand Up @@ -76,11 +75,12 @@ dev = [
# Oldest version that works on Python >3.11
"watchdog==1.0.2",
"coverage==4.4.2",
"icecream==2.1.3",
"icecream==2.2.0",
# Formatting
"black==22.12.0",
# Linting
"flake8~=7.3.0",
"ruff~=0.15"
]

[tool.ruff]
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[pytest]
testpaths = tests
python_files = *
pythonpath = .

addopts = "-ra --capture=sys"
14 changes: 10 additions & 4 deletions tests/autodoc/_support/conf.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from os.path import dirname
import sys
from os.path import dirname, abspath, join

# Basic path setup
support = abspath(dirname(__file__))
repo_root = abspath(join(support, "..", "..", ".."))

# Add local support dir to path so tasks modules may be imported by autodoc
sys.path.insert(0, dirname(__file__))
if repo_root not in sys.path:
sys.path.insert(0, repo_root)
if support not in sys.path:
sys.path.insert(0, support)

master_doc = "index"
extensions = ["invocations.autodoc"]
autodoc_default_options = dict(members=True)
autodoc_default_options = {"members": True}
project = "Invocations"
1 change: 1 addition & 0 deletions tests/autodoc/_support/docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ API
===

.. automodule:: mytasks
:members:
Loading