aiida-pythonjob 0.2.5__tar.gz → 0.3.0__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 (54) hide show
  1. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/.readthedocs.yml +1 -0
  2. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/PKG-INFO +2 -1
  3. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/environment.yml +1 -1
  4. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/gallery/autogen/pyfunction.py +8 -8
  5. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/gallery/autogen/pythonjob.py +46 -17
  6. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/requirements.txt +3 -1
  7. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/pyproject.toml +4 -0
  8. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/__init__.py +4 -1
  9. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/calculations/pyfunction.py +15 -4
  10. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/decorator.py +6 -2
  11. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/launch.py +33 -26
  12. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/parsers/pythonjob.py +6 -3
  13. aiida_pythonjob-0.3.0/src/aiida_pythonjob/ports_adapter.py +106 -0
  14. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/utils.py +132 -186
  15. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_parser.py +16 -22
  16. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_pyfunction.py +72 -76
  17. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_pythonjob.py +11 -25
  18. aiida_pythonjob-0.2.5/examples/test_add.py +0 -15
  19. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/.github/workflows/ci.yml +0 -0
  20. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/.github/workflows/python-publish.yml +0 -0
  21. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/.gitignore +0 -0
  22. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/.pre-commit-config.yaml +0 -0
  23. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/LICENSE +0 -0
  24. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/README.md +0 -0
  25. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/Makefile +0 -0
  26. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/gallery/autogen/GALLERY_HEADER.rst +0 -0
  27. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/make.bat +0 -0
  28. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/conf.py +0 -0
  29. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/index.rst +0 -0
  30. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/installation.rst +0 -0
  31. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/tutorial/dft.ipynb +0 -0
  32. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/tutorial/html/atomization_energy.html +0 -0
  33. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/tutorial/html/pythonjob_eos_emt.html +0 -0
  34. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/docs/source/tutorial/index.rst +0 -0
  35. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/calculations/__init__.py +0 -0
  36. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/calculations/pythonjob.py +0 -0
  37. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/calculations/utils.py +0 -0
  38. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/config.py +0 -0
  39. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/__init__.py +0 -0
  40. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/atoms.py +0 -0
  41. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/data_wrapper.py +0 -0
  42. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/deserializer.py +0 -0
  43. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/jsonable_data.py +0 -0
  44. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/pickled_data.py +0 -0
  45. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/serializer.py +0 -0
  46. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/data/utils.py +0 -0
  47. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/src/aiida_pythonjob/parsers/__init__.py +0 -0
  48. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/conftest.py +0 -0
  49. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/inputs_folder/another_input.txt +0 -0
  50. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_create_env.py +0 -0
  51. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_data.py +0 -0
  52. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_entry_points.py +0 -0
  53. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_serializer.py +0 -0
  54. {aiida_pythonjob-0.2.5 → aiida_pythonjob-0.3.0}/tests/test_utils.py +0 -0
@@ -12,6 +12,7 @@ build:
12
12
  - rabbitmq-server -detached
13
13
  - sleep 5
14
14
  - rabbitmq-diagnostics status
15
+ - pip list
15
16
  - verdi presto
16
17
  - verdi daemon start
17
18
  - verdi status
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiida-pythonjob
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: Run Python functions on a remote computer.
5
5
  Project-URL: Source, https://github.com/aiidateam/aiida-pythonjob
6
6
  Author-email: Xing Wang <xingwang1991@gmail.com>
@@ -37,6 +37,7 @@ Requires-Python: >=3.9
37
37
  Requires-Dist: aiida-core<3,>=2.3
38
38
  Requires-Dist: ase
39
39
  Requires-Dist: cloudpickle
40
+ Requires-Dist: node-graph==0.2.22
40
41
  Provides-Extra: dev
41
42
  Requires-Dist: hatch; extra == 'dev'
42
43
  Provides-Extra: docs
@@ -3,5 +3,5 @@ channels:
3
3
  - conda-forge
4
4
  - defaults
5
5
  dependencies:
6
- - aiida-core
6
+ - aiida-core~=2.6.3
7
7
  - aiida-core.services
@@ -6,14 +6,14 @@ PyFunction
6
6
 
7
7
  ######################################################################
8
8
  # Default outputs
9
- # --------------
9
+ # -----------------
10
10
  #
