playmolecule 2.7.2__tar.gz → 2.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. {playmolecule-2.7.2/playmolecule.egg-info → playmolecule-2.7.4}/PKG-INFO +1 -1
  2. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/__init__.py +1 -1
  3. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_docker.py +27 -4
  4. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_http.py +16 -6
  5. playmolecule-2.7.4/playmolecule/_dump.py +198 -0
  6. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_identity.py +22 -8
  7. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_public_api.py +32 -16
  8. {playmolecule-2.7.2 → playmolecule-2.7.4/playmolecule.egg-info}/PKG-INFO +1 -1
  9. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/SOURCES.txt +2 -1
  10. playmolecule-2.7.4/tests/bench_dump_api.py +92 -0
  11. playmolecule-2.7.4/tests/test_dump_api.py +257 -0
  12. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_backend.py +85 -2
  13. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_identity.py +14 -5
  14. playmolecule-2.7.2/playmolecule/_dump.py +0 -59
  15. playmolecule-2.7.2/tests/test_dump_manifests.py +0 -30
  16. {playmolecule-2.7.2 → playmolecule-2.7.4}/.github/workflows/docs.yml +0 -0
  17. {playmolecule-2.7.2 → playmolecule-2.7.4}/.github/workflows/pythonpublish.yml +0 -0
  18. {playmolecule-2.7.2 → playmolecule-2.7.4}/.gitignore +0 -0
  19. {playmolecule-2.7.2 → playmolecule-2.7.4}/LICENSE +0 -0
  20. {playmolecule-2.7.2 → playmolecule-2.7.4}/MANIFEST.in +0 -0
  21. {playmolecule-2.7.2 → playmolecule-2.7.4}/Makefile +0 -0
  22. {playmolecule-2.7.2 → playmolecule-2.7.4}/README.md +0 -0
  23. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/Makefile +0 -0
  24. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/getversion.py +0 -0
  25. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/make.bat +0 -0
  26. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/.gitignore +0 -0
  27. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/conf.py +0 -0
  28. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/apps-and-manifests.md +0 -0
  29. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/architecture.md +0 -0
  30. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/artifacts-and-files.md +0 -0
  31. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/executable-directory.md +0 -0
  32. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/index.md +0 -0
  33. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/job-lifecycle.md +0 -0
  34. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/check-job-status.md +0 -0
  35. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/index.md +0 -0
  36. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/install-apps-for-a-cluster.md +0 -0
  37. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/list-and-inspect-apps.md +0 -0
  38. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/log-in-to-the-http-backend.md +0 -0
  39. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/pass-input-files-to-an-app.md +0 -0
  40. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-an-app-locally.md +0 -0
  41. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-an-app-on-slurm.md +0 -0
  42. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-built-in-app-tests.md +0 -0
  43. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-many-jobs-on-one-gpu.md +0 -0
  44. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/select-an-app-version.md +0 -0
  45. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/switch-between-docker-and-apptainer.md +0 -0
  46. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/update-installed-apps.md +0 -0
  47. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/use-app-artifacts.md +0 -0
  48. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/index.md +0 -0
  49. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/installation.md +0 -0
  50. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/llms-full.txt +0 -0
  51. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/cli.md +0 -0
  52. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/environment-variables.md +0 -0
  53. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/index.md +0 -0
  54. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/01-first-app-run.md +0 -0
  55. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/02-using-app-versions-and-tests.md +0 -0
  56. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/03-running-on-slurm.md +0 -0
  57. {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/index.md +0 -0
  58. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_appfiles.py +0 -0
  59. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/__init__.py +0 -0
  60. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_local.py +0 -0
  61. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_slurm.py +0 -0
  62. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_channels.py +0 -0
  63. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_cli.py +0 -0
  64. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_config.py +0 -0
  65. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_devutils.py +0 -0
  66. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_dynamicfunction.py +0 -0
  67. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_protocols.py +0 -0
  68. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_tests.py +0 -0
  69. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_update.py +0 -0
  70. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_validation.py +0 -0
  71. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/apps/__init__.py +0 -0
  72. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/logging.ini +0 -0
  73. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/share/apptainer_run.sh +0 -0
  74. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/share/docker_run.sh +0 -0
  75. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/dependency_links.txt +0 -0
  76. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/entry_points.txt +0 -0
  77. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/requires.txt +0 -0
  78. {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/top_level.txt +0 -0
  79. {playmolecule-2.7.2 → playmolecule-2.7.4}/pyproject.toml +0 -0
  80. {playmolecule-2.7.2 → playmolecule-2.7.4}/setup.cfg +0 -0
  81. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/3ptb.pdb +0 -0
  82. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/conftest.py +0 -0
  83. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_app_docs_namespace.py +0 -0
  84. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_apps_linking.py +0 -0
  85. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_backends.py +0 -0
  86. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_channel_config.py +0 -0
  87. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_channels.py +0 -0
  88. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_defer_discovery.py +0 -0
  89. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_describe_apps.py +0 -0
  90. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_docker_backend.py +0 -0
  91. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_docker_docs_split.py +0 -0
  92. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_find_apps_resilient.py +0 -0
  93. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_func_name_lowercase.py +0 -0
  94. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_get_app_list_channels.py +0 -0
  95. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_channel.py +0 -0
  96. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_docs_split.py +0 -0
  97. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_backend.py +0 -0
  98. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_backend_cancel.py +0 -0
  99. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_docs_and_lowercase.py +0 -0
  100. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/__init__.py +0 -0
  101. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/__init__.py +0 -0
  102. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/__init__.py +0 -0
  103. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/crypticscout.py +0 -0
  104. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/files/tutorials/my_tutorial.ipynb +0 -0
  105. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/ProteinPrepare_v1.json +0 -0
  106. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/datasets/3ptb.pdb +0 -0
  107. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/3ptb.pdb +0 -0
  108. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/587HG92V.pdb +0 -0
  109. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/reprepare.pickle +0 -0
  110. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/web_content.pickle +0 -0
  111. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tutorials/learn_this_app.ipynb +0 -0
  112. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/run.sh +0 -0
  113. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/ProteinPrepareNew_v1.json +0 -0
  114. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/datasets/3ptb.pdb +0 -0
  115. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/3ptb.pdb +0 -0
  116. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/587HG92V.pdb +0 -0
  117. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/reprepare.pickle +0 -0
  118. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/web_content.pickle +0 -0
  119. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tutorials/learn_this_app.ipynb +0 -0
  120. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/run.sh +0 -0
  121. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/ProteinPrepareNew_v1.json +0 -0
  122. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/datasets/3ptb.pdb +0 -0
  123. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/3ptb.pdb +0 -0
  124. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/587HG92V.pdb +0 -0
  125. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/reprepare.pickle +0 -0
  126. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/web_content.pickle +0 -0
  127. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tutorials/learn_this_app.ipynb +0 -0
  128. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/run.sh +0 -0
  129. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apptainer_run.sh +0 -0
  130. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/datasets/3ptb.pdb +0 -0
  131. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/datasets/datasets.json +0 -0
  132. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule.py +0 -0
  133. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_pmbackend.py +0 -0
  134. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_render_runsh_linking.py +0 -0
  135. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_runsh_render.py +0 -0
  136. {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_slpm_manifest.py +0 -0
  137. {playmolecule-2.7.2 → playmolecule-2.7.4}/update_license_header.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: playmolecule
3
- Version: 2.7.2
3
+ Version: 2.7.4
4
4
  Summary: PlayMolecule python API
5
5
  Author-email: Acellera <info@acellera.com>
6
6
  Project-URL: Homepage, https://github.com/Acellera/playmolecule
@@ -15,7 +15,7 @@ from playmolecule._protocols import __find_protocols_by_import, __find_protocols
15
15
  from playmolecule._config import _get_config
16
16
  from playmolecule._public_api import JobStatus, ExecutableDirectory, describe_apps
17
17
  from playmolecule._backends._http import login, logout, _check_login_status
18
- from playmolecule._dump import dump_manifests
18
+ from playmolecule._dump import dump_api
19
19
 
20
20
  # from importlib.resources import files
21
21
 
@@ -773,10 +773,33 @@ def _update_docker_apps_from_gcloud(
773
773
  tag = image_info["full_tag"]
774
774
  try:
775
775
  # Check if image exists locally
776
- docker_client.images.get(tag)
777
- # If we get here, the image exists locally, so update it
778
- logger.info(f"Updating existing image: {tag} ...")
779
- docker_client.images.pull(tag)
776
+ local_image = docker_client.images.get(tag)
777
+ # Image exists locally - compare local vs remote digest first so
778
+ # we only pull (and announce) the images that actually changed.
779
+ logger.debug(f"Checking {tag} for updates ...")
780
+ try:
781
+ remote_digest = backend._get_remote_digest(
782
+ image_info["project_id"],
783
+ image_info["location"],
784
+ image_info["repo"],
785
+ image_info["image"],
786
+ image_info["tag"],
787
+ )
788
+ repo_digests = local_image.attrs.get("RepoDigests", [])
789
+ up_to_date = bool(remote_digest) and any(
790
+ remote_digest in rd for rd in repo_digests
791
+ )
792
+ except Exception as e:
793
+ # Couldn't determine the remote digest - fall back to pulling
794
+ logger.debug(f"Could not check remote digest for {tag}: {e}")
795
+ up_to_date = False
796
+
797
+ if up_to_date:
798
+ logger.info(f"Skipping {tag} - already up to date")
799
+ else:
800
+ logger.info(f"Updating existing image: {tag} ...")
801
+ docker_client.images.pull(tag)
802
+ logger.info(f"Successfully updated {tag}")
780
803
  except docker.errors.ImageNotFound:
781
804
  # Image doesn't exist locally
782
805
  if pull_new:
@@ -604,7 +604,7 @@ class _HTTPExecutionBackend:
604
604
  job_id: Optional[str] = None,
605
605
  prefix: Optional[str] = None,
606
606
  _logger: bool = True,
607
- ) -> str:
607
+ ) -> tuple[str, Optional[str]]:
608
608
  """Run the app via HTTP API.
609
609
 
610
610
  Parameters
@@ -622,8 +622,8 @@ class _HTTPExecutionBackend:
622
622
 
623
623
  Returns
624
624
  -------
625
- str
626
- Job ID
625
+ tuple[str, str | None]
626
+ The ``(job_id, run_id)`` pair assigned by the server.
627
627
  """
628
628
  config = _get_config()
629
629
  session = self._session.session
@@ -668,7 +668,8 @@ class _HTTPExecutionBackend:
668
668
 
669
669
  if res.status_code != 200:
670
670
  raise RuntimeError(f"Failed to run job on {self.base_url}: {res.text}")
671
- return res.json()["job_id"]
671
+ data = res.json()
672
+ return data["job_id"], data.get("run_id")
672
673
  finally:
673
674
  # Always close file handles
674
675
  for fh in file_handles:
@@ -677,7 +678,9 @@ class _HTTPExecutionBackend:
677
678
  except Exception as e:
678
679
  logger.warning(f"Failed to close file handle: {e}")
679
680
 
680
- def get_status(self, dirname: str, job_id: str) -> JobStatus:
681
+ def get_status(
682
+ self, dirname: str, job_id: str, run_id: str | None = None
683
+ ) -> JobStatus:
681
684
  """Get job status from HTTP API.
682
685
 
683
686
  Parameters
@@ -686,6 +689,10 @@ class _HTTPExecutionBackend:
686
689
  Output directory (for downloading results)
687
690
  job_id : str
688
691
  Job ID
692
+ run_id : str, optional
693
+ When given, narrows the query to the single run with this id
694
+ (the ``(job_id, run_id)`` pair is the unique key); otherwise the
695
+ prefix-only query is used.
689
696
 
690
697
  Returns
691
698
  -------
@@ -699,8 +706,11 @@ class _HTTPExecutionBackend:
699
706
  try:
700
707
  headers = _get_headers()
701
708
  headers["Connection"] = "close"
709
+ url = f"{self.base_url}/jobs?prefix={job_id}"
710
+ if run_id is not None:
711
+ url += f"&run_id={run_id}"
702
712
  res = session.get(
703
- f"{self.base_url}/jobs?prefix={job_id}",
713
+ url,
704
714
  timeout=_DEFAULT_TIMEOUT,
705
715
  headers=headers,
706
716
  )
@@ -0,0 +1,198 @@
1
+ # (c) 2015-2023 Acellera Ltd http://www.acellera.com
2
+ # All Rights Reserved
3
+ # Distributed under HTMD Software License Agreement
4
+ # No redistribution in whole or part
5
+ #
6
+ """Dump app API reference docs for the agent sandbox.
7
+
8
+ Introspects the live generated functions (build info is never touched).
9
+ Requires discovery — run without PM_DEFER_DISCOVERY.
10
+ """
11
+ import inspect
12
+ import logging
13
+ import os
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _fenced(code: str) -> str:
19
+ """Wrap code in a python-fenced block."""
20
+ return "```python\n" + code.rstrip() + "\n```"
21
+
22
+
23
+ def _render_function_block(func) -> str:
24
+ """`def <name><signature>:` + the function's __doc__, as a python fence."""
25
+ sig = str(inspect.signature(func))
26
+ # Render Path annotations cleanly (Python 3.13 exposes pathlib._local.Path).
27
+ sig = sig.replace("pathlib._local.Path", "Path").replace("pathlib.Path", "Path")
28
+ lines = [f"def {func.__name__}{sig}:"]
29
+ doc = (func.__doc__ or "").rstrip()
30
+ if doc:
31
+ lines.append(' """')
32
+ lines.extend((" " + dl).rstrip() for dl in doc.splitlines())
33
+ lines.append(' """')
34
+ lines.append(" ...")
35
+ return _fenced("\n".join(lines))
36
+
37
+
38
+ def _arg_repr(value) -> str:
39
+ """Render a test argument. Bundled files become a clear placeholder; other
40
+ values use repr (so strings get quotes, numbers stay bare). Recurses into
41
+ dict/list/tuple so nested _File objects never leak their internal .path."""
42
+ # Deferred import to avoid a circular import at module load time.
43
+ from playmolecule._appfiles import _File
44
+
45
+ if isinstance(value, _File):
46
+ return f"<file {value.name!r}>"
47
+ if isinstance(value, dict):
48
+ inner = ", ".join(
49
+ f"{_arg_repr(k)}: {_arg_repr(v)}" for k, v in value.items()
50
+ )
51
+ return "{" + inner + "}"
52
+ if isinstance(value, (list, tuple)):
53
+ inner = ", ".join(_arg_repr(item) for item in value)
54
+ if isinstance(value, tuple):
55
+ return "(" + inner + ("," if len(value) == 1 else "") + ")"
56
+ return "[" + inner + "]"
57
+ return repr(value)
58
+
59
+
60
+ def _render_tests(func) -> str:
61
+ """A python fence of `# test: <name>` + a concrete call per func.tests entry.
62
+ Empty string when the function has no tests."""
63
+ tests = getattr(func, "tests", None)
64
+ items = vars(tests) if tests is not None else {}
65
+ if not items:
66
+ return ""
67
+ blocks = []
68
+ for test in items.values():
69
+ args = (getattr(test, "config", {}) or {}).get("arguments", {})
70
+ argstr = ", ".join(f"{k}={_arg_repr(v)}" for k, v in args.items())
71
+ blocks.append(f"# test: {test.name}\n{func.__name__}({argstr})")
72
+ return _fenced("\n".join(blocks))
73
+
74
+
75
+ def _render_datasets(app: str, module) -> str:
76
+ """Markdown bullets of module.datasets (accessor name + description), with a
77
+ header showing how to reference them. Empty string when the app declares
78
+ none."""
79
+ ds = getattr(module, "datasets", None)
80
+ items = vars(ds) if ds is not None else {}
81
+ if not items:
82
+ return ""
83
+ lines = [f"Datasets (reference as `{app}.datasets.<name>`):", ""]
84
+ for name, f in items.items():
85
+ desc = getattr(f, "description", None)
86
+ lines.append(f"- `{name}`" + (f" — {desc}" if desc else ""))
87
+ return "\n".join(lines)
88
+
89
+
90
+ def _render_app_md(app: str, version: str, module, funcs) -> str:
91
+ """Assemble the full <app>/<vN>.md body. Sections are omitted when empty.
92
+ Each function is rendered independently; a broken one is logged and skipped
93
+ so sibling functions still appear."""
94
+ parts = [f"# {app} · {version}", ""]
95
+ rendered_funcs = []
96
+ for func in funcs:
97
+ try:
98
+ parts.append(_render_function_block(func))
99
+ parts.append("")
100
+ rendered_funcs.append(func)
101
+ except Exception as e:
102
+ logger.warning(
103
+ f"Skipping function {getattr(func, '__name__', repr(func))} "
104
+ f"in {app}.{version}: {e}"
105
+ )
106
+ # Usage: unversioned import (resolves to latest), per prompt guidance.
107
+ # Base the example on the first successfully rendered function, not funcs[0].
108
+ first = rendered_funcs[0].__name__ if rendered_funcs else app
109
+ parts.append("Usage:")
110
+ parts.append(_fenced(
111
+ f"from playmolecule.apps.{app}.{version} import {first}\n{first}(...).run()"
112
+ ))
113
+ parts.append("")
114
+ test_blocks = []
115
+ for func in rendered_funcs:
116
+ try:
117
+ t = _render_tests(func)
118
+ if t:
119
+ test_blocks.append(t)
120
+ except Exception as e:
121
+ logger.warning(
122
+ f"Skipping tests for {getattr(func, '__name__', repr(func))} "
123
+ f"in {app}.{version}: {e}"
124
+ )
125
+ tests = "\n\n".join(test_blocks)
126
+ if tests:
127
+ parts.append("Tested invocations:")
128
+ parts.append(tests)
129
+ parts.append("")
130
+ datasets = _render_datasets(app, module)
131
+ if datasets:
132
+ parts.append(datasets)
133
+ parts.append("")
134
+ parts.append(f"> Tutorials: ./{version}/docs/")
135
+ return "\n".join(parts).rstrip() + "\n"
136
+
137
+
138
+ def dump_api(out_dir: str, registry: str = None) -> None:
139
+ """Write a markdown API reference for the agent sandbox: one
140
+ ``<app>/<vN>.md`` per app version (signatures + docstrings + tested
141
+ invocations + datasets) and ``<app>/<vN>/docs/<name>.md`` tutorial
142
+ sidecars. Introspects the *generated functions* (build info is never
143
+ touched). Requires discovery — run without PM_DEFER_DISCOVERY. Never raises
144
+ on a single bad app; logs and skips."""
145
+ import sys
146
+ from playmolecule.apps import _function_dict, _find_apps
147
+
148
+ if registry is None:
149
+ from playmolecule._config import _get_config
150
+
151
+ regs = _get_config().registries or []
152
+ else:
153
+ regs = [registry]
154
+
155
+ # Discovery: only run when the function tree is not yet populated.
156
+ if not _function_dict and regs:
157
+ try:
158
+ _find_apps(regs)
159
+ except Exception as e:
160
+ logger.error(f"Discovery failed; nothing dumped: {e}")
161
+ return
162
+
163
+ os.makedirs(out_dir, exist_ok=True)
164
+
165
+ # Group generated functions by (app, version).
166
+ by_version = {}
167
+ prefix = "playmolecule.apps."
168
+ for fpath, func in _function_dict.items():
169
+ rest = fpath[len(prefix):] if fpath.startswith(prefix) else fpath
170
+ bits = rest.split(".")
171
+ if len(bits) != 3:
172
+ continue
173
+ app, version, _fn = bits
174
+ by_version.setdefault((app, version), []).append(func)
175
+
176
+ for (app, version), funcs in by_version.items():
177
+ module_path = f"{prefix}{app}.{version}"
178
+ module = sys.modules.get(module_path)
179
+ try:
180
+ md = _render_app_md(app, version, module, funcs)
181
+ os.makedirs(os.path.join(out_dir, app), exist_ok=True)
182
+ with open(os.path.join(out_dir, app, f"{version}.md"), "w") as f:
183
+ f.write(md)
184
+ except Exception as e:
185
+ logger.error(f"Failed to write API reference for {app}.{version}: {e}")
186
+ continue
187
+
188
+ # Tutorials sidecars from module.docs ({name: markdown}); names may nest.
189
+ docs = getattr(module, "docs", None) or {}
190
+ docs_dir = os.path.join(out_dir, app, version, "docs")
191
+ for name, content in docs.items():
192
+ try:
193
+ path = os.path.join(docs_dir, name)
194
+ os.makedirs(os.path.dirname(path), exist_ok=True)
195
+ with open(path, "w") as f:
196
+ f.write(content)
197
+ except Exception as e:
198
+ logger.error(f"Failed to write doc {name} for {app}.{version}: {e}")
@@ -20,18 +20,32 @@ def app_identity(manifest: dict):
20
20
  """Return ``(app_lower, "vN")`` for a manifest, or ``None`` (logged) if it
21
21
  has no usable identity. Never raises.
22
22
 
23
- Container-built manifests carry ``name``/``version`` under
24
- ``container_config``; local (flat) manifests carry them at the top level.
25
- Read from ``container_config`` when present, otherwise from the top level.
23
+ ``name``/``version`` may live under ``container_config`` (container-built
24
+ manifests) or at the top level. Local manifests keep them at the top level
25
+ and use ``container_config`` only for build directives
26
+ (``appfunction``/``condaenvs``/``base_image``/...). Each field is read from
27
+ ``container_config`` when present there, otherwise from the top level.
26
28
  """
29
+ if not isinstance(manifest, dict):
30
+ logger.error(
31
+ "Skipping app with unusable identity in manifest: expected dict, got "
32
+ f"{type(manifest).__name__}"
33
+ )
34
+ return None
35
+ cc = manifest.get("container_config")
36
+ cc = cc if isinstance(cc, dict) else {}
37
+ name = cc.get("name", manifest.get("name"))
38
+ version = cc.get("version", manifest.get("version"))
39
+ if name is None or version is None:
40
+ missing = "name" if name is None else "version"
41
+ logger.error(f"Skipping app with unusable identity in manifest: {missing!r}")
42
+ return None
27
43
  try:
28
- cc = manifest["container_config"] if "container_config" in manifest else manifest
29
- app = str(cc["name"]).lower()
30
- major = int(cc["version"])
31
- except (KeyError, TypeError, ValueError) as e:
44
+ major = int(version)
45
+ except (TypeError, ValueError) as e:
32
46
  logger.error(f"Skipping app with unusable identity in manifest: {e}")
33
47
  return None
34
- return app, f"v{major}"
48
+ return str(name).lower(), f"v{major}"
35
49
 
36
50
 
37
51
  def func_name(function_ref: str, appname: str) -> str:
@@ -45,23 +45,32 @@ class ExecutableDirectory:
45
45
  self.dirname = dirname
46
46
 
47
47
  if inputs_dir is None:
48
- # Find all the run_ folders in the directory and use the most recent one
49
- run_folders = [
50
- f
51
- for f in os.listdir(dirname)
52
- if f.startswith("run_") and os.path.isdir(os.path.join(dirname, f))
53
- ]
54
- run_folders.sort(key=lambda x: os.path.getmtime(os.path.join(dirname, x)))
55
- inputs_dir = os.path.join(dirname, run_folders[-1])
56
-
57
- self.runsh = os.path.basename(inputs_dir) + ".sh"
48
+ # Reconstructed from a path: use the most-recent run_ folder if the
49
+ # directory exists and has one; otherwise leave it unresolved
50
+ # (None) rather than crashing — status can still fall back to the
51
+ # prefix-only query.
52
+ run_folders = []
53
+ if os.path.isdir(dirname):
54
+ run_folders = [
55
+ f
56
+ for f in os.listdir(dirname)
57
+ if f.startswith("run_") and os.path.isdir(os.path.join(dirname, f))
58
+ ]
59
+ run_folders.sort(key=lambda x: os.path.getmtime(os.path.join(dirname, x)))
60
+ inputs_dir = os.path.join(dirname, run_folders[-1]) if run_folders else None
61
+
58
62
  self.inputs_dir = inputs_dir
63
+ self.runsh = (os.path.basename(inputs_dir) + ".sh") if inputs_dir else None
59
64
 
60
65
  self.execution_resources = _execution_resources
61
66
  self.slurmq = None
62
67
  self._input_json = _input_json
63
68
  self._execution_backend = _execution_backend
69
+ # job_id and run_id are recovered lazily in `status` for a reconstructed
70
+ # directory (job_id needs the configured prefix; run_id comes from the
71
+ # run folder). A fresh `.run()` sets both from the server response.
64
72
  self._job_id = None
73
+ self._run_id = None
65
74
 
66
75
  def _get_backend(self):
67
76
  """Get the execution backend, from stored value or config."""
@@ -93,14 +102,19 @@ class ExecutableDirectory:
93
102
  if isinstance(backend, _HTTPExecutionBackend):
94
103
  if self._job_id is None:
95
104
  # ExecutableDirectory was reconstructed from a path (e.g. by a
96
- # protocol resuming from disk), so the in-memory job_id from
97
- # .run() is gone. Recover the full id (prefix + relpath) so
98
- # the status prefix-query matches what the server stored at
99
- # submission time.
105
+ # protocol resuming from disk), so the in-memory ids from
106
+ # .run() are gone. Recover the job_id (prefix + relpath) so the
107
+ # status query matches what the server stored at submission, and
108
+ # the run_id from the most-recent run folder (the server lays
109
+ # results out under {run_id}/) so the query resolves a single
110
+ # run. If neither can be determined, fall back to the
111
+ # prefix-only query.
100
112
  self._job_id = _derive_job_id_from_dirname(
101
113
  self.dirname, include_prefix=True
102
114
  )
103
- return backend.get_status(self.dirname, self._job_id)
115
+ run_name = os.path.basename(self.inputs_dir) if self.inputs_dir else ""
116
+ self._run_id = run_name if run_name.startswith("run_") else None
117
+ return backend.get_status(self.dirname, self._job_id, self._run_id)
104
118
  else:
105
119
  # Local backend
106
120
  return backend.get_status(self.dirname, self.inputs_dir, self.slurmq)
@@ -149,7 +163,9 @@ class ExecutableDirectory:
149
163
 
150
164
  # HTTP backend
151
165
  if isinstance(backend, _HTTPExecutionBackend):
152
- self._job_id = backend.run(self.dirname, self._input_json, **kwargs)
166
+ self._job_id, self._run_id = backend.run(
167
+ self.dirname, self._input_json, **kwargs
168
+ )
153
169
  return
154
170
 
155
171
  # Local execution
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: playmolecule
3
- Version: 2.7.2
3
+ Version: 2.7.4
4
4
  Summary: PlayMolecule python API
5
5
  Author-email: Acellera <info@acellera.com>
6
6
  Project-URL: Homepage, https://github.com/Acellera/playmolecule
@@ -72,6 +72,7 @@ playmolecule/apps/__init__.py
72
72
  playmolecule/share/apptainer_run.sh
73
73
  playmolecule/share/docker_run.sh
74
74
  tests/3ptb.pdb
75
+ tests/bench_dump_api.py
75
76
  tests/conftest.py
76
77
  tests/test_app_docs_namespace.py
77
78
  tests/test_apps_linking.py
@@ -82,7 +83,7 @@ tests/test_defer_discovery.py
82
83
  tests/test_describe_apps.py
83
84
  tests/test_docker_backend.py
84
85
  tests/test_docker_docs_split.py
85
- tests/test_dump_manifests.py
86
+ tests/test_dump_api.py
86
87
  tests/test_find_apps_resilient.py
87
88
  tests/test_func_name_lowercase.py
88
89
  tests/test_get_app_list_channels.py
@@ -0,0 +1,92 @@
1
+ """Benchmark dump_api against the live v3 catalog. Run manually:
2
+ PM_DEFER_DISCOVERY=1 .venv/bin/python tests/bench_dump_api.py
3
+ Prints http / parse / structure / build / serialize+write / total seconds.
4
+
5
+ The fetch is broken down into raw HTTP transfer, JSON parse, and the
6
+ identity-keying "structure" step (the latter mirrors what
7
+ ``_HTTPManifestBackend.get_apps`` does after fetch+parse) so each cost is
8
+ visible separately instead of hidden inside ``get_apps_and_docs``.
9
+ """
10
+ import os
11
+ import sys
12
+ import time
13
+ import tempfile
14
+
15
+ REGISTRY = "https://open.playmolecule.org/v3"
16
+
17
+
18
+ def main():
19
+ os.environ["PM_DEFER_DISCOVERY"] = "1" # import without discovery; we time it
20
+ from playmolecule._backends import _get_manifest_backend
21
+ from playmolecule._backends._http import _get_headers, _DEFAULT_TIMEOUT
22
+ from playmolecule._identity import app_identity
23
+ from playmolecule.apps import _manifest_to_func, _function_dict
24
+ from playmolecule import dump_api
25
+
26
+ backend = _get_manifest_backend(REGISTRY)
27
+ sess = backend._session.session
28
+ pc = time.perf_counter
29
+
30
+ # --- /apps/manifests: raw HTTP transfer, then JSON parse ---
31
+ t0 = pc()
32
+ rsp_m = sess.get(
33
+ f"{backend.base_url}/apps/manifests",
34
+ headers=_get_headers(),
35
+ timeout=_DEFAULT_TIMEOUT,
36
+ )
37
+ t1 = pc()
38
+ manifests_json = rsp_m.json()
39
+ t2 = pc()
40
+
41
+ # --- /apps/docs: raw HTTP transfer, then JSON parse ---
42
+ rsp_d = sess.get(
43
+ f"{backend.base_url}/apps/docs",
44
+ headers=_get_headers(),
45
+ timeout=_DEFAULT_TIMEOUT,
46
+ )
47
+ t3 = pc()
48
+ docs = rsp_d.json() if rsp_d.status_code == 200 else {}
49
+ t4 = pc()
50
+
51
+ # --- structure: app_identity keying (what get_apps does beyond fetch+parse) ---
52
+ apps = {}
53
+ for _key, manifest in manifests_json.items():
54
+ identity = app_identity(manifest)
55
+ if identity is None:
56
+ continue
57
+ app_name, version = identity
58
+ apps.setdefault(app_name, {})[version] = {
59
+ "manifest": manifest,
60
+ "appdir": None,
61
+ "run.sh": None,
62
+ "files": backend._get_app_files(manifest),
63
+ }
64
+ t5 = pc()
65
+
66
+ # --- build the dynamic function tree ---
67
+ for appname in apps:
68
+ _manifest_to_func(appname, apps[appname])
69
+ for version in apps[appname]:
70
+ vmod = sys.modules.get(f"playmolecule.apps.{appname}.{version}")
71
+ if vmod is not None:
72
+ setattr(vmod, "docs", docs.get(f"{appname}.{version}", {}))
73
+ t6 = pc()
74
+
75
+ # --- serialize + write (tree already built; dump_api skips re-discovery) ---
76
+ with tempfile.TemporaryDirectory() as out:
77
+ dump_api(out, registry=REGISTRY)
78
+ t7 = pc()
79
+
80
+ http = (t1 - t0) + (t3 - t2)
81
+ parse = (t2 - t1) + (t4 - t3)
82
+ print(f"apps: {len(apps)} functions: {len(_function_dict)}")
83
+ print(f"http : {http:.3f}s (manifests {t1 - t0:.3f}s + docs {t3 - t2:.3f}s)")
84
+ print(f"parse : {parse:.3f}s (manifests {t2 - t1:.3f}s + docs {t4 - t3:.3f}s)")
85
+ print(f"structure : {t5 - t4:.3f}s")
86
+ print(f"build : {t6 - t5:.3f}s")
87
+ print(f"serialize+write: {t7 - t6:.3f}s")
88
+ print(f"total : {t7 - t0:.3f}s")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()