physistool 0.1.2__tar.gz → 0.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physistool
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Open-source PhysisFold client — CLI + SDK for constraint-guided all-atom protein folding via a remote (Nitro Enclave) endpoint.
5
5
  Author: MorphoComputing
6
6
  License: MIT
@@ -29,4 +29,4 @@ from .physis_client import FoldError, fold
29
29
 
30
30
  __all__ = ["fold", "FoldError"]
31
31
 
32
- __version__ = "0.1.2"
32
+ __version__ = "0.1.4"
@@ -36,7 +36,7 @@ _TIMEOUT = 15 # seconds
36
36
  # in sync with ``build/package.py`` VERSION — this is the version of the shipped
37
37
  # wheel. It is *spoofable* by design: the gate is a forced-upgrade lever, not an
38
38
  # anti-tamper check — the real enforcement lives server-side.
39
- CLIENT_VERSION = "0.1.2"
39
+ CLIENT_VERSION = "0.1.3"
40
40
 
41
41
 
42
42
  class MeteringError(Exception):
@@ -52,6 +52,25 @@ class MeteringError(Exception):
52
52
  self.detail = detail or message
53
53
 
54
54
 
55
+ class UpgradeRequiredError(MeteringError):
56
+ """The server's MIN_CLIENT_VERSION gate refused this build (HTTP 403).
57
+
58
+ Enforced server-side on the charge path (reserve) BEFORE any credit moves, so
59
+ an old or cracked client cannot fold. Carries the required + current versions
60
+ and a ready-to-print ``pip install -U`` message. Subclasses MeteringError so
61
+ existing ``except MeteringError`` sites still catch it, but callers may catch
62
+ this specifically to show a clean forced-upgrade prompt.
63
+ """
64
+
65
+ def __init__(self, min_client_version: str, your_version: str):
66
+ self.min_client_version = min_client_version
67
+ self.your_version = your_version
68
+ msg = (f"this physistool client ({your_version}) is below the minimum "
69
+ f"supported version ({min_client_version}). "
70
+ f"Upgrade with: pip install -U physistool")
71
+ super().__init__(msg, status=403, detail=msg)
72
+
73
+
55
74
  def _post(base_url: str, path: str, payload: dict) -> dict:
56
75
  url = f"{base_url}{path}"
57
76
  # client_version rides along on every call (see CLIENT_VERSION); an explicit
@@ -66,7 +85,16 @@ def _post(base_url: str, path: str, payload: dict) -> dict:
66
85
  body = resp.read().decode("utf-8")
67
86
  return json.loads(body) if body else {}
68
87
  except urllib.error.HTTPError as e:
69
- detail = _read_error_detail(e)
88
+ raw, obj = _read_error_body(e)
89
+ # Forced-upgrade gate: a 403 carrying min_client_version means this build
90
+ # is below the server's floor. Surface it as a dedicated, actionable error
91
+ # (the client prints "pip install -U") instead of a generic reserve failure.
92
+ if e.code == 403 and isinstance(obj, dict) and obj.get("min_client_version"):
93
+ raise UpgradeRequiredError(
94
+ str(obj["min_client_version"]),
95
+ str(obj.get("your_version") or CLIENT_VERSION)) from e
96
+ detail = (obj.get("error") or obj.get("detail") or raw) if isinstance(obj, dict) \
97
+ else (raw or e.reason or "unknown error")
70
98
  raise MeteringError(f"backend returned HTTP {e.code}: {detail}",
71
99
  status=e.code, detail=detail) from e
72
100
  except urllib.error.URLError as e:
@@ -79,13 +107,17 @@ def _post(base_url: str, path: str, payload: dict) -> dict:
79
107
  status=0, detail=str(e)) from e
80
108
 
81
109
 
82
- def _read_error_detail(e: "urllib.error.HTTPError") -> str:
110
+ def _read_error_body(e: "urllib.error.HTTPError") -> "tuple[str, object]":
111
+ """Read an HTTPError body once, returning (raw_text, parsed_json_or_None)."""
112
+ raw = ""
113
+ try:
114
+ raw = e.read().decode("utf-8", "replace")
115
+ except OSError:
116
+ return "", None
83
117
  try:
84
- body = e.read().decode("utf-8")
85
- obj = json.loads(body)
86
- return obj.get("error") or obj.get("detail") or body
87
- except (ValueError, OSError):
88
- return e.reason or "unknown error"
118
+ return raw, json.loads(raw)
119
+ except ValueError:
120
+ return raw, None
89
121
 
90
122
 
91
123
  # ── lifecycle calls ─────────────────────────────────────────────────────────
@@ -668,6 +668,10 @@ def fold(
668
668
  backend, api_key, credits=credits,
669
669
  idempotency_key=str(uuid.uuid4()), input_hash=input_hash,
670
670
  )
671
+ except metering_mod.UpgradeRequiredError as exc:
672
+ # Forced-upgrade gate (server MIN_CLIENT_VERSION). Not a reserve/credit
673
+ # problem — the build is too old to fold. Surface the pip command as-is.
674
+ raise FoldError(exc.detail, status=exc.status, error="please_upgrade") from exc
671
675
  except metering_mod.MeteringError as exc:
672
676
  raise FoldError(
673
677
  f"could not reserve {credits} credit(s): {exc.detail}",
@@ -825,10 +829,17 @@ def fold(
825
829
  if not pdb_b64:
826
830
  raise FoldError("server reported ok but returned no pdb_b64", status=status)
827
831
  pdb_bytes = base64.b64decode(pdb_b64)
832
+ # Create the --out directory if the customer pointed at a new subfolder
833
+ # (e.g. --out run1/fold.pdb). open() alone would raise on a missing dir;
834
+ # batch mode already does this per-job, so match that behaviour here.
835
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
828
836
  with open(out_path, "wb") as fh:
829
837
  fh.write(pdb_bytes)
830
838
  if report_path is None:
831
839
  report_path = os.path.splitext(out_path)[0] + ".json"
840
+ # --report-json may point at its own (possibly missing) subfolder; create it
841
+ # too. Harmless when it is the same dir as --out (already made above).
842
+ os.makedirs(os.path.dirname(os.path.abspath(report_path)) or ".", exist_ok=True)
832
843
  with open(report_path, "w", encoding="utf-8") as fh:
833
844
  json.dump(report_json, fh, indent=2)
834
845
  # Value layer (§2.3): interactive .html + .md/.pml/CSVs/trajectory/ensemble/
@@ -952,6 +963,41 @@ _MANIFEST_EVIDENCE = {dest: kwarg for _f, dest, kwarg, *_ in _EVIDENCE_FLAGS}
952
963
  _MANIFEST_EVIDENCE["input_af2"] = _MANIFEST_EVIDENCE["input_ai"]
953
964
 
954
965
 
966
+ def _job_override(spec: "dict", key: str, default):
967
+ """Per-job manifest override: the job's own value when present (non-null),
968
+ else the batch-global ``default``.
969
+
970
+ Lets a manifest set ``best_of`` / ``seed`` / ``helix_prior`` / evidence knobs /
971
+ ``out`` per job instead of one value shared by every job. A ``null`` (or absent)
972
+ field falls back to the global flag, so mixed manifests stay concise."""
973
+ if not isinstance(spec, dict):
974
+ return default
975
+ v = spec.get(key)
976
+ return default if v is None else v
977
+
978
+
979
+ def _job_best_of(spec: "dict", global_best_of) -> int:
980
+ """A job's effective ``best_of`` (its own override, else the global), ≥ 1.
981
+ Used for the credit total and worker sizing as well as the fold itself."""
982
+ return max(1, int(_job_override(spec, "best_of", global_best_of)))
983
+
984
+
985
+ def _job_out_path(override: "str | None", out_dir: str, jid: str) -> str:
986
+ """Resolve a job's output PDB path.
987
+
988
+ Default (no manifest ``out``): ``out_dir/<jid>/<jid>.pdb``. A manifest ``out``
989
+ field overrides it, with the same feel as the single-fold ``--out``: a *file*
990
+ path (``run1/my.pdb``) sets the directory + stem for the whole bundle (report,
991
+ HTML, CSVs re-stem to it); a *directory* (trailing separator or no extension)
992
+ drops ``<jid>.pdb`` inside. Relative paths resolve against the batch ``out_dir``."""
993
+ if not override:
994
+ return os.path.join(out_dir, jid, f"{jid}.pdb")
995
+ p = override if os.path.isabs(override) else os.path.join(out_dir, override)
996
+ if override.endswith(("/", "\\")) or not os.path.splitext(p)[1]:
997
+ return os.path.join(p, f"{jid}.pdb")
998
+ return p
999
+
1000
+
955
1001
  def fold_batch(
956
1002
  manifest_path: str,
957
1003
  out_dir: str,
@@ -979,8 +1025,14 @@ def fold_batch(
979
1025
  an optional ``distogram_artifact``, any ``input_<name>`` evidence fields
980
1026
  (path or list; relative paths resolve against the manifest dir), and an optional
981
1027
  ``held_out_input`` path (independent contacts scored post-fold into that job's
982
- report — §C5). Global flags (``best_of`` / ``seed`` / ``disto_max_bin`` / helix /
983
- evidence params / held-out threshold+neff / ``encrypt``) apply to every job. Outputs go to
1028
+ report — §C5). The function arguments (``best_of`` / ``seed`` / ``disto_max_bin`` /
1029
+ helix / evidence params / held-out threshold+neff / ``encrypt``) are the batch-wide
1030
+ DEFAULTS; **any job may override them per-id** by carrying the same-named field
1031
+ (``best_of``, ``seed``, ``helix_prior``, ``helix_prior_ensemble``, ``disto_max_bin``,
1032
+ ``template_sigma``, ``noe_mode``, ``soi_fill``, ``held_out_threshold``,
1033
+ ``held_out_msa_neff``, ``encrypt``). A per-job ``out`` field further overrides where
1034
+ that job writes (a file path sets the whole bundle's dir+stem; a directory drops
1035
+ ``<id>.pdb`` inside; relative to ``out_dir``). Absent overrides default to
984
1036
  ``out_dir/<id>/<id>.{pdb,json}``; the batch aggregate is returned.
985
1037
 
986
1038
  Metering mirrors the single fold, per job: each sequence reserves ``best_of``
@@ -1034,9 +1086,14 @@ def fold_batch(
1034
1086
  if held_out_msa_neff is not None:
1035
1087
  held_out_params["msa_neff"] = float(held_out_msa_neff)
1036
1088
 
1037
- env = _import_envelope() if encrypt else None
1089
+ # Encryption may be global (``--encrypt``) OR requested by any single job (a
1090
+ # manifest ``encrypt: true`` field). Load the envelope + enclave key if EITHER
1091
+ # asks for it; each job then seals (or not) per its own effective flag.
1092
+ any_encrypt = encrypt or any(
1093
+ isinstance(s, dict) and bool(s.get("encrypt")) for s in jobs_spec)
1094
+ env = _import_envelope() if any_encrypt else None
1038
1095
  enclave_pub = None
1039
- if encrypt:
1096
+ if any_encrypt:
1040
1097
  if not trusted_pub:
1041
1098
  raise FoldError("attestation is missing enclave_pubkey; cannot encrypt")
1042
1099
  enclave_pub = env.load_public_key_b64(trusted_pub)
@@ -1044,6 +1101,7 @@ def fold_batch(
1044
1101
  batch: list[dict] = []
1045
1102
  cust_keys: dict[str, object] = {} # jid -> customer private key (encrypt)
1046
1103
  reservations: dict[str, str] = {} # jid -> reservation_id (metering)
1104
+ job_out_paths: dict[str, str] = {} # jid -> resolved output PDB path
1047
1105
  try:
1048
1106
  for idx, spec in enumerate(jobs_spec):
1049
1107
  if not isinstance(spec, dict) or not spec.get("sequence"):
@@ -1078,30 +1136,55 @@ def fold_batch(
1078
1136
  f"job '{jid}': blind (FASTA) input needs evidence "
1079
1137
  "(an input_<name> field) or a distogram_artifact")
1080
1138
 
1139
+ # ── Per-job overrides (a manifest field beats the batch-global flag) ──
1140
+ job_best_of = _job_best_of(spec, best_of)
1141
+ job_seed = _job_override(spec, "seed", seed)
1142
+ job_helix = bool(_job_override(spec, "helix_prior", helix_prior))
1143
+ job_helix_ens = bool(_job_override(
1144
+ spec, "helix_prior_ensemble", helix_prior_ensemble))
1145
+ job_disto_max_bin = _job_override(spec, "disto_max_bin", disto_max_bin)
1146
+ job_encrypt = bool(_job_override(spec, "encrypt", encrypt))
1147
+ # Evidence / held-out knobs: start from the batch-global dict, then let
1148
+ # the job override individual keys.
1149
+ job_ev_params = dict(evidence_params)
1150
+ if spec.get("template_sigma") is not None:
1151
+ job_ev_params["template_sigma"] = float(spec["template_sigma"])
1152
+ if spec.get("noe_mode") is not None:
1153
+ job_ev_params["noe_mode"] = spec["noe_mode"]
1154
+ if spec.get("soi_fill") is not None:
1155
+ job_ev_params["soi_fill"] = spec["soi_fill"]
1156
+ job_ho_params = dict(held_out_params)
1157
+ if spec.get("held_out_threshold") is not None:
1158
+ job_ho_params["threshold"] = float(spec["held_out_threshold"])
1159
+ if spec.get("held_out_msa_neff") is not None:
1160
+ job_ho_params["msa_neff"] = float(spec["held_out_msa_neff"])
1161
+ job_out_paths[jid] = _job_out_path(
1162
+ _job_override(spec, "out", None), out_dir, jid)
1163
+
1081
1164
  args_obj = {
1082
- "best_of": int(best_of),
1165
+ "best_of": job_best_of,
1083
1166
  "distogram_artifact_b64": distogram_b64,
1084
- "helix_prior": bool(helix_prior),
1085
- "helix_prior_ensemble": bool(helix_prior and helix_prior_ensemble),
1167
+ "helix_prior": job_helix,
1168
+ "helix_prior_ensemble": bool(job_helix and job_helix_ens),
1086
1169
  }
1087
- if seed is not None:
1088
- args_obj["seed"] = int(seed)
1089
- if disto_max_bin is not None:
1090
- args_obj["disto_max_bin"] = str(disto_max_bin)
1091
- if evidence_params:
1092
- args_obj["evidence_params"] = evidence_params
1093
- if held_out_params:
1094
- args_obj["held_out_params"] = held_out_params
1170
+ if job_seed is not None:
1171
+ args_obj["seed"] = int(job_seed)
1172
+ if job_disto_max_bin is not None:
1173
+ args_obj["disto_max_bin"] = str(job_disto_max_bin)
1174
+ if job_ev_params:
1175
+ args_obj["evidence_params"] = job_ev_params
1176
+ if job_ho_params:
1177
+ args_obj["held_out_params"] = job_ho_params
1095
1178
 
1096
1179
  fold_token = None
1097
1180
  if metering_on:
1098
1181
  try:
1099
1182
  res = metering_mod.reserve(
1100
- backend, api_key, credits=int(best_of),
1183
+ backend, api_key, credits=job_best_of,
1101
1184
  idempotency_key=str(uuid.uuid4()), input_hash=input_hash)
1102
1185
  except metering_mod.MeteringError as exc:
1103
1186
  raise FoldError(
1104
- f"job '{jid}': could not reserve {best_of} credit(s): "
1187
+ f"job '{jid}': could not reserve {job_best_of} credit(s): "
1105
1188
  f"{exc.detail}", status=exc.status, error=str(exc)) from exc
1106
1189
  fold_token = res.get("fold_token")
1107
1190
  rid = res.get("reservation_id")
@@ -1112,7 +1195,7 @@ def fold_batch(
1112
1195
  reservations[jid] = rid
1113
1196
 
1114
1197
  job_el: dict = {"id": jid, "fold_token": fold_token}
1115
- if encrypt:
1198
+ if job_encrypt:
1116
1199
  cust_priv, _pub = env.generate_keypair()
1117
1200
  cust_keys[jid] = cust_priv
1118
1201
  job_el["customer_pubkey"] = env.public_b64(cust_priv)
@@ -1157,7 +1240,8 @@ def fold_batch(
1157
1240
  jid = r.get("id") or "job"
1158
1241
  st = r.get("status")
1159
1242
  if st == "ok":
1160
- if encrypt and r.get("result_envelope") is not None:
1243
+ # Decrypt per job: only jobs that sealed their input carry a private key.
1244
+ if r.get("result_envelope") is not None and jid in cust_keys:
1161
1245
  blob = _join_envelope(r["result_envelope"])
1162
1246
  inner = json.loads(env.decrypt(blob, cust_keys[jid]).decode("utf-8"))
1163
1247
  pdb_b64 = inner.get("pdb_b64")
@@ -1173,14 +1257,15 @@ def fold_batch(
1173
1257
  n_failed += 1
1174
1258
  print(f"[batch] {jid}: ok but no PDB — skipping write", file=sys.stderr)
1175
1259
  continue
1176
- job_out = os.path.join(out_dir, jid)
1177
- os.makedirs(job_out, exist_ok=True)
1178
- job_pdb = os.path.join(job_out, f"{jid}.pdb")
1260
+ # Per-job output path (manifest ``out`` field) or the default subdir;
1261
+ # its directory is created if it does not exist (like the single fold).
1262
+ job_pdb = job_out_paths.get(jid) or os.path.join(out_dir, jid, f"{jid}.pdb")
1263
+ os.makedirs(os.path.dirname(os.path.abspath(job_pdb)) or ".", exist_ok=True)
1179
1264
  with open(job_pdb, "wb") as fh:
1180
1265
  fh.write(base64.b64decode(pdb_b64))
1181
- with open(os.path.join(job_out, f"{jid}.json"), "w", encoding="utf-8") as fh:
1266
+ with open(os.path.splitext(job_pdb)[0] + ".json", "w", encoding="utf-8") as fh:
1182
1267
  json.dump(report_json, fh, indent=2)
1183
- # Value layer for this job (re-stemmed to <jid>.*, written into job_out).
1268
+ # Value layer for this job (re-stemmed to the job PDB name, same dir).
1184
1269
  _write_artifacts(artifacts, artifacts_stem, job_pdb)
1185
1270
  n_ok += 1
1186
1271
  else:
@@ -1267,10 +1352,14 @@ TYPED EVIDENCE (all repeatable; different types combine freely)
1267
1352
  (a pre-built --distogram-artifact is used as-is and skips all of the above.)
1268
1353
 
1269
1354
  BATCH MANIFEST (jobs.json) - a JSON array; paths resolve against the manifest dir:
1270
- [ { "id": "P1", "sequence": "p1.pdb", "input_template": ["h.pdb"] },
1355
+ [ { "id": "P1", "sequence": "p1.pdb", "input_template": ["h.pdb"],
1356
+ "best_of": 8, "helix_prior": true, "out": "hard/P1.pdb" },
1271
1357
  { "sequence": "p2.fasta", "distogram_artifact": "p2.distogram.json" } ]
1272
- Global flags (--best-of/--seed/--helix-prior/evidence knobs/--encrypt) apply to
1273
- every job; outputs go to <--out>/<id>/<id>.{pdb,json}.
1358
+ The CLI flags (--best-of/--seed/--helix-prior/evidence knobs/--encrypt) are the
1359
+ batch-wide DEFAULTS; any job may override them per-id with a same-named field
1360
+ (best_of, seed, helix_prior, helix_prior_ensemble, disto_max_bin, template_sigma,
1361
+ noe_mode, soi_fill, held_out_threshold, held_out_msa_neff, encrypt). A per-job
1362
+ "out" also overrides where that job writes. Default output: <--out>/<id>/<id>.{pdb,json}.
1274
1363
 
1275
1364
  GOOD TO KNOW
1276
1365
  - Billing is enforced server-side: the enclave refuses any fold without a
@@ -1339,11 +1428,18 @@ BATCH (many sequences, one request)
1339
1428
 
1340
1429
  fold_batch(
1341
1430
  "jobs.json", "results/", # manifest + results root
1342
- best_of=2, encrypt=False, # global flags apply to every job
1431
+ best_of=2, encrypt=False, # batch-wide DEFAULTS (any job may override)
1343
1432
  )
1344
1433
  # Manifest is a JSON array; per-job keys: sequence, id, distogram_artifact,
1345
1434
  # input_<name> evidence fields, held_out_input. Paths resolve against the
1346
1435
  # manifest dir. Outputs go to results/<id>/<id>.{pdb,json}.
1436
+ #
1437
+ # PER-JOB OVERRIDES — a job may carry any of these to beat the batch default:
1438
+ # best_of, seed, helix_prior, helix_prior_ensemble, disto_max_bin,
1439
+ # template_sigma, noe_mode, soi_fill, held_out_threshold, held_out_msa_neff,
1440
+ # encrypt, and `out` (a file path -> that job's dir+stem, or a directory).
1441
+ # e.g. {"id":"hard","sequence":"h.fasta","input_msa":"h.a3m",
1442
+ # "best_of":8,"helix_prior":true,"out":"hard/h.pdb"}
1347
1443
 
1348
1444
  NOTES
1349
1445
  - The dispatcher (default-on for the CLI) is a CLI-layer convenience; the SDK
@@ -1717,18 +1813,22 @@ def _print_batch_preview(manifest_path: str, args) -> None:
1717
1813
  except (OSError, ValueError) as exc:
1718
1814
  print(f"[error] cannot read manifest {manifest_path}: {exc}", file=sys.stderr)
1719
1815
  return
1720
- n = len(jobs) if isinstance(jobs, list) else 0
1721
- credits = n * max(1, int(args.best_of))
1816
+ job_list = jobs if isinstance(jobs, list) else []
1817
+ n = len(job_list)
1818
+ credits = sum(_job_best_of(s, args.best_of) for s in job_list)
1722
1819
  metered = not args.no_metering
1723
1820
  print("Physis Fold — BATCH DRY RUN (nothing folded, no credits charged)")
1724
1821
  print(f" manifest : {manifest_path} ({n} job(s))")
1725
- for i, spec in enumerate(jobs if isinstance(jobs, list) else []):
1822
+ for i, spec in enumerate(job_list):
1726
1823
  jid = (spec.get("id") if isinstance(spec, dict) else None) or f"job_{i}"
1727
1824
  seq = spec.get("sequence", "?") if isinstance(spec, dict) else "?"
1728
- print(f" - {jid:<14} {seq}")
1825
+ bo = _job_best_of(spec, args.best_of)
1826
+ out_ov = spec.get("out") if isinstance(spec, dict) else None
1827
+ extra = f"best-of {bo}" + (f", out {out_ov}" if out_ov else "")
1828
+ print(f" - {jid:<14} {seq:<28} ({extra})")
1729
1829
  print(f" out (root) : {args.out or '<--out required to run>'}")
1730
- print(f" best-of : {args.best_of} seed(s)")
1731
- print(f" credit cost : {credits} credit(s) ({n} × {args.best_of})"
1830
+ print(f" best-of : {args.best_of} seed(s) (global default; per-job overrides apply)")
1831
+ print(f" credit cost : {credits} credit(s) (sum of per-job best-of)"
1732
1832
  + ("" if metered else " [metering off - no charge]"))
1733
1833
  print(" --> Re-run with --run to execute for real.")
1734
1834
 
@@ -1745,10 +1845,12 @@ def _command_foldings(args) -> int:
1745
1845
  try:
1746
1846
  with open(args.batch, encoding="utf-8") as fh:
1747
1847
  jobs = json.load(fh)
1748
- n = len(jobs) if isinstance(jobs, list) else 1
1749
1848
  except (OSError, ValueError):
1750
- n = 1
1751
- return best_of * max(1, n)
1849
+ return best_of
1850
+ if not isinstance(jobs, list) or not jobs:
1851
+ return best_of
1852
+ # Per-job best_of may differ; sum the effective foldings across all jobs.
1853
+ return sum(_job_best_of(s, best_of) for s in jobs)
1752
1854
  return best_of
1753
1855
 
1754
1856
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physistool
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Open-source PhysisFold client — CLI + SDK for constraint-guided all-atom protein folding via a remote (Nitro Enclave) endpoint.
5
5
  Author: MorphoComputing
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physistool"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Open-source PhysisFold client — CLI + SDK for constraint-guided all-atom protein folding via a remote (Nitro Enclave) endpoint."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes