Skip to content

test_py3_compat

Revit-hosted engine-portability tests for the Python 3 migration.

Run from Revit via the pyRevit DevTools "Py3 Compat Tests" buttons (doc-project context). One button runs on the attached IronPython engine, its twin carries a python3 shebang and runs the same suite on CPython, so results can be compared per engine.

The suite exercises the compatibility hotspots cataloged in IRONPYTHON_TO_PYTHON3_ANALYSIS.md sections 4.3-4.5 and 6.1: Python-2-only idioms, IronPython-only CLR loading, out/ref marshaling, and heterogeneous sorting. A failing test is a coverage signal, not necessarily a regression: it marks a spot the current engine does not support yet.

Static counterpart: dev/scripts/check_py3_compat.py (no Revit needed).

Attributes

FAMILY_FILE = None module-attribute

FAMILY_UTILS_FILE = None module-attribute

CORE_MODULES = ['pyrevit', 'pyrevit.compat', 'pyrevit.coreutils', 'pyrevit.coreutils.envvars', 'pyrevit.coreutils.pyutils', 'pyrevit.forms', 'pyrevit.framework', 'pyrevit.output', 'pyrevit.revit', 'pyrevit.script'] module-attribute

RPW_MODULES = ['rpw', 'rpw.db', 'rpw.ui.forms'] module-attribute

INTEROP_MODULES = ['pyrevit.interop.dxf', 'pyrevit.interop.ifc'] module-attribute

INTEROP_NATIVE_MODULES = ['pyrevit.interop.adc', 'pyrevit.interop.rhino'] module-attribute

TEST_NATIVE_INTEROP = False module-attribute

Classes

ImportTests

Bases: TestCase

Every supported module must be importable on the running engine.

Methods:

test_requests_backend_matches_engine()

IronPython uses the CLR HTTP shim; CPython uses requests.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_requests_backend_matches_engine(self):
    """IronPython uses the CLR HTTP shim; CPython uses requests."""
    from pyrevit import compat

    expected_module = "pyrevit.netrequests" if IRONPY else "requests"
    self.assertEqual(expected_module, compat.requests.__name__)
test_vendored_requests_imports()

Direct requests imports remain available on every Python 3 engine.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_vendored_requests_imports(self):
    """Direct requests imports remain available on every Python 3 engine."""
    failures = _import_failures(["urllib3", "requests"])
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )
test_tool_dependency_imports()

Bundled packages used by Keynotes, Excel tools, and Revit Server import.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_tool_dependency_imports(self):
    """Bundled packages used by Keynotes, Excel tools, and Revit Server import."""
    failures = _import_failures(["natsort", "pyrevit.interop.xl", "rpws"])
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )
    from natsort import natsorted, ns

    self.assertEqual(
        ["Sheet1", "Sheet2", "Sheet10"], natsorted(["Sheet10", "Sheet2", "Sheet1"])
    )
    self.assertEqual(
        ["apple", "Apple", "Banana", "banana"],
        natsorted(["Banana", "apple", "banana", "Apple"], alg=ns.IGNORECASE),
    )
    self.assertEqual(
        ["Apple", "apple", "Banana", "banana"],
        natsorted(["Banana", "apple", "banana", "Apple"], alg=ns.GROUPLETTERS),
    )
    if hasattr(str, "casefold"):
        sharp_s = chr(0xDF)
        self.assertEqual(
            [sharp_s, "ss"],
            natsorted([sharp_s, "ss"], alg=ns.IGNORECASE),
        )
test_vendored_requests_wraps_invalid_json()

Malformed JSON raises the documented Requests exception.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_vendored_requests_wraps_invalid_json(self):
    """Malformed JSON raises the documented Requests exception."""
    import requests

    response = requests.Response()
    response._content = b"{"
    response.encoding = "utf-8"

    with self.assertRaises(requests.exceptions.JSONDecodeError):
        response.json()
test_core_module_imports()

