spinta 0.2.dev18__py3-none-any.whl → 0.2.dev20__py3-none-any.whl

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 (42) hide show
  1. spinta/__init__.py +2 -0
  2. spinta/adapters/rc/__init__.py +1 -0
  3. spinta/adapters/rc/signature_adapter.py +118 -0
  4. spinta/adapters/soap_plugins.py +122 -0
  5. spinta/backends/postgresql/commands/summary.py +2 -2
  6. spinta/cli/config.py +13 -7
  7. spinta/commands/read.py +2 -3
  8. spinta/components.py +1 -0
  9. spinta/config.py +1 -1
  10. spinta/config.yml +3 -0
  11. spinta/core/access.py +2 -1
  12. spinta/core/ufuncs.py +5 -1
  13. spinta/datasets/backends/dataframe/backends/soap/ufuncs/ufuncs.py +108 -15
  14. spinta/datasets/backends/dataframe/commands/check.py +33 -0
  15. spinta/datasets/backends/dataframe/ufuncs/query/components.py +10 -0
  16. spinta/datasets/backends/dataframe/ufuncs/query/ufuncs.py +62 -11
  17. spinta/datasets/commands/link.py +4 -2
  18. spinta/exceptions.py +5 -3
  19. spinta/manifests/components.py +1 -1
  20. spinta/manifests/dict/components.py +108 -1
  21. spinta/manifests/dict/helpers.py +538 -316
  22. spinta/manifests/helpers.py +4 -4
  23. spinta/manifests/sql/helpers.py +8 -2
  24. spinta/manifests/xsd/components.py +1 -1
  25. spinta/manifests/xsd/helpers.py +1076 -946
  26. spinta/nodes.py +4 -0
  27. spinta/testing/client.py +2 -0
  28. spinta/testing/pytest.py +6 -2
  29. spinta/types/config.py +9 -0
  30. spinta/types/model.py +33 -38
  31. spinta/ufuncs/querybuilder/ufuncs.py +1 -0
  32. {spinta-0.2.dev18.dist-info → spinta-0.2.dev20.dist-info}/METADATA +2 -2
  33. {spinta-0.2.dev18.dist-info → spinta-0.2.dev20.dist-info}/RECORD +37 -38
  34. spinta/manifests/xsd2/commands/__init__.py +0 -0
  35. spinta/manifests/xsd2/commands/configure.py +0 -15
  36. spinta/manifests/xsd2/commands/load.py +0 -60
  37. spinta/manifests/xsd2/components.py +0 -9
  38. spinta/manifests/xsd2/helpers.py +0 -1310
  39. /spinta/{manifests/xsd2 → adapters}/__init__.py +0 -0
  40. {spinta-0.2.dev18.dist-info → spinta-0.2.dev20.dist-info}/WHEEL +0 -0
  41. {spinta-0.2.dev18.dist-info → spinta-0.2.dev20.dist-info}/entry_points.txt +0 -0
  42. {spinta-0.2.dev18.dist-info → spinta-0.2.dev20.dist-info}/licenses/LICENSE +0 -0
spinta/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
1
  import importlib.metadata
2
2
 
3
3
  __version__ = importlib.metadata.version(__name__)
4
+
5
+ HTTP_URL_PREFIXES = ("http://", "https://")
@@ -0,0 +1 @@
1
+ """Organization-specific SOAP helpers (e.g. RC broker request signing)."""
@@ -0,0 +1,118 @@
1
+ import base64
2
+ import subprocess
3
+
4
+ from lxml import etree
5
+
6
+ from spinta.components import Context
7
+ from spinta.core.config import RawConfig
8
+ from spinta.core.ufuncs import Expr
9
+ from spinta.datasets.backends.dataframe.backends.soap.ufuncs.components import SoapQueryBuilder
10
+ from spinta.datasets.backends.dataframe.backends.soap.ufuncs.ufuncs import MakeCDATA
11
+
12
+ RC_ACTION_TYPE_FIELD = "ActionType"
13
+ RC_CALLER_CODE_FIELD = "CallerCode"
14
+ RC_END_USER_INFO_FIELD = "EndUserInfo"
15
+ RC_PARAMETERS_FIELD = "Parameters"
16
+ RC_TIME_FIELD = "Time"
17
+
18
+
19
+ def is_quoted(value: str) -> bool:
20
+ """True if ``value`` has matching outer ``'`` or ``"`` quotes (Katalogas ``is_quoted``)."""
21
+ return len(value) > 1 and value[0] == value[-1] and value[0] in ('"', "'")
22
+
23
+
24
+ def _require_rc_private_key_path(raw_config: RawConfig | None) -> str:
25
+ """Return the configured PEM path, or raise if raw config / key path is missing."""
26
+ if raw_config is None:
27
+ raise RuntimeError("RC signature adapter was loaded but application configuration is missing.")
28
+ path = raw_config.get("rc_signature", "private_key_path", default=None)
29
+ if not path or not str(path).strip():
30
+ raise RuntimeError(
31
+ "When using the RC signature SOAP adapter, set `rc_signature.private_key_path` in configuration."
32
+ )
33
+ return str(path)
34
+
35
+
36
+ def validate_soap_adapter_config(raw_config: RawConfig | None) -> None:
37
+ """Invoked by soap plugin loader when this module is listed in ``soap_adapter_modules``."""
38
+ _require_rc_private_key_path(raw_config)
39
+
40
+
41
+ def _get_private_key_path(context: Context) -> str:
42
+ return _require_rc_private_key_path(context.get("rc"))
43
+
44
+
45
+ def _compute_rc_signature(args: str, key_path: str) -> str:
46
+ """Compute RC-style signature using openssl CLI.
47
+ This mirrors the legacy shell example:
48
+ echo -n "$ARGS" | openssl dgst -sha256 -sign $PRIVATE_KEY_FILE | base64 -w0
49
+ """
50
+ proc = subprocess.run(
51
+ ["openssl", "dgst", "-sha256", "-sign", key_path],
52
+ input=args.encode("utf-8"),
53
+ stdout=subprocess.PIPE,
54
+ stderr=subprocess.PIPE,
55
+ check=True,
56
+ )
57
+ sig_b64 = base64.b64encode(proc.stdout).decode("ascii")
58
+
59
+ return sig_b64.replace("\r", "").replace("\n", "")
60
+
61
+
62
+ def build_rc_string_to_sign(soap_body: dict[str, object]) -> str:
63
+ """Return the plaintext that RC signs and verifies for SOAP calls.
64
+
65
+ The broker does not sign the whole XML; it expects an RSA-SHA256 signature over one
66
+ concatenated string, in this exact order with no delimiters: ActionType, CallerCode,
67
+ EndUserInfo, Parameters, Time. The server rebuilds the same string and checks the
68
+ signature, so we must assemble identical pieces from `soap_body`.
69
+ """
70
+
71
+ input_block = soap_body.get("input")
72
+ input_dict = input_block if isinstance(input_block, dict) else None
73
+
74
+ def _get(name: str, default: str = "") -> str:
75
+ value = soap_body.get(f"input/{name}")
76
+ if value is None and input_dict is not None:
77
+ value = input_dict.get(name)
78
+
79
+ if value is None:
80
+ value = soap_body.get(name, default)
81
+
82
+ if isinstance(value, MakeCDATA):
83
+ value = value.data
84
+
85
+ if isinstance(value, etree.CDATA):
86
+ value = str(value)
87
+
88
+ if name == RC_PARAMETERS_FIELD and isinstance(value, str) and is_quoted(value):
89
+ value = value[1:-1]
90
+
91
+ return "" if value is None else str(value)
92
+
93
+ action_type = _get(RC_ACTION_TYPE_FIELD)
94
+ caller_code = _get(RC_CALLER_CODE_FIELD)
95
+ end_user_info = _get(RC_END_USER_INFO_FIELD, "")
96
+ parameters = _get(RC_PARAMETERS_FIELD, "")
97
+ time_value = _get(RC_TIME_FIELD)
98
+
99
+ return f"{action_type}{caller_code}{end_user_info}{parameters}{time_value}"
100
+
101
+
102
+ def compute_rc_signature_from_body(soap_body: dict[str, object], context: Context) -> str:
103
+ """Build string-to-sign from soap_body and return base64 signature."""
104
+ key_path = _get_private_key_path(context)
105
+ args = build_rc_string_to_sign(soap_body)
106
+
107
+ return _compute_rc_signature(args, key_path)
108
+
109
+
110
+ def get_deferred_prepare_names() -> list[str]:
111
+ return ["rc_signature"]
112
+
113
+
114
+ def get_body_resolvers() -> dict[str, object]:
115
+ def rc_signature_resolver(env: SoapQueryBuilder, expr: Expr | None = None) -> str:
116
+ return compute_rc_signature_from_body(env.soap_request_body, env.context)
117
+
118
+ return {"rc_signature": rc_signature_resolver}
@@ -0,0 +1,122 @@
1
+ """Load optional SOAP adapter modules from ``soap_adapter_modules`` paths in config."""
2
+
3
+ import importlib.util
4
+ import logging
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+ from typing import Any, Callable
8
+
9
+ from spinta.core.ufuncs import Expr, UFuncRegistry
10
+ from spinta.core.config import RawConfig
11
+ from spinta.ufuncs.loadbuilder.components import LoadBuilder
12
+ from spinta.datasets.backends.dataframe.backends.soap.ufuncs.components import SoapQueryBuilder
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ _deferred_prepare_names_cache: set[str] | None = None
18
+
19
+
20
+ def get_deferred_prepare_names() -> set[str]:
21
+ """Names of prepare ufuncs left unresolved until the SOAP body is built (empty before register)."""
22
+ return _deferred_prepare_names_cache or set()
23
+
24
+
25
+ def _load_adapter_module_from_path(file_path: str, raw_config: RawConfig | None = None) -> ModuleType | None:
26
+ try:
27
+ path = Path(file_path)
28
+ if not path.exists():
29
+ log.warning("SOAP adapter module not found: %s", file_path)
30
+ return None
31
+ spec = importlib.util.spec_from_file_location(path.stem, path)
32
+ if spec is None or spec.loader is None:
33
+ log.warning("Failed to create module spec for: %s", file_path)
34
+ return None
35
+ module = importlib.util.module_from_spec(spec)
36
+ spec.loader.exec_module(module)
37
+ log.info("Loaded SOAP adapter module from: %s", file_path)
38
+ if hasattr(module, "validate_soap_adapter_config"):
39
+ module.validate_soap_adapter_config(raw_config)
40
+ return module
41
+ except Exception as e:
42
+ log.warning("Failed to load SOAP adapter module %s: %s", file_path, e)
43
+ return None
44
+
45
+
46
+ def _load_adapters_from_config(raw_config: RawConfig | None) -> tuple[set[str], dict[str, Callable[..., Any]]]:
47
+ deferred: set[str] = set()
48
+ body_resolvers: dict[str, Callable[..., Any]] = {}
49
+
50
+ if raw_config is None:
51
+ return deferred, body_resolvers
52
+
53
+ module_paths = raw_config.get("soap_adapter_modules", default=[])
54
+ if not module_paths:
55
+ return deferred, body_resolvers
56
+
57
+ if isinstance(module_paths, str):
58
+ module_paths = [module_paths]
59
+
60
+ for module_path in module_paths:
61
+ module = _load_adapter_module_from_path(module_path, raw_config)
62
+ if module is None:
63
+ continue
64
+
65
+ if hasattr(module, "get_deferred_prepare_names"):
66
+ try:
67
+ result = module.get_deferred_prepare_names()
68
+ except Exception as e:
69
+ log.warning("Failed to call get_deferred_prepare_names in %s: %s", module_path, e)
70
+ else:
71
+ if isinstance(result, (list, tuple)):
72
+ deferred.update(result)
73
+ else:
74
+ deferred.add(str(result))
75
+
76
+ if hasattr(module, "get_body_resolvers"):
77
+ try:
78
+ result = module.get_body_resolvers()
79
+ except Exception as e:
80
+ log.warning("Failed to call get_body_resolvers in %s: %s", module_path, e)
81
+ else:
82
+ if isinstance(result, dict):
83
+ body_resolvers.update(result)
84
+
85
+ return deferred, body_resolvers
86
+
87
+
88
+ def _deferred_prepare_resolver(env, expr: Expr) -> Expr:
89
+ env.param.soap_body = {env.this: expr}
90
+ return expr
91
+
92
+
93
+ def _make_body_resolver(callable_fn: Callable[..., Any]) -> Callable[[SoapQueryBuilder, Expr], Any]:
94
+ """Adapter may implement ``fn(env)`` only; fall back if ``fn(env, expr)`` raises ``TypeError``."""
95
+
96
+ def body_resolver(env: SoapQueryBuilder, expr: Expr) -> Any:
97
+ try:
98
+ return callable_fn(env, expr)
99
+ except TypeError:
100
+ return callable_fn(env)
101
+
102
+ return body_resolver
103
+
104
+
105
+ def register_soap_ufuncs(registry: UFuncRegistry, raw_config: RawConfig | None = None) -> None:
106
+ """Register SOAP adapter resolvers from config (`soap_adapter_modules`).
107
+ Adapters are loaded from local Python files listed in:
108
+ soap_adapter_modules:
109
+ - /path/to/adapter.py
110
+ Call this after resolver.collect().
111
+ """
112
+ global _deferred_prepare_names_cache
113
+
114
+ deferred, body_resolvers = _load_adapters_from_config(raw_config)
115
+
116
+ _deferred_prepare_names_cache = deferred
117
+
118
+ for name in deferred:
119
+ registry.register(name, _deferred_prepare_resolver, (LoadBuilder, Expr))
120
+
121
+ for name, callable_fn in body_resolvers.items():
122
+ registry.register(name, _make_body_resolver(callable_fn), (SoapQueryBuilder, Expr))
@@ -6,7 +6,7 @@ from spinta.backends.helpers import get_table_name
6
6
  from spinta.backends.postgresql.helpers.name import get_pg_table_name, get_pg_column_name