11
11
  # The default output of the function is `result`. The `pyfunction` task
12
12
  # will store the result as one node in the database with the key `result`.
13
13
  #
14
14
  from aiida import load_profile
15
15
  from aiida.engine import run_get_node
16
- from aiida_pythonjob import pyfunction
16
+ from aiida_pythonjob import pyfunction, spec
17
17
 
18
18
  load_profile()
19
19
 
@@ -35,7 +35,7 @@ print("result: ", result)
35
35
  #
36
36
 
37
37
 
38
- @pyfunction(outputs=[{"name": "sum"}, {"name": "diff"}])
38
+ @pyfunction(outputs=spec.namespace(sum=any, diff=any))
39
39
  def add(x, y):
40
40
  return {"sum": x + y, "diff": x - y}
41
41
 
@@ -48,7 +48,7 @@ print("diff: ", result["diff"])
48
48
 
49
49
  ######################################################################
50
50
  # Namespace Output
51
- # --------------
51
+ # -----------------
52
52
  #
53
53
  # The `pyfunction` allows users to define namespace outputs. A namespace output
54
54
  # is a dictionary with keys and values returned by a function. Each value in
@@ -70,7 +70,7 @@ from ase import Atoms # noqa: E402
70
70
  from ase.build import bulk # noqa: E402
71
71
 
72
72
 
73
- @pyfunction(outputs=[{"name": "scaled_structures", "identifier": "namespace"}])
73
+ @pyfunction(outputs=spec.dynamic(Atoms))
74
74
  def generate_structures(structure: Atoms, factor_lst: list) -> dict:
75
75
  """Scale the structure by the given factor_lst."""
76
76
  scaled_structures = {}
@@ -78,12 +78,12 @@ def generate_structures(structure: Atoms, factor_lst: list) -> dict:
78
78
  atoms = structure.copy()
79
79
  atoms.set_cell(atoms.cell * factor_lst[i], scale_atoms=True)
80
80
  scaled_structures[f"s_{i}"] = atoms
81
- return {"scaled_structures": scaled_structures}
81
+ return scaled_structures
82
82
 
83
83
 
84
84
  result, node = run_get_node(generate_structures, structure=bulk("Al"), factor_lst=[0.95, 1.0, 1.05])
85
85
  print("scaled_structures: ")
86
- for key, value in result["scaled_structures"].items():
86
+ for key, value in result.items():
87
87
  print(key, value)
88
88
 
89
89
 
@@ -115,7 +115,7 @@ print("exit_message:", node.exit_message)
115
115
 
116
116
  ######################################################################
117
117
  # Define your data serializer and deserializer
118
- # --------------
118
+ # ----------------------------------------------
119
119
  #
120
120
  # PythonJob search data serializer from the `aiida.data` entry point by the
121
121
  # module name and class name (e.g., `ase.atoms.Atoms`).
@@ -63,14 +63,14 @@ PythonJob
63
63
 
64
64
  ######################################################################
65
65
  # Default outputs
66
- # --------------
66
+ # ----------------
67
67
  #
68
68
  # The default output of the function is `result`. The `PythonJob` task
69
69
  # will store the result as one node in the database with the key `result`.
70
70
  #
71
71
  from aiida import load_profile
72
72
  from aiida.engine import run_get_node
73
- from aiida_pythonjob import PythonJob, prepare_pythonjob_inputs
73
+ from aiida_pythonjob import PythonJob, prepare_pythonjob_inputs, spec
74
74
 
75
75
  load_profile()
76
76
 
@@ -91,7 +91,7 @@ print("result: ", result["result"])
91
91
  # Custom outputs
92
92
  # --------------
93
93
  # If the function return a dictionary with fixed number of keys, and you
94
- # want to store the values as separate outputs, you can specify the `output_ports` parameter.
94
+ # want to store the values as separate outputs, you can specify the `outputs_spec` parameter.
95
95
  # For a dynamic number of outputs, you can use the namespace output, which is explained later.
96
96
  #
97
97
 
@@ -103,10 +103,7 @@ def add(x, y):
103
103
  inputs = prepare_pythonjob_inputs(
104
104
  add,
105
105
  function_inputs={"x": 1, "y": 2},
106
- output_ports=[
107
- {"name": "sum"},
108
- {"name": "diff"},
109
- ],
106
+ outputs_spec=spec.namespace(sum=any, diff=any),
110
107
  )
111
108
  result, node = run_get_node(PythonJob, **inputs)
112
109
 
@@ -117,7 +114,7 @@ print("diff: ", result["diff"])
117
114
 
118
115
  ######################################################################
119
116
  # Using parent folder
120
- # --------------
117
+ # -----------------------
121
118
  # The parent_folder parameter allows a task to access the output files of
122
119
  # a parent task. This feature is particularly useful when you want to reuse
123
120
  # data generated by a previous computation in subsequent computations. In
@@ -142,7 +139,6 @@ def multiply(x, y):
142
139
  inputs1 = prepare_pythonjob_inputs(
143
140
  add,
144
141
  function_inputs={"x": 1, "y": 2},
145
- output_ports=[{"name": "sum"}],
146
142
  )
147
143
 
148
144
  result1, node1 = run_get_node(PythonJob, inputs=inputs1)
@@ -150,7 +146,6 @@ result1, node1 = run_get_node(PythonJob, inputs=inputs1)
150
146
  inputs2 = prepare_pythonjob_inputs(
151
147
  multiply,
152
148
  function_inputs={"x": 1, "y": 2},
153
- output_ports=[{"name": "product"}],
154
149
  parent_folder=result1["remote_folder"],
155
150
  )
156
151
 
@@ -160,7 +155,7 @@ print("result: ", result2)
160
155
 
161
156
  ######################################################################
162
157
  # Upload files or folders to the remote computer
163
- # --------------
158
+ # -------------------------------------------------
164
159
  # The `upload_files` parameter allows users to upload files or folders to
165
160
  # the remote computer. The files will be uploaded to the working directory of the remote computer.
166
161
  #
@@ -202,7 +197,7 @@ print("result: ", result["result"])
202
197
 
203
198
  ######################################################################
204
199
  # Retrieve additional files from the remote computer
205
- # --------------
200
+ # ----------------------------------------------------
206
201
  # Sometimes, one may want to retrieve additional files from the remote
207
202
  # computer after the job has finished. For example, one may want to retrieve
208
203
  # the output files generated by the `pw.x` calculation in Quantum ESPRESSO.
@@ -235,7 +230,7 @@ print("retrieved files: ", result["retrieved"].list_object_names())
235
230
 
236
231
  ######################################################################
237
232
  # Namespace Output
238
- # --------------
233
+ # ------------------
239
234
  #
240
235
  # The `PythonJob` allows users to define namespace outputs. A namespace output
241
236
  # is a dictionary with keys and values returned by a function. Each value in
@@ -264,21 +259,55 @@ def generate_structures(structure: Atoms, factor_lst: list) -> dict:
264
259
  atoms = structure.copy()
265
260
  atoms.set_cell(atoms.cell * factor_lst[i], scale_atoms=True)
266
261
  scaled_structures[f"s_{i}"] = atoms
267
- return {"scaled_structures": scaled_structures}
262
+ return scaled_structures
268
263
 
269
264
 
270
265
  inputs = prepare_pythonjob_inputs(
271
266
  generate_structures,
272
267
  function_inputs={"structure": bulk("Al"), "factor_lst": [0.95, 1.0, 1.05]},
273
- output_ports=[{"name": "scaled_structures", "identifier": "namespace"}],
268
+ outputs_spec=spec.dynamic(Atoms),
274
269
  )
275
270
 
276
271
  result, node = run_get_node(PythonJob, inputs=inputs)
277
272
  print("scaled_structures: ")
278
- for key, value in result["scaled_structures"].items():
273
+ for key, value in result.items():
279
274
  print(key, value)
280
275
 
281
276
 
