|
| 1 | +import tempfile |
1 | 2 | import unittest |
| 3 | +from pathlib import Path |
| 4 | +from unittest import mock |
2 | 5 |
|
| 6 | +from docutils import nodes |
3 | 7 | from parameterized import parameterized |
| 8 | +from sphinx import addnodes |
4 | 9 | from yaml import Loader, load |
5 | 10 |
|
6 | | -from docfx_yaml import extension |
| 11 | +from docfx_yaml import extension, markdown_utils, utils |
7 | 12 |
|
8 | 13 |
|
9 | 14 | class TestGenerate(unittest.TestCase): |
@@ -1289,6 +1294,176 @@ def test_merges_markdown_and_package_toc(self): |
1289 | 1294 | ), |
1290 | 1295 | ) |
1291 | 1296 |
|
| 1297 | + def test_get_class_lines(self): |
| 1298 | + source = ( |
| 1299 | + "class TopLevel:\n" # line 1 |
| 1300 | + " pass\n" |
| 1301 | + "\n" |
| 1302 | + "@dec1\n" # line 4 |
| 1303 | + "@dec2\n" # line 5 |
| 1304 | + "class Decorated:\n" # line 6 |
| 1305 | + " class Nested:\n" # line 7 |
| 1306 | + " pass\n" |
| 1307 | + "\n" |
| 1308 | + " @nested_dec\n" # line 10 |
| 1309 | + " class DecoratedNested:\n" # line 11 |
| 1310 | + " pass\n" |
| 1311 | + "\n" |
| 1312 | + "def factory():\n" # line 14 |
| 1313 | + " class LocalClass:\n" # line 15 |
| 1314 | + " pass\n" |
| 1315 | + "\n" |
| 1316 | + "async def async_factory():\n" # line 18 |
| 1317 | + " @local_dec\n" # line 19 |
| 1318 | + " class AsyncLocalClass:\n" # line 20 |
| 1319 | + " pass\n" |
| 1320 | + ) |
| 1321 | + with tempfile.NamedTemporaryFile( |
| 1322 | + mode="w", suffix=".py", delete=False |
| 1323 | + ) as tmp_file: |
| 1324 | + tmp_file.write(source) |
| 1325 | + tmp_path = tmp_file.name |
| 1326 | + |
| 1327 | + try: |
| 1328 | + extension._get_class_lines.cache_clear() |
| 1329 | + class_lines = extension._get_class_lines(tmp_path) |
| 1330 | + self.assertEqual( |
| 1331 | + class_lines, |
| 1332 | + { |
| 1333 | + "TopLevel": 1, |
| 1334 | + "Decorated": 4, |
| 1335 | + "Decorated.Nested": 7, |
| 1336 | + "Decorated.DecoratedNested": 10, |
| 1337 | + "factory.<locals>.LocalClass": 15, |
| 1338 | + "async_factory.<locals>.AsyncLocalClass": 19, |
| 1339 | + }, |
| 1340 | + ) |
| 1341 | + # Non-existent file returns empty dict without raising. |
| 1342 | + self.assertEqual( |
| 1343 | + extension._get_class_lines(tmp_path + ".missing"), |
| 1344 | + {}, |
| 1345 | + ) |
| 1346 | + finally: |
| 1347 | + Path(tmp_path).unlink(missing_ok=True) |
| 1348 | + |
| 1349 | + def test_transform_node_does_not_mutate_live_doctree_or_pending_xref(self): |
| 1350 | + app = mock.MagicMock() |
| 1351 | + |
| 1352 | + def fake_resolve_references(doctree, fromdocname, builder): |
| 1353 | + for xref in list(doctree.traverse(addnodes.pending_xref)): |
| 1354 | + ref = nodes.reference("", "", refuri=xref["reftarget"]) |
| 1355 | + ref.append(nodes.Text(xref.astext())) |
| 1356 | + xref.replace_self(ref) |
| 1357 | + |
| 1358 | + app.env.resolve_references.side_effect = fake_resolve_references |
| 1359 | + |
| 1360 | + # 1. Detached node without pending_xref skips resolve_references. |
| 1361 | + plain_node = nodes.paragraph("", "Plain text") |
| 1362 | + self.assertEqual(utils.transform_node(app, plain_node).strip(), "Plain text") |
| 1363 | + app.env.resolve_references.assert_not_called() |
| 1364 | + |
| 1365 | + # 2. Live doctree node with nested pending_xref is deep-copied so |
| 1366 | + # neither its parent pointer nor its nested pending_xref child is mutated. |
| 1367 | + live_parent = nodes.section() |
| 1368 | + live_para = nodes.paragraph("", "See ") |
| 1369 | + live_xref = addnodes.pending_xref( |
| 1370 | + "", |
| 1371 | + nodes.literal("", "Client"), |
| 1372 | + refdomain="py", |
| 1373 | + reftype="class", |
| 1374 | + reftarget="google.cloud.Client", |
| 1375 | + ) |
| 1376 | + live_para.append(live_xref) |
| 1377 | + live_parent.append(live_para) |
| 1378 | + |
| 1379 | + rendered = utils.transform_node(app, live_para) |
| 1380 | + |
| 1381 | + app.env.resolve_references.assert_called_once() |
| 1382 | + self.assertIs(live_para.parent, live_parent) |
| 1383 | + self.assertIn(live_xref, live_para.children) |
| 1384 | + self.assertIs(live_xref.parent, live_para) |
| 1385 | + self.assertEqual(rendered.strip(), "See <xref:google.cloud.Client>") |
| 1386 | + |
| 1387 | + def test_run_sphinx_markdown(self): |
| 1388 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 1389 | + html_outdir = Path(tmp_dir) / "html" |
| 1390 | + html_outdir.mkdir() |
| 1391 | + markdown_outdir = Path(tmp_dir) / "markdown" |
| 1392 | + |
| 1393 | + orig_builder = mock.MagicMock() |
| 1394 | + orig_builder.outdir = str(html_outdir) |
| 1395 | + |
| 1396 | + app = mock.MagicMock() |
| 1397 | + app.builder = orig_builder |
| 1398 | + app.env.found_docs = {"b_doc", "a_doc"} |
| 1399 | + |
| 1400 | + doctree_a = mock.sentinel.doctree_a |
| 1401 | + doctree_b = mock.sentinel.doctree_b |
| 1402 | + app.env.get_and_resolve_doctree.side_effect = [doctree_a, doctree_b] |
| 1403 | + |
| 1404 | + observed_builders = [] |
| 1405 | + |
| 1406 | + def record_builder(docname, doctree): |
| 1407 | + observed_builders.append((docname, doctree, app.builder)) |
| 1408 | + |
| 1409 | + with ( |
| 1410 | + mock.patch("os.getcwd", return_value="/tmp/work"), |
| 1411 | + mock.patch( |
| 1412 | + "sphinx_markdown_builder.markdown_builder.MarkdownBuilder" |
| 1413 | + ) as mock_md_builder_cls, |
| 1414 | + ): |
| 1415 | + mock_md_builder = mock_md_builder_cls.return_value |
| 1416 | + mock_md_builder.write_doc.side_effect = record_builder |
| 1417 | + |
| 1418 | + markdown_utils.run_sphinx_markdown(app) |
| 1419 | + |
| 1420 | + mock_md_builder_cls.assert_called_once_with(app) |
| 1421 | + self.assertEqual(mock_md_builder.outdir, str(markdown_outdir)) |
| 1422 | + mock_md_builder.set_environment.assert_called_once_with(app.env) |
| 1423 | + mock_md_builder.init.assert_called_once_with() |
| 1424 | + mock_md_builder.prepare_writing.assert_called_once_with( |
| 1425 | + ["a_doc", "b_doc"] |
| 1426 | + ) |
| 1427 | + self.assertEqual( |
| 1428 | + observed_builders, |
| 1429 | + [ |
| 1430 | + ("a_doc", doctree_a, mock_md_builder), |
| 1431 | + ("b_doc", doctree_b, mock_md_builder), |
| 1432 | + ], |
| 1433 | + ) |
| 1434 | + # Original builder is restored after run_sphinx_markdown completes. |
| 1435 | + self.assertIs(app.builder, orig_builder) |
| 1436 | + |
| 1437 | + # Subsequent call when markdown_outdir already exists is a no-op. |
| 1438 | + mock_md_builder_cls.reset_mock() |
| 1439 | + markdown_utils.run_sphinx_markdown(app) |
| 1440 | + mock_md_builder_cls.assert_not_called() |
| 1441 | + |
| 1442 | + def test_convert_cross_references_fast_path_checks_both_ends(self): |
| 1443 | + # All UIDs start with "google." and content has no "google." -> fast-path. |
| 1444 | + all_google_uids = ["google.cloud.storage.Client", "google.api_core.Retry"] |
| 1445 | + content_no_google = "Plain description with custom_pkg.Client reference." |
| 1446 | + self.assertEqual( |
| 1447 | + extension.convert_cross_references( |
| 1448 | + content_no_google, |
| 1449 | + "google.cloud.storage.Blob", |
| 1450 | + all_google_uids, |
| 1451 | + ), |
| 1452 | + content_no_google, |
| 1453 | + ) |
| 1454 | + |
| 1455 | + # Mixed sorted list where known_uids[0] starts with "google." but |
| 1456 | + # known_uids[-1] does not -> must NOT skip non-google references. |
| 1457 | + mixed_uids = ["google.cloud.storage.Client", "custom_pkg.Client"] |
| 1458 | + self.assertEqual( |
| 1459 | + extension.convert_cross_references( |
| 1460 | + content_no_google, |
| 1461 | + "google.cloud.storage.Blob", |
| 1462 | + mixed_uids, |
| 1463 | + ), |
| 1464 | + 'Plain description with <xref uid="custom_pkg.Client">custom_pkg.Client</xref> reference.', |
| 1465 | + ) |
| 1466 | + |
1292 | 1467 |
|
1293 | 1468 | if __name__ == "__main__": |
1294 | 1469 | unittest.main() |
0 commit comments