7
7
  from spinta.core.ufuncs import Expr
8
8
  from spinta.types.datatype import Integer, Number, Boolean, String, Date, DateTime, Time, Ref
9
- from spinta import commands
9
+ from spinta import commands, HTTP_URL_PREFIXES
10
10
  from spinta.components import Context, Property
11
11
  from spinta.components import Model
12
12
  from spinta.exceptions import NotFoundError, NotImplementedFeature, InvalidRequestQuery
@@ -322,7 +322,7 @@ def summary(context: Context, dtype: Ref, backend: PostgreSQL, **kwargs):
322
322
  prefixes = dtype.model.external.dataset.prefixes
323
323
  label = None
324
324
  if uri and ":" in uri:
325
- if uri.startswith(("http://", "https://")):
325
+ if uri.startswith(HTTP_URL_PREFIXES):
326
326
  label = uri
327
327
  else:
328
328
  split = uri.split(":")
spinta/cli/config.py CHANGED
@@ -1,16 +1,15 @@
1
- from typing import List
2
- from typing import Optional
1
+ from typing import List, Optional
3
2
 
4
- from typer import Argument
3
+ from typer import Argument, Option, echo
5
4
  from typer import Context as TyperContext
6
- from typer import Option
7
- from typer import echo
8
5
 