277
+ ######################################################################
278
+ # --------------------------
279
+ # Nested Namespace
280
+ # --------------------------
281
+ #
282
+ # One can also define nested namespace outputs by specifying the "ports" parameter.
283
+
284
+
285
+ def generate_structures(structure: Atoms, factor_lst: list) -> dict:
286
+ """Scale the structure by the given factor_lst."""
287
+ scaled_structures = {}
288
+ volumes = {}
289
+ for i in range(len(factor_lst)):
290
+ atoms = structure.copy()
291
+ atoms.set_cell(atoms.cell * factor_lst[i], scale_atoms=True)
292
+ scaled_structures[f"s_{i}"] = atoms
293
+ volumes[f"v_{i}"] = atoms.get_volume()
294
+ return {
295
+ "scaled_structures": scaled_structures,
296
+ "volume": volumes,
297
+ }
298
+
299
+
300
+ inputs = prepare_pythonjob_inputs(
301
+ generate_structures,
302
+ function_inputs={"structure": bulk("Al"), "factor_lst": [0.95, 1.0, 1.05]},
303
+ outputs_spec=spec.namespace(scaled_structures=spec.dynamic(Atoms), volume=spec.dynamic(float)),
304
+ )
305
+
306
+ result, node = run_get_node(PythonJob, inputs=inputs)
307
+ print("result: ", result["scaled_structures"])
308
+ print("volumes: ", result["volume"])
309
+
310
+
282
311
  ######################################################################
283
312
  # What if my calculation fails?
284
313
  # --------------------------------
@@ -375,7 +404,7 @@ print("exit_message:", node.exit_message)
375
404
 
376
405
  ######################################################################
377
406
  # Define your data serializer and deserializer
378
- # --------------
407
+ # ----------------------------------------------
379
408
  #
380
409
  # PythonJob search data serializer from the `aiida.data` entry point by the
381
410
  # module name and class name (e.g., `ase.atoms.Atoms`).
@@ -1,4 +1,6 @@
1
- sphinx_rtd_theme==1.2.2
1
+ sphinx_rtd_theme~=3.0
2
+ click~=8.1.0
3
+ pydantic~=2.11.0
2
4
  sphinx-gallery
3
5
  nbsphinx==0.9.2
4
6
  ipython
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "aiida-core>=2.3,<3",
25
25
  "ase",
26
26
  "cloudpickle",
27
+ "node-graph==0.2.22",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
@@ -163,3 +164,6 @@ features = ["docs"]
163
164
  build = [
164
165
  "make -C docs"
165
166
  ]
