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.
- {playmolecule-2.7.2/playmolecule.egg-info → playmolecule-2.7.4}/PKG-INFO +1 -1
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/__init__.py +1 -1
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_docker.py +27 -4
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_http.py +16 -6
- playmolecule-2.7.4/playmolecule/_dump.py +198 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_identity.py +22 -8
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_public_api.py +32 -16
- {playmolecule-2.7.2 → playmolecule-2.7.4/playmolecule.egg-info}/PKG-INFO +1 -1
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/SOURCES.txt +2 -1
- playmolecule-2.7.4/tests/bench_dump_api.py +92 -0
- playmolecule-2.7.4/tests/test_dump_api.py +257 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_backend.py +85 -2
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_identity.py +14 -5
- playmolecule-2.7.2/playmolecule/_dump.py +0 -59
- playmolecule-2.7.2/tests/test_dump_manifests.py +0 -30
- {playmolecule-2.7.2 → playmolecule-2.7.4}/.github/workflows/docs.yml +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/.github/workflows/pythonpublish.yml +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/.gitignore +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/LICENSE +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/MANIFEST.in +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/Makefile +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/README.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/Makefile +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/getversion.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/make.bat +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/.gitignore +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/conf.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/apps-and-manifests.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/architecture.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/artifacts-and-files.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/executable-directory.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/index.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/explanation/job-lifecycle.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/check-job-status.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/index.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/install-apps-for-a-cluster.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/list-and-inspect-apps.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/log-in-to-the-http-backend.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/pass-input-files-to-an-app.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-an-app-locally.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-an-app-on-slurm.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-built-in-app-tests.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/run-many-jobs-on-one-gpu.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/select-an-app-version.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/switch-between-docker-and-apptainer.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/update-installed-apps.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/howto/use-app-artifacts.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/index.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/installation.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/llms-full.txt +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/cli.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/environment-variables.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/reference/index.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/01-first-app-run.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/02-using-app-versions-and-tests.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/03-running-on-slurm.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/docs/source/tutorials/index.md +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_appfiles.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/__init__.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_local.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_backends/_slurm.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_channels.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_cli.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_config.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_devutils.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_dynamicfunction.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_protocols.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_tests.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_update.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/_validation.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/apps/__init__.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/logging.ini +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/share/apptainer_run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule/share/docker_run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/dependency_links.txt +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/entry_points.txt +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/requires.txt +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/playmolecule.egg-info/top_level.txt +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/pyproject.toml +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/setup.cfg +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/conftest.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_app_docs_namespace.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_apps_linking.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_backends.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_channel_config.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_channels.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_defer_discovery.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_describe_apps.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_docker_backend.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_docker_docs_split.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_find_apps_resilient.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_func_name_lowercase.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_get_app_list_channels.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_channel.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_http_docs_split.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_backend.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_backend_cancel.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_local_docs_and_lowercase.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/__init__.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/__init__.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/__init__.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/crypticscout.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/acellera-protocols/protocols/crypticscout/v1/files/tutorials/my_tutorial.ipynb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/ProteinPrepare_v1.json +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/datasets/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/587HG92V.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/reprepare.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tests/web_content.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/files/tutorials/learn_this_app.ipynb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinprepare/v1/run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/ProteinPrepareNew_v1.json +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/datasets/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/587HG92V.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/reprepare.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tests/web_content.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/files/tutorials/learn_this_app.ipynb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v1/run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/ProteinPrepareNew_v1.json +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/datasets/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/587HG92V.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/reprepare.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tests/web_content.pickle +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/files/tutorials/learn_this_app.ipynb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apps/proteinpreparenew/v2/run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/apptainer_run.sh +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/datasets/3ptb.pdb +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule/datasets/datasets.json +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_playmolecule.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_pmbackend.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_render_runsh_linking.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_runsh_render.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/tests/test_slpm_manifest.py +0 -0
- {playmolecule-2.7.2 → playmolecule-2.7.4}/update_license_header.py +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
97
|
-
# .run()
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -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/
|
|
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()
|