Core pyrevit modules import cleanly on this engine.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_core_module_imports(self):
    """Core pyrevit modules import cleanly on this engine."""
    failures = _import_failures(CORE_MODULES)
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )
test_interop_module_imports()

Interop modules import cleanly (needs the framework CLR shim).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipIf(
    NETCORE,
    "interop assemblies (IxMilia.Dxf, Ifc.Net) are not shipped for "
    ".NET 8 hosts — dev/libs/netcore omits them",
)
def test_interop_module_imports(self):
    """Interop modules import cleanly (needs the framework CLR shim)."""
    failures = _import_failures(INTEROP_MODULES)
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )
test_framework_asm_file_reference()

framework.add_reference_to_file loads assemblies on any engine.

Uses an extensionless path to a managed assembly pyRevit already ships, exercising the extension-resolution branch of the shim.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_framework_asm_file_reference(self):
    """framework.add_reference_to_file loads assemblies on any engine.

    Uses an extensionless path to a managed assembly pyRevit already
    ships, exercising the extension-resolution branch of the shim.
    """
    import os.path as op

    from pyrevit import BIN_DIR, framework

    framework.add_reference_to_file(op.join(BIN_DIR, "pyRevitLabs.Json"))
    import pyRevitLabs.Json  # noqa pylint: disable=import-error,unused-import
test_interop_native_module_imports()

Interop modules that load native/external binaries (opt-in).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_interop_native_module_imports(self):
    """Interop modules that load native/external binaries (opt-in)."""
    if not TEST_NATIVE_INTEROP:
        self.skipTest(
            "loads native binaries into the Revit process; set "
            "test_py3_compat.TEST_NATIVE_INTEROP to run"
        )
    failures = _import_failures(INTEROP_NATIVE_MODULES)
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )

Py2IdiomTests

Bases: TestCase

Runtime behavior of the section 4.3 syntax residuals.

Methods:

test_basewrapper_repr()

ElementWrapper repr works (crashes on .iteritems under Py3).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_basewrapper_repr(self):
    """ElementWrapper repr works (crashes on .iteritems under Py3)."""
    from pyrevit import revit
    from pyrevit.revit import db

    if not revit.doc or revit.doc.IsFamilyDocument:
        self.skipTest("Requires open project document")
    wrapper = db.ElementWrapper(revit.doc.ProjectInformation)
    self.assertIn("pyrevit.revit.db.ElementWrapper", repr(wrapper))
test_forms_paramdef_truthiness()

ParamDef instances stay truthy on Python 3 engines (bool).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY, "forms WPF classes are IronPython-only")
def test_forms_paramdef_truthiness(self):
    """ParamDef instances stay truthy on Python 3 engines (__bool__)."""
    from pyrevit.forms import _ipy

    param_def = _ipy.ParamDef(
        "name", False, None, False, False, None, "", True, None, False, 0
    )
    self.assertTrue(bool(param_def))
test_forms_listitem_truthiness()

TemplateListItem truthiness follows checked state (bool).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY, "forms WPF classes are IronPython-only")
def test_forms_listitem_truthiness(self):
    """TemplateListItem truthiness follows checked state (__bool__)."""
    from pyrevit import forms

    unchecked = forms.TemplateListItem("item", checked=False)
    checked = forms.TemplateListItem("item", checked=True)
    self.assertFalse(
        bool(unchecked),
        "unchecked item is truthy; __nonzero__ is ignored on Python 3 "
        "engines without a __bool__ alias",
    )
    self.assertTrue(bool(checked))

RpwCompatibilityTests

Bases: TestCase

The bundled RevitPythonWrapper imports and forms work on IronPython.

Methods:

test_rpw_module_imports()

RPW's public namespaces import under the selected IronPython engine.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY, "RPW requires an IronPython Revit host")
def test_rpw_module_imports(self):
    """RPW's public namespaces import under the selected IronPython engine."""
    failures = _import_failures(RPW_MODULES)
    self.assertEqual(
        [], failures, "import failures:\n{}".format("\n".join(failures))
    )
test_rpw_combobox_sorts_dictionary_options()

Dictionary options materialize before sorting on Python 3 engines.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY3, "RPW IPY3 coverage requires IronPython 3")
def test_rpw_combobox_sorts_dictionary_options(self):
    """Dictionary options materialize before sorting on Python 3 engines."""
    from rpw.ui.forms import ComboBox

    combobox = ComboBox("choice", {"zulu": 2, "alpha": 1})
    self.assertEqual("alpha", combobox.SelectedItem)
    self.assertEqual(1, combobox.value)
test_rpw_ipy3_wpf_loader()

RPW exposes the WPF LoadComponent helper through the IPY3 engine.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY3, "RPW IPY3 coverage requires IronPython 3")
def test_rpw_ipy3_wpf_loader(self):
    """RPW exposes the WPF LoadComponent helper through the IPY3 engine."""
    from rpw.ui.forms import resources

    self.assertTrue(hasattr(resources.wpf, "LoadComponent"))
test_rpw_ipy3_flexform_construction()

RPW forms used by shipped tools can instantiate without showing UI.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY3, "RPW IPY3 coverage requires IronPython 3")
def test_rpw_ipy3_flexform_construction(self):
    """RPW forms used by shipped tools can instantiate without showing UI."""
    from rpw.ui.forms import Button, FlexForm, Label

    form = FlexForm("py3compat", [Label("Check"), Button("OK")])
    try:
        self.assertEqual(2, form.MainGrid.Children.Count)
    finally:
        form.Close()
test_rpw_ipy3_wraps_project_information()

RPW wraps a live Revit element without Python 2 conversion paths.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY3, "RPW IPY3 coverage requires IronPython 3")
def test_rpw_ipy3_wraps_project_information(self):
    """RPW wraps a live Revit element without Python 2 conversion paths."""
    from pyrevit import revit as pyrevit_revit
    from pyrevit.compat import get_elementid_value_func

    if not pyrevit_revit.doc or pyrevit_revit.doc.IsFamilyDocument:
        self.skipTest("Requires an open project document")

    from rpw import db, revit

    project_info = pyrevit_revit.doc.ProjectInformation
    self.assertEqual(pyrevit_revit.doc, revit.doc)
    wrapped = db.Element(project_info)
    get_elementid_value = get_elementid_value_func()
    self.assertEqual(
        get_elementid_value(project_info.Id),
        get_elementid_value(wrapped.unwrap().Id),
    )
    self.assertIn("id:", repr(wrapped))

SortingTests

Bases: TestCase

Heterogeneous-data sorting (the Settings.smartbutton fix pattern).

Methods:

test_envvars_sortable_by_name()

Env vars dict sorts by str-coerced key without comparing values.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_envvars_sortable_by_name(self):
    """Env vars dict sorts by str-coerced key without comparing values."""
    from pyrevit.coreutils import envvars

    env_vars = envvars.get_pyrevit_env_vars()
    items = sorted(env_vars.items(), key=lambda kv: str(kv[0]))
    self.assertEqual(len(items), len(env_vars))

QueryStringLookupTests

Bases: TestCase

String-identifier lookups in revit.db.query (isinstance str checks).

Methods:

setUp()

Require an open project document for name-based Revit queries.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def setUp(self):
    """Require an open project document for name-based Revit queries."""
    from pyrevit import revit

    if not revit.doc:
        self.skipTest("Requires open document")
    if revit.doc.IsFamilyDocument:
        self.skipTest("Requires project document, not family document")
    self.doc = revit.doc
test_get_param_by_name()

get_param resolves a parameter passed by name string.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_get_param_by_name(self):
    """get_param resolves a parameter passed by name string."""
    from pyrevit.revit import query

    pinfo = self.doc.ProjectInformation
    param = None
    for candidate in pinfo.Parameters:
        param = candidate
        break
    if param is None:
        self.skipTest("ProjectInformation has no parameters")
    found = query.get_param(pinfo, param.Definition.Name)
    self.assertIsNotNone(found)
test_get_category_by_name()