6
+ from spinta import commands
9
7
  from spinta.cli.helpers.manifest import convert_str_to_manifest_path
10
8
  from spinta.cli.helpers.store import prepare_manifest
11
- from spinta.core.enums import Mode
9
+ from spinta.components import Context, Store
12
10
  from spinta.core.config import KeyFormat
13
11
  from spinta.core.context import configure_context
12
+ from spinta.core.enums import Mode
14
13
 
15
14
 
16
15
  def config(
@@ -32,9 +31,16 @@ def check(
32
31
  ):
33
32
  """Check configuration and manifests"""
34
33
  manifests = convert_str_to_manifest_path(manifests)
35
- context = configure_context(ctx.obj, manifests, mode=mode, check_names=check_names)
34
+ context: Context = configure_context(ctx.obj, manifests, mode=mode, check_names=check_names)
36
35
  prepare_manifest(context, ensure_config_dir=True, full_load=True)
37
36
  manager = context.get("error_manager")
37
+
38
+ store: Store = context.get("store")
39
+ if store.manifest:
40
+ for model in commands.get_models(context, store.manifest).values():
41
+ if model.external and model.external.resource and model.external.resource.backend:
42
+ commands.check(context, model, model.external.resource.backend)
43
+
38
44
  handler = manager.handler
39
45
 
40
46
  if handler.get_counts():
spinta/commands/read.py CHANGED
@@ -123,11 +123,10 @@ def getall(context: Context, model: Model, page: Page, *, query: Expr = None, li
123
123
  page.size = size + 1
124
124
 
125
125
  page_meta = PaginationMetaData(page_size=size, limit=limit)
126
+ paginated_query = add_page_expr(query, page)
126
127
  while not page_meta.is_finished:
127
128
  page_meta.is_finished = True
128
- query = add_page_expr(query, page)
129
- rows = commands.getall(context, model, backend, query=query, **kwargs)
130
-
129
+ rows = commands.getall(context, model, backend, query=paginated_query, **kwargs)
131
130
  yield from get_paginated_values(page, page_meta, rows, extract_source_page_keys)
132
131
 
133
132
 
spinta/components.py CHANGED
@@ -1095,6 +1095,7 @@ class Config:
1095
1095
  scope_log: bool
1096
1096
  check_contract_scopes: bool
1097
1097
  default_auth_client: str
1098
+ default_access_level: str
1098
1099
  http_basic_auth: bool
1099
1100
  token_validation_key: dict | None = None
1100
1101
  token_validation_keys_download_url: str | None = None
spinta/config.py CHANGED
@@ -54,7 +54,6 @@ CONFIG = {
54
54
  "xml": "spinta.manifests.dict.components:XmlManifest",
55
55
  "internal": "spinta.manifests.internal_sql.components:InternalSQLManifest",
56
56
  "xsd": "spinta.manifests.xsd.components:XsdManifest",
57
- "xsd2": "spinta.manifests.xsd2.components:XsdManifest2",
58
57
  "openapi": "spinta.manifests.open_api.components:OpenAPIManifest",
59
58
  },
60
59
  "backends": {
@@ -347,6 +346,7 @@ CONFIG = {
347
346
  },
348
347
  "config_path": pathlib.Path("tests/config"),
349
348
  "default_auth_client": "baa448a8-205c-4faa-a048-a10e4b32a136",
349
+ "default_access_level": "protected",
350
350
  "sync_retry_count": 0,
351
351
  },
352
352
  },
spinta/config.yml CHANGED
@@ -195,6 +195,9 @@ always_show_id:
195
195
  default_auth_client:
196
196
  type: string
197
197
 
198
+ default_access_level:
199
+ type: string
200
+
198
201
  check:
199
202
  type: object
200
203
  items:
spinta/core/access.py CHANGED
@@ -73,6 +73,7 @@ def link_access_param(
73
73
  ] = (),
74
74
  *,
75
75
  use_given: bool = True,
76
+ default_access: Access = Access.private,
76
77
  ) -> None:
77
78
  if component.access is None:
78
79
  for parent in parents:
@@ -83,4 +84,4 @@ def link_access_param(
83
84
  component.access = candidate
84
85
  break
85
86
  else:
86
- component.access = Access.protected
87
+ component.access = default_access
spinta/core/ufuncs.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import functools
5
5
  import importlib
6
- from typing import Any, Optional, List, Union
6
+ from typing import Any, Optional, List, Union, Callable
7
7
  from typing import Dict
8
8
  from typing import TYPE_CHECKING
9
9
  from typing import Tuple
@@ -140,6 +140,10 @@ class UFuncRegistry:
140
140
  # automatically by __call__.
141
141
  importlib.import_module(module)
142
142
 
143
+ def register(self, name: str, func: Callable, types: tuple) -> None:
144
+ """Register a single (name, func, types) for plugin-discovered resolvers."""
145
+ self._ufuncs.append((name, func, types))
146
+
143
147
  def ufuncs(self):
144
148
  # Create commands from collected ufuncs.
145
149
  ufuncs = {}
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  from typing import Any
4
5
 
5
6
  from lxml import etree
6
7
 
8
+ from spinta.adapters.soap_plugins import get_deferred_prepare_names
7
9
  from spinta.auth import authorized, query_client
8
10
  from spinta.components import Property
9
11
  from spinta.core.enums import Action
@@ -21,6 +23,10 @@ from spinta.utils.config import get_clients_path
21
23
  from spinta.utils.data import take
22
24
  from spinta.utils.schema import NA
23
25
 
26
+ log = logging.getLogger(__name__)
27
+
28
+ SOAP_BODY_VALUE_TYPE_CDATA = "cdata"
29
+
24
30
 
25
31
  class MakeCDATA:
26
32
  """
@@ -47,7 +53,7 @@ def eq_(env: SoapQueryBuilder, field: Bind, value: object) -> Expr | None:
47
53
  try:
48
54
  prop = env.resolve_property(field)
49
55
  except PropertyNotFound:
50
- # leave query parameter for other query builders to resolve
56
+ # leave query parameter for other resolvers
51
57
  return Expr("eq", field, value)
52
58
 
53
59
  if not isinstance(prop.external.prepare, Expr):
@@ -59,7 +65,7 @@ def eq_(env: SoapQueryBuilder, field: Bind, value: object) -> Expr | None:
59
65
  @ufunc.resolver(SoapQueryBuilder, Expr, name="and")
60
66
  def and_(env: SoapQueryBuilder, expr: Expr) -> list[Any]:
61
67
  args, kwargs = expr.resolve(env)
62
- args = [a for a in args if a is not None]
68
+ args = [arg_item for arg_item in args if arg_item is not None]
63
69
  return env.call("and", args)
64
70
 
65
71
 
@@ -72,16 +78,88 @@ def and_(env: SoapQueryBuilder, args: list) -> Any:
72
78
 
73
79
 
74
80
  def _finalize_soap_request_body_resolve(env: SoapQueryBuilder) -> None:
81
+ deferred_names = get_deferred_prepare_names()
82
+
75
83
  for param_body_key, param_body_value in env.soap_request_body.items():
76
84
  if not isinstance(param_body_value, Expr):
77
85
  continue
86
+
87
+ if getattr(param_body_value, "name", None) in deferred_names:
88
+ continue
89
+
78
90
  env.soap_request_body[param_body_key] = env.resolve(param_body_value)
79
91
 
80
92
 
93
+ def _param_for_soap_body_key(env: SoapQueryBuilder, param_source: str, expr: Expr | None = None) -> Param | None:
94
+ """Find the resource Param that owns this SOAP body slot (flat key like ``input/Signature``)."""
95
+ param_lower = param_source.lower()
96
+
97
+ for resource_param in env.params.values():
98
+ soap_body = getattr(resource_param, "soap_body", None)
99
+ if not soap_body:
100
+ continue
101
+
102
+ if param_source in soap_body:
103
+ return resource_param
104
+
105
+ if any(soap_key.lower() == param_lower for soap_key in soap_body):
106
+ return resource_param
107
+
108
+ if expr is not None:
109
+ for prepared_value in soap_body.values():
110
+ if prepared_value is expr:
111
+ return resource_param
112
+
113
+ return None
114
+
115
+
116
+ def _property_value_key_for_deferred(matched_param: Param | None, param_source: str) -> str:
117
+ if matched_param is not None:
118
+ return matched_param.name
119
+ path_suffix = param_source.rsplit("/", 1)[-1]
120
+ return path_suffix.lower() if path_suffix else param_source
121
+
122
+
123
+ def _resolve_deferred_soap_request_body_exprs(env: SoapQueryBuilder) -> None:
124
+ deferred_names = get_deferred_prepare_names()
125
+ if not deferred_names:
126
+ return
127
+
128
+ for param_source, deferred_expr in list(env.soap_request_body.items()):
129
+ if not isinstance(deferred_expr, Expr):
130
+ continue
131
+
132
+ if getattr(deferred_expr, "name", None) not in deferred_names:
133
+ continue
134
+
135
+ resolved = env.resolve(deferred_expr)
136
+ matched_param = _param_for_soap_body_key(env, param_source, expr=deferred_expr)
137
+
138
+ if (
139
+ matched_param
140
+ and getattr(matched_param, "soap_body_value_type", None) == SOAP_BODY_VALUE_TYPE_CDATA
141
+ and resolved
142
+ ):
143
+ env.soap_request_body[param_source] = MakeCDATA(resolved)
144
+ else:
145
+ env.soap_request_body[param_source] = resolved
146
+
147
+ property_value_key = _property_value_key_for_deferred(matched_param, param_source)
148
+ env.property_values[property_value_key] = resolved
149
+
150
+ if matched_param is None:
151
+ log.warning(
152
+ "SOAP deferred resolve: no Param matched for %r; property_values updated under %r",
153
+ param_source,
154
+ property_value_key,
155
+ )
156
+
157
+
81
158
  def _populate_soap_request_body_with_url_values(env: SoapQueryBuilder) -> None:
82
159
  for prop in take(env.model.properties).values():
83
160
  if not authorized(env.context, prop, Action.GETALL):
84
161
  continue
162
+
85
163
  env.call("soap_request_body", prop)
86
164
 
87
165
 
@@ -93,25 +171,24 @@ def soap_request_body(env: SoapQueryBuilder) -> None:
93
171
  for param in env.params.values():
94
172
  if not hasattr(param, "soap_body"):
95
173
  continue
174
+
96
175
  env.soap_request_body.update(param.soap_body)
97
176
 
98
177
  _finalize_soap_request_body_resolve(env)
99
178
  _populate_soap_request_body_with_url_values(env)
179
+ _resolve_deferred_soap_request_body_exprs(env)
100
180
 
101
181
 
102
182
  @ufunc.resolver(SoapQueryBuilder, Property)
103
183
  def soap_request_body(env: SoapQueryBuilder, prop: Property) -> None:
104
184
  """
105
- Only care about properties that describe URL query parameters:
185
+ Only care for properties that describe URL query parameters:
106
186
  properties without `source` and with `prepare`.
107
- We ignore the rest.
108
187
  """
109
188
  if prop.external.name:
110
- # Ignore properties with `source`
111
189
  return None
112
190
 
113
191
  if not isinstance(prop.external.prepare, Expr):
114
- # Ignore properties without `prepare` expression
115
192
  return None
116
193
 
117
194
  resource_param = env(this=prop).resolve(prop.external.prepare)
@@ -129,25 +206,41 @@ def _get_final_soap_request_body_value(env: SoapQueryBuilder, property_name: str
129
206
 
130
207
  @ufunc.resolver(SoapQueryBuilder, Property, Param)
131
208
  def soap_request_body(env: SoapQueryBuilder, prop: Property, param: Param) -> None:
132
- """Replace default param.soap_body values with url_param values"""
133
- param_source = next(iter(param.soap_body))
209
+ """Merge URL query params into the SOAP body; resolve non-deferred Expr; keep deferred Expr for a later pass."""
210
+ soap_body = getattr(param, "soap_body", None)
211
+ if not soap_body:
212
+ return
213
+
214
+ param_source = next(iter(soap_body))
215
+ deferred_names = get_deferred_prepare_names()
216
+
134
217
  final_value = _get_final_soap_request_body_value(env, prop.place, param_source)
135
218
 
219
+ if isinstance(final_value, Expr) and getattr(final_value, "name", None) not in deferred_names:
220
+ final_value = env.resolve(final_value)
221
+
222
+ is_deferred_expr = isinstance(final_value, Expr) and getattr(final_value, "name", None) in deferred_names
223
+
136
224
  if final_value is NA:
137
- # If value not in URL and DSA has no default - remove it from SOAP request completely
138
225
  env.soap_request_body.pop(param_source, None)
139
226
  final_value = None
140
- elif final_value:
141
- # If value should be sent as CDATA - change it to etree.CDATA
142
- soap_final_value = MakeCDATA(final_value) if param.soap_body_value_type == "cdata" else final_value
227
+
228
+ elif is_deferred_expr:
229
+ env.soap_request_body[param_source] = final_value
230
+
231
+ else:
232
+ if param.soap_body_value_type == SOAP_BODY_VALUE_TYPE_CDATA and final_value:
233
+ soap_final_value = MakeCDATA(final_value)
234
+ else:
235
+ soap_final_value = final_value
236
+
143
237
  env.soap_request_body[param_source] = soap_final_value
144
238
 
145
- # Check if required property values exist
146
239
  if prop.dtype.required and param_source not in env.soap_request_body:
147
240
  raise MissingRequiredProperty(prop, prop=prop.name)
148
241
 
149
- # Update property values. Even if value is not sent via SOAP, it should have None value when displayed by Spinta
150
- env.property_values.update({param.name: final_value})
242
+ if not is_deferred_expr:
243
+ env.property_values.update({param.name: final_value})
151
244
 
152
245
 
153
246
  @ufunc.resolver(SoapQueryBuilder, str)
@@ -0,0 +1,33 @@
1
+ from spinta import commands, exceptions
2
+ from spinta.components import Context, Model
3
+ from spinta.core.ufuncs import Expr
4
+ from spinta.datasets.backends.dataframe.components import DaskBackend
5
+ from spinta.datasets.backends.dataframe.ufuncs.query.ufuncs import COMPARE
6
+ from spinta.handlers import ErrorManager
7
+
8
+
9
+ def _get_compare_operators(expr: Expr) -> list[str]:
10
+ """Recursively collect compare operator (filters) names from an Expr tree."""
11
+ if not isinstance(expr, Expr):
12
+ return []
13
+ filters: list[str] = []
14
+ if expr.name in COMPARE:
15
+ filters.append(expr.name)
16
+ for arg in expr.args:
17
+ filters.extend(_get_compare_operators(arg))
18
+ for val in expr.kwargs.values():
19
+ filters.extend(_get_compare_operators(val))
20
+ return filters
21
+
22
+
23
+ @commands.check.register(Context, Model, DaskBackend)
24
+ def check(context: Context, model: Model, backend: DaskBackend) -> None:
25
+ # If there are any compare operators in the prepare expression,
26
+ # raise an error since they are not supported for Dask backend.
27
+ if (
28
+ model.external
29
+ and model.external.prepare
30
+ and (compare_operators := list(dict.fromkeys(_get_compare_operators(model.external.prepare))))
31
+ ):
32
+ manager: ErrorManager = context.get("error_manager")
33
+ manager.handle_error(exceptions.DaskBackendCompareNotSupported(model, operators=compare_operators))
@@ -8,6 +8,7 @@ from spinta.components import Model, Property
8
8
  from spinta.core.ufuncs import Env, Expr
9
9
 
10
10
  from spinta.exceptions import UnknownMethod
11
+ from spinta.ufuncs.propertyresolver.components import PropertyResolver
11
12
  from spinta.ufuncs.querybuilder.components import Selected
12
13
  from spinta.utils.schema import NA
13
14
  from spinta.datasets.backends.dataframe.components import DaskBackend
@@ -19,6 +20,7 @@ class DaskDataFrameQueryBuilder(Env):
19
20
  dataframe: DataFrame
20
21
  params: dict
21
22
  url_query_params: Expr | None
23
+ property_resolver: PropertyResolver = None
22
24
 
23
25
  def init(self, backend: DaskBackend, dataframe: DataFrame, params: dict) -> DaskDataFrameQueryBuilder:
24
26
  return self(
@@ -55,6 +57,14 @@ class DaskDataFrameQueryBuilder(Env):
55
57
  def default_resolver(self, expr, *args, **kwargs):
56
58
  raise UnknownMethod(name=expr.name, expr=str(expr(*args, **kwargs)))
57
59
 
60
+ def resolve_property(self, *args, **kwargs) -> Property:
61
+ if self.property_resolver is None:
62
+ resolver = PropertyResolver(self.context)
63
+ resolver = resolver.init(model=self.model, ufunc_types=True)
64
+ self.property_resolver = resolver
65
+ result = self.property_resolver.resolve_property(*args, **kwargs)
66
+ return result
67
+
58
68
 
59
69
  class DaskSelected(Selected):
60
70
  # Item name in select list.