167
+
168
+ [tool.hatch.metadata]
169
+ allow-direct-references = true
@@ -1,6 +1,8 @@
1
1
  """AiiDA plugin that run Python function on remote computers."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.3.0"
4
+
5
+ from node_graph import spec
4
6
 
5
7
  from .calculations import PythonJob
6
8
  from .decorator import pyfunction
@@ -13,4 +15,5 @@ __all__ = (
13
15
  "PickledData",
14
16
  "prepare_pythonjob_inputs",
15
17
  "PythonJobParser",
18
+ "spec",
16
19
  )
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import traceback
6
6
  import typing as t
7
7
 
8
+ import cloudpickle
9
+ import plumpy
8
10
  from aiida.common.lang import override
9
11
  from aiida.engine import Process, ProcessSpec
10
12
  from aiida.engine.processes.exit_code import ExitCode
@@ -30,10 +32,18 @@ class PyFunction(Process):
30
32
  super().__init__(enable_persistence=False, *args, **kwargs) # type: ignore[misc]
31
33
  self._func = None
32
34
 
35
+ @override
36
+ def load_instance_state(
37
+ self, saved_state: t.MutableMapping[str, t.Any], load_context: plumpy.persistence.LoadSaveContext
38
+ ) -> None:
39
+ """Load the instance state from the saved state."""
40
+
41
+ super().load_instance_state(saved_state, load_context)
42
+ # Restore the function from the pickled data
43
+ self._func = cloudpickle.loads(self.inputs.function_data.pickled_function)
44
+
33
45
  @property
34
46
  def func(self) -> t.Callable[..., t.Any]:
35
- import cloudpickle
36
-
37
47
  if self._func is None:
38
48
  self._func = cloudpickle.loads(self.inputs.function_data.pickled_function)
39
49
  return self._func
@@ -189,7 +199,8 @@ class PyFunction(Process):
189
199
  if exit_code:
190
200
  return exit_code
191
201
  # Store the outputs
192
- for output in self.output_ports["ports"]:
193
- self.out(output["name"], output["value"])
202
+ for name, port in self.output_ports["ports"].items():
203
+ if "value" in port:
204
+ self.out(name, port["value"])
194
205
 
195
206
  return ExitCode()
@@ -60,8 +60,10 @@ def pyfunction(
60
60
  manager = get_manager()
61
61
  runner = manager.get_runner()
62
62
  # # Remove all the known inputs from the kwargs
63
- output_ports = kwargs.pop("output_ports", None) or outputs
64
- input_ports = kwargs.pop("input_ports", None) or inputs
63
+ outputs_spec = kwargs.pop("outputs_spec", None) or outputs
64
+ inputs_spec = kwargs.pop("inputs_spec", None) or inputs
65
+ input_ports = kwargs.pop("input_ports", None)
66
+ output_ports = kwargs.pop("output_ports", None)
65
67
  metadata = kwargs.pop("metadata", None)
66
68
  function_data = kwargs.pop("function_data", None)
67
69
  deserializers = kwargs.pop("deserializers", None)
@@ -73,6 +75,8 @@ def pyfunction(
73
75
  process_inputs = prepare_pyfunction_inputs(
74
76
  function=function,
75
77
  function_inputs=function_inputs,
78
+ inputs_spec=inputs_spec,
79
+ outputs_spec=outputs_spec,
76
80
  input_ports=input_ports,
77
81
  output_ports=output_ports,
78
82
  metadata=metadata,
@@ -2,23 +2,22 @@ from __future__ import annotations
2
2
 
3
3
  import inspect
4
4
  import os
5
- from typing import Any, Callable, Dict, List, Optional, Union
5
+ from typing import Any, Callable, Dict, Optional, Union
6
6
 
7
7
  from aiida import orm
8
+ from node_graph.nodes.utils import generate_input_sockets, generate_output_sockets
8
9
 
9
- from .utils import (
10
- build_function_data,
11
- build_input_port_definitions,
12
- format_input_output_ports,
13
- get_or_create_code,
14
- )
10
+ from .ports_adapter import inputs_sockets_to_ports, outputs_sockets_to_ports
11
+ from .utils import build_function_data, get_or_create_code, serialize_ports
15
12
 
16
13
 
17
14
  def prepare_pythonjob_inputs(
18
15
  function: Optional[Callable[..., Any]] = None,
19
16
  function_inputs: Optional[Dict[str, Any]] = None,
20
- input_ports: Optional[List[str | dict]] = None,
21
- output_ports: Optional[List[str | dict]] = None,
17
+ inputs_spec: Optional[type] = None,
18
+ outputs_spec: Optional[type] = None,
19
+ output_ports: Optional[Dict[str, Any]] = None,
20
+ input_ports: Optional[Dict[str, Any]] = None,
22
21
  code: Optional[orm.AbstractCode] = None,
23
22
  command_info: Optional[Dict[str, str]] = None,
24
23
  computer: Union[str, orm.Computer] = "localhost",
@@ -32,7 +31,6 @@ def prepare_pythonjob_inputs(
32
31
  **kwargs: Any,
33
32
  ) -> Dict[str, Any]:
34
33
  """Prepare the inputs for PythonJob"""
35
- from .utils import serialize_ports
36
34
 
37
35
  if function is None and function_data is None:
38
36
  raise ValueError("Either function or function_data must be provided")
@@ -62,14 +60,18 @@ def prepare_pythonjob_inputs(
62
60
  if code is None:
63
61
  command_info = command_info or {}
64
62
  code = get_or_create_code(computer=computer, **command_info)
65
- output_ports = output_ports or [{"name": "result"}]
66
- output_ports = {"name": "outputs", "identifier": "namespace", "ports": output_ports or [{"name": "result"}]}
67
- input_ports = {"name": "inputs", "identifier": "namespace", "ports": input_ports or []}
68
- input_ports = format_input_output_ports(input_ports)
69
- input_ports = build_input_port_definitions(func=function, input_ports=input_ports)
70
- function_data["output_ports"] = format_input_output_ports(output_ports)
63
+ # outputs
64
+ if not output_ports:
65
+ node_outputs = generate_output_sockets(function or (lambda **_: None), outputs=outputs_spec)
66
+ output_ports = outputs_sockets_to_ports(node_outputs)
67
+ # inputs
68
+ if not input_ports:
69
+ node_inputs = generate_input_sockets(function or (lambda **_: None), inputs=inputs_spec)
70
+ input_ports = inputs_sockets_to_ports(node_inputs)
71
+
72
+ function_data["output_ports"] = output_ports
71
73
  function_data["input_ports"] = input_ports
72
- # serialize the kwargs into AiiDA Data
74
+ # serialize kwargs against the (nested) input schema
73
75
  function_inputs = function_inputs or {}
74
76
  function_inputs = serialize_ports(python_data=function_inputs, port_schema=input_ports, serializers=serializers)
75
77
  # replace "." with "__dot__" in the keys of a dictionary
@@ -112,8 +114,10 @@ def create_inputs(func, *args: Any, **kwargs: Any) -> dict[str, Any]:
112
114
  def prepare_pyfunction_inputs(
113
115
  function: Optional[Callable[..., Any]] = None,
114
116
  function_inputs: Optional[Dict[str, Any]] = None,
115
- input_ports: Optional[List[str | dict]] = None,
116
- output_ports: Optional[List[str | dict]] = None,
117
+ inputs_spec: Optional[type] = None,
118
+ outputs_spec: Optional[type] = None,
119
+ output_ports: Optional[Dict[str, Any]] = None,
120
+ input_ports: Optional[Dict[str, Any]] = None,
117
121
  metadata: Optional[Dict[str, Any]] = None,
118
122
  process_label: Optional[str] = None,
119
123
  function_data: dict | None = None,
@@ -125,8 +129,6 @@ def prepare_pyfunction_inputs(
125
129
  """Prepare the inputs for PythonJob"""
126
130
  import types
127
131
 
128
- from .utils import serialize_ports
129
-
130
132
  if function is None and function_data is None:
131
133
  raise ValueError("Either function or function_data must be provided")
132
134
  if function is not None and function_data is not None:
@@ -139,11 +141,16 @@ def prepare_pyfunction_inputs(
139
141
  raise NotImplementedError("Built-in functions are not supported yet")
140
142
  else:
141
143
  raise ValueError("Invalid function type")
142
- output_ports = {"name": "outputs", "identifier": "namespace", "ports": output_ports or [{"name": "result"}]}
143
- input_ports = {"name": "inputs", "identifier": "namespace", "ports": input_ports or []}
144
- input_ports = format_input_output_ports(input_ports)
145
- input_ports = build_input_port_definitions(func=function, input_ports=input_ports)
146
- function_data["output_ports"] = format_input_output_ports(output_ports)
144
+ # outputs
145
+ if not output_ports:
146
+ node_outputs = generate_output_sockets(function or (lambda **_: None), outputs=outputs_spec)
147
+ output_ports = outputs_sockets_to_ports(node_outputs)
148
+ # inputs
149
+ if not input_ports:
150
+ node_inputs = generate_input_sockets(function or (lambda **_: None), inputs=inputs_spec)
151
+ input_ports = inputs_sockets_to_ports(node_inputs)
152
+
153
+ function_data["output_ports"] = output_ports
147
154
  function_data["input_ports"] = input_ports
148
155
  # serialize the kwargs into AiiDA Data
149
156
  function_inputs = function_inputs or {}
@@ -72,11 +72,14 @@ class PythonJobParser(Parser):
72
72
  return exit_code
73
73
 
74
74
  # Store the outputs
75
- for output in self.output_ports["ports"]:
76
- self.out(output["name"], output["value"])
75
+ for name, port in self.output_ports["ports"].items():
76
+ if "value" in port:
77
+ self.out(name, port["value"])
77
78
 
78
79
  except OSError:
79
80
  return self.exit_codes.ERROR_READING_OUTPUT_FILE
80
81
  except ValueError as exception:
81
- self.logger.error(exception)
82
+ self.logger.error(
83
+ f"An error occurred when attempting to parse the output of the calculation: ValueError: {exception!s}"
84
+ )
82
85
  return self.exit_codes.ERROR_INVALID_OUTPUT
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ def _is_namespace_identifier(identifier: str, *, ns_id: str = "node_graph.namespace") -> bool:
7
+ return identifier == ns_id or identifier.endswith(".namespace")
8
+
9
+
10
+ def _socket_meta_required(sock: Dict[str, Any]) -> Optional[bool]:
11
+ meta = sock.get("metadata", {}) or {}
12
+ if "required" in meta and meta["required"] is not None:
13
+ return bool(meta["required"])
14
+ if sock.get("property", {}).get("default", None) is not None:
15
+ return False
16
+ return None
17
+
18
+
19
+ def _socket_meta_help(sock: Dict[str, Any]) -> Optional[str]:
20
+ return (sock.get("metadata") or {}).get("help")
21
+
22
+
23
+ def _socket_to_port_object(sock: Dict[str, Any]) -> Dict[str, Any]:
24
+ ident = sock.get("identifier", "")
25
+ meta = sock.get("metadata", {}) or {}
26
+
27
+ if _is_namespace_identifier(ident):
28
+ ports: Dict[str, Dict[str, Any]] = {}
29
+ for name, child in (sock.get("sockets") or {}).items():
30
+ ports[name] = _socket_to_port_object(child)
31
+
32
+ obj: Dict[str, Any] = {"identifier": "NAMESPACE", "ports": ports}
33
+
34
+ if meta.get("dynamic"):
35
+ obj["dynamic"] = True
36
+ # Prefer full nested item namespace when present
37
+ if isinstance(meta.get("item"), dict):
38
+ obj["item"] = _socket_to_port_object(meta["item"])
39
+ else:
40
+ # fallback: identifier only (treat namespace id as empty namespace)
41
+ item_ident = meta.get("item_identifier", "node_graph.any")
42
+ obj["item"] = (
43
+ {"identifier": "NAMESPACE", "ports": {}}
44
+ if _is_namespace_identifier(item_ident)
45
+ else {"identifier": "ANY"}
46
+ )
47
+
48
+ h = _socket_meta_help(sock)
49
+ if h:
50
+ obj["help"] = h
51
+ req = _socket_meta_required(sock)
52
+ if req is not None:
53
+ obj["required"] = req
54
+ return obj
55
+
56
+ # Leaf
57
+ obj = {"identifier": "ANY"}
58
+ h = _socket_meta_help(sock)
59
+ if h:
60
+ obj["help"] = h
61
+ req = _socket_meta_required(sock)
62
+ if req is not None:
63
+ obj["required"] = req
64
+ return obj
65
+
66
+
67
+ def inputs_sockets_to_ports(node_inputs: Dict[str, Any]) -> Dict[str, Any]:
68
+ ports_map: Dict[str, Dict[str, Any]] = {}
69
+ for name, sock in (node_inputs.get("sockets") or {}).items():
70
+ ports_map[name] = _socket_to_port_object(sock)
71
+
72
+ schema: Dict[str, Any] = {"name": "inputs", "identifier": "NAMESPACE", "ports": ports_map}
73
+ meta = node_inputs.get("metadata", {}) or {}
74
+ if meta.get("dynamic"):
75
+ schema["dynamic"] = True
76
+ if isinstance(meta.get("item"), dict):
77
+ schema["item"] = _socket_to_port_object(meta["item"])
78
+ else:
79
+ item_ident = meta.get("item_identifier", "node_graph.any")
80
+ schema["item"] = (
81
+ {"identifier": "NAMESPACE", "ports": {}}
82
+ if _is_namespace_identifier(item_ident)
83
+ else {"identifier": "ANY"}
84
+ )
85
+
86
+ return schema
87
+
88
+
89
+ def outputs_sockets_to_ports(node_outputs: Dict[str, Any]) -> Dict[str, Any]:
90
+ meta = node_outputs.get("metadata", {}) or {}
91
+ sockets = node_outputs.get("sockets") or {}
92
+
93
+ ports_map = {name: _socket_to_port_object(sock) for name, sock in sockets.items()}
94
+ schema: Dict[str, Any] = {"name": "outputs", "identifier": "NAMESPACE", "ports": ports_map}
95
+ if meta.get("dynamic"):
96
+ schema["dynamic"] = True
97
+ if isinstance(meta.get("item"), dict):
98
+ schema["item"] = _socket_to_port_object(meta["item"])
99
+ else:
100
+ item_ident = meta.get("item_identifier", "node_graph.any")
101
+ schema["item"] = (
102
+ {"identifier": "NAMESPACE", "ports": {}}
103
+ if _is_namespace_identifier(item_ident)
104
+ else {"identifier": "ANY"}
105
+ )
106
+ return schema