get_category resolves a category passed by name string.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_get_category_by_name(self):
    """get_category resolves a category passed by name string."""
    from pyrevit.revit import query

    category = None
    for candidate in self.doc.Settings.Categories:
        category = candidate
        break
    found = query.get_category(category.Name, doc=self.doc)
    self.assertIsNotNone(found)
    self.assertEqual(found.Name, category.Name)

FamilyLoaderTests

Bases: TestCase

The Load Families tool preserves Revit's direct load result.

Methods:

test_overwrite_result_is_not_derived_from_symbols()

FamilyLoader returns the helper's result for overwrite loads.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_overwrite_result_is_not_derived_from_symbols(self):
    """FamilyLoader returns the helper's result for overwrite loads."""
    import os.path as op
    import runpy

    from pyrevit import revit

    if not FAMILY_UTILS_FILE or not op.isfile(FAMILY_UTILS_FILE):
        self.skipTest("Family loader fixture not provided")
    family_utils = runpy.run_path(FAMILY_UTILS_FILE)
    calls = []
    original_load = revit.create.load_family_with_result

    def refuse_load(path):
        calls.append(path)
        return False, [object()]

    try:
        revit.create.load_family_with_result = refuse_load
        loader = family_utils["FamilyLoader"]("existing.rfa", overwrite=True)
        self.assertFalse(loader._load_family())
    finally:
        revit.create.load_family_with_result = original_load

    self.assertEqual(["existing.rfa"], calls)

OutParamMarshalingTests

Bases: TestCase

The two clr.Reference out/ref sites (sections 4.5 / 6.1).

Methods:

setUp()

Require an open project document for Revit API marshaling tests.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def setUp(self):
    """Require an open project document for Revit API marshaling tests."""
    from pyrevit import revit

    if not revit.doc:
        self.skipTest("Requires open document")
    if revit.doc.IsFamilyDocument:
        self.skipTest("Requires project document, not family document")
    self.doc = revit.doc
test_load_family_out_param()

create.load_family_with_result marshals the out-param family reference.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_load_family_out_param(self):
    """create.load_family_with_result marshals the out-param family reference."""
    import os.path as op

    from pyrevit.revit import create, query
    from pyrevit import coreutils

    if not FAMILY_FILE or not op.isfile(FAMILY_FILE):
        self.skipTest("No family file fixture provided")
    family_name = coreutils.get_file_name(FAMILY_FILE)
    if query.get_family(family_name, doc=self.doc):
        self.skipTest("Family fixture is already loaded in this document")
    txn = self._rollback_transaction("py3compat-load-family")
    try:
        loaded, symbols = create.load_family_with_result(FAMILY_FILE, doc=self.doc)
        self.assertTrue(loaded)
        self.assertIsInstance(symbols, list)
    finally:
        txn.RollBack()
test_load_family_result_preserves_refusal_status()

Existing symbols do not turn a refused family load into success.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
@unittest.skipUnless(IRONPY, "Requires IronPython out-param marshaling")
def test_load_family_result_preserves_refusal_status(self):
    """Existing symbols do not turn a refused family load into success."""
    from pyrevit.revit import create

    class Reference(object):
        Value = None

    class ReferenceFactory(object):
        def __getitem__(self, _):
            return Reference

    class Clr(object):
        Reference = ReferenceFactory()

    class Family(object):
        pass

    class RefusedLoadDocument(object):
        def LoadFamily(self, *_):
            return False

    original_clr = create.clr
    original_db = create.DB
    original_get_family = create.query.get_family
    existing_symbol = object()
    create.clr = Clr()
    create.DB = type("DBStub", (object,), {"Family": Family})
    create.query.get_family = lambda *_args, **_kwargs: [existing_symbol]
    try:
        loaded, symbols = create.load_family_with_result(
            "existing.rfa", doc=RefusedLoadDocument()
        )
    finally:
        create.clr = original_clr
        create.DB = original_db
        create.query.get_family = original_get_family

    self.assertFalse(loaded)
    self.assertEqual([existing_symbol], symbols)
