diff --git a/BUILD b/BUILD index 80284fe40..20a0fef50 100644 --- a/BUILD +++ b/BUILD @@ -20,6 +20,16 @@ docs( data = [ "@score_process//:needs_json", ], + bundles = [ + { + "bundle": "//src/extensions/score_mounts/docs:concept", + "mount_at": "concepts/mounts", + }, + { + "bundle": "//src/extensions/score_mounts/docs:howto", + "mount_at": "how-to/mounts", + }, + ], scan_code = [ "//scripts_bazel:sources", "//src:all_sources", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1570a3bb6..d8cd028da 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1153,6 +1153,122 @@ "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz": "ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl": "048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8" }, + "ignore-python": { + "https://files.pythonhosted.org/packages/00/19/64cc90ed114d1ec38cf20e7e1825c4885bf4d66b2ce9ca1208ca13540c64/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "9bccb48b57b7a85677c1022afbaaf86e5cda8c1ecff00ad96877e10166d1eddc", + "https://files.pythonhosted.org/packages/03/76/2b0cf84c80d973b285831ae59f795635eb518342e00aab1d96a0c918bbe6/ignore_python-0.3.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "a999ef004caa048e5ecccb5f3383d857105baa37b8895a0a2b7cd66f9cb0b0b4", + "https://files.pythonhosted.org/packages/06/a2/6db321813eba49f04f680af7b83dddda117f09e5a33e8fa2de7cbbe26f36/ignore_python-0.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "4f8c85a6738c632abd477c217297f8929ec1cafb261b94fa05a7fcf28095d70f", + "https://files.pythonhosted.org/packages/0c/f8/8ae1f5dc1e9b5fd3b4460ac685bb84c22a327f14f76daeebcd7eeeaba9ff/ignore_python-0.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl": "22c216e3130077060eb4cce99a8bf79074826655bbea6c594d36d8a0735fac6c", + "https://files.pythonhosted.org/packages/0d/93/2a28bd91ab75f22e21448e47b2972670ea5320fb37bf5d3f3f0167be8743/ignore_python-0.3.3-cp311-cp311-win_amd64.whl": "0a6b2d7900ce82acd61ab6714b10103c37d0a1c16ee7af46fb6e94d14a2e4dca", + "https://files.pythonhosted.org/packages/0e/00/aee2481903dc3578998df4edf94dc0460644f7f8a9fbee6e84189a24d278/ignore_python-0.3.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "68f393318292a6346c6d72c2b8ee301a081bff778dfb0e6ef6f0c36da4053374", + "https://files.pythonhosted.org/packages/0f/e4/e260e5cb1b289230a6be99de15db5eb0697e6334830cb1ff3907568414ab/ignore_python-0.3.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl": "df78545bc5d54abf875a70a70db7e1e12e606377d1c9f4e68b6763e8c701a748", + "https://files.pythonhosted.org/packages/12/30/976ce0f8e1002fc19781ea48b6d5519200f734b19be03d769eefeb0ca749/ignore_python-0.3.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "71dc7505c0520e066c5d567f49d7173703c34192af1b8f89ce401a34098391f5", + "https://files.pythonhosted.org/packages/13/96/1b66b3be8862b771dc6eae3c522609ee22309c91276a48cfee1f17b469b5/ignore_python-0.3.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "f93a3425169961aa7f0c7193dc12f01414700f86f4fb99ec078c18780432b77a", + "https://files.pythonhosted.org/packages/1c/0d/f0b3e6a35b66570d6212850711067a719604034b72ee0c8cec312e5be13f/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "2b608a30d3505720f9aceaba7d6d26769867d71c212fc5c8a80fae1a8afec663", + "https://files.pythonhosted.org/packages/1c/76/ea5c9a892f9f10eefda168b0ad8e894b838dc0128317e5f965d7b546205d/ignore_python-0.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "875fdfa0e3e9102164a540509ca2d5ad959f1f53858cf11a4174a1845e3c575c", + "https://files.pythonhosted.org/packages/1c/b7/41b9bea87bdab57c2b705767bdd9c930417e5f1f14502aac9ac6044d86ee/ignore_python-0.3.3-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl": "a30070520aa114133feffc2413ba62bfcd8ef2f9826ebe7616de123f73b57977", + "https://files.pythonhosted.org/packages/1e/ce/f82e5156b64c3f8e8835c36ef1cb046d280af8294192672ec99a5f0efb6d/ignore_python-0.3.3-cp314-cp314-macosx_11_0_arm64.whl": "c3430b73a99af300b0b1203da2cd30f1831f504466d94343b065b1ef0435802c", + "https://files.pythonhosted.org/packages/23/2f/509eeeb1d61e6e66518601274c4fe1c1ebb45493ae5704033c4a01f2a721/ignore_python-0.3.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl": "c68dc5db03a22aada43f23f55e359109e00afe3886824d6fecfbd6821dcdbd6f", + "https://files.pythonhosted.org/packages/28/d3/91b086d42cd2e9654e8d532975d068fdfe64dda176a56642608f3627002b/ignore_python-0.3.3-cp310-cp310-win_amd64.whl": "be6a4e3244c33f133d3c0bc43f9725c61dc9a11f7100c615639ce1daee064766", + "https://files.pythonhosted.org/packages/29/94/17a644d95f13c08845bc6be9750f16e19e7d0650a6f43e6ce63de57556b0/ignore_python-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl": "8dd30b865dfd3206756212796cb13686b6c45befa5cc495ccc9866108215f7c1", + "https://files.pythonhosted.org/packages/2a/fd/dbb1ed9af9bc50d53a2edcb71e0e236d32d28fdb60a62ccc965f3cad8811/ignore_python-0.3.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "0f0edb622f5a8b7f735e14a7ca13d4cb7ca04b6fd7e844f5fa0fd9f86622b986", + "https://files.pythonhosted.org/packages/2d/bd/8db6909e5ab266b8827fc5659fe98ea905d15bad818fa088d923da46643e/ignore_python-0.3.3-cp39-cp39-musllinux_1_2_x86_64.whl": "5055772fa6f09148a09e6958770c4e4f4435f6e3de33476f815af9c5db13e29c", + "https://files.pythonhosted.org/packages/30/60/d0c7be4619a9d8537e0e930b26d358b870f14820ec89eaba80e97d00481d/ignore_python-0.3.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "1206189e1c988a3d0fb7de3298e5f7dd5284b4a23781878fb8f9f6224659b270", + "https://files.pythonhosted.org/packages/34/36/eb0337f76ea09ffe68246228f9cb1d14d8c84deb6bda8dcbbedf16bdb373/ignore_python-0.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "8d26f91ac1fea52abd16dab224b4ac016d8914f28db02e582f4e589ee2b5faa5", + "https://files.pythonhosted.org/packages/37/46/3c93a7bcbd21d23faa9eb0c90613b711e6afb218d09b6122325cac4f3ae0/ignore_python-0.3.3-cp311-cp311-musllinux_1_2_x86_64.whl": "3b6536698628af08b6db260d338b41e7c48b2a6c5c93b12de4d1ecafc1bb86ae", + "https://files.pythonhosted.org/packages/3a/17/f0d111f0d90ae0d2322ba0c4a2002b2b6634dd64555ad9f42eaae20b633b/ignore_python-0.3.3-cp314-cp314-musllinux_1_2_x86_64.whl": "dda677506171a0c4f27925e13f9f8b4b652941969f0e0e6b555ce795e18b7467", + "https://files.pythonhosted.org/packages/3d/00/176044e51c351465221f6b98ced3639d7660330bb12d89fb99e13423803c/ignore_python-0.3.3-cp313-cp313-win_amd64.whl": "2af502d988282cc360094dc7b2733a7b68e54a8e3cf0128178ab1ba84f9ec290", + "https://files.pythonhosted.org/packages/40/dc/76b1fc8e4d51680656c963338d5ca2f17b6bc04ad1c4425c2cc498630908/ignore_python-0.3.3-cp311-cp311-macosx_11_0_arm64.whl": "c478ee58fd2d6f5f7b75b32288d8a099904f710e83dd878f40058a309a0f6060", + "https://files.pythonhosted.org/packages/40/f0/3ad575b0a10d4d69efe87e745c7e94a4bbc824963707b24272d49a150bda/ignore_python-0.3.3-cp312-cp312-win_arm64.whl": "3623ce12eb96976c0db36a0ad99c65c669fdafece71e7221a538f12fa6fa41ef", + "https://files.pythonhosted.org/packages/43/d7/7ad0916a3aa863f26f8654dfe095adf65e720a201043093e1f8614dc0e16/ignore_python-0.3.3-cp310-cp310-musllinux_1_2_i686.whl": "275f5b3e4c5b25fcb58ad1eedd983a346866939a6140e564b9c56ee9f8fc7760", + "https://files.pythonhosted.org/packages/43/ee/611f7c7d8973d288e2908a14aaf48b8e65ee0d3e11ba59c7b62e8b07ef69/ignore_python-0.3.3-cp312-cp312-musllinux_1_2_x86_64.whl": "e9c95f8c3a68449f7d3bd5860ea832b5827a04803337796da72d8340786e9268", + "https://files.pythonhosted.org/packages/49/14/32e3a2c4313367a38b9c8b564785444bb3ed31317386fb81efd80496a5af/ignore_python-0.3.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "0f50dd3c3f0ef982e256d9e702b44c1d81cbe0da2d98746d5b189bc1fd5191cd", + "https://files.pythonhosted.org/packages/4d/45/fcb27a731e98ff8d71ff7559f5558091efdb107eafd4ee7aef6ac91f5dad/ignore_python-0.3.3-cp311-cp311-musllinux_1_2_armv7l.whl": "901a862196bf610745e164d29c518e0cbf727eaaedc83d5058a7895721be2173", + "https://files.pythonhosted.org/packages/4e/4b/c5abae65901dce53a197c6e37178821b259b0cc40b8ff99183e409946a71/ignore_python-0.3.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl": "931744cdcbb84d77159daf6b54e3b459e9f0a0ba52ea6b99d1d228e32556c2b9", + "https://files.pythonhosted.org/packages/58/fc/f7cfe4a5ea6be67bbaa42afb6fe3d8fbaca6d3a71aa63bf86c22a7fac53b/ignore_python-0.3.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "44bf617535f5ead500a6f83178426f7c607e015bcf9e614e18ae88aa3de1a340", + "https://files.pythonhosted.org/packages/5c/83/ba162a3b82eca12ddbb7f229a3d05b79ec5be66bde44007eab3de7abed7b/ignore_python-0.3.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl": "0c9408949156bf4ae07e60d5c8f356114be1482dc8f708ecd5785151e9ae21fa", + "https://files.pythonhosted.org/packages/5e/e9/b8e55bd15b231ff44028b7ad5ffd231cf038b8181bf288b58f8ce2324072/ignore_python-0.3.3-cp313-cp313-macosx_10_12_x86_64.whl": "593679d7714b4228d7e8c3bda0005badb9f0d4cb37cd8dda8385ef734e675e23", + "https://files.pythonhosted.org/packages/5f/f3/b6aa2183016cefb19175308a5b65ba72dddc2f9ff2dce6ef9bb9f2e61b1e/ignore_python-0.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl": "d94d9b91eece76104077a7e0b3276bb14728a4bff00ba23b0f0bea2a10c0e6e8", + "https://files.pythonhosted.org/packages/61/2c/fe119eea2eb12c3fa3cf2793b0a8664e396c9b158967b37f2ed3e959f6d2/ignore_python-0.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "82fecbeb7fa309245aaaa7e3aaa09c744f1059fc238e2f7acd889d803f1a0be7", + "https://files.pythonhosted.org/packages/62/d0/ac58193a1ec6c8ec06da209fcbead510938a8dde89fc6478cace59cb77b7/ignore_python-0.3.3-cp314-cp314-musllinux_1_2_i686.whl": "4252f803f3cdae6c8775f1e905f5babd6e77860781f4c29cf798452ee5fd6936", + "https://files.pythonhosted.org/packages/63/03/c1890e428c05445eb14523e76fe87ba71151bae370874e05bb0f32276700/ignore_python-0.3.3-cp313-cp313t-musllinux_1_2_i686.whl": "7e9bea86436a59eb3f24e8e15bb3b3a36cdb98aec950c70aa619e33f35eb6beb", + "https://files.pythonhosted.org/packages/63/63/31483b9985ca2c4b1d828457ecccc1e60b02f3887d548ecbaa565176618a/ignore_python-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl": "356b783877c7e88eba69c99bcc5e2d9fb07c2fe54f2c64e03897b7ae2adce2a7", + "https://files.pythonhosted.org/packages/6a/b8/7ef6326e648aacbc34ee31f2c4c9db073473f85d1f649cd5bda799f47d60/ignore_python-0.3.3-cp314-cp314-musllinux_1_2_armv7l.whl": "b0f0bc9eab99a36f54e0a4e3043768e529107565bc67a7f420b4febff8006f32", + "https://files.pythonhosted.org/packages/6b/37/57e38f4bc0f7584b578751dbfbb9081cf5af38848893aec69a814db24d03/ignore_python-0.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl": "b123951f9befe6052a7397778fa64ade18983345d79fd9477160b11dfd736df0", + "https://files.pythonhosted.org/packages/71/81/f2dd6e0ab7aa9b860d82c1b3ce195ed5d7284e92cde2cbe5a6915241a8c4/ignore_python-0.3.3-cp314-cp314-win_amd64.whl": "fabb25c4352d3d4f1a2a197d6fb403848026344aab73e1674bcb31e4a7f54914", + "https://files.pythonhosted.org/packages/74/ef/767d321b08e9b21d8d7a5b34d3f9d4b4eaf4d3c33b4761dcd0b6c0d87560/ignore_python-0.3.3-cp311-cp311-musllinux_1_2_i686.whl": "477bb090189b79b8a81753c74a59ccb6c0ebf91985f30e926177f062c8616d05", + "https://files.pythonhosted.org/packages/75/be/c220b77b9997f2ae81d30af3562bf7dee8e4c68a4de0a0404098b98fb49f/ignore_python-0.3.3-cp313-cp313-musllinux_1_2_armv7l.whl": "0dfa531bbf65f45f9a233e2809f8b0a49aa9078686dbb0e4f3e30848e09cc208", + "https://files.pythonhosted.org/packages/77/d2/be1a05941346a51384a5a71e1aa9933cfdc8a4508ed6f03ae925bb37dffc/ignore_python-0.3.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl": "8e9085cec8d730b43ac8c86ef5da0c902f0f57da8da2ee009045e7006fe4860f", + "https://files.pythonhosted.org/packages/7a/dc/a90ba9f5eab933e8bfd65396ee88c14221641e8fb48d93ce45454083e50d/ignore_python-0.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl": "7d63688dc696b72d54623dd55f269d5203fcd5de5eeeba7bc276864300a8d790", + "https://files.pythonhosted.org/packages/7b/8f/083356b171a87168b281a0af3da5d558e55fde13a792ee7b079a99ad01ee/ignore_python-0.3.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "5756229d699ec5ab8caf4cec3ce9827d02145059c79a2416f363e2df4406d378", + "https://files.pythonhosted.org/packages/7c/b4/aadf114682a3495361f12b827a38d1f4ee8f452166747513cfbc18818121/ignore_python-0.3.3-cp312-cp312-musllinux_1_2_i686.whl": "de10b31770a8978e381c8f0c05479e18d6ea1defa18a0aa825b0487acd1f082c", + "https://files.pythonhosted.org/packages/7e/ea/4bd00b6848a5c93a7c9f179709ab1e09edea7728287177145f878c68630d/ignore_python-0.3.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl": "8f0e8379d4eff6842b61c01c7f03dc7415afac994b96e4a7d24f80b1078d5c1d", + "https://files.pythonhosted.org/packages/80/b8/167a424011bfe0af21304de7c8df8114e7e680e290f5e1a7aa1318179420/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl": "1b23770925db422fe9c94920da82e0606516aca09fd851880bc8c8ed68fe6455", + "https://files.pythonhosted.org/packages/83/2b/f52307ec26252690f6a4f31df7d727a53807a39c2e70727342b3ae4343e7/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl": "4ba31985a790af71bc45c16643d4773b829b8233acf4175bba2af466a2bbc959", + "https://files.pythonhosted.org/packages/86/62/7989a0a1e3d133b4d70727732020cd690969e693227615e45e275b6af46c/ignore_python-0.3.3-cp310-cp310-musllinux_1_2_aarch64.whl": "7e3b89f96cda6df85687b9532eac4faffdf15d2d918b239399cab6c78dd5282b", + "https://files.pythonhosted.org/packages/89/7e/908bf9ee614a5e3c4cc695e620c07c3a1449508a1ebdd54ab688a6c71e8b/ignore_python-0.3.3-cp310-cp310-musllinux_1_2_armv7l.whl": "1225e6210e302e0725265504a11a367f0b8cb882e3e2e231748559d5b05179c8", + "https://files.pythonhosted.org/packages/8b/b2/24102dc6c85b3fcfb29c9928d69e8d8656557e14ec50d1dd5caf437a6e6f/ignore_python-0.3.3-cp312-cp312-win_amd64.whl": "152c5aecf42e709138c8e3da2ac733d00f5efdcd004c240a95193e62b6bd024e", + "https://files.pythonhosted.org/packages/8c/00/f3f82228067b68f0f66594261a1f51e090fbc32616e1494fbe32339a13fd/ignore_python-0.3.3-cp313-cp313-musllinux_1_2_aarch64.whl": "1b2ff29dbe59bbbd370feacb99e5bec7791abe730dd535b5db848de5b5210d7e", + "https://files.pythonhosted.org/packages/8c/90/7d1d5ddca496189b61d8ccccbc0fbb579a7c2c5b2dbd5e41eec944068dc8/ignore_python-0.3.3-cp313-cp313t-musllinux_1_2_armv7l.whl": "cc49302f8fdbd3426eba4b99671ba10f0d150d90ea4f344a0204e4ac8e4fcb66", + "https://files.pythonhosted.org/packages/90/db/d28201d52730132f63ecc3bebcc4ba8694a2764157c65425ffd86ba05903/ignore_python-0.3.3-cp314-cp314-musllinux_1_2_aarch64.whl": "1ac4491082df61d370f7fc087d5c0b16bc84b647e126e3f45a97d31cf3b2f514", + "https://files.pythonhosted.org/packages/94/09/a89778efcd14cc4d7c332133a58ce2cb3933ee0dc066aeeb7f749637c3b9/ignore_python-0.3.3-cp312-cp312-macosx_10_12_x86_64.whl": "429a9b792afdca7dd9cff59f5ace44c2f55d01fc30b0ce3d28d63bee223116ed", + "https://files.pythonhosted.org/packages/94/8e/360c8ec57ec2b7be346609571b3fa070882c0b20cc60b0c0cb1dd8c11f5f/ignore_python-0.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "d9778479aaeac4f000d2b9c0f6da76149926011588b08a6b22892ae921c4f563", + "https://files.pythonhosted.org/packages/94/c1/28ad4b5e4e4108407542f7adebccd98d554bfa5f6fcd99f959d6aea9057b/ignore_python-0.3.3-cp38-cp38-musllinux_1_2_x86_64.whl": "f65475871a57413dee3094cc5d479ed202af85c38bc90bda5d7ee4435ff96819", + "https://files.pythonhosted.org/packages/95/cf/1417966361c38b3acb80e2a427cf34883b9c1dc4274dc1097cd9c3c6af2d/ignore_python-0.3.3-cp313-cp313-musllinux_1_2_i686.whl": "e4786eac47d2e9dab245fc376157bdc458f4b3269b81006b80a74d514b37efb7", + "https://files.pythonhosted.org/packages/97/ff/c3707e735606b0197484c4be4ac8e6a32a8899c95d0db25a9bedc301776c/ignore_python-0.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "c8b112dd1906487fe56525361cbeca57b07adcc9496b641560327654aced7e8f", + "https://files.pythonhosted.org/packages/a2/67/13e76d9a9bf4484b212645631884f48503cdf37ca1f8a0cd761d63faf8ac/ignore_python-0.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "b4f2df2bbca999f9749430e54dcd13bcc35289520b63e09ed4d2e1877a524260", + "https://files.pythonhosted.org/packages/a4/12/60370837ef24be9ae16ee5189b8c2070d429e45a616106303acece8d9d0e/ignore_python-0.3.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl": "56e8f00aef89a19b0305cb233fb59736ad14aa8c6db05720b13ac0bca811b5e7", + "https://files.pythonhosted.org/packages/a4/bc/d510141c02207147c6ca5eb16412cfbbab556abd8ec2fc4cba061cd67981/ignore_python-0.3.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "e397f034d675ef14980f2df0a074dec3218963a912b7011d0e8e94f9a3d87d41", + "https://files.pythonhosted.org/packages/a6/1b/10bc2fce9d9f7d669755ea6b22f463f1950eb5538806cbf873f25092b9d3/ignore_python-0.3.3-cp311-cp311-macosx_10_12_x86_64.whl": "5134be429fb954ec589857dbc6152dc09014902313e3d2293af9338a4b7799ae", + "https://files.pythonhosted.org/packages/a8/c6/53f2cff5be0b05f153aaa1e4f72e14f43c5b3980d4453672d9d718e62ff9/ignore_python-0.3.3-cp312-cp312-musllinux_1_2_armv7l.whl": "b730d11384a02bc4fb195b8a73555cb450e325d2676b39c8b5d20a0786102f37", + "https://files.pythonhosted.org/packages/aa/8e/97ed261d173b12101c590682b812759d6e787cd1161fb49921707399149b/ignore_python-0.3.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "c73743c4c8622ebed7998990c5f18a2c2a4fae915288798aeb8fef6d5411743b", + "https://files.pythonhosted.org/packages/aa/b1/c3d851fbf68d1a70cbe49e66b17d10f43af831ef06c34d7d6d385b59499e/ignore_python-0.3.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl": "d8be9b991c63fd8c76a6a9deac24ed164533824da1bdb2356a33511bfa446d54", + "https://files.pythonhosted.org/packages/ab/c6/d9daf9b3919a8d60d1128507d1ef96992563c6d4158f3a0409255542f19f/ignore_python-0.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl": "7ad2cc34fb600ab4aa22014bc8cc9b8bae2d467f772074d3a4929deb4adf64d6", + "https://files.pythonhosted.org/packages/ac/83/1d9de21d59d37ebf984b4caa40c745cfebb10f0d10400b1783e4231b5283/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "8fc3b2fcb6ee1c2b1512a0b29d26bb9b9e945d0d50c0a85673a675622fbfb0f4", + "https://files.pythonhosted.org/packages/af/93/c2aef57d2a83cc33fae854d5a8a33f13bd1d4e8a5accbee52bb2c8ef41d6/ignore_python-0.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "59e26bf7bbbd5937a196f01480050d3d80418ade8933164b28e3e36734baecd7", + "https://files.pythonhosted.org/packages/b2/30/2f337d260e0169a3a8ee1425c59093b08e055735110c9b62dab5a785f4bf/ignore_python-0.3.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "05ccc5bdf2ad1f840a0a2296efff5f4761a26f015185ed0bb835ad1901f08ee8", + "https://files.pythonhosted.org/packages/be/44/0e091481938ffff7bbef9f1964a49249d0a098f11a3b459f05c279d15429/ignore_python-0.3.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "85f3ec2d15ba134e6159ddebdb1a0af89f99431d93336f8b3cb71aa2de9f3324", + "https://files.pythonhosted.org/packages/bf/89/abbd4c122ccc82cff70ccec42c9f2f4a096e5e3c9946cd76436b28b1d6c3/ignore_python-0.3.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "2d637f1a6b2ec7bcb74b2ac11f75e15eaf6092535b8dc31415305a73a3762e3e", + "https://files.pythonhosted.org/packages/c0/1b/90b799876f7129bc045811ee80025f5f1a2cfed300fed972bb5790466b35/ignore_python-0.3.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "17749af58a6fe6aaa3198b285c874fbf0f036ef2cb684225ef62288b62948d26", + "https://files.pythonhosted.org/packages/c2/a9/a326f1a295e3ecbcadf90699c01b7389276f6405fa4da67472bcc9423ada/ignore_python-0.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "e61a305384a59997fa1971a6c886db9b8e2ff59fca3ee9bb4e1fe191b6d02593", + "https://files.pythonhosted.org/packages/c6/bd/a37cd31e6d4c6eaabe6ceb22defdb278246973d4f50f9deffea12b1ea037/ignore_python-0.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl": "3c40627f3bde32a37e75950b97733b586e9167fd545c958195dbf1bb73d96a81", + "https://files.pythonhosted.org/packages/c7/64/dbbbe3d4bc43207772aba5ea9aee8c24444e578790088bca4bb7acd8572e/ignore_python-0.3.3-cp312-cp312-musllinux_1_2_aarch64.whl": "af35c6b8c3a9a27721e5c2a849e2ee21973e14b8b7d2e76e15940475a8ace443", + "https://files.pythonhosted.org/packages/c7/89/c1a25074ee04c2db36ec1a1638a72dc91f0056674924783ed3f227fb2346/ignore_python-0.3.3-cp313-cp313-win32.whl": "97db61620be5c56a78115967d05e0d7a130a27a68f401eb98bf0753d3f770cb5", + "https://files.pythonhosted.org/packages/c8/c9/8929fdfbc3c0464ed4f5c5d6ad4b31a8c003dec1bca4446fcf9b8ba44866/ignore_python-0.3.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "dab6441f4403483a420de9481987e68ab75a28ade5bac8f1d82a3533ed83a6eb", + "https://files.pythonhosted.org/packages/c8/d7/545c0aabd0c51a08f2245062dfa4be2ea50285148ae78a5fcf013841a37a/ignore_python-0.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "1ed9c8c858dfe2ba91bc4ab60ebd9d12dd4602a4d0d555e0fee9c8621f9ca292", + "https://files.pythonhosted.org/packages/cb/2a/86804702f3d0820b75c67fa19180812a6cb7c945a720d7054862823c3246/ignore_python-0.3.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "39e7b9d976c12c68b09b9944328a000b012725f1f7c4655510eb890761016fb3", + "https://files.pythonhosted.org/packages/cc/32/3e44ceceb0111c9d6cdcb24aaf7d531c4dc0037c51cdd22a75fd2d71de9d/ignore_python-0.3.3-cp313-cp313-musllinux_1_2_x86_64.whl": "4f88aa2ee7335399c823aa050edec118e28ddafe7859e0db4093de0ca815467e", + "https://files.pythonhosted.org/packages/cc/cb/30c8862795c88c8c249a34e584ee95e27748ca890466ab9044e94f32b717/ignore_python-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl": "4aac18bf9346f06fd17412d5bcfca53090a72456f53f3ffdf3b1e896f15cc356", + "https://files.pythonhosted.org/packages/cc/dd/9b22e9d958261346fffc5f5062b03af44c7cb77f9ec9265c25ec607e4c39/ignore_python-0.3.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "48cf09c7668b5b8bb7dcaf185618130b8ea7839df0eb0f6ae4fae7b7162a0c37", + "https://files.pythonhosted.org/packages/cd/92/02801ce20abad2103c8e634582d1b45cca21985a226d29870ee9ebde0338/ignore_python-0.3.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "0c9ea5d81e6b1a1284f5463203f57cffb3c7e391c0d8ce3fe0e4d838621367fb", + "https://files.pythonhosted.org/packages/cd/b5/aaba0c76d2694873a1dbf31886575c15b11bf7e250a1958deac7221c502a/ignore_python-0.3.3-cp39-cp39-musllinux_1_2_i686.whl": "b2c072a4615c9610bd43cde816882ede0c910599702e54cbc07371874d1ca95b", + "https://files.pythonhosted.org/packages/d2/a8/84baf48e05b603480b963f655e73b97230f7296433d3cc8206b742c167fd/ignore_python-0.3.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "ebb00b54e5a01d8b10a51a7de7d2f884359375f4abb5352378f6f7f2c8878a58", + "https://files.pythonhosted.org/packages/d2/b6/2f093454e0c4390128eeeec265181b2b770214ecf1dba920664cdef56756/ignore_python-0.3.3-cp38-cp38-musllinux_1_2_armv7l.whl": "3001adecf33250dfa90c6e727affd559b31ad3b32b8519920c14bef53410444f", + "https://files.pythonhosted.org/packages/d5/d8/b868b289dc2466f3339e72602861379905d4ba1ef32d5c89c0874bf5a1e0/ignore_python-0.3.3-cp313-cp313-win_arm64.whl": "842572b228382c9bb6283428f14ff4481b3822cb7488ce4388281a8c6c465a81", + "https://files.pythonhosted.org/packages/d6/31/71cc339d9a76585f0d3dedf9ea47e070278e792ba87ab88779d02e3765a2/ignore_python-0.3.3-cp313-cp313-macosx_11_0_arm64.whl": "000ae12f6187791bc4748e40e7073b9769ca6ddb0cabfc11d1c682ca0e6a3953", + "https://files.pythonhosted.org/packages/d7/2c/ca2816e41d182f1b60d859906414e831b0cb6182006eb76f7cb4ade0630b/ignore_python-0.3.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl": "f24c6af8fd78b5c58e0f2683004e0b6fac146ee047b2d8fc8d7b16c69e0a3aaa", + "https://files.pythonhosted.org/packages/d7/cf/ae857c74b643ad0cda78501c3fd9ef4107cc4551d0dce04b0a3907f616f5/ignore_python-0.3.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "89b2efb712c5bc0c57cc5a9deabfbcb2196504139d2d108a4afedd140c02063f", + "https://files.pythonhosted.org/packages/d9/3a/33afae6b7102e957506dd702ae74346fe368a541d4675e9aba5725381997/ignore_python-0.3.3-cp312-cp312-macosx_11_0_arm64.whl": "365ab0bf94b64f3def2264fdca58f6e9811763830ad1a48a41d70574a496e5b9", + "https://files.pythonhosted.org/packages/d9/4b/2a07a65878f256effc14bce8afd8d1245549da231c364e4489a6b0cf773d/ignore_python-0.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl": "62afd51edaf5634e21206a65e9e244e038b747e39ba969ebcc9361b63825a4f9", + "https://files.pythonhosted.org/packages/db/20/54bfd6688b19fcd7978163e39078c43f397c9e1730a3ae6149c9573b6401/ignore_python-0.3.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl": "7e49780796e39812ade8001b0c7d2a2f1a9aeac90964e8e757c2013d30dfbe4e", + "https://files.pythonhosted.org/packages/db/de/aa50c31cdabf694b4e76e42f0a771ba6527e5d9e2b0d1c2bdc9e2a3a6b2b/ignore_python-0.3.3-cp314-cp314-win_arm64.whl": "c545cfd062a463c6d8e90b2738ec93fb9228f12c0aa80866fdc80f64fbc8f1a3", + "https://files.pythonhosted.org/packages/de/d4/2e9a778269b535c17ef016f6e4300e4811688119beaf0367df9fee909690/ignore_python-0.3.3-cp311-cp311-musllinux_1_2_aarch64.whl": "e571e0e3de9bca0b70afc4261aa2df6a305bfff94b092942ccd081fa07b8a148", + "https://files.pythonhosted.org/packages/e3/da/299d98fdb31e2a51d9f814fb0a341efbf3febed212fe69c8796aad8d3811/ignore_python-0.3.3-cp39-cp39-musllinux_1_2_armv7l.whl": "a894e6bf85988edee94474ff1399b61685d5fa7399d1c25a40efa28f7a2799ea", + "https://files.pythonhosted.org/packages/e5/e7/8df35dc4e6e162306c07c9912e8bf4e4aca2bc3e41dbbaf4feb953089d2f/ignore_python-0.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "da0ebc45da1d4e918fba53a2bc059339d00a1f666b3b72b9acd4fe4eeaec8343", + "https://files.pythonhosted.org/packages/e6/ae/9cbd37a23b4f7367369fb289d7729b0995c68e651f1b06b720e24a5169f3/ignore_python-0.3.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "5f3d88554e779f03567c05286f31d2ce21f6103892c7412bdf350ef2fb50184a", + "https://files.pythonhosted.org/packages/e8/cb/f246ba7e4b6ea4c819eb6f6469bcb68d5abc372068117f305849d04f8be6/ignore_python-0.3.3-cp38-cp38-musllinux_1_2_i686.whl": "fb712f94825c04fa605b989d6f5889195b51c927f5045c34b9ff7a448cd0a6f0", + "https://files.pythonhosted.org/packages/eb/ea/f30078aa0359d777056bf83efc38463258056ae6e904246588200f55157d/ignore_python-0.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl": "a2cdfbd3c9df9e98dd067858fce7d6ab919f2fb038f6b3124fc5f05b8825b546", + "https://files.pythonhosted.org/packages/ec/66/70338e7d59df3050658b3e34512f572f1865849520352b5002b7723025aa/ignore_python-0.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl": "3da9e102800f162468ddb5d2d392b79e2481d3709e886f875037ef7066df5481", + "https://files.pythonhosted.org/packages/f0/01/6c3396e839ddc339bff4245015a259496986afc0ffa1f0aec28957829502/ignore_python-0.3.3-cp39-cp39-musllinux_1_2_aarch64.whl": "e3754a2529b0c163bece327be6c5e92fe885cd01d066bc4a3fc13224eae5d221", + "https://files.pythonhosted.org/packages/f0/63/9c4665a4ea2894eb269bd69043f2004c571024a5699cc231a11219aa00a9/ignore_python-0.3.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl": "130d9ace07988b69669e871ed84dc77088d7b5ce35265efbb0b0d425085e2a99", + "https://files.pythonhosted.org/packages/f0/65/4730b11443d4da0fef9ed7525879529d452d862001c5287f1383d9c2a9ac/ignore_python-0.3.3-cp314-cp314-macosx_10_12_x86_64.whl": "d01b0578252d0df17b64400103ffbfea5b8332483a3da01d116f4919467128de", + "https://files.pythonhosted.org/packages/f3/7f/ab5f380507de1e1d1e1e20f8b64a241581a538d234ce3b66d3f74d4ed66a/ignore_python-0.3.3-cp310-cp310-musllinux_1_2_x86_64.whl": "12827fc970d57865b6f31a82f79838bea24b63d85d9f61f1821b7cf8bd01128c", + "https://files.pythonhosted.org/packages/f3/a1/e907e02bc252f8c7efdab0d317019741cb243dc5eb00bf07e6680f66096f/ignore_python-0.3.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "8528c819c151ccabbd1bb9e591dd99495c2d0423b10ccdef47d48fe25da2b2d6", + "https://files.pythonhosted.org/packages/f4/4a/37928a560a345c6efb207452cf81d3c14f25a6d83df0fa5a00752c0c912b/ignore_python-0.3.3.tar.gz": "dc80ac80ace112da6d02f44681b6beb2ccecb68d6ac2b5e1b82d7f84347e1cf6", + "https://files.pythonhosted.org/packages/f9/a5/2aee7c1997f904aa0fdf5e34471c6e3c5cd6254a55b9aa8c3f2e0a158353/ignore_python-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl": "49ccb834be168a7e72b104ffc02024d4eb4d91840bc30e2948787551f5242ff0", + "https://files.pythonhosted.org/packages/fc/15/728225102093748271ef402e8d9c1ad1cce823289de79e236ab436d64979/ignore_python-0.3.3-cp38-cp38-musllinux_1_2_aarch64.whl": "558f79f48d2cd7bd42ce3e6747752be9483504d3f2f84c1d39c39bcf1961bac3", + "https://files.pythonhosted.org/packages/fd/34/af959f5525f98f93167a2a06e70373a1532734f5f1fc716d9f885c9a6164/ignore_python-0.3.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "d4f8b4137b0153c95ea7e4e3acff4c5e6f6c25206d3e3b86dbe879658b00c927", + "https://files.pythonhosted.org/packages/ff/67/bd846385bc059e58cc2be198de8953fdfcf7417cced6bc9686b62a523338/ignore_python-0.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl": "cb82264552ae789f2d8c2543b3b1eb2b3e091977c22f180ab268f5b677825279" + }, "imagesize": { "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl": "5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz": "8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3" @@ -1876,6 +1992,10 @@ "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz": "d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl": "f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282" }, + "sphinx-mounts": { + "https://files.pythonhosted.org/packages/50/43/3ed3fafb632fd168a62f8b9a1b5edc00e000802961f81dd4e922df946e60/sphinx_mounts-0.1.0.tar.gz": "d1818e3a0b7e0b327c6fa265afcf4d43f5bb7147276e69a9b35cd046deeb9439", + "https://files.pythonhosted.org/packages/5e/a2/1318ed0af240e580d1b3c3f1e33708b8bb4fba91dfc0b3c9754b08ab337a/sphinx_mounts-0.1.0-py3-none-any.whl": "aff3450756727d0ca43538ea72b67fddb8b8799c52110f41de8871c59d3d1540" + }, "sphinx-needs": { "https://files.pythonhosted.org/packages/b9/dd/69c5d151e1b4d98e85371fdf8ce2c1fe497f4345f32e0429cec20442d4de/sphinx_needs-8.0.0-py3-none-any.whl": "540c380c074d4088a557ea353e91513bfc1cb7712b10925c13ac9e5ebb7be091", "https://files.pythonhosted.org/packages/e4/77/909f87ae766363e8a0bb3907f54af4a7a963538a7cdc7fe6d331e7098ed1/sphinx_needs-8.0.0.tar.gz": "c4336ee0e3c949eff9eb11a14910f7b6b68cb8284d731cfddf97694037337674" diff --git a/bzl/basics.bzl b/bzl/basics.bzl new file mode 100644 index 000000000..96734a310 --- /dev/null +++ b/bzl/basics.bzl @@ -0,0 +1,45 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +def join_path(prefix, rest): + """Compose two docname segments with `/`. + + Args: + prefix: Leading docname segment, possibly empty. + rest: Trailing docname segment, possibly empty. + + Returns: + The combined docname. + """ + if not prefix or prefix == ".": + return rest + if not rest: + return prefix + return prefix + "/" + rest + +def dirname(path): + idx = path.rfind("/") + return "" if idx < 0 else path[:idx] + +def glob_doc_sources(prefix): + """Return glob patterns for documentation sources below ``prefix``.""" + extensions = [ + "png", "svg", "md", "rst", "html", "css", + "puml", "need", "yaml", "json", "csv", "inc", + ] + if prefix == ".": + prefix = "" + elif prefix and not prefix.endswith("/"): + prefix += "/" + param = [prefix + "**/*." + ext for ext in extensions] + srcs = native.glob(param, allow_empty = True) + return srcs diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl new file mode 100644 index 000000000..b6d5f7e54 --- /dev/null +++ b/bzl/bundle_rules.bzl @@ -0,0 +1,343 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Internal Bazel support for composing reusable documentation bundles.""" + +# `docs_bundle` and `sphinx_docs_library` operate at a similar architectural level: +# both describe reusable, transitively composable collections of documentation sources +# that are later assembled into a Sphinx source tree. + +# However, their data models and responsibilities differ significantly. + +# `sphinx_docs_library` primarily models file placement. Each library contributes files +# together with a `strip_prefix` and a `prefix`, allowing the final Sphinx rule to map +# every source file to a new location in the generated source tree. + +# `docs_bundle` instead models documentation structure at the bundle level. In +# addition to the source files, it propagates information such as: + +# * where a bundle is mounted, * which document it is attached to, * which document acts +# as its entry point, * which repository owns its sources, * whether it is an internal +# or external bundle, * and how nested +# bundles are rebased when composed. + +# It also performs bundle-specific validation and conflict detection. The propagated unit +# is therefore not just a set of files with path transformations, but a structured +# documentation component with composition semantics. + +# Using `sphinx_docs_library` directly would not preserve the metadata required by this +# model. We would need a second provider alongside it and would still have to implement +# most of the bundle traversal, rebasing, validation, and composition logic ourselves. + +# Extending `sphinx_docs_library` is also not a good fit. Its provider represents +# individual file mappings, while our provider represents complete mounted bundles. Adding +# the required metadata would therefore not be a small extension of the existing +# abstraction; it would change its propagated unit and its semantics. It would also couple +# SCORE-specific composition rules to the generic `rules_sphinxdocs` implementation. + +# We therefore reimplement the relatively small overlapping part—transitive source +# collection—while keeping the richer bundle model explicit and independent. + +# The name `docs_bundle` reflects that relationship: it fills the same general role +# as `sphinx_docs_library`, but uses a SCORE-specific data model for composing structured +# documentation bundles. + + + +load("@score_docs_as_code//:bzl/basics.bzl", "join_path") + +# Internal data passed between bundle targets and eventually consumed by an +# adapter such as the Sphinx mounts manifest. Users configure bundles through +# `docs_bundle()` and `docs()`; they do not need to reference this provider. +DocsBundleInfo = provider( + doc = "A documentation bundle with its source and placement metadata.", + fields = { + "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", + "sourcelinks": "Source-code-link JSON files together with their owning repository.", + "external_runfiles": "Documentation source files from external repositories needed in runfiles.", + }, +) + +def _parent_index_docname(mount_at): + """Choose the page that links to a bundled subtree by default.""" + parent = mount_at.rsplit("/", 1)[0] if "/" in mount_at else "" + return join_path(parent, "index") + +def _validate_and_deduplicate_entries(entries): + """Keep one entry per source directory and reject conflicting metadata.""" + seen = {} + out = [] + for entry in entries: + key = entry.runtime_path + if key in seen: + differing_fields = [ + field + for field in ["mount_at", "attach_to", "entry_doc", "src_root", "external", "repository"] + if getattr(seen[key], field) != getattr(entry, field) + ] + if differing_fields: + fail(("bundle conflict: source directory %r has conflicting %s; " + + "a bundle must resolve to one complete placement declaration") % + (key, differing_fields)) + continue + seen[key] = entry + out.append(entry) + return out + +def _bundle_runtime_path(ctx): + """Return this bundle source directory's Bazel runtime path.""" + source_file = ctx.files.srcs[0].short_path + external_prefix = "" + if source_file.startswith("../"): + path_parts = source_file.split("/") + external_prefix = path_parts[0] + "/" + path_parts[1] + "/" + return external_prefix + ctx.attr.strip_prefix.rstrip("/") + +def _bundle_execroot_path(runtime_path): + """Return the execroot-relative spelling of an external runtime path.""" + if runtime_path.startswith("../"): + return "external/" + runtime_path[3:] + return runtime_path + +def _rebase_bundle_entry(entry, mount_at, attach_to, entry_doc): + """Place a bundle entry below a requested documentation-tree location.""" + is_bundle_root = not entry.mount_at + if is_bundle_root: + rebased_attach_to = attach_to or _parent_index_docname(mount_at) + else: + rebased_attach_to = join_path(mount_at, entry.attach_to) + + return struct( + runtime_path = entry.runtime_path, + src_root = entry.src_root, + mount_at = join_path(mount_at, entry.mount_at), + attach_to = rebased_attach_to, + entry_doc = entry_doc if is_bundle_root else entry.entry_doc, + external = entry.external, + repository = entry.repository, + ) + +def _entries_visible_through(ctx, child): + """Keep an external module's own docs, but not its foreign mounts.""" + entries = child[DocsBundleInfo].entries + child_repository = child.label.workspace_name + if child_repository == ctx.label.workspace_name: + return entries + return [entry for entry in entries if entry.repository == child_repository] + +def _sourcelinks_visible_through(ctx, child): + """Apply the same external-module boundary to traceability metadata.""" + sourcelinks = child[DocsBundleInfo].sourcelinks + child_repository = child.label.workspace_name + if child_repository == ctx.label.workspace_name: + return sourcelinks + return [link for link in sourcelinks if link.repository == child_repository] + +def _validate_docname(value, field_name, allow_empty = False): + """Validate a relative documentation docname used in a bundle declaration.""" + if type(value) != "string": + fail("%s must be a string, got %r" % (field_name, value)) + if not value: + if allow_empty: + return + fail("%s must not be empty" % field_name) + invalid_segments = [segment for segment in value.split("/") if not segment or segment in [".", ".."]] + if invalid_segments: + fail("%s must be a relative docname without empty, '.' or '..' segments; got %r" % + (field_name, value)) + +def _parse_bundle_declaration(bundle): + """Read one nested-bundle declaration and fill in optional values.""" + if type(bundle) != "dict": + fail("each bundle declaration must be a dict, got %r" % bundle) + + allowed_keys = ["bundle", "mount_at", "attach_to", "entry_doc"] + unknown = [key for key in bundle if key not in allowed_keys] + if unknown: + fail("unknown key(s) %r in %r; allowed keys: %r" % + (unknown, bundle, allowed_keys)) + if "bundle" not in bundle or "mount_at" not in bundle: + fail("each entry needs 'bundle' and 'mount_at'; got %r" % bundle) + + mount_at = bundle["mount_at"] + attach_to = bundle.get("attach_to", "") + entry_doc = bundle.get("entry_doc", "index") + _validate_docname(mount_at, "mount_at", allow_empty = True) + _validate_docname(attach_to, "attach_to", allow_empty = True) + _validate_docname(entry_doc, "entry_doc") + + return struct( + bundle = bundle["bundle"], + mount_at = mount_at, + attach_to = attach_to, + entry_doc = entry_doc, + ) + +def _docs_bundle_impl(ctx): + """Compose source files and nested bundles into a reusable bundle.""" + entries = [] + own_source_files = [] + own_external_runfiles = [] + + if ctx.files.srcs: + runtime_path = _bundle_runtime_path(ctx) + external = runtime_path.startswith("../") + entries.append(struct( + runtime_path = runtime_path, + # The execution root and runfiles tree spell external repositories + # differently. Keep both locations so every public docs() target can + # resolve them in its own context. + src_root = _bundle_execroot_path(runtime_path), + mount_at = "", + attach_to = "", + entry_doc = "index", + external = external, + repository = ctx.label.workspace_name, + )) + own_source_files.extend(ctx.files.srcs) + # Local sources are read directly from the workspace by ``bazel run``. + # Only sources from external repositories must be staged in runfiles. + if external: + own_external_runfiles.extend(ctx.files.srcs) + + child_source_files = [] + child_external_runfiles = [] + sourcelinks = [ + struct(file = source_link, repository = ctx.label.workspace_name) + for source_link in ctx.files.sourcelinks + ] + for index, child in enumerate(ctx.attr.bundles): + entries.extend([ + _rebase_bundle_entry( + entry, + ctx.attr.bundle_mount_ats[index], + ctx.attr.bundle_attach_tos[index], + ctx.attr.bundle_entry_docs[index], + ) + for entry in _entries_visible_through(ctx, child) + ]) + child_source_files.append(child[DefaultInfo].files) + child_external_runfiles.append(child[DocsBundleInfo].external_runfiles) + sourcelinks.extend(_sourcelinks_visible_through(ctx, child)) + + deduplicated_entries = _validate_and_deduplicate_entries(entries) + all_source_files = depset( + direct = own_source_files, + transitive = child_source_files, + ) + external_runfiles = depset( + direct = own_external_runfiles, + transitive = child_external_runfiles, + ) + return [ + DefaultInfo(files = all_source_files), + DocsBundleInfo( + entries = deduplicated_entries, + sourcelinks = sourcelinks, + external_runfiles = external_runfiles, + ), + ] + +_docs_bundle = rule( + implementation = _docs_bundle_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "sourcelinks": attr.label_list(allow_files = True), + "strip_prefix": attr.string(default = ""), + "bundles": attr.label_list(providers = [DocsBundleInfo]), + "bundle_mount_ats": attr.string_list(), + "bundle_attach_tos": attr.string_list(), + "bundle_entry_docs": attr.string_list(), + }, + doc = "Internal rule that carries bundle files and their documentation-tree locations.", +) + +def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", visibility = None, **kwargs): + """Create a reusable documentation bundle from files and child declarations.""" + parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] + _docs_bundle( + name = name, + srcs = srcs, + sourcelinks = sourcelinks, + strip_prefix = strip_prefix, + bundles = [bundle.bundle for bundle in parsed_bundles], + bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles], + bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles], + bundle_entry_docs = [bundle.entry_doc for bundle in parsed_bundles], + visibility = visibility, + **kwargs + ) + return ":" + name + +def _external_docs_runfiles_impl(ctx): + """Expose external documentation sources needed under ``bazel run``.""" + return [DefaultInfo(files = ctx.attr.bundle[DocsBundleInfo].external_runfiles)] + +_external_docs_runfiles = rule( + implementation = _external_docs_runfiles_impl, + attrs = { + "bundle": attr.label(providers = [DocsBundleInfo]), + }, + doc = "Internal adapter from a docs bundle to its runtime runfiles.", +) + +def external_docs_runfiles(name, bundle, visibility = None): + """Create a target containing only external bundle sources for ``bazel run``.""" + _external_docs_runfiles( + name = name, + bundle = bundle, + visibility = visibility, + ) + return ":" + name + +def _merge_bundle_sourcelinks_impl(ctx): + """Merge source-code links propagated by a documentation bundle.""" + sourcelinks = [link.file for link in ctx.attr.bundle[DocsBundleInfo].sourcelinks] + out = ctx.actions.declare_file(ctx.label.name + ".json") + args = ctx.actions.args() + args.add("--output", out.path) + if ctx.file.known_good: + args.add("--known_good", ctx.file.known_good.path) + args.add_all(sourcelinks) + inputs = [depset(sourcelinks)] + if ctx.file.known_good: + inputs.append(depset([ctx.file.known_good])) + ctx.actions.run( + executable = ctx.executable._merge_sourcelinks, + arguments = [args], + inputs = depset(transitive = inputs), + outputs = [out], + mnemonic = "MergeBundleSourcelinks", + ) + return [DefaultInfo(files = depset([out]))] + +_merge_bundle_sourcelinks = rule( + implementation = _merge_bundle_sourcelinks_impl, + attrs = { + "bundle": attr.label(providers = [DocsBundleInfo]), + "known_good": attr.label(allow_single_file = True), + "_merge_sourcelinks": attr.label( + default = Label("//scripts_bazel:merge_sourcelinks"), + cfg = "exec", + executable = True, + ), + }, +) + +def merge_bundle_sourcelinks(name, bundle, known_good = None, visibility = None): + """Create one source-code-link JSON file for a complete docs bundle.""" + _merge_bundle_sourcelinks( + name = name, + bundle = bundle, + known_good = known_good, + visibility = visibility, + ) diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl new file mode 100644 index 000000000..aa710795d --- /dev/null +++ b/bzl/mount_rules.bzl @@ -0,0 +1,52 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +Conversion of documentation bundles from Bazel into mount metadata. +""" + +load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo") + +def _mounts_manifest_impl(ctx): + """Generate the canonical Sphinx mount manifest.""" + entries = ctx.attr.bundle[DocsBundleInfo].entries + + json_mounts = [] + for entry in entries: + json_mounts.append({ + "src_root": entry.src_root, + "runtime_path": entry.runtime_path, + "mount_at": entry.mount_at, + "attach_to": entry.attach_to, + "entry_doc": entry.entry_doc, + "external": entry.external, + }) + + out = ctx.actions.declare_file(ctx.label.name + ".json") + ctx.actions.write(out, json.encode({"mounts": json_mounts})) + return [DefaultInfo(files = depset([out]))] + +_create_mounts_manifest = rule( + implementation = _mounts_manifest_impl, + attrs = { + "bundle": attr.label(providers = [DocsBundleInfo]), + }, + doc = "Writes a Sphinx mount manifest from reusable documentation bundles.", +) + +def create_mounts_manifest(name, bundle): + """Create a Sphinx mount manifest from reusable documentation bundles.""" + _create_mounts_manifest( + name = name, + bundle = bundle, + ) + return ":" + name diff --git a/docs.bzl b/docs.bzl index 30c0cdaa3..01f369848 100644 --- a/docs.bzl +++ b/docs.bzl @@ -44,56 +44,63 @@ Easy streamlined way for S-CORE docs-as-code. load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_venv") load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") load("@sphinxdocs//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") - -def _rewrite_needs_json_to_docs_sources(labels): - """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" - out = [] - for x in labels: - s = str(x) - if s.endswith(":needs_json"): - out.append(s.replace(":needs_json", ":docs_sources")) - else: - out.append(s) - return out - -def _rewrite_needs_json_to_sourcelinks(labels): - """Replace '@repo//:needs_json' -> '@repo//:sourcelinks_json' for every item.""" - out = [] - for x in labels: - s = str(x) - if s.endswith(":needs_json"): - out.append(s.replace(":needs_json", ":sourcelinks_json")) - #Items which do not end up with ':needs_json' shall not be appended to 'out'. - #They are treated separately and are not related to source code linking. - return out - -def _merge_sourcelinks(name, sourcelinks, known_good = None): - """Merge multiple sourcelinks JSON files into a single file. +load( + "@score_docs_as_code//:bzl/basics.bzl", + "glob_doc_sources", + "join_path", +) +load( + "@score_docs_as_code//:bzl/bundle_rules.bzl", + "create_bundle", + "merge_bundle_sourcelinks", + "external_docs_runfiles", +) +load( + "@score_docs_as_code//:bzl/mount_rules.bzl", + "create_mounts_manifest", +) + +def docs_bundle(name, source_dir = None, bundles = [], scan_code = [], visibility = None, **kwargs): + """A docs bundle, optionally composed of others. Args: - name: Name for the merged sourcelinks target - sourcelinks: List of sourcelinks JSON file targets + name: target name. + source_dir: optional directory holding this bundle's own doc sources. It is + globbed like `docs()` (same file kinds) and the contents are stored after + stripping the `source_dir` prefix. Leave it unset for a pure aggregator. + bundles: nested bundles to compose, each a dict + { + "bundle": , + "mount_at": , + "attach_to": + }. + scan_code: Source-code targets to scan for source-code links owned by this + bundle. + visibility: Target visibility. + **kwargs: Additional attributes forwarded to the underlying rule. """ - extra_srcs = [] - known_good_arg = "" - if known_good != None: - extra_srcs = [known_good] - known_good_arg = "--known_good $(location %s)" % known_good + srcs = glob_doc_sources(source_dir) if source_dir != None else [] + sourcelinks = [] + if scan_code: + sourcelinks_name = name + "_sourcelinks_json" + _sourcelinks_json(name = sourcelinks_name, srcs = scan_code) + sourcelinks = [":" + sourcelinks_name] - merge_sourcelinks_tool = Label("//scripts_bazel:merge_sourcelinks") + # Store the source directory relative to the workspace so bundle consumers + # can locate the original files without copying them. + pkg = native.package_name() + strip_prefix = join_path(pkg, source_dir) if source_dir != None else "" - native.genrule( + # The helper validates child declarations and creates the internal target. + create_bundle( name = name, - srcs = sourcelinks + extra_srcs, - outs = [name + ".json"], - cmd = """ - $(location {merge_sourcelinks_tool}) \ - --output $@ \ - {known_good_arg} \ - $(SRCS) - """.format(known_good_arg = known_good_arg, merge_sourcelinks_tool = merge_sourcelinks_tool), - tools = [merge_sourcelinks_tool], + srcs = srcs, + sourcelinks = sourcelinks, + strip_prefix = strip_prefix, + bundles = bundles, + visibility = visibility, + **kwargs ) def _missing_requirements(deps): @@ -127,7 +134,16 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources = [], known_good = None, metamodel = None): +def docs( + source_dir = "docs", + data = [], + deps = [], + scan_code = [], + test_sources = [], + known_good = None, + metamodel = None, + bundles = [], + ): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -142,17 +158,40 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources known_good: Optional label to a "known good" JSON file for source links. metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this file instead of the default metamodel shipped with score_metamodel. + bundles: List of placement dicts describing documentation bundles to overlay + into this documentation's source tree. Each entry is a dict + { + "bundle": , + "mount_at": , + "attach_to": , + "entry_doc": , + }. + Note: a bundle label may also point at another module's auto-exposed + bundle, e.g. "@score_process//:docs_bundle". """ - metamodel_data = [] - metamodel_env = {} - metamodel_opts = [] - if metamodel != None: - metamodel_data = [metamodel] - metamodel_env = {"SCORE_METAMODEL_YAML": "$(location " + str(metamodel) + ")"} - metamodel_opts = ["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"] + source_config = ":" + ("" if source_dir == "." else source_dir + "/") + "conf.py" + + # Convention in this macro: an optional Bazel label is named ``*_label`` + # but represented as a 0/1 list. This lets it be appended directly to + # list-valued attributes such as ``data`` and ``tools``. + metamodel_label = [metamodel] if metamodel else [] + + mounts_manifest_label = [] + if bundles: + mounts_bundle = create_bundle( + name = "_docs_mounts", + bundles = bundles, + visibility = ["//visibility:private"], + ) + + mounts_manifest_label = [ + create_mounts_manifest( + name = "_mounts_manifest", + bundle = mounts_bundle, + ) + ] - module_deps = deps deps = deps + _missing_requirements(deps) deps = deps + [ Label("//src:plantuml_for_python"), @@ -164,64 +203,56 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources sphinx_build_binary( name = "sphinx_build", visibility = ["//visibility:private"], - data = data + metamodel_data, + data = data + metamodel_label + [":docs_bundle"], deps = deps, ) - # If the source directory is the root (".") we must omit it, otherwise: - # > invalid glob pattern './**/*.png': segment '.' not permitted - if source_dir == ".": - source_prefix = "" - else: - source_prefix = source_dir + "/" - - native.filegroup( - name = "docs_sources", - srcs = native.glob([ - source_prefix + "**/*.png", - source_prefix + "**/*.svg", - source_prefix + "**/*.md", - source_prefix + "**/*.rst", - source_prefix + "**/*.html", - source_prefix + "**/*.css", - source_prefix + "**/*.puml", - source_prefix + "**/*.need", - source_prefix + "**/*.yaml", - source_prefix + "**/*.json", - source_prefix + "**/*.csv", - source_prefix + "**/*.inc", - ], allow_empty = True), + known_good_label = [known_good] if known_good else [] + # The public bundle carries both the complete source tree and the + # transitive source-code links of every nested bundle. + docs_bundle( + name = "docs_bundle", + source_dir = source_dir, + bundles = bundles, + scan_code = scan_code, visibility = ["//visibility:public"], ) + merge_bundle_sourcelinks( + name = "sourcelinks_json", + bundle = ":docs_bundle", + known_good = known_good, + ) - _sourcelinks_json(name = "sourcelinks_json", srcs = scan_code) + external_docs_runfiles( + name = "_external_docs_runfiles", + bundle = ":docs_bundle", + visibility = ["//visibility:private"], + ) - data_with_docs_sources = _rewrite_needs_json_to_docs_sources(data) - additional_combo_sourcelinks = _rewrite_needs_json_to_sourcelinks(data) - _merge_sourcelinks(name = "merged_sourcelinks", sourcelinks = [":sourcelinks_json"] + additional_combo_sourcelinks, known_good = known_good) - docs_data = data + metamodel_data + [":sourcelinks_json"] - combo_data = data_with_docs_sources + metamodel_data + [":merged_sourcelinks"] + # ``bazel run`` reads local documentation from the workspace. Passing the + # complete bundle here would add those files to runfiles and could collide + # with the executable target name (for example ``docs`` and ``docs/``). + # External bundles do need runfiles, so keep only those sources. + docs_data = data + metamodel_label + [":sourcelinks_json", ":_external_docs_runfiles"] + mounts_manifest_label docs_env = { "SOURCE_DIRECTORY": source_dir, "PACKAGE_DIR": native.package_name(), - "DATA": str(data), "TEST_SOURCES": str(test_sources), + "DATA": str(data), + # `bazel run` starts from a runfiles tree, so this logical path is + # resolved by score_mounts through ``RUNFILES_DIR``. + "MOUNTS_MANIFEST": "$(rlocationpath :_mounts_manifest)" if bundles else "", "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", - } | metamodel_env - docs_sources_env = { - "SOURCE_DIRECTORY": source_dir, - "PACKAGE_DIR": native.package_name(), - "DATA": str(data_with_docs_sources), - "TEST_SOURCES": str(test_sources), - "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", - } | metamodel_env - if known_good: - known_good_str = str(known_good) + } + if metamodel: + # The interactive ``py_binary`` targets run from a runfiles tree. + # incremental.py resolves this logical path through ``RUNFILES_DIR``. + docs_env["SCORE_METAMODEL_YAML"] = "$(rlocationpath " + str(metamodel) + ")" + if known_good_label: + known_good_str = str(known_good_label[0]) docs_env["KNOWN_GOOD_JSON"] = "$(location " + known_good_str + ")" - docs_sources_env["KNOWN_GOOD_JSON"] = "$(location " + known_good_str + ")" - docs_data.append(known_good) - combo_data.append(known_good) + docs_data += known_good_label docs_env["ACTION"] = "incremental" @@ -234,22 +265,6 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources env = docs_env ) - docs_sources_env["ACTION"] = "incremental" - py_binary( - name = "docs_combo", - tags = ["cli_help=Build full documentation with all dependencies:\nbazel run //:docs_combo"], - srcs = [incremental_src], - data = combo_data, - deps = deps, - env = docs_sources_env - ) - - native.alias( - name = "docs_combo_experimental", - actual = ":docs_combo", - deprecation = "Target '//:docs_combo_experimental' is deprecated. Use '//:docs_combo' instead.", - ) - docs_env["ACTION"] = "linkcheck" py_binary( name = "docs_link_check", @@ -280,16 +295,6 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources env = docs_env ) - docs_sources_env["ACTION"] = "live_preview" - py_binary( - name = "live_preview_combo_experimental", - tags = ["cli_help=Live preview full documentation with all dependencies in the browser:\nbazel run //:live_preview_combo_experimental"], - srcs = [incremental_src], - data = combo_data, - deps = deps, - env = docs_sources_env - ) - py_venv( name = "ide_support", tags = ["cli_help=Create virtual environment (.venv_docs) for documentation support:\nbazel run //:ide_support"], @@ -301,8 +306,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources sphinx_docs( name = "needs_json", - srcs = [":docs_sources"], - config = ":" + source_prefix + "conf.py", + srcs = [":docs_bundle"], + config = source_config, extra_opts = [ "-W", "--keep-going", @@ -310,12 +315,15 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources "--jobs", "auto", "--define=external_needs_source=" + str(data), + # ``sphinx_docs`` is a sandboxed build action, so it needs the + # action-input path rather than the runfiles-relative spelling. + "--define=mounts_manifest=" + ("$(location :_mounts_manifest)" if bundles else ""), "--define=score_sourcelinks_json=$(location :sourcelinks_json)", "--define=score_source_code_linker_plain_links=1", - ], + ] + (["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"] if metamodel else []), formats = ["needs"], sphinx = ":sphinx_build", - tools = data + [":sourcelinks_json"], + tools = data + metamodel_label + [":sourcelinks_json", ":docs_bundle"] + mounts_manifest_label, visibility = ["//visibility:public"], # Persistent workers cause stale symlinks after dependency version # changes, corrupting the Bazel cache. diff --git a/docs/concepts/bidirectional_traceability.rst b/docs/concepts/bidirectional_traceability.rst index 1df0065c3..ddd45b483 100644 --- a/docs/concepts/bidirectional_traceability.rst +++ b/docs/concepts/bidirectional_traceability.rst @@ -48,17 +48,18 @@ That other module's need elements will not have backlinks. At least not immediately. In a later revision they can update their dependency on the first module and then the references are updated in their documentation. -Build with copies +Build with mounts ~~~~~~~~~~~~~~~~~ .. code-block:: sh - bazel run //:docs_combo + bazel run //:docs -The documentation build does not depend on the needs.json but on whole documentation source code. +The documentation build mounts the whole documentation source tree declared through +``bundles`` instead of depending on a copied ``needs.json``. -Using `sphinx_collections `_ -not just the current module is built but all referenced modules are included. +Using `sphinx-mounts `_, the current +documentation source tree is extended with the declared bundles. The advantage is that the produced documentation is consistent and stays that way. There is no outwards hyperlink which could break or be outdated. diff --git a/docs/how-to/add_extensions.rst b/docs/how-to/add_extensions.rst index 54673bda8..6b7cb2790 100644 --- a/docs/how-to/add_extensions.rst +++ b/docs/how-to/add_extensions.rst @@ -12,6 +12,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +.. _howto_add_extensions: + Add Extensions =================== diff --git a/docs/how-to/other_modules.rst b/docs/how-to/other_modules.rst index a64c65568..07f40902b 100644 --- a/docs/how-to/other_modules.rst +++ b/docs/how-to/other_modules.rst @@ -19,8 +19,9 @@ This document explains how to enable cross-module (bi-directional) linking betwe In short: 1. Make the other module available to Bazel via the `MODULE` (aka `MODULE.bazel`) file. -2. Add the external module's documentation targets to your `docs(data=[...])` target so Sphinx can see the other module's built inventory. -3. Reference remote needs using the normal Sphinx-Needs referencing syntax. +2. Choose one integration mode: import its built ``needs.json`` **or** mount its + documentation sources. +3. Reference Needs using the normal Sphinx-Needs referencing syntax. Details and Example ------------------- @@ -37,8 +38,8 @@ A minimal example (add or extend the existing `bazel_deps` stanza): bazel_dep(name = "score_process", version = "1.5.3") -2) Extend your `docs` rule so Sphinx picks up the other module's inventory -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +2a) Import the other module's built inventory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The documentation build in this project is exposed via a Bazel macro/rule that accepts a `data` parameter. Add the external module's ``:needs_json`` target to that list @@ -58,6 +59,35 @@ Example `BUILD` snippet (consumer module): More details in :ref:`docs_bidirectional_traceability`. + +2b) Mount the external module's documentation bundle +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The documentation build in this project is exposed via a Bazel macro that accepts +a ``bundles`` parameter. Mount the external module's auto-exposed +``:docs_bundle`` bundle. The mounted sources define their needs in the host +build, so do **not** also add that module's ``:needs_json`` to ``data`` — doing +so would create duplicate need IDs. + +Example `BUILD` snippet (consumer module): + +.. code-block:: starlark + + load("@score_docs_as_code//:docs.bzl", "docs") + docs( + bundles = [ + { + "bundle": "@score_process//:docs_bundle", + "mount_at": "process", + "attach_to": "index", + }, + ], + source_dir = "docs", + ) + +See :ref:`howto_mount_external_sources` for the full mount reference, and +:ref:`docs_bidirectional_traceability` for more on cross-module linking. + 3) Reference needs across modules ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/internals/extensions/sync_toml.rst b/docs/internals/extensions/sync_toml.rst index 46ad26b91..9fddd890d 100644 --- a/docs/internals/extensions/sync_toml.rst +++ b/docs/internals/extensions/sync_toml.rst @@ -31,20 +31,18 @@ is needed to extract this information and make it available to the IDE extension The basic idea is to stay with the programmed configuration system for Sphinx and Sphinx-Needs as it exists in S-CORE, but use it to generate the ``ubproject.toml`` file. -The ``ubproject.toml`` file is generated into the directory holding the ``conf.py`` file -(called ``confdir`` in Sphinx) and should be checked into the version control system -alongside ``conf.py``. +A single ``ubproject.toml`` is generated at the **git repo root** — the one directory +that is an ancestor of every source tree, so IDE tooling walking up from any open file +(including files in mounted bundles, see :ref:`howto_mount_external_sources`) reaches it. +The output location is resolved via ``find_git_root()``, which works under ``bazel run`` +and esbonio / direct Sphinx alike; a sandboxed ``bazel build`` has no git root and its +copy is discarded. The ``ubproject.toml`` file is generated on each Sphinx build, so any changes to the -Sphinx-Needs configuration are automatically reflected in the generated file. -If changes are detected, a warning is emitted during the Sphinx build to remind the user -to commit the updated ``ubproject.toml`` file. Changes may occur because docs-as-code -updated a configuration or a new Sphinx-Needs version added configuration. - -Committing the generated ``ubproject.toml`` file allows the IDE extension to work -without requiring any Sphinx build to be performed first. For a fully complete network -of need items, required external or imported ``needs.json`` files must first be -generated by Bazel. +Sphinx-Needs configuration are automatically reflected in the generated file. Because it +is regenerated every build, it is currently gitignored rather than committed. For a +fully complete network of need items, required external or imported ``needs.json`` files +must first be generated by Bazel. The command line tool ``ubc`` uses the same configuration as ``ubCode`` and can be used to lint and format all RST files in any of the S-CORE documentations. diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 09554b505..5e15504c8 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -38,6 +38,10 @@ Minimal example (root ``BUILD``) # labels to any extra tools or data you want included # e.g. "//:needs_json" or other tool targets ], + bundles = [ + # dicts describing bundles to mount into this project + # e.g. {"bundle": "@score_process//:docs_bundle", "mount_at": "process"} + ], deps = [ # additional bazel labels providing Python deps or other runfiles ], @@ -49,9 +53,17 @@ Minimal example (root ``BUILD``) - ``data`` (list of bazel labels) Extra runfiles / data targets that should be made available to the documentation targets. - Typical entries are targets that generate or provide external data used by the docs, for - example a ``:needs_json`` producer. The items in ``data`` are added to the py_binaries and - to the Sphinx tooling so they are available at build time. + The items in ``data`` are added to the py_binaries and to the Sphinx tooling so they are + available at build time. + + .. note:: + + To pull in another module's needs for cross-referencing, add its + ``:needs_json`` target here. + +- ``bundles`` (list of placement dicts) + Documentation bundles to overlay into this project's documentation tree, each with its + placement (``mount_at``). See :ref:`howto_mount_external_sources` for the full reference. - ``deps`` (list of bazel labels) Additional Bazel dependencies to add to the Python binaries and the virtual environment @@ -85,10 +97,68 @@ Minimal example (root ``BUILD``) for extension processing. When ``metamodel`` is omitted the default metamodel is used unchanged. +.. _docs_bundle_macro: + +Bazel macro: ``docs_bundle`` +---------------------------- + +``docs_bundle`` (also from ``docs.bzl``) declares a **mountable documentation +bundle**: a chunk of RST/Markdown content that can be overlaid into a host +documentation project. A bundle carries only *content* — it has **no placement of its +own**. Where it appears is decided by whoever mounts it (a composing +``docs_bundle`` or the :ref:`docs(bundles=[...]) ` call +site). + +.. code-block:: python + + load("//:docs.bzl", "docs_bundle") + + docs_bundle( + name = "docs_dir", + source_dir = "docs", + bundles = [], + visibility = ["//visibility:public"], + ) + +Signature: ``docs_bundle(name, source_dir = None, bundles = [], scan_code = [], visibility = None)``. + +- ``source_dir`` (string, optional) + Directory holding the bundle's own doc sources. It is globbed the same way as + ``docs()`` (RST, Markdown, images, and the other doc file kinds). The + ``source_dir`` itself is the mount root, so the files mount relative to it (so + ``concept/index.rst`` with ``source_dir = "concept"`` becomes ``index.rst``). + The bundle exposes those files as a Bazel depset (via the ``DocsBundleInfo`` + provider) and records the ``source_dir`` path; sphinx-mounts walks that original + directory directly — no copy is made. Leave it unset for a pure aggregator that only + composes ``bundles``. + +- ``bundles`` (list of composition dicts, optional) + Nested bundles to compose into this one, so a bundle can aggregate other + bundles transitively. Each entry is a dict: + + - ``bundle`` — label of another ``docs_bundle`` target. + - ``mount_at`` — the docname prefix at which the child bundle appears *inside* + this bundle. + - ``attach_to`` (optional) — a docname (relative to this bundle) whose toctree + receives the child's entry document. + - ``entry_doc`` (optional, default ``"index"``) — the child-relative docname of + the entry document, used together with ``attach_to``. + + A child's ``mount_at``/``attach_to`` **prefix-stack** with the placement this + bundle later receives, so composition is fully transitive. The same underlying + bundle resolving to two different final ``mount_at`` values is a hard build + error. See :ref:`howto_mount_external_sources` for a worked example and + :ref:`docs_concept_mounts` for the composition and transitivity semantics. + +.. note:: + + A bundle is **placement-free**: its ``mount_at`` / ``attach_to`` / + ``entry_doc`` are assigned by the mounter, never by the bundle itself. This is + what lets the same bundle be mounted at different locations by different + consumers. + Edge cases ---------- - If your Sphinx ``conf.py`` expects files generated by other Bazel targets, make sure those targets are included in the ``data`` list so they are available to the build driver. -- The experimental "combo" targets rewrite some ``data`` labels for combined builds; those - are intended for advanced use and are optional for normal doc workflows. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 7ec765f4c..99026c8a7 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -23,10 +23,8 @@ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `bazel run //:docs` | Builds documentation (also writes `metrics.json`) | | `bazel run //:docs_check` | Verifies documentation correctness | -| `bazel run //:docs_combo` | Builds combined documentation with all external dependencies included | | `bazel run //:traceability_gate -- --metrics-json bazel-bin/needs_json/_build/needs/metrics.json --min-req-code 70 --min-req-test 70 --min-req-fully-linked 60 --min-tests-linked 70` | Reads the pre-computed metrics.json and fails if coverage thresholds are not met | | `bazel run //:live_preview` | Creates a live_preview of the documentation viewable in a local server | -| `bazel run //:live_preview_combo_experimental` | Creates a live_preview of the full documentation with all dependencies viewable in a local server | | `bazel run //:ide_support` | Sets up a Python venv for esbonio (Remember to restart VS Code!) | ## Internal targets (do not use directly) diff --git a/src/BUILD b/src/BUILD index 5f8dd1bef..aa9885ef8 100644 --- a/src/BUILD +++ b/src/BUILD @@ -38,7 +38,7 @@ score_pytest( "incremental_dirty_build_test.py", "incremental.py", ], - deps = all_requirements, + deps = all_requirements + ["//src/extensions/score_sphinx_bundle:score_sphinx_bundle"], pytest_config = "//:pyproject.toml", ) @@ -50,6 +50,7 @@ filegroup( "//src/extensions/score_draw_uml_funcs:all_sources", "//src/extensions/score_layout:all_sources", "//src/extensions/score_metamodel:all_sources", + "//src/extensions/score_mounts:all_sources", "//src/extensions/score_source_code_linker:all_sources", "//src/extensions/score_sphinx_bundle:all_sources", "//src/extensions/score_sync_toml:all_sources", diff --git a/src/extensions/score_mounts/BUILD b/src/extensions/score_mounts/BUILD new file mode 100644 index 000000000..7033526d7 --- /dev/null +++ b/src/extensions/score_mounts/BUILD @@ -0,0 +1,55 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("@docs_as_code_hub_env//:requirements.bzl", "requirement") +load("//:score_pytest.bzl", "score_pytest") + +filegroup( + name = "sources", + srcs = glob(["*.py"]), +) + +filegroup( + name = "tests", + srcs = glob(["tests/*.py"]), +) + +filegroup( + name = "all_sources", + srcs = [ + ":sources", + ":tests", + ], + visibility = ["//visibility:public"], +) + +py_library( + name = "score_mounts", + srcs = [":sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = [ + requirement("sphinx"), + requirement("sphinx-mounts"), + "//src/helper_lib", + ], +) + +score_pytest( + name = "score_mounts_tests", + size = "small", + srcs = glob(["tests/*.py"]), + deps = [":score_mounts"], + pytest_config = "//:pyproject.toml", +) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py new file mode 100644 index 000000000..c9a3dc3af --- /dev/null +++ b/src/extensions/score_mounts/__init__.py @@ -0,0 +1,130 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +""" +Bridge extension: consume the mounts manifest authored by Bazel rules and feed it to +``sphinx_mounts``. + +All mount paths originate from Bazel; this extension performs no path computation. It: + +* sets ``config.mounts`` so ``sphinx_mounts`` can build the documentation; +``score_sync_toml`` reads the resulting ``config.mounts`` directly to write the +generated ``ubproject.toml``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx.util import logging + +from src.extensions.score_mounts._resolver import ( + load_mounts_manifest, + resolve_walk_dir, +) +from src.helper_lib import find_ws_root, get_runfiles_dir + +logger = logging.getLogger(__name__) + + +def _read_manifest(config: Config): + """Locate and load the mounts manifest, or return ``None`` when unset. + + The manifest path is passed by Bazel either via the ``mounts_manifest`` config + value or the ``MOUNTS`` env var. Its interpretation depends on the build + context: under ``bazel run`` it is a runfiles-relative path + (``$(rlocationpath)``) resolved against the runfiles dir; in a sandbox build + it is relative to the exec root (``$(location)``). Resolving the path here + keeps that context branch out of the pure ``_resolver`` module. + """ + raw = getattr(config, "mounts_manifest", None) or os.environ.get( + "MOUNTS_MANIFEST", None + ) + if not raw or not raw.strip() or not isinstance(raw, str): + return None + + # ``bazel run`` passes an rlocation-relative path; ``sphinx_docs`` in a + # sandbox passes its execroot-relative ``$(location)`` path directly. + manifest_path = get_runfiles_dir() / raw if find_ws_root() else Path(raw) + + return load_mounts_manifest(manifest_path) + + +def _on_config_inited(app: Sphinx, config: Config) -> None: + """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. + + Runs on Sphinx's ``config-inited`` event (before ``sphinx_mounts``, see the + priority in ``setup``). For each mount it resolves the directory + ``sphinx_mounts`` should walk and writes the assembled list to + ``config.mounts``. A missing or empty manifest is a no-op. + """ + manifest = _read_manifest(config) + if manifest is None or not manifest.mounts: + return + + # In every context sphinx_mounts walks the bundle's original files (no copy is + # made); only where those files are staged differs: + # * external bundle: use its runfiles-relative location under ``bazel run`` + # and its execroot-relative location in a sandboxed Bazel build. + # * in-tree bundle under `bazel run`: use the live workspace source + # (ws_root/src_root) -- editable, best for live preview / jump-to-def. + # * in-tree bundle in a sandbox build: the bundle's source files are staged + # as inputs at their exec-root-relative path. The manifest lives under + # bazel-out/ and is NOT colocated with them, so src_root is resolved + # against the exec root (the sphinx action's cwd), not the manifest. + ws_root = find_ws_root() + + runtime_mounts: list[dict[str, object]] = [] + for spec in manifest.mounts: + walk_dir = resolve_walk_dir(manifest, spec, ws_root) + if not walk_dir.is_dir(): + raise ValueError( + "score_mounts: resolved mount dir does not exist: " + f"{walk_dir} (mount_at={spec.mount_at})" + ) + + runtime_mounts.append( + { + "dir": str(walk_dir), + "mount_at": spec.mount_at, + "attach_to": spec.attach_to, + "entry_doc": spec.entry_doc, + } + ) + + config.mounts = runtime_mounts + # Prevent sphinx_mounts._on_load_toml from overwriting our config with a + # possibly-stale docs/ubproject.toml entry. + config.mounts_from_toml = None + + logger.info("score_mounts: registered %d mount(s)", len(runtime_mounts)) + + +def setup(app: Sphinx) -> dict[str, object]: + """Sphinx extension entry point: register the config value and event hook. + + ``mounts_manifest`` carries the Bazel-resolved manifest path. The + ``config-inited`` handler is connected at priority 300 (< 400) so it runs + before ``sphinx_mounts._on_load_toml`` and can override the mount config the + latter would otherwise load from ``ubproject.toml``. + """ + app.add_config_value("mounts_manifest", default="", rebuild="env", types=(str,)) + app.connect("config-inited", _on_config_inited, priority=300) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py new file mode 100644 index 000000000..e4787d407 --- /dev/null +++ b/src/extensions/score_mounts/_resolver.py @@ -0,0 +1,106 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Load the mounts manifest JSON emitted by the ``_mounts_manifest`` Bazel rule. + +All mount paths are authored by Bazel (where ``File`` objects have real paths) +and shipped in a small JSON manifest. This module only *reads* that manifest — it +performs no label-to-path reconstruction. Every path stored in the manifest is a +Bazel ``short_path`` and therefore resolves relative to the manifest's own +directory.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class MountSpec: + src_root: str + runtime_path: str + mount_at: str + attach_to: str | None = None + entry_doc: str = "index" + external: bool = False + + +@dataclass(frozen=True) +class MountsManifest: + manifest_path: Path + mounts: list[MountSpec] + + @property + def root(self) -> Path: + """Directory the manifest lives in — the base for its short_path entries.""" + return self.manifest_path.parent + + def runtime_dir(self, spec: MountSpec) -> Path: + """Absolute path of a bundle's staged mount-root directory. + + ``runtime_path`` is a Bazel ``short_path`` (the bundle's source + directory, staged in place — not a copy) and resolves relative to the + manifest's own directory. Main-repo sources resolve the same way inside a + sandbox build. External sources (``../+/X``) are only ever walked + under ``bazel run`` with an external bundle, where ``../`` steps out + of ``_main`` to the sibling repo dir in the runfiles tree — so no + context-dependent mapping is needed here. + """ + return Path(os.path.abspath(str(self.root / spec.runtime_path))) + + +def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: + """Read the manifest JSON at ``manifest_path`` (an already-resolved path). + + Context-dependent path resolution (runfiles under ``bazel run`` vs. the + exec root in a sandbox) is the caller's responsibility. + """ + manifest_path = Path(manifest_path) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError( + f"mounts manifest must be a JSON object, got {type(data).__name__}: {data!r}" + ) + mounts: list[MountSpec] = [] + for entry in data.get("mounts", []): + if "src_root" not in entry or "mount_at" not in entry: + raise ValueError( + f"mounts manifest entry missing 'src_root'/'mount_at': {entry!r}" + ) + mounts.append( + MountSpec( + src_root=entry["src_root"], + runtime_path=entry.get("runtime_path", ""), + mount_at=entry["mount_at"], + attach_to=entry.get("attach_to") or None, + entry_doc=entry.get("entry_doc") or "index", + external=entry.get("external", False), + ) + ) + return MountsManifest( + manifest_path=manifest_path, + mounts=mounts, + ) + + +def resolve_walk_dir( + manifest: MountsManifest, spec: MountSpec, ws_root: Path | None +) -> Path: + """Resolve a mount directory for either ``bazel run`` or a sandbox build.""" + if spec.external and ws_root is not None: + return manifest.runtime_dir(spec) + if ws_root is not None: + return ws_root / spec.src_root + return Path(os.path.abspath(spec.src_root)) diff --git a/src/extensions/score_mounts/docs/BUILD b/src/extensions/score_mounts/docs/BUILD new file mode 100644 index 000000000..561b235de --- /dev/null +++ b/src/extensions/score_mounts/docs/BUILD @@ -0,0 +1,30 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs_bundle") + +# The mount feature's own documentation, mounted back into the site via the +# mount feature itself (dogfooding). One subdirectory per bundle: mounts are +# directory-scoped, so co-locating both files in one dir would graft both at +# each mount point. +docs_bundle( + name = "concept", + source_dir = "concept", + visibility = ["//visibility:public"], +) + +docs_bundle( + name = "howto", + source_dir = "howto", + visibility = ["//visibility:public"], +) diff --git a/src/extensions/score_mounts/docs/concept/index.rst b/src/extensions/score_mounts/docs/concept/index.rst new file mode 100644 index 000000000..afabbe947 --- /dev/null +++ b/src/extensions/score_mounts/docs/concept/index.rst @@ -0,0 +1,263 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +.. _docs_concept_mounts: + +====================================== +Mounts: which directory Sphinx walks +====================================== + +Mounting another documentation bundle into this project's Sphinx tree grafts the +*contents of one directory* into the doc tree. This page explains which directory +that is — and why it is always the bundle's **original files**, never a copy. + +For the user-facing "how do I mount another module" guide, see +:ref:`howto_mount_external_sources`. + +Mounting overlays a directory +============================= + +**Mounting** is the placement/overlay operation: at runtime ``sphinx_mounts`` +takes the contents of one directory and grafts them into the doc tree at the +bundle's ``mount_at`` (optionally attached under ``attach_to``). A ``docs_bundle`` +with a ``source_dir`` contributes exactly that directory. + +A ``source_dir`` bundle **is already a mount-ready directory**: its files sit on +disk under ``source_dir`` in the layout they should mount in. So the bundle makes +no copy — it only records the *mount root* (the ``source_dir`` path as a Bazel +``short_path``) and lets ``sphinx_mounts`` walk the real files. Sphinx therefore +always operates on the originals, which keeps live preview and jump-to-definition +pointing at editable source. + +Transitive composition +======================= + +A ``docs_bundle`` exposes its content through the ``DocsBundleInfo`` provider, +whose ``entries`` field is an **ordered list** of placed content entries. +When a bundle composes children (via ``bundles = [...]``), each child's entries +are appended in declaration order and re-based under the child's ``mount_at``. +A mounter therefore sees one flat, ordered list regardless of how deeply the +graph nests. + +**Placement composes by prefix-stacking.** A bundle is placement-free; its own +root takes the placement it is mounted with, and every nested entry gets the +enclosing ``mount_at`` (and ``attach_to``) prefixed onto its own. A child mounted +at ``mount_at = "child"`` inside a parent that is later mounted at +``internals/code_docs`` resolves to ``internals/code_docs/child``. Resolution is +independent of nesting depth. + +**One bundle, one placement.** After the graph is flattened, the same underlying +bundle directory resolving to two different final ``mount_at`` values is a hard +build error (``mount conflict … a bundle must resolve to a single mount_at``). +The same directory at the *same* ``mount_at`` is simply deduplicated. + +**Composition stops at external module boundaries.** A module may mount another +module for its own documentation build. When a third module mounts the first, +it receives the first module's source tree and in-repo bundles, but not foreign +modules the first one mounted. Consumers opt in to every external module +explicitly, keeping ownership and collision handling predictable. + +For data-only integration, **needs stay one ``needs.json`` per module**. A +consumer that imports another module's ``needs.json`` does not mount its sources; +cross-module references then use the external-needs mechanism. A consumer that +mounts a bundle instead builds the mounted sources and Need directives as part +of its own Sphinx project. + +Which directory gets walked +=========================== + +The runtime resolver (``score_mounts``) picks the directory per mount — always an +original source directory, differing only in *where* that directory is staged: + +.. code-block:: python + + if spec.external and ws_root: # bazel run: sibling repo in the runfiles tree + walk_dir = manifest.runtime_dir(spec) + elif ws_root is not None: # bazel run, in-tree: the live workspace source + walk_dir = ws_root / spec.src_root + else: # sandbox, in-tree: source staged at the exec root + walk_dir = Path(os.path.abspath(spec.src_root)) + +* ``ws_root`` is only set under ``bazel run`` (it points at + ``BUILD_WORKSPACE_DIRECTORY``); in a sandboxed ``bazel build`` it is ``None``. +* ``external`` marks a bundle whose sources come from another Bazel module. +* The manifest (a ``bazel-out`` artifact) is colocated with the sources only in the + runfiles tree; in a sandbox the in-tree sources are resolved against the exec + root instead, which is why the branch splits three ways. + +So the mount walks: + +* the **live workspace source** under ``bazel run`` with an in-tree bundle (edits + show up immediately — best for live preview and jump-to-definition); +* the **in-place staged inputs** in a sandbox build (``needs_json``), where only + the bundle's globbed files are present at their ``source_dir`` path; +* the **staged sibling-repo directory** for an external bundle under + ``bazel run`` (``../+/source_dir`` in the runfiles tree), or its + ``external/+/source_dir`` execroot path in a sandboxed build. + +In all three cases the payload is the untouched source directory; a stray file +that is not doc source (e.g. a ``conf.py``) is simply ignored by Sphinx. + +Without an external bundle declaration, a dependency is referenced via its +prebuilt ``needs.json`` and its sources are not walked at all. + +.. _docs_concept_mounts_rematerialize: + +Re-introducing materialization later +==================================== + +Earlier versions copied each bundle into a normalized ``declare_directory`` at +build time ("materialization"). That was dropped because a ``source_dir`` bundle +is *already* a mount-ready directory — the copy was a byte-for-byte duplicate of a +directory Bazel stages anyway. This section records how to bring materialization +back should the bundle model grow beyond whole-``source_dir`` inputs. + +.. note:: + + Materialization should be a **last resort**. Before re-adding a build-time + copy, investigate whether ``sphinx_mounts`` can already express the need + directly — e.g. a **file/glob mode** or **include/exclude filtering** on the + mount itself. Filtering a subset or picking individual files at the mount layer + avoids duplicating the tree and keeps Sphinx pointed at the originals; prefer + that over reintroducing a copy action. + +**When it becomes necessary.** Materialization earns its keep only when a bundle +is *not* already one ready-to-mount directory on disk *and* ``sphinx_mounts`` +cannot select the payload itself: + +* **File globs / a filtered subset** — the bundle is an explicit list of files + rather than a whole directory, so no single existing directory holds exactly the + payload. +* **A custom ``strip_prefix``** that differs from the files' on-disk layout — the + mount-relative paths then exist only in a rewritten tree. +* **Provider-supplying rules** — a rule that emits ``DocsBundleInfo`` for content + it *generates* (no stable on-disk source directory to point at). + +**Sketch of the mechanism** (intentionally high level — flesh out when needed): + +#. In the bundle rule, ``ctx.actions.declare_directory(name)`` and a copy action + assemble the normalized, mount-relative tree from ``ctx.files.srcs``. +#. The content entry then carries that directory ``File`` (alongside, or instead + of, ``runtime_path``); the emitted ``runtime_path`` points at it. +#. A materialized directory is a ``bazel-out`` artifact colocated with the manifest + in every context, so it resolves via ``manifest.runtime_dir`` — the same + manifest-relative rule the external branch already uses. That sidesteps the + exec-root vs. runfiles split the in-tree source walk has to handle, which is + the resolver-simplicity that materialization used to buy. + +**Provider contract.** A content entry must let the runtime resolve a directory to +walk. Today that is ``runtime_path`` (a source-directory ``short_path``) plus +``src_root`` (the live in-tree path, empty for external). A materialized entry +would instead (or additionally) carry a directory ``File`` whose ``short_path`` +serves as ``runtime_path``. Either representation is valid as long as +the mount-entry deduplication has a stable identity key and ``_mounts_manifest`` can emit a +``runtime_path``. + + +The problem +----------- + +The S-CORE documentation toolchain has historically assumed that every +RST/Markdown file under a Sphinx project lives under its source +directory (``docs/`` in this repository). Two situations break that +assumption: + +* **Generated content** — RST produced by a Bazel rule lands under + ``bazel-bin/...`` and is therefore outside ``docs/`` by construction. + Examples: API reference tables generated from code, requirement + catalogues exported from upstream modules, traceability matrices. + +* **In-repo content owned by another tree** — for example, README-style + documentation that lives next to its source code under ``src/`` and + must remain there for code-ownership reasons but should still appear + in the rendered docs site. + +Historical workarounds either (a) copied or symlinked the files into +``docs/`` — which loses the original source location for IDE +navigation, complicates ``git blame``, and risks stale copies — or +(b) materialized an entire merged source tree at build time and +pointed Sphinx at that. The latter solves the build-side problem but +keeps Sphinx on the IDE critical path. Useful editing in an IDE +requires validation **as you type**, and that is hard to achieve from +any tool without live knowledge of every file and dependency in the +project. Sphinx is built for batch document processing, not for the +millisecond-latency feedback an editor needs; routing IDE feedback +through it therefore caps the editing experience at the speed and +scope of the next rebuild. + + +What ``sphinx-mounts`` does +--------------------------- + +`sphinx-mounts`_ is a Sphinx extension that registers external source +trees with Sphinx's project map by **absolute path**, without copying +or staging. The original files stay exactly where they live; Sphinx +reads them from there. Configuration is declarative TOML in +``ubproject.toml``, the file already shared with Sphinx-Needs, +sphinx-codelinks, and ubCode. + +.. _sphinx-mounts: https://sphinx-mounts.useblocks.com/ + +The key consequence: **every consumer reads the same file**. ubCode, +language servers, indexers, and CI gates can all parse +``ubproject.toml`` to discover where a project's RST sources live — +including the mounted ones — without ever invoking Sphinx. That +preserves the IDE editing experience (real-time validation, jump-to- +definition pointing at the real source, schema-aware autocomplete) +while still letting Sphinx produce the published HTML. + + +Why this matters for IDE support +-------------------------------- + +ubCode (and similar tooling) walks **up** the directory tree from an +open ``.rst`` / ``.md`` file to find the nearest ``ubproject.toml``, +treats that directory as the project root, and reads the file to +learn the type system, link types, layouts, and field defaults the +project uses. A file inside ``docs/`` and a file inside a **mounted** +bundle (for example, ``src/docs/overview.rst``) live in different +subtrees, so no single ``ubproject.toml`` placed *inside* either tree +is visible from the other. + +To close this gap, the toolchain emits a **single** ``ubproject.toml`` +at the **git repo root** — the one directory that is an ancestor of +both ``docs/`` and every in-repo bundle. The walk-up from any source +file therefore reaches it. Because it is the only config file, it +carries the host's full type system *and* the ``[[mounts]]`` entries; +there are no sanitized per-bundle copies to keep in sync. + + +Comparison with the materialization approach +-------------------------------------------- + ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Concern | Materialize-then-Sphinx | sphinx-mounts (this approach) | ++=======================================+=======================================+=======================================+ +| IDE feedback latency | bounded by next Sphinx rebuild | direct file access via TOML | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| As-you-type validation | not feasible (Sphinx is a batch tool) | works on real files directly | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Live preview | autobuild-based | ``sphinx-autobuild`` works as-is | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| "Go to definition" lands in | the materialized copy under bazel-bin | the real source file | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| ``conf.py`` execution required for IDE| yes | no — TOML is enough | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Sandbox-friendly Bazel build | yes | yes | ++---------------------------------------+---------------------------------------+---------------------------------------+ + +The two approaches are not mutually exclusive — a materialized-tree +rule can coexist if a downstream consumer needs it. But sphinx-mounts +is the lighter-weight surface and the primary entry point for new +bundles. diff --git a/src/extensions/score_mounts/docs/howto/index.rst b/src/extensions/score_mounts/docs/howto/index.rst new file mode 100644 index 000000000..649cf4b61 --- /dev/null +++ b/src/extensions/score_mounts/docs/howto/index.rst @@ -0,0 +1,322 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +.. _howto_mount_external_sources: + +Mounting external source bundles +================================ + +This guide explains how to surface RST or Markdown content that lives +**outside** ``docs/`` into the docs-as-code build. + +.. contents:: + :local: + :depth: 2 + +Declaring a bundle with ``docs_bundle`` +--------------------------------------- + +A mountable bundle carries only **content**: the source files. It is a +Bazel target created with the ``docs_bundle`` rule from ``docs.bzl``, +declared next to the bundle's sources: + +.. code-block:: starlark + + # src/BUILD + load("//:docs.bzl", "docs_bundle") + + docs_bundle( + name = "docs_dir", + source_dir = "docs", + visibility = ["//visibility:public"], + ) + +Each attribute: + +* ``source_dir`` — directory holding the bundle's own doc sources. It is + globbed the same way as ``docs()`` (RST, Markdown, images, and the + other doc file kinds). The ``source_dir`` itself *is* the mount root, so + the files mount relative to it (``docs/index.rst`` becomes ``index.rst``). + The bundle exposes those files as a Bazel depset (via the + ``DocsBundleInfo`` provider) and records the ``source_dir`` path; + sphinx-mounts walks that original directory directly — no copy is made. + +The bundle carries **no placement** — where it appears in a host project +is decided by the *consumer*, so the same bundle can be mounted at +different locations by different consumers (see the next section). + +Placement at the consumer: ``docs(bundles=[...])`` +--------------------------------------------------- + +The ``docs()`` macro's ``bundles`` argument is a list of placement dicts. +Each dict pairs a bundle label with where it goes in *this* project: + +.. code-block:: starlark + + load("//:docs.bzl", "docs") + + docs( + bundles = [ + { + "bundle": "//src:docs_dir", + "mount_at": "internals/code_docs", + "attach_to": "internals/index", + }, + ], + source_dir = "docs", + ) + +Each placement key: + +* ``bundle`` — label of a ``docs_bundle`` target (in-repo or from another + module, see :ref:`cross-repo mounts `). + +* ``mount_at`` — the docname prefix at which the bundle appears in the + host project. With ``mount_at = "internals/code_docs"``, a bundle file + ``overview.rst`` is reachable in the host as the docname + ``internals/code_docs/overview``. + +* ``attach_to`` (optional) — a host docname whose toctree should + automatically receive the bundle's entry document. With + ``attach_to = "internals/index"``, the bundle's ``index`` doc is + appended to the first toctree in ``docs/internals/index.rst`` at build + time; that host doc does not need a manual entry. + +* ``entry_doc`` (optional, default ``"index"``) — the mount-relative + docname of the bundle's entry document, used together with + ``attach_to``. + +The bundle's ``dir`` in the generated ``ubproject.toml`` is **derived +automatically** from the source file paths — there is no ``src_root`` +attribute. + +Every project that uses ``docs()`` also **auto-exposes its own** +``source_dir`` as a public bundle named ``docs_bundle``. No extra +wiring is needed: because a consumer's ``docs()`` call *is* this macro, +``@//:docs_bundle`` exists for free and can be mounted elsewhere. + +Composing bundles +----------------- + +A ``docs_bundle`` may itself mount other bundles through its ``bundles`` +argument, so one bundle can aggregate a whole sub-tree of content. Each entry +uses the same placement keys as ``docs(bundles=[...])`` (``bundle``, ``mount_at``, +optional ``attach_to`` / ``entry_doc``), but the placement is *relative to the +composing bundle* rather than to a host project: + +.. code-block:: starlark + + docs_bundle( + name = "guide", + source_dir = "guide", + bundles = [ + {"bundle": "//some/pkg:api_docs", "mount_at": "reference", "attach_to": "index"}, + ], + ) + +Here ``guide`` bundles ``api_docs`` under ``reference`` and attaches its entry +doc to ``guide``'s own ``index``. ``guide`` is still **placement-free**: when a +consumer mounts ``guide`` at, say, ``mount_at = "internals/code_docs"``, the two +placements **prefix-stack**. The nested ``api_docs`` then resolves to +``internals/code_docs/reference`` in the host, and its ``attach_to`` resolves to +``internals/code_docs/index``. Composition is therefore fully transitive: a +bundle nested any number of levels deep lands at the concatenation of every +``mount_at`` above it. + +If the **same** underlying bundle resolves to two different final ``mount_at`` +values (for example, mounted both directly and again via a composing bundle), the +build fails hard with a ``mount conflict … a bundle must resolve to a single +mount_at`` error — duplicating a bundle's pages would collide docnames and need +IDs. See :ref:`docs_concept_mounts` for the composition semantics. + +.. _cross_repo_mounts: + +Cross-repo mounts +----------------- + +A bundle label in ``bundles`` may point at another Bazel module's +auto-exposed bundle. For example, this repository mounts the process +description — already a dependency and itself a ``docs()`` user — with: + +.. code-block:: starlark + + bundles = [ + {"bundle": "@score_process//:docs_bundle", "mount_at": "process", "attach_to": "index"}, + ] + +Mounting an external bundle also mounts the Need directives authored in that +module's sources. Do not add the same module's ``:needs_json`` to ``data``: +that would import a second copy of every Need and Sphinx-Needs rejects the +duplicate IDs. Use ``data = ["@module//:needs_json"]`` only for a JSON-only +dependency whose documentation sources are not mounted. If that module mounts +further external modules for its own site, those are not re-exported; mount each +such module explicitly when it is wanted in this project. + +For an **external** bundle the sources do not exist in the consumer's +git tree; they live under ``bazel-*/external/+/…``. The ``dir`` +in ``ubproject.toml`` therefore points at the staged source directory under +``bazel-bin/external/+/…`` — the same pattern already used for +external ``needs.json`` (``json_path = "bazel-bin/external/…"``). +"Go to definition" for such a mount lands in that read-only staged module +tree, not in an editable source file. In-repo bundles keep pointing at their +real, editable sources. + + +How the wiring works +-------------------- + +The pieces fit together like this: + +.. code-block:: text + + docs_bundle(...) in a BUILD ← bundle: files depset + mount root + │ (DocsBundleInfo provider, content only) + ▼ + docs(bundles = [{"bundle": "//src:docs_dir", "mount_at": ...}]) + │ placement lives at the call site + ▼ + docs.bzl: _mounts_manifest rule ← reads the providers + placement, + derives each bundle's source dir from + the file paths, and emits ONE canonical + JSON manifest + │ + ▼ + sphinx-build (mounts_manifest = manifest path) + │ + ▼ + score_mounts extension ← reads the manifest and configures runtime + mounts plus neutral metadata: + │ + ┌───┴────┐ + ▼ ▼ + sphinx_mounts score_sync_toml + walks the dir derives TOML paths, serializes and merges /ubproject.toml + +The key inversion from earlier iterations: **Bazel is the single +source of truth for mount paths**. All paths are computed in the rule, +where ``File`` objects have real paths, instead of being reconstructed +from label strings at Sphinx runtime. + +After a successful ``bazel run //:docs_check``, the repo-root +``ubproject.toml`` contains a mount entry like: + +.. code-block:: toml + + mounts = [ + { dir = "src/docs", mount_at = "internals/code_docs", attach_to = "internals/index" }, + ] + +The ``dir`` value points at the bundle's **real source location** +(here, ``src/docs/`` — derived automatically from the bundle's source +files), not at any bazel-bin path. It is relative to the +``ubproject.toml`` location (the git root). ubCode and similar tools +that follow this mount entry therefore navigate to the original files; +jump-to-definition and ``git blame`` work as the author wrote them. + +This block is what every external consumer of the project (ubCode, +sphinx-build, CI) reads to discover the bundle. + +``score_sync_toml`` serializes the structured entries to TOML and passes that +temporary fragment to ``needs-config-writer``, the same writer that emits the +rest of the project's type system. Bazel never emits TOML. + +Building from Bazel +~~~~~~~~~~~~~~~~~~~ + +Relevant targets wired by the ``docs()`` macro: + +* ``bazel run //:docs`` — incremental HTML build for day-to-day + editing; outputs to ``_build/``. Resolves mounts via runfiles + (fast, dev-local). + +* ``bazel run //:docs_check`` — same as above but with the ``check`` + action; also regenerates the repo-root ``ubproject.toml``. Run this + after editing the mount list or to refresh the IDE-facing TOML. + +* ``bazel build //:needs_json`` — sandboxed needs-only build. Verifies + that mounted bundles resolve correctly without ``bazel run``. + + +A single ``ubproject.toml`` at the git root +-------------------------------------------- + +The whole project — host and every mounted bundle — is described by +**one** ``ubproject.toml`` at the git repo root. ``needs-config-writer`` +relativizes every path field against the output file's directory, so +anchoring the file at the root makes ``external_needs`` JSON paths, the +mount ``dir`` values, and schema paths all root-relative and valid for +any consumer that reads them from there. + +The output location is set by ``score_sync_toml`` to +``find_git_root() / "ubproject.toml"``. ``find_git_root()`` resolves the +repo root both under ``bazel run`` and under esbonio / direct Sphinx +(via a working-directory fallback), so the file lands at the root in +every IDE-facing context. In a sandboxed ``bazel build`` there is no +git root; the writer falls back to the confdir default and that copy is +discarded with the sandbox. + +The file is gitignored (``/ubproject.toml``): it is regenerated on +every build and is not a source artifact. + + +Caveats and known limitations +----------------------------- + +* **External-repository bundles read the staged bazel-bin tree.** Bundles + from another Bazel module are supported (see :ref:`cross_repo_mounts`), but + their ``dir`` points at the staged ``bazel-bin/external/…`` source tree + rather than at editable sources, and that tree only exists after a + build. + +* **One source directory per bundle.** A bundle's content is a single + ``source_dir``; that directory is exactly the mount-relative tree + sphinx-mounts walks, so there is no cross-directory layout to reconcile. + +* **Standard confdir assumption.** Anchoring at the git root assumes + the git root is an ancestor of every source tree (host and bundles), + which holds for the standard layout. A repo whose sources live + outside its git tree would need a different anchor. + + +Cross-bundle references work +---------------------------- + +A need authored inside a mounted bundle can be linked from anywhere in +the host project, just like a need authored in ``docs/`` itself. This +page *is* a mounted bundle, so we dogfood it directly: the stakeholder +requirement below is authored right here, in the mounted score_mounts +how-to bundle, yet the host-side ``tool_req__docs_mount_traceability`` +(in ``docs/internals/requirements/``) carries a ``:satisfies:`` link +straight to it. + +The link resolves at host build time with no copy or materialisation, +and is enforced by ``sphinx-needs`` schema validation. That +cross-boundary link uses only stock relations from +``score_metamodel`` (``tool_req`` may satisfy ``stkh_req`` without any +metamodel extension): the bundle owns its own ``.rst`` and lives next +to its code, but its needs participate in the host's traceability +graph as first-class citizens. + + +Further reading +--------------- + +* `sphinx-mounts documentation`_ — full configuration reference, + TOML schema, behaviour of ``attach_to`` and ``entry_doc``. +* `ubCode`_ — the IDE extension that reads ``ubproject.toml``. +* :ref:`howto_add_extensions` — how to plug other Sphinx extensions into + the docs-as-code build. + +.. _sphinx-mounts documentation: https://sphinx-mounts.useblocks.com/ +.. _ubCode: https://ubcode.useblocks.com/ diff --git a/src/extensions/score_mounts/tests/__init__.py b/src/extensions/score_mounts/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py new file mode 100644 index 000000000..969b795d1 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -0,0 +1,182 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Unit tests for the mounts manifest loader (``_resolver``). + +These cover the pure parsing layer only: reading the JSON manifest into +``MountSpec`` objects, applying defaults, resolving ``runtime_dir`` relative +to the manifest, and rejecting malformed +input. Context-dependent path resolution (runfiles vs. exec root) lives in the +extension's ``__init__`` and is exercised via the consumer tests instead.""" + +import json +import os +from pathlib import Path + +import pytest + +from src.extensions.score_mounts._resolver import ( + MountSpec, + load_mounts_manifest, + resolve_walk_dir, +) + + +def _write_manifest(tmp_path: Path, payload: dict) -> Path: + manifest = tmp_path / "_mounts_manifest.json" + manifest.write_text(json.dumps(payload), encoding="utf-8") + return manifest + + +def test_load_single_entry(tmp_path: Path): + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "src/docs", + "runtime_path": "src/docs_dir", + "mount_at": "internals/code_docs", + } + ], + }, + ) + result = load_mounts_manifest(str(manifest)) + assert result is not None + assert result.mounts == [ + MountSpec( + src_root="src/docs", + runtime_path="src/docs_dir", + mount_at="internals/code_docs", + ) + ] + + +def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path): + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "src/docs", + "runtime_path": "src/docs_dir", + "mount_at": "x", + "attach_to": "internals/index", + "entry_doc": "start", + } + ], + }, + ) + spec = load_mounts_manifest(str(manifest)).mounts[0] + assert spec.attach_to == "internals/index" + assert spec.entry_doc == "start" + + +def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path): + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "src/docs", + "runtime_path": "src/docs_dir", + "mount_at": "x", + }, + { + "src_root": "external/score_process+/docs_as_mount", + "runtime_path": "../score_process+/docs_as_mount", + "mount_at": "process", + "external": True, + }, + ], + }, + ) + specs = load_mounts_manifest(str(manifest)).mounts + assert specs[0].src_root == "src/docs" + assert specs[1].src_root == "external/score_process+/docs_as_mount" + assert specs[1].external is True + + +def test_runtime_dir_resolves_next_to_manifest(tmp_path: Path): + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "src/docs", + "runtime_path": "src/docs_dir", + "mount_at": "x", + } + ], + }, + ) + result = load_mounts_manifest(str(manifest)) + assert result.runtime_dir(result.mounts[0]) == tmp_path / "src" / "docs_dir" + + +def test_load_missing_required_key_raises(tmp_path: Path): + manifest = _write_manifest(tmp_path, {"mounts": [{"runtime_path": "src/docs_dir"}]}) + with pytest.raises(ValueError, match="missing 'src_root'/'mount_at'"): + load_mounts_manifest(str(manifest)) + + +def test_load_non_object_raises(tmp_path: Path): + manifest = tmp_path / "_mounts_manifest.json" + manifest.write_text('["not", "an", "object"]', encoding="utf-8") + with pytest.raises(ValueError, match="must be a JSON object"): + load_mounts_manifest(str(manifest)) + + +def test_runtime_dir_external_path_resolves_relative_to_manifest(tmp_path: Path): + # Under `bazel run`, the '../+/...' short_path resolves natively in + # the runfiles tree. runtime_dir must NOT remap '../' to 'external/'. + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "external/score_process+/docs_as_mount", + "runtime_path": "../score_process+/docs_as_mount", + "mount_at": "process", + "external": True, + } + ], + }, + ) + result = load_mounts_manifest(str(manifest)) + # runtime_dir uses os.path.abspath (lexical, no symlink resolution); mirror + # that here so the assertion never diverges on a symlinked tmp dir. + expected = Path( + os.path.abspath(tmp_path / ".." / "score_process+" / "docs_as_mount") + ) + assert result.runtime_dir(result.mounts[0]) == expected + + +def test_external_mount_uses_execroot_path_in_sandbox(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "external/score_process+/docs_as_mount", + "runtime_path": "../score_process+/docs_as_mount", + "mount_at": "process", + "external": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + assert resolve_walk_dir(load_mounts_manifest(manifest), spec, None) == ( + tmp_path / "external" / "score_process+" / "docs_as_mount" + ) diff --git a/src/extensions/score_plantuml.py b/src/extensions/score_plantuml.py index d3d2854cb..3484f83af 100644 --- a/src/extensions/score_plantuml.py +++ b/src/extensions/score_plantuml.py @@ -24,6 +24,7 @@ In addition it sets common PlantUML options, like output to svg_obj. """ +import subprocess from pathlib import Path from sphinx.application import Sphinx @@ -52,6 +53,30 @@ def find_correct_path(runfiles: Path) -> Path: return runfiles / module / "src" / "plantuml" +def check_graphviz(app: Sphinx) -> None: + """Report a missing Graphviz dependency before rendering any diagrams.""" + + # Ensure plantuml only for HTML builder + if "html" not in app.builder.name: + return + + result = subprocess.run( + [app.config.plantuml, "-version"], + capture_output=True, + check=False, + text=True, + ) + output = (result.stdout + result.stderr).strip() + + if "Dot executable does not exist" in output: + logger.error( + "PlantUML requires Graphviz, but its 'dot' executable is not " + "available on PATH. Install the 'graphviz' package in the " + "development environment.\n\nPlantUML output:\n" + output + ) + raise SystemExit(1) + + def setup(app: Sphinx): # we must overwrite the plantuml path due to Bazel app.config.plantuml = str(find_correct_path(get_runfiles_dir())) @@ -60,6 +85,7 @@ def setup(app: Sphinx): config_setdefault(app.config, "needs_build_needumls", "_plantuml_sources") logger.debug(f"PlantUML binary found at {app.config.plantuml}") + app.connect("builder-inited", check_graphviz) # The extension is not even active at runtime. return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/src/extensions/score_source_code_linker/helpers.py b/src/extensions/score_source_code_linker/helpers.py index 9ab9dbfe0..b6cd7cc46 100644 --- a/src/extensions/score_source_code_linker/helpers.py +++ b/src/extensions/score_source_code_linker/helpers.py @@ -45,7 +45,7 @@ def get_github_link( git_root = find_git_root() or Path() # Local path (//:docs) return get_github_link_from_git(git_root, link) - # Ref-Integration path (//:docs_combo..) + # Reference-integration path (mounted external documentation) return get_github_link_from_json(metadata, link) diff --git a/src/extensions/score_source_code_linker/needlinks.py b/src/extensions/score_source_code_linker/needlinks.py index fb099e14a..2998240fc 100644 --- a/src/extensions/score_source_code_linker/needlinks.py +++ b/src/extensions/score_source_code_linker/needlinks.py @@ -217,8 +217,7 @@ def load_source_code_links_json(file: Path) -> list[NeedLink]: Returns: [NeedLink, NeedLink, ...] - This normally should be the one called in combo builds - => :docs_combo_experimental target + This is used when mounted external documentation contributes source links. """ if not file.is_absolute(): # use env variable set by Bazel diff --git a/src/extensions/score_source_code_linker/xml_parser.py b/src/extensions/score_source_code_linker/xml_parser.py index c40443ce6..194c2651f 100644 --- a/src/extensions/score_source_code_linker/xml_parser.py +++ b/src/extensions/score_source_code_linker/xml_parser.py @@ -229,7 +229,7 @@ def read_test_xml_file( testcasename = testcase.get("name", "") testclassname = testcase.get("classname", "") assert testclassname or testcasename, ( - f"Testcase: {testcase} does not have a 'name' or 'classname' attribute." + f"One testcase in {file} does not have a 'name' or 'classname' attribute." "One of which is mandatory. This should not happen, something is wrong." ) if testclassname: diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index c34d2907e..9058f698f 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -28,6 +28,7 @@ py_library( "@score_docs_as_code//src/extensions/score_draw_uml_funcs", "@score_docs_as_code//src/extensions/score_layout", "@score_docs_as_code//src/extensions/score_metamodel", + "@score_docs_as_code//src/extensions/score_mounts", "@score_docs_as_code//src/extensions/score_source_code_linker", "@score_docs_as_code//src/extensions/score_metrics", "@score_docs_as_code//src/extensions/score_sync_toml", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index ded7ea095..0c2296ade 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -24,6 +24,11 @@ "score_metamodel", "sphinx_design", "myst_parser", + # sphinx_mounts provides the mount machinery; score_mounts feeds it the + # Bazel-emitted mount config and must be loaded right after it (its + # config-inited hook runs before sphinx_mounts' own, see score_mounts.setup). + "sphinx_mounts", + "score_mounts", "score_source_code_linker", "score_draw_uml_funcs", "score_layout", diff --git a/src/extensions/score_sync_toml/BUILD b/src/extensions/score_sync_toml/BUILD index 34ba6584d..ffbbd38c4 100644 --- a/src/extensions/score_sync_toml/BUILD +++ b/src/extensions/score_sync_toml/BUILD @@ -12,6 +12,7 @@ # ******************************************************************************* load("@aspect_rules_py//py:defs.bzl", "py_library") +load("//:score_pytest.bzl", "score_pytest") load("@docs_as_code_hub_env//:requirements.bzl", "requirement") filegroup( @@ -31,5 +32,14 @@ py_library( deps = [ requirement("sphinx"), requirement("needs-config-writer"), + "//src/helper_lib", ], ) + +score_pytest( + name = "score_sync_toml_tests", + size = "small", + srcs = ["test_mounts.py"], + deps = [":score_sync_toml"], + pytest_config = "//:pyproject.toml", +) diff --git a/src/extensions/score_sync_toml/__init__.py b/src/extensions/score_sync_toml/__init__.py index 42709b218..610f0e8d2 100644 --- a/src/extensions/score_sync_toml/__init__.py +++ b/src/extensions/score_sync_toml/__init__.py @@ -14,7 +14,8 @@ from sphinx.application import Sphinx -from src.helper_lib import config_setdefault +from src.extensions.score_sync_toml._mounts import register_mounts +from src.helper_lib import config_setdefault, find_git_root def setup(app: Sphinx) -> dict[str, str | bool]: @@ -24,8 +25,16 @@ def setup(app: Sphinx) -> dict[str, str | bool]: See https://needs-config-writer.useblocks.com """ - config_setdefault(app.config, "needscfg_outpath", "ubproject.toml") - """Write to the confdir directory.""" + # Emit a single ubproject.toml at the git repo root, where UI extensions + # (ubCode / esbonio) look for it. needs-config-writer relativizes every path + # field against the output file's directory, so anchoring the file at the + # root yields root-relative paths automatically. find_git_root() resolves the + # root under `bazel run` and esbonio alike; in a sandbox build it returns None + # and we fall back to the confdir default (that copy is ephemeral / discarded). + git_root = find_git_root() + outpath = str(git_root / "ubproject.toml") if git_root else "ubproject.toml" + config_setdefault(app.config, "needscfg_outpath", outpath) + """Write a single ubproject.toml at the git repo root.""" config_setdefault(app.config, "needscfg_overwrite", True) """Any changes to the shared/local configuration updates the generated config.""" @@ -33,7 +42,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: config_setdefault(app.config, "needscfg_write_all", True) """Write full config, so the final configuration is visible in one file.""" - config_setdefault(app.config, "needscfg_exclude_defaults", True) + config_setdefault(app.config, "needscfg_exclude_defaults", False) """Exclude default values from the generated configuration.""" # This is disabled for right now as it causes a lot of issues @@ -46,6 +55,13 @@ def setup(app: Sphinx) -> dict[str, str | bool]: ) """Merge the static TOML file into the generated configuration.""" + # score_mounts resolves Bazel's JSON manifest during ``config-inited``. Run + # afterwards and serialize its structured entries here, alongside the rest + # of the needs-config-writer configuration. + app.connect( + "config-inited", lambda app, config: register_mounts(config), priority=500 + ) + app.config.needscfg_relative_path_fields.extend( [ "needs_external_needs[*].json_path", diff --git a/src/extensions/score_sync_toml/_mounts.py b/src/extensions/score_sync_toml/_mounts.py new file mode 100644 index 000000000..d84cf3412 --- /dev/null +++ b/src/extensions/score_sync_toml/_mounts.py @@ -0,0 +1,70 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Serialize Bazel-derived mount metadata for ``needs-config-writer``.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import Any + +from sphinx.config import Config + +from src.helper_lib import find_git_root, get_runfiles_dir + + +def _toml_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _toml_dir(entry: dict[str, Any]) -> str: + """Derive a stable TOML path from a resolved ``config.mounts`` entry.""" + walk_dir = Path(entry["dir"]).resolve() + git_root = find_git_root() + if git_root is not None: + try: + return str(walk_dir.relative_to(git_root)) + except ValueError: + pass + + try: + external_path = walk_dir.relative_to(get_runfiles_dir()) + except ValueError: + return str(walk_dir) + if external_path.parts[0] == "_main": + return str(Path(*external_path.parts[1:])) + return "bazel-bin/external/" + str(external_path) + + +def materialize_mounts(entries: list[dict[str, Any]]) -> Path | None: + """Write resolved mounts as a temporary, Git-root-relative TOML merge file.""" + if not entries: + return None + lines: list[str] = [] + for entry in entries: + lines.extend( + [ + "[[mounts]]", + f"dir = {_toml_string(_toml_dir(entry))}", + f"mount_at = {_toml_string(entry['mount_at'])}", + ] + ) + if entry.get("attach_to"): + lines.append(f"attach_to = {_toml_string(entry['attach_to'])}") + if entry.get("entry_doc", "index") != "index": + lines.append(f"entry_doc = {_toml_string(entry['entry_doc'])}") + lines.append("") + outdir = Path(tempfile.mkdtemp(prefix="score_sync_toml_")) + fragment = outdir / "score_mounts.toml" + fragment.write_text("\n".join(lines), encoding="utf-8") + return fragment + + +def register_mounts(config: Config) -> None: + """Merge configured mount entries into the generated ``ubproject.toml``.""" + fragment = materialize_mounts(config.mounts) + if fragment is not None: + config.needscfg_merge_toml_files.append(str(fragment)) diff --git a/src/extensions/score_sync_toml/test_mounts.py b/src/extensions/score_sync_toml/test_mounts.py new file mode 100644 index 000000000..f057e2aa8 --- /dev/null +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -0,0 +1,65 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from src.extensions.score_sync_toml import _mounts +from src.extensions.score_sync_toml._mounts import materialize_mounts + + +def test_materialize_mounts_serializes_structured_entries(): + fragment = materialize_mounts( + [ + { + "dir": 'docs/with "quotes"', + "mount_at": "guide", + "attach_to": "index", + "entry_doc": "start", + } + ] + ) + + assert fragment is not None + assert fragment.read_text(encoding="utf-8") == ( + "[[mounts]]\n" + 'dir = "docs/with \\"quotes\\""\n' + 'mount_at = "guide"\n' + 'attach_to = "index"\n' + 'entry_doc = "start"\n' + ) + + +def test_materialize_mounts_omits_default_fields(): + fragment = materialize_mounts( + [{"dir": "docs", "mount_at": "guide", "attach_to": None, "entry_doc": "index"}] + ) + + assert fragment is not None + assert ( + fragment.read_text(encoding="utf-8") + == '[[mounts]]\ndir = "docs"\nmount_at = "guide"\n' + ) + + +def test_materialize_mounts_maps_external_runfiles_path_to_bazel_bin( + tmp_path, monkeypatch +): + runfiles_dir = tmp_path / "runfiles" + walk_dir = runfiles_dir / "score_process+" / "process" + walk_dir.mkdir(parents=True) + monkeypatch.setattr(_mounts, "find_git_root", lambda: None) + monkeypatch.setattr(_mounts, "get_runfiles_dir", lambda: runfiles_dir) + + fragment = materialize_mounts( + [ + { + "dir": str(walk_dir), + "mount_at": "process", + } + ] + ) + + assert fragment is not None + assert fragment.read_text(encoding="utf-8") == ( + '[[mounts]]\ndir = "bazel-bin/external/score_process+/process"\nmount_at = "process"\n' + ) diff --git a/src/incremental.py b/src/incremental.py index 0519dff63..edcd9c73c 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -26,6 +26,9 @@ main as sphinx_autobuild_main, # type: ignore[reportUnknownVariableType] # sphinx_autobuild doesn't provide complete type annotations ) +from src.extensions.score_mounts._resolver import load_mounts_manifest, resolve_walk_dir +from src.helper_lib import find_ws_root, get_runfiles_dir + logger = logging.getLogger(__name__) _MODULE_HASH_FILE = ".module_bazel_hash" @@ -71,6 +74,18 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: (build_dir / _MODULE_HASH_FILE).write_text(_compute_hash(sentinel_files)) +def _mounted_watch_dirs(manifest_path: Path, ws_root: Path | None) -> list[str]: + """Return the directories provided by docs bundles for ``sphinx-autobuild``. + + This deliberately uses the same manifest and path-resolution rules as the + ``score_mounts`` extension. The extension consumes the paths during a + Sphinx build; autobuild needs them separately to notice edits that happen + outside the primary Sphinx source directory. + """ + manifest = load_mounts_manifest(manifest_path) + return [str(resolve_walk_dir(manifest, spec, ws_root)) for spec in manifest.mounts] + + if __name__ == "__main__": parser = argparse.ArgumentParser() # Add debuging functionality @@ -124,13 +139,22 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: "auto", f"--define=external_needs_source={get_env('DATA')}", f"--define=testcase_source_dirs={os.environ.get('TEST_SOURCES', '[]')}", + # Path to the Bazel-emitted mounts manifest (empty when no mounts are + # configured); consumed by the score_mounts extension. + f"--define=mounts_manifest={os.environ.get('MOUNTS_MANIFEST', '')}", ] metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") if metamodel_yaml: - # Normalize to absolute path so it resolves correctly after Sphinx changes cwd + # ``docs`` passes a runfiles-relative path under ``bazel run``. Keep + # the workspace-relative fallback for direct invocations. if not os.path.isabs(metamodel_yaml): - metamodel_yaml = str(ws_root / metamodel_yaml) + runfiles_dir = os.environ.get("RUNFILES_DIR", "") + metamodel_yaml = str( + (Path(runfiles_dir) / metamodel_yaml) + if runfiles_dir + else (ws_root / metamodel_yaml) + ) metamodel_yaml = os.path.abspath(metamodel_yaml) base_arguments.append(f"--define=score_metamodel_yaml={metamodel_yaml}") @@ -150,6 +174,18 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: action = get_env("ACTION") if action == "live_preview": (build_dir / "score_source_code_linker_cache.json").unlink(missing_ok=True) + mounts_manifest = os.environ.get("MOUNTS_MANIFEST", "") + watch_arguments: list[str] = [] + if mounts_manifest: + # ``MOUNTS_MANIFEST`` is runfiles-relative under ``bazel run`` and + # an ordinary path for direct invocations, matching score_mounts. + manifest_path = ( + get_runfiles_dir() / mounts_manifest + if find_ws_root() + else Path(mounts_manifest) + ) + for watch_dir in _mounted_watch_dirs(manifest_path, find_ws_root()): + watch_arguments.extend(["--watch", watch_dir]) sphinx_autobuild_main( base_arguments + [ @@ -157,6 +193,7 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: "--define=skip_rescanning_via_source_code_linker=1", f"--port={args.port}", ] + + watch_arguments ) else: if action == "incremental": diff --git a/src/incremental_dirty_build_test.py b/src/incremental_dirty_build_test.py index 8c3252a9e..d59e6837a 100644 --- a/src/incremental_dirty_build_test.py +++ b/src/incremental_dirty_build_test.py @@ -13,11 +13,12 @@ # Unit Tests of incremental.py +import json from pathlib import Path from pyfakefs.fake_filesystem import FakeFilesystem as FFS -from incremental import clean_builddir_if_stale, update_module_hash +from incremental import _mounted_watch_dirs, clean_builddir_if_stale, update_module_hash _BUILD = Path("/build") _MODULE = Path("/MODULE.bazel") @@ -112,3 +113,33 @@ def test_missing_hash_file_triggers_clean(fs: FFS) -> None: clean_builddir_if_stale(_BUILD, [_MODULE]) assert not _BUILD.exists() + + +def test_mounted_watch_dirs_match_sphinx_mount_paths(tmp_path: Path) -> None: + manifest_path = tmp_path / "_mounts_manifest.json" + manifest_path.write_text( + json.dumps( + { + "mounts": [ + { + "src_root": "extensions/local/docs", + "runtime_path": "extensions/local/docs", + "mount_at": "local", + }, + { + "src_root": "external/vendor+/docs", + "runtime_path": "../vendor+/docs", + "mount_at": "external", + "external": True, + }, + ] + } + ), + encoding="utf-8", + ) + workspace = tmp_path / "workspace" + + assert _mounted_watch_dirs(manifest_path, workspace) == [ + str(workspace / "extensions/local/docs"), + str(manifest_path.parent.parent / "vendor+" / "docs"), + ] diff --git a/src/requirements.in b/src/requirements.in index 04317ea12..09ec79ae2 100644 --- a/src/requirements.in +++ b/src/requirements.in @@ -30,6 +30,9 @@ needs-config-writer == 0.2.4 # use this for a specific commit for fast development iterations # needs-config-writer @ https://github.com/useblocks/needs-config-writer/archive/032a5f8.zip +# Mount external source trees into the Sphinx project without copying. +sphinx-mounts + # Need this to enable non bazel execution bazel-runfiles diff --git a/src/requirements.txt b/src/requirements.txt index 191ba1bf6..b309a1d45 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -520,6 +520,122 @@ idna==3.15 \ # via # anyio # requests +ignore-python==0.3.3 \ + --hash=sha256:000ae12f6187791bc4748e40e7073b9769ca6ddb0cabfc11d1c682ca0e6a3953 \ + --hash=sha256:05ccc5bdf2ad1f840a0a2296efff5f4761a26f015185ed0bb835ad1901f08ee8 \ + --hash=sha256:0a6b2d7900ce82acd61ab6714b10103c37d0a1c16ee7af46fb6e94d14a2e4dca \ + --hash=sha256:0c9408949156bf4ae07e60d5c8f356114be1482dc8f708ecd5785151e9ae21fa \ + --hash=sha256:0c9ea5d81e6b1a1284f5463203f57cffb3c7e391c0d8ce3fe0e4d838621367fb \ + --hash=sha256:0dfa531bbf65f45f9a233e2809f8b0a49aa9078686dbb0e4f3e30848e09cc208 \ + --hash=sha256:0f0edb622f5a8b7f735e14a7ca13d4cb7ca04b6fd7e844f5fa0fd9f86622b986 \ + --hash=sha256:0f50dd3c3f0ef982e256d9e702b44c1d81cbe0da2d98746d5b189bc1fd5191cd \ + --hash=sha256:1206189e1c988a3d0fb7de3298e5f7dd5284b4a23781878fb8f9f6224659b270 \ + --hash=sha256:1225e6210e302e0725265504a11a367f0b8cb882e3e2e231748559d5b05179c8 \ + --hash=sha256:12827fc970d57865b6f31a82f79838bea24b63d85d9f61f1821b7cf8bd01128c \ + --hash=sha256:130d9ace07988b69669e871ed84dc77088d7b5ce35265efbb0b0d425085e2a99 \ + --hash=sha256:152c5aecf42e709138c8e3da2ac733d00f5efdcd004c240a95193e62b6bd024e \ + --hash=sha256:17749af58a6fe6aaa3198b285c874fbf0f036ef2cb684225ef62288b62948d26 \ + --hash=sha256:1ac4491082df61d370f7fc087d5c0b16bc84b647e126e3f45a97d31cf3b2f514 \ + --hash=sha256:1b23770925db422fe9c94920da82e0606516aca09fd851880bc8c8ed68fe6455 \ + --hash=sha256:1b2ff29dbe59bbbd370feacb99e5bec7791abe730dd535b5db848de5b5210d7e \ + --hash=sha256:1ed9c8c858dfe2ba91bc4ab60ebd9d12dd4602a4d0d555e0fee9c8621f9ca292 \ + --hash=sha256:22c216e3130077060eb4cce99a8bf79074826655bbea6c594d36d8a0735fac6c \ + --hash=sha256:275f5b3e4c5b25fcb58ad1eedd983a346866939a6140e564b9c56ee9f8fc7760 \ + --hash=sha256:2af502d988282cc360094dc7b2733a7b68e54a8e3cf0128178ab1ba84f9ec290 \ + --hash=sha256:2b608a30d3505720f9aceaba7d6d26769867d71c212fc5c8a80fae1a8afec663 \ + --hash=sha256:2d637f1a6b2ec7bcb74b2ac11f75e15eaf6092535b8dc31415305a73a3762e3e \ + --hash=sha256:3001adecf33250dfa90c6e727affd559b31ad3b32b8519920c14bef53410444f \ + --hash=sha256:356b783877c7e88eba69c99bcc5e2d9fb07c2fe54f2c64e03897b7ae2adce2a7 \ + --hash=sha256:3623ce12eb96976c0db36a0ad99c65c669fdafece71e7221a538f12fa6fa41ef \ + --hash=sha256:365ab0bf94b64f3def2264fdca58f6e9811763830ad1a48a41d70574a496e5b9 \ + --hash=sha256:39e7b9d976c12c68b09b9944328a000b012725f1f7c4655510eb890761016fb3 \ + --hash=sha256:3b6536698628af08b6db260d338b41e7c48b2a6c5c93b12de4d1ecafc1bb86ae \ + --hash=sha256:3c40627f3bde32a37e75950b97733b586e9167fd545c958195dbf1bb73d96a81 \ + --hash=sha256:3da9e102800f162468ddb5d2d392b79e2481d3709e886f875037ef7066df5481 \ + --hash=sha256:4252f803f3cdae6c8775f1e905f5babd6e77860781f4c29cf798452ee5fd6936 \ + --hash=sha256:429a9b792afdca7dd9cff59f5ace44c2f55d01fc30b0ce3d28d63bee223116ed \ + --hash=sha256:44bf617535f5ead500a6f83178426f7c607e015bcf9e614e18ae88aa3de1a340 \ + --hash=sha256:477bb090189b79b8a81753c74a59ccb6c0ebf91985f30e926177f062c8616d05 \ + --hash=sha256:48cf09c7668b5b8bb7dcaf185618130b8ea7839df0eb0f6ae4fae7b7162a0c37 \ + --hash=sha256:49ccb834be168a7e72b104ffc02024d4eb4d91840bc30e2948787551f5242ff0 \ + --hash=sha256:4aac18bf9346f06fd17412d5bcfca53090a72456f53f3ffdf3b1e896f15cc356 \ + --hash=sha256:4ba31985a790af71bc45c16643d4773b829b8233acf4175bba2af466a2bbc959 \ + --hash=sha256:4f88aa2ee7335399c823aa050edec118e28ddafe7859e0db4093de0ca815467e \ + --hash=sha256:4f8c85a6738c632abd477c217297f8929ec1cafb261b94fa05a7fcf28095d70f \ + --hash=sha256:5055772fa6f09148a09e6958770c4e4f4435f6e3de33476f815af9c5db13e29c \ + --hash=sha256:5134be429fb954ec589857dbc6152dc09014902313e3d2293af9338a4b7799ae \ + --hash=sha256:558f79f48d2cd7bd42ce3e6747752be9483504d3f2f84c1d39c39bcf1961bac3 \ + --hash=sha256:56e8f00aef89a19b0305cb233fb59736ad14aa8c6db05720b13ac0bca811b5e7 \ + --hash=sha256:5756229d699ec5ab8caf4cec3ce9827d02145059c79a2416f363e2df4406d378 \ + --hash=sha256:593679d7714b4228d7e8c3bda0005badb9f0d4cb37cd8dda8385ef734e675e23 \ + --hash=sha256:59e26bf7bbbd5937a196f01480050d3d80418ade8933164b28e3e36734baecd7 \ + --hash=sha256:5f3d88554e779f03567c05286f31d2ce21f6103892c7412bdf350ef2fb50184a \ + --hash=sha256:62afd51edaf5634e21206a65e9e244e038b747e39ba969ebcc9361b63825a4f9 \ + --hash=sha256:68f393318292a6346c6d72c2b8ee301a081bff778dfb0e6ef6f0c36da4053374 \ + --hash=sha256:71dc7505c0520e066c5d567f49d7173703c34192af1b8f89ce401a34098391f5 \ + --hash=sha256:7ad2cc34fb600ab4aa22014bc8cc9b8bae2d467f772074d3a4929deb4adf64d6 \ + --hash=sha256:7d63688dc696b72d54623dd55f269d5203fcd5de5eeeba7bc276864300a8d790 \ + --hash=sha256:7e3b89f96cda6df85687b9532eac4faffdf15d2d918b239399cab6c78dd5282b \ + --hash=sha256:7e49780796e39812ade8001b0c7d2a2f1a9aeac90964e8e757c2013d30dfbe4e \ + --hash=sha256:7e9bea86436a59eb3f24e8e15bb3b3a36cdb98aec950c70aa619e33f35eb6beb \ + --hash=sha256:82fecbeb7fa309245aaaa7e3aaa09c744f1059fc238e2f7acd889d803f1a0be7 \ + --hash=sha256:842572b228382c9bb6283428f14ff4481b3822cb7488ce4388281a8c6c465a81 \ + --hash=sha256:8528c819c151ccabbd1bb9e591dd99495c2d0423b10ccdef47d48fe25da2b2d6 \ + --hash=sha256:85f3ec2d15ba134e6159ddebdb1a0af89f99431d93336f8b3cb71aa2de9f3324 \ + --hash=sha256:875fdfa0e3e9102164a540509ca2d5ad959f1f53858cf11a4174a1845e3c575c \ + --hash=sha256:89b2efb712c5bc0c57cc5a9deabfbcb2196504139d2d108a4afedd140c02063f \ + --hash=sha256:8d26f91ac1fea52abd16dab224b4ac016d8914f28db02e582f4e589ee2b5faa5 \ + --hash=sha256:8dd30b865dfd3206756212796cb13686b6c45befa5cc495ccc9866108215f7c1 \ + --hash=sha256:8e9085cec8d730b43ac8c86ef5da0c902f0f57da8da2ee009045e7006fe4860f \ + --hash=sha256:8f0e8379d4eff6842b61c01c7f03dc7415afac994b96e4a7d24f80b1078d5c1d \ + --hash=sha256:8fc3b2fcb6ee1c2b1512a0b29d26bb9b9e945d0d50c0a85673a675622fbfb0f4 \ + --hash=sha256:901a862196bf610745e164d29c518e0cbf727eaaedc83d5058a7895721be2173 \ + --hash=sha256:931744cdcbb84d77159daf6b54e3b459e9f0a0ba52ea6b99d1d228e32556c2b9 \ + --hash=sha256:97db61620be5c56a78115967d05e0d7a130a27a68f401eb98bf0753d3f770cb5 \ + --hash=sha256:9bccb48b57b7a85677c1022afbaaf86e5cda8c1ecff00ad96877e10166d1eddc \ + --hash=sha256:a2cdfbd3c9df9e98dd067858fce7d6ab919f2fb038f6b3124fc5f05b8825b546 \ + --hash=sha256:a30070520aa114133feffc2413ba62bfcd8ef2f9826ebe7616de123f73b57977 \ + --hash=sha256:a894e6bf85988edee94474ff1399b61685d5fa7399d1c25a40efa28f7a2799ea \ + --hash=sha256:a999ef004caa048e5ecccb5f3383d857105baa37b8895a0a2b7cd66f9cb0b0b4 \ + --hash=sha256:af35c6b8c3a9a27721e5c2a849e2ee21973e14b8b7d2e76e15940475a8ace443 \ + --hash=sha256:b0f0bc9eab99a36f54e0a4e3043768e529107565bc67a7f420b4febff8006f32 \ + --hash=sha256:b123951f9befe6052a7397778fa64ade18983345d79fd9477160b11dfd736df0 \ + --hash=sha256:b2c072a4615c9610bd43cde816882ede0c910599702e54cbc07371874d1ca95b \ + --hash=sha256:b4f2df2bbca999f9749430e54dcd13bcc35289520b63e09ed4d2e1877a524260 \ + --hash=sha256:b730d11384a02bc4fb195b8a73555cb450e325d2676b39c8b5d20a0786102f37 \ + --hash=sha256:be6a4e3244c33f133d3c0bc43f9725c61dc9a11f7100c615639ce1daee064766 \ + --hash=sha256:c3430b73a99af300b0b1203da2cd30f1831f504466d94343b065b1ef0435802c \ + --hash=sha256:c478ee58fd2d6f5f7b75b32288d8a099904f710e83dd878f40058a309a0f6060 \ + --hash=sha256:c545cfd062a463c6d8e90b2738ec93fb9228f12c0aa80866fdc80f64fbc8f1a3 \ + --hash=sha256:c68dc5db03a22aada43f23f55e359109e00afe3886824d6fecfbd6821dcdbd6f \ + --hash=sha256:c73743c4c8622ebed7998990c5f18a2c2a4fae915288798aeb8fef6d5411743b \ + --hash=sha256:c8b112dd1906487fe56525361cbeca57b07adcc9496b641560327654aced7e8f \ + --hash=sha256:cb82264552ae789f2d8c2543b3b1eb2b3e091977c22f180ab268f5b677825279 \ + --hash=sha256:cc49302f8fdbd3426eba4b99671ba10f0d150d90ea4f344a0204e4ac8e4fcb66 \ + --hash=sha256:d01b0578252d0df17b64400103ffbfea5b8332483a3da01d116f4919467128de \ + --hash=sha256:d4f8b4137b0153c95ea7e4e3acff4c5e6f6c25206d3e3b86dbe879658b00c927 \ + --hash=sha256:d8be9b991c63fd8c76a6a9deac24ed164533824da1bdb2356a33511bfa446d54 \ + --hash=sha256:d94d9b91eece76104077a7e0b3276bb14728a4bff00ba23b0f0bea2a10c0e6e8 \ + --hash=sha256:d9778479aaeac4f000d2b9c0f6da76149926011588b08a6b22892ae921c4f563 \ + --hash=sha256:da0ebc45da1d4e918fba53a2bc059339d00a1f666b3b72b9acd4fe4eeaec8343 \ + --hash=sha256:dab6441f4403483a420de9481987e68ab75a28ade5bac8f1d82a3533ed83a6eb \ + --hash=sha256:dc80ac80ace112da6d02f44681b6beb2ccecb68d6ac2b5e1b82d7f84347e1cf6 \ + --hash=sha256:dda677506171a0c4f27925e13f9f8b4b652941969f0e0e6b555ce795e18b7467 \ + --hash=sha256:de10b31770a8978e381c8f0c05479e18d6ea1defa18a0aa825b0487acd1f082c \ + --hash=sha256:df78545bc5d54abf875a70a70db7e1e12e606377d1c9f4e68b6763e8c701a748 \ + --hash=sha256:e3754a2529b0c163bece327be6c5e92fe885cd01d066bc4a3fc13224eae5d221 \ + --hash=sha256:e397f034d675ef14980f2df0a074dec3218963a912b7011d0e8e94f9a3d87d41 \ + --hash=sha256:e4786eac47d2e9dab245fc376157bdc458f4b3269b81006b80a74d514b37efb7 \ + --hash=sha256:e571e0e3de9bca0b70afc4261aa2df6a305bfff94b092942ccd081fa07b8a148 \ + --hash=sha256:e61a305384a59997fa1971a6c886db9b8e2ff59fca3ee9bb4e1fe191b6d02593 \ + --hash=sha256:e9c95f8c3a68449f7d3bd5860ea832b5827a04803337796da72d8340786e9268 \ + --hash=sha256:ebb00b54e5a01d8b10a51a7de7d2f884359375f4abb5352378f6f7f2c8878a58 \ + --hash=sha256:f24c6af8fd78b5c58e0f2683004e0b6fac146ee047b2d8fc8d7b16c69e0a3aaa \ + --hash=sha256:f65475871a57413dee3094cc5d479ed202af85c38bc90bda5d7ee4435ff96819 \ + --hash=sha256:f93a3425169961aa7f0c7193dc12f01414700f86f4fb99ec078c18780432b77a \ + --hash=sha256:fabb25c4352d3d4f1a2a197d6fb403848026344aab73e1674bcb31e4a7f54914 \ + --hash=sha256:fb712f94825c04fa605b989d6f5889195b51c927f5045c34b9ff7a448cd0a6f0 + # via sphinx-mounts imagesize==2.0.0 \ --hash=sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 \ --hash=sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3 @@ -1260,6 +1376,7 @@ sphinx==9.1.0 \ # sphinx-collections # sphinx-data-viewer # sphinx-design + # sphinx-mounts # sphinx-needs # sphinxcontrib-jquery # sphinxcontrib-mermaid @@ -1280,6 +1397,10 @@ sphinx-design==0.7.0 \ --hash=sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a \ --hash=sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282 # via -r requirements.in +sphinx-mounts==0.1.0 \ + --hash=sha256:aff3450756727d0ca43538ea72b67fddb8b8799c52110f41de8871c59d3d1540 \ + --hash=sha256:d1818e3a0b7e0b327c6fa265afcf4d43f5bb7147276e69a9b35cd046deeb9439 + # via -r requirements.in sphinx-needs[plotting]==8.0.0 \ --hash=sha256:540c380c074d4088a557ea353e91513bfc1cb7712b10925c13ac9e5ebb7be091 \ --hash=sha256:c4336ee0e3c949eff9eb11a14910f7b6b68cb8284d731cfddf97694037337674 diff --git a/src/tests/docs_e2e/BUILD b/src/tests/docs_e2e/BUILD new file mode 100644 index 000000000..37b21f466 --- /dev/null +++ b/src/tests/docs_e2e/BUILD @@ -0,0 +1,46 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("//:docs.bzl", "docs") +load("//:score_pytest.bzl", "score_pytest") + +py_library( + name = "e2e_support", + srcs = ["support.py"], + imports = ["."], + visibility = ["//visibility:public"], +) + +docs( + source_dir = "host_docs", +) + +score_pytest( + name = "docs_e2e_test", + size = "medium", + srcs = ["test_docs_e2e.py"], + data = [ + ":docs", + ":sourcelinks_json", + "host_docs/conf.py", + "host_docs/index.rst", + ], + deps = [":e2e_support"], + env = { + "DOCS_BINARY": "$(rootpath :docs)", + "DOCS_CONF": "$(rootpath host_docs/conf.py)", + "SOURCELINKS": "$(rootpath :sourcelinks_json)", + }, + pytest_config = "//:pyproject.toml", +) diff --git a/src/tests/docs_e2e/host_docs/conf.py b/src/tests/docs_e2e/host_docs/conf.py new file mode 100644 index 000000000..717d335bf --- /dev/null +++ b/src/tests/docs_e2e/host_docs/conf.py @@ -0,0 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "Docs E2E fixture" +project_url = "https://example.invalid/docs-e2e" +extensions = ["score_sphinx_bundle"] diff --git a/src/tests/docs_e2e/host_docs/index.rst b/src/tests/docs_e2e/host_docs/index.rst new file mode 100644 index 000000000..cfabcc31f --- /dev/null +++ b/src/tests/docs_e2e/host_docs/index.rst @@ -0,0 +1,16 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Docs E2E fixture +================ diff --git a/src/tests/docs_e2e/support.py b/src/tests/docs_e2e/support.py new file mode 100644 index 000000000..ccead1eb3 --- /dev/null +++ b/src/tests/docs_e2e/support.py @@ -0,0 +1,66 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Shared runner for end-to-end tests of docs() binaries.""" + +import os +import shutil +import subprocess +from pathlib import Path + + +def runfile(path_env: str) -> Path: + """Resolve a runfiles-relative path passed through a test environment variable.""" + return ( + Path(os.environ["TEST_SRCDIR"]) + / os.environ["TEST_WORKSPACE"] + / os.environ[path_env] + ) + + +def run_docs_build( + tmp_path: Path, + *, + docs_binary: Path, + source_dir: Path, + sourcelinks: Path, + mounts_manifest: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a docs() binary in an isolated writable workspace. + + The binary normally runs from a developer's workspace. This helper provides + the small writable equivalent needed by a Bazel test while preserving the + runfiles layout used to resolve in-tree mount sources. + """ + runfiles_workspace = Path(os.environ["TEST_SRCDIR"]) / os.environ["TEST_WORKSPACE"] + (tmp_path / "src").symlink_to(runfiles_workspace / "src", target_is_directory=True) + + copied_source_dir = tmp_path / "docs" + shutil.copytree(source_dir, copied_source_dir) + for name in ["MODULE.bazel", "MODULE.bazel.lock", "BUILD"]: + (tmp_path / name).touch() + + env = os.environ.copy() + env["SOURCE_DIRECTORY"] = str(copied_source_dir) + env["MOUNTS_MANIFEST"] = str(mounts_manifest) if mounts_manifest else "" + env["DATA"] = "[]" + env["ACTION"] = "incremental" + env["SCORE_SOURCELINKS"] = str(sourcelinks) + + return subprocess.run( + [str(docs_binary)], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) diff --git a/src/tests/docs_e2e/test_docs_e2e.py b/src/tests/docs_e2e/test_docs_e2e.py new file mode 100644 index 000000000..d570262c3 --- /dev/null +++ b/src/tests/docs_e2e/test_docs_e2e.py @@ -0,0 +1,30 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""End-to-end test for a minimal docs() project without mounts.""" + +from pathlib import Path + +from support import run_docs_build, runfile + + +def test_docs_builds_html(tmp_path: Path): + """docs() builds the host documentation without a mount configuration.""" + result = run_docs_build( + tmp_path, + docs_binary=runfile("DOCS_BINARY"), + source_dir=runfile("DOCS_CONF").parent, + sourcelinks=runfile("SOURCELINKS"), + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / "_build" / "index.html").is_file() diff --git a/src/tests/mounts_conflict/BUILD b/src/tests/mounts_conflict/BUILD new file mode 100644 index 000000000..9cc1e58df --- /dev/null +++ b/src/tests/mounts_conflict/BUILD @@ -0,0 +1,102 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("//:docs.bzl", "docs_bundle") + +docs_bundle( + name = "child", + source_dir = "child", +) + +docs_bundle( + name = "wrapper_x", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "x"}, + ], +) + +docs_bundle( + name = "wrapper_y", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "y"}, + ], +) + +# These wrappers have the same final document location, but disagree about +# metadata that the manifest can represent only once. +docs_bundle( + name = "attach_to_a", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "same", "attach_to": "a/index"}, + ], +) + +docs_bundle( + name = "attach_to_b", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "same", "attach_to": "b/index"}, + ], +) + +docs_bundle( + name = "entry_doc_index", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "same", "entry_doc": "index"}, + ], +) + +docs_bundle( + name = "entry_doc_overview", + source_dir = "_empty", + bundles = [ + {"bundle": ":child", "mount_at": "same", "entry_doc": "overview"}, + ], +) + +# The child reaches the final bundle at two different locations, so analysis +# must stop before Sphinx would receive two copies of the same pages. +docs_bundle( + name = "bad", + source_dir = "_empty", + bundles = [ + {"bundle": ":wrapper_x", "mount_at": "root"}, + {"bundle": ":wrapper_y", "mount_at": "root"}, + ], + # Tagged manual so `bazel build //...` never expands to it; + # the test builds it explicitly and asserts the analysis error. + tags = ["manual"], +) + +docs_bundle( + name = "bad_attach_to", + source_dir = "_empty", + bundles = [ + {"bundle": ":attach_to_a", "mount_at": "root"}, + {"bundle": ":attach_to_b", "mount_at": "root"}, + ], + tags = ["manual"], +) + +docs_bundle( + name = "bad_entry_doc", + source_dir = "_empty", + bundles = [ + {"bundle": ":entry_doc_index", "mount_at": "root"}, + {"bundle": ":entry_doc_overview", "mount_at": "root"}, + ], + tags = ["manual"], +) diff --git a/src/tests/mounts_conflict/child/a.rst b/src/tests/mounts_conflict/child/a.rst new file mode 100644 index 000000000..4b7aaaac4 --- /dev/null +++ b/src/tests/mounts_conflict/child/a.rst @@ -0,0 +1,18 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Conflict Child +============== + +Fixture page for the mount-conflict negative test. diff --git a/src/tests/mounts_contract/BUILD b/src/tests/mounts_contract/BUILD new file mode 100644 index 000000000..0ff7323cb --- /dev/null +++ b/src/tests/mounts_contract/BUILD @@ -0,0 +1,93 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:bzl/mount_rules.bzl", "create_mounts_manifest") +load("//:docs.bzl", "docs", "docs_bundle") +load("//:score_pytest.bzl", "score_pytest") + +docs_bundle( + name = "child", + source_dir = "child", + scan_code = [":scan_code"], +) + +filegroup( + name = "scan_code", + srcs = ["child/example.py"], +) + +# The child is nested below the parent bundle before the parent itself is +# mounted into the synthetic host tree below. +docs_bundle( + name = "parent", + source_dir = "parent", + bundles = [{ + "bundle": ":child", + "mount_at": "child", + }], +) + +# An aggregator deliberately has no source_dir. It only propagates its child +# and must preserve the declaration order in the generated manifest. +docs_bundle( + name = "ordered_aggregate", + bundles = [ + {"bundle": ":other", "mount_at": "first"}, + {"bundle": ":child", "mount_at": "second"}, + ], +) + +docs_bundle( + name = "other", + source_dir = "other", +) + +create_mounts_manifest( + name = "ordered_aggregate_manifest", + bundle = ":ordered_aggregate", +) + +# A complete docs() invocation is the synthetic host project used by the +# end-to-end test below. +docs( + source_dir = "host_docs", + bundles = [{ + "bundle": ":parent", + "mount_at": "concepts/example_bundle", + }], +) + +score_pytest( + name = "mount_docs_e2e_test", + size = "medium", + srcs = ["test_mount_docs_e2e.py"], + data = [ + ":docs", + ":_mounts_manifest", + ":ordered_aggregate_manifest", + ":sourcelinks_json", + ":parent", + "host_docs/conf.py", + "host_docs/concepts/index.rst", + "host_docs/index.rst", + ], + deps = ["//src/tests/docs_e2e:e2e_support"], + env = { + "FIXTURE_DOCS_BINARY": "$(rootpath :docs)", + "FIXTURE_MOUNTS_MANIFEST": "$(rootpath :_mounts_manifest)", + "ORDERED_AGGREGATE_MANIFEST": "$(rootpath :ordered_aggregate_manifest)", + "FIXTURE_SOURCELINKS": "$(rootpath :sourcelinks_json)", + "HOST_DOCS_CONF": "$(rootpath host_docs/conf.py)", + }, + pytest_config = "//:pyproject.toml", +) diff --git a/src/tests/mounts_contract/README.md b/src/tests/mounts_contract/README.md new file mode 100644 index 000000000..9ac58f981 --- /dev/null +++ b/src/tests/mounts_contract/README.md @@ -0,0 +1,83 @@ + + +# End-to-end test for Bazel documentation mounts + +This directory contains one black-box test for the public `docs_bundle()` and +`docs()` macros. It does not inspect a Starlark provider or the generated +mount manifest. Instead, it runs the same `:docs` executable that a user would +start with `bazel run` and asserts the visible Sphinx result. + +Run the test with: + +```console +bazel test //src/tests/mounts_contract:mount_docs_e2e_test +``` + +## Fixture + +The fixture builds this small documentation site: + +```text +host_docs/ +└── concepts/index + └── parent bundle at concepts/example_bundle + └── child bundle at child +``` + +`docs_bundle()` defines the parent and child bundles. `docs()` defines the +synthetic host project and creates its ordinary `:docs` binary, just as it +does in a real project. + +## Why the test starts a subprocess + +`docs()` is a Starlark macro, not an executable function. During `bazel test` +Bazel evaluates the macro and creates targets, including this fixture's +`:docs` `py_binary`. It does not run that binary merely because the macro was +evaluated. + +The Python test starts that already-built binary in a subprocess: + +```text +docs() macro + -> defines :docs +bazel test + -> builds :docs and starts the pytest test +pytest subprocess + -> executes :docs + -> incremental.py runs Sphinx + -> score_mounts configures sphinx-mounts +``` + +This is deliberately not a nested `bazel run`: a Bazel test must not start +another Bazel process in its sandbox. The subprocess directly executes the +`:docs` file that Bazel placed in the test runfiles. + +## What the test verifies + +`test_docs_build_mounts_bundle_and_extends_toctree` creates a writable +temporary workspace, runs the fixture's `:docs` binary, and checks: + +- Sphinx reports that it extended the `concepts/index` toctree. +- The mounted document is rendered as + `_build/concepts/example_bundle/index.html`. + +Together these assertions cover bundle composition, the mount manifest, +runfiles path resolution, the Sphinx extension, the target Toctree, and HTML +output. A wrong `mount_at` or `attach_to` fails this test as a user would see +it: Sphinx cannot extend the expected Toctree or the mounted page is absent. + +## Related tests + +- `bazel test //src/extensions/score_mounts:score_mounts_tests` contains + Python unit tests for the `score_mounts` extension. +- `bazel build //src/tests/mounts_conflict:bad`, `:bad_attach_to`, and + `:bad_entry_doc` are manually invoked negative fixtures. They must fail + because one source directory cannot produce conflicting mount locations, + Toctree targets, or entry documents. +- `bazel run //:docs` remains the project-wide documentation smoke check. diff --git a/src/tests/mounts_contract/child/example.py b/src/tests/mounts_contract/child/example.py new file mode 100644 index 000000000..ca83634c4 --- /dev/null +++ b/src/tests/mounts_contract/child/example.py @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# req-traceability: REQ_CHILD diff --git a/src/tests/mounts_contract/child/index.rst b/src/tests/mounts_contract/child/index.rst new file mode 100644 index 000000000..5c3b0816e --- /dev/null +++ b/src/tests/mounts_contract/child/index.rst @@ -0,0 +1,16 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Child bundle +============ diff --git a/src/tests/mounts_contract/host_docs/concepts/index.rst b/src/tests/mounts_contract/host_docs/concepts/index.rst new file mode 100644 index 000000000..5da136269 --- /dev/null +++ b/src/tests/mounts_contract/host_docs/concepts/index.rst @@ -0,0 +1,19 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Concepts +======== + +.. toctree:: + :maxdepth: 1 diff --git a/src/tests/mounts_contract/host_docs/conf.py b/src/tests/mounts_contract/host_docs/conf.py new file mode 100644 index 000000000..f87d4a9c2 --- /dev/null +++ b/src/tests/mounts_contract/host_docs/conf.py @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "Mount contract fixture" +project_url = "https://example.invalid/mount-contract" +extensions = ["score_sphinx_bundle"] +suppress_warnings = ["score_source_code_linker"] diff --git a/src/tests/mounts_contract/host_docs/index.rst b/src/tests/mounts_contract/host_docs/index.rst new file mode 100644 index 000000000..a345eb0cf --- /dev/null +++ b/src/tests/mounts_contract/host_docs/index.rst @@ -0,0 +1,21 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Mount contract fixture +====================== + +.. toctree:: + :maxdepth: 1 + + concepts/index diff --git a/src/tests/mounts_contract/other/index.rst b/src/tests/mounts_contract/other/index.rst new file mode 100644 index 000000000..236c2d131 --- /dev/null +++ b/src/tests/mounts_contract/other/index.rst @@ -0,0 +1,16 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Other bundle +============ diff --git a/src/tests/mounts_contract/parent/index.rst b/src/tests/mounts_contract/parent/index.rst new file mode 100644 index 000000000..d14b5c54e --- /dev/null +++ b/src/tests/mounts_contract/parent/index.rst @@ -0,0 +1,16 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Parent bundle +============= diff --git a/src/tests/mounts_contract/test_mount_docs_e2e.py b/src/tests/mounts_contract/test_mount_docs_e2e.py new file mode 100644 index 000000000..14082546c --- /dev/null +++ b/src/tests/mounts_contract/test_mount_docs_e2e.py @@ -0,0 +1,69 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""End-to-end test for a documentation build with a mounted bundle.""" + +import json +from pathlib import Path + +from src.tests.docs_e2e.support import run_docs_build, runfile + + +def test_docs_build_mounts_bundle_and_extends_toctree(tmp_path: Path): + """A nested bundle keeps its placement and becomes part of the host site.""" + mounts_manifest = runfile("FIXTURE_MOUNTS_MANIFEST") + manifest = json.loads(mounts_manifest.read_text(encoding="utf-8")) + assert [mount["mount_at"] for mount in manifest["mounts"]] == [ + "concepts/example_bundle", + "concepts/example_bundle/child", + ] + sourcelinks = json.loads(runfile("FIXTURE_SOURCELINKS").read_text(encoding="utf-8")) + assert sourcelinks == [ + { + "file": "src/tests/mounts_contract/child/example.py", + "line": 14, + "tag": "# req-traceability:", + "need": "REQ_CHILD", + "full_line": "# req-traceability: REQ_CHILD", + "repo_name": "local_repo", + "hash": "", + "url": "", + } + ] + + result = run_docs_build( + tmp_path, + docs_binary=runfile("FIXTURE_DOCS_BINARY"), + source_dir=runfile("HOST_DOCS_CONF").parent, + mounts_manifest=mounts_manifest, + sourcelinks=runfile("FIXTURE_SOURCELINKS"), + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "extended toctree #0 in 'concepts/index'" in result.stdout + assert ( + tmp_path / "_build" / "concepts" / "example_bundle" / "index.html" + ).is_file() + toml = (tmp_path / "docs" / "ubproject.toml").read_text(encoding="utf-8") + assert 'mount_at = "concepts/example_bundle"' in toml + assert 'mount_at = "concepts/example_bundle/child"' in toml + + +def test_pure_aggregator_keeps_declared_mount_order(): + """An aggregator contributes no implicit package sources or reordered mounts.""" + manifest = json.loads( + runfile("ORDERED_AGGREGATE_MANIFEST").read_text(encoding="utf-8") + ) + assert [mount["mount_at"] for mount in manifest["mounts"]] == [ + "first", + "second", + ] diff --git a/src/tests/mounts_external/BUILD b/src/tests/mounts_external/BUILD new file mode 100644 index 000000000..b9f98c06d --- /dev/null +++ b/src/tests/mounts_external/BUILD @@ -0,0 +1,25 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs") + +# This fixture exercises the public Bzlmod contract. In particular, the +# sandboxed :needs_json target must be able to walk an external bundle rather +# than silently omitting it. +docs( + source_dir = "host_docs", + bundles = [{ + "bundle": "@score_process//:docs_bundle", + "mount_at": "process", + }], +) diff --git a/src/tests/mounts_external/host_docs/conf.py b/src/tests/mounts_external/host_docs/conf.py new file mode 100644 index 000000000..123a2f7da --- /dev/null +++ b/src/tests/mounts_external/host_docs/conf.py @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "External mount fixture" +project_url = "https://example.invalid/external-mount" +extensions = ["score_sphinx_bundle"] +suppress_warnings = ["score_source_code_linker"] diff --git a/src/tests/mounts_external/host_docs/index.rst b/src/tests/mounts_external/host_docs/index.rst new file mode 100644 index 000000000..1535c0c0b --- /dev/null +++ b/src/tests/mounts_external/host_docs/index.rst @@ -0,0 +1,19 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +External mount fixture +====================== + +.. toctree:: + :maxdepth: 1