test_load_family_symbol_out_param()

create.load_family_symbol marshals the out-param symbol reference.

Two host-API details shape this test.

The symbol is checked against DB.FamilySymbol rather than through rpw: rpw's Element constructor always returns the base rpw.db.Element wrapper, never the specific subclass, so an isinstance check against rpw.db.family.FamilySymbol cannot pass on any input. Revit hands back a generated subclass (this family's type is an AnnotationSymbolType) whose MRO still derives from FamilySymbol, so the host-type check is the one that expresses the claim.

That same generated subclass is why the type name cannot be read as .Name: the member is present in the binding but raises AttributeError on read, on IronPython and CPython alike. The symbol-name parameter is the stable read for a family type name.

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_load_family_symbol_out_param(self):
    """create.load_family_symbol marshals the out-param symbol reference.

    Two host-API details shape this test.

    The symbol is checked against ``DB.FamilySymbol`` rather than through
    rpw: rpw's ``Element`` constructor always returns the base
    ``rpw.db.Element`` wrapper, never the specific subclass, so an
    ``isinstance`` check against ``rpw.db.family.FamilySymbol`` cannot pass
    on any input. Revit hands back a generated subclass (this family's type
    is an ``AnnotationSymbolType``) whose MRO still derives from
    ``FamilySymbol``, so the host-type check is the one that expresses the
    claim.

    That same generated subclass is why the type name cannot be read as
    ``.Name``: the member is present in the binding but raises
    ``AttributeError`` on read, on IronPython and CPython alike. The
    symbol-name parameter is the stable read for a family type name.
    """
    import os.path as op

    from pyrevit.revit import create, query
    from pyrevit import coreutils
    from pyrevit import DB

    if not FAMILY_FILE or not op.isfile(FAMILY_FILE):
        self.skipTest("No family file fixture provided")
    family_name = coreutils.get_file_name(FAMILY_FILE)
    if query.get_family(family_name, doc=self.doc):
        self.skipTest("Family fixture is already loaded in this document")
    discovery_txn = self._rollback_transaction("py3compat-discover-family-symbol")
    try:
        symbols = create.load_family(FAMILY_FILE, doc=self.doc)
        if not symbols:
            self.skipTest("Family fixture contains no loadable symbols")
        self.assertIsInstance(symbols[0], DB.FamilySymbol)
        name_param = symbols[0].get_Parameter(DB.BuiltInParameter.SYMBOL_NAME_PARAM)
        self.assertIsNotNone(name_param, "family symbol carries no symbol name")
        symbol_name = name_param.AsString()
    finally:
        discovery_txn.RollBack()

    load_txn = self._rollback_transaction("py3compat-load-family-symbol")
    try:
        self.assertTrue(
            create.load_family_symbol(FAMILY_FILE, symbol_name, doc=self.doc)
        )
    finally:
        load_txn.RollBack()
test_curve_intersect_out_param()

geom.intersect_curves marshals intersection results.

Exercises the Curve.Intersect wrapper on pure geometry, with no element creation or view dependency. Works across both host API generations (out-param overload and CurveIntersectResultOption).

Source code in pyrevitlib/pyrevit/unittests/test_py3_compat.py
def test_curve_intersect_out_param(self):
    """geom.intersect_curves marshals intersection results.

    Exercises the Curve.Intersect wrapper on pure geometry, with no
    element creation or view dependency. Works across both host API
    generations (out-param overload and CurveIntersectResultOption).
    """
    from pyrevit import DB
    from pyrevit.revit import geom

    line_ns = DB.Line.CreateBound(DB.XYZ(0, -10, 0), DB.XYZ(0, 10, 0))
    line_ew = DB.Line.CreateBound(DB.XYZ(-10, 0, 0), DB.XYZ(10, 0, 0))
    intres, points = geom.intersect_curves(line_ns, line_ew)
    self.assertEqual(intres, DB.SetComparisonResult.Overlap)
    self.assertEqual(len(points), 1)
    self.assertIsInstance(points[0], DB.XYZ)