port-ocean 0.28.8__py3-none-any.whl → 0.28.11__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.

Potentially problematic release.


This version of port-ocean might be problematic. Click here for more details.

@@ -48,7 +48,6 @@ RUN apt-get update \
48
48
  curl \
49
49
  acl \
50
50
  sudo \
51
- jq \
52
51
  && apt-get clean
53
52
 
54
53
  LABEL INTEGRATION_VERSION=${INTEGRATION_VERSION}
@@ -26,7 +26,6 @@ RUN apt-get update \
26
26
  python3-pip \
27
27
  python3-poetry \
28
28
  build-essential\
29
- jq \
30
29
  git \
31
30
  python3-venv \
32
31
  acl \
@@ -301,7 +301,7 @@ class IntegrationClientMixin:
301
301
  headers=headers,
302
302
  json={
303
303
  "items": raw_data,
304
- "extractionTimestamp": datetime.now().isoformat(),
304
+ "extractionTimestamp": int(datetime.now().timestamp()),
305
305
  },
306
306
  )
307
307
  handle_port_status_code(response, should_log=False)
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  from asyncio import Task
3
3
  from dataclasses import dataclass, field
4
+
4
5
  from functools import lru_cache
5
- import json
6
6
  from typing import Any, Optional
7
7
  import jq # type: ignore
8
8
  from loguru import logger
9
+
9
10
  from port_ocean.context.ocean import ocean
10
11
  from port_ocean.core.handlers.entity_processor.base import BaseEntityProcessor
11
12
  from port_ocean.core.handlers.port_app_config.models import ResourceConfig
@@ -21,11 +22,6 @@ from port_ocean.core.utils.utils import (
21
22
  )
22
23
  from port_ocean.exceptions.core import EntityProcessorException
23
24
  from port_ocean.utils.queue_utils import process_in_queue
24
- from port_ocean.core.handlers.entity_processor.jq_input_evaluator import (
25
- InputEvaluationResult,
26
- evaluate_input,
27
- should_shortcut_no_input,
28
- )
29
25
 
30
26
 
31
27
  class ExampleStates:
@@ -80,7 +76,7 @@ class MappedEntity:
80
76
 
81
77
  entity: dict[str, Any] = field(default_factory=dict)
82
78
  did_entity_pass_selector: bool = False
83
- raw_data: Optional[dict[str, Any] | tuple[dict[str, Any], str]] = None
79
+ raw_data: Optional[dict[str, Any]] = None
84
80
  misconfigurations: dict[str, str] = field(default_factory=dict)
85
81
 
86
82
 
@@ -137,35 +133,17 @@ class JQEntityProcessor(BaseEntityProcessor):
137
133
  return await loop.run_in_executor(
138
134
  None, self._stop_iterator_handler(func.first)
139
135
  )
140
- except Exception as exc:
141
- logger.error(
142
- f"Search failed for pattern '{pattern}' in data: {data}, Error: {exc}"
143
- )
144
- return None
145
-
146
- @lru_cache
147
- async def _search_stringified(self, data: str, pattern: str) -> Any:
148
- try:
149
- loop = asyncio.get_event_loop()
150
- compiled_pattern = self._compile(pattern)
151
- func = compiled_pattern.input_text(data)
152
- return await loop.run_in_executor(
153
- None, self._stop_iterator_handler(func.first)
154
- )
155
136
  except Exception as exc:
156
137
  logger.debug(
157
138
  f"Search failed for pattern '{pattern}' in data: {data}, Error: {exc}"
158
139
  )
159
140
  return None
160
141
 
161
- async def _search_as_bool(self, data: dict[str, Any] | str, pattern: str) -> bool:
142
+ async def _search_as_bool(self, data: dict[str, Any], pattern: str) -> bool:
162
143
  loop = asyncio.get_event_loop()
163
144
 
164
145
  compiled_pattern = self._compile(pattern)
165
- if isinstance(data, str):
166
- func = compiled_pattern.input_text(data)
167
- else:
168
- func = compiled_pattern.input_value(data)
146
+ func = compiled_pattern.input_value(data)
169
147
 
170
148
  value = await loop.run_in_executor(
171
149
  None, self._stop_iterator_handler(func.first)
@@ -178,7 +156,7 @@ class JQEntityProcessor(BaseEntityProcessor):
178
156
 
179
157
  async def _search_as_object(
180
158
  self,
181
- data: dict[str, Any] | str,
159
+ data: dict[str, Any],
182
160
  obj: dict[str, Any],
183
161
  misconfigurations: dict[str, str] | None = None,
184
162
  ) -> dict[str, Any | None]:
@@ -210,12 +188,7 @@ class JQEntityProcessor(BaseEntityProcessor):
210
188
  self._search_as_object(data, value, misconfigurations)
211
189
  )
212
190
  else:
213
- if isinstance(data, str):
214
- search_tasks[key] = asyncio.create_task(
215
- self._search_stringified(data, value)
216
- )
217
- else:
218
- search_tasks[key] = asyncio.create_task(self._search(data, value))
191
+ search_tasks[key] = asyncio.create_task(self._search(data, value))
219
192
 
220
193
  result: dict[str, Any | None] = {}
221
194
  for key, task in search_tasks.items():
@@ -239,18 +212,16 @@ class JQEntityProcessor(BaseEntityProcessor):
239
212
 
240
213
  async def _get_mapped_entity(
241
214
  self,
242
- data: dict[str, Any] | tuple[dict[str, Any], str],
215
+ data: dict[str, Any],
243
216
  raw_entity_mappings: dict[str, Any],
244
- items_to_parse_key: str | None,
245
217
  selector_query: str,
246
218
  parse_all: bool = False,
247
219
  ) -> MappedEntity:
248
- should_run = await self._should_map_entity(
249
- data, selector_query, items_to_parse_key
250
- )
220
+ should_run = await self._search_as_bool(data, selector_query)
251
221
  if parse_all or should_run:
252
- misconfigurations, mapped_entity = await self._map_entity(
253
- data, raw_entity_mappings, items_to_parse_key
222
+ misconfigurations: dict[str, str] = {}
223
+ mapped_entity = await self._search_as_object(
224
+ data, raw_entity_mappings, misconfigurations
254
225
  )
255
226
  return MappedEntity(
256
227
  mapped_entity,
@@ -266,69 +237,6 @@ class JQEntityProcessor(BaseEntityProcessor):
266
237
  misconfigurations={},
267
238
  )
268
239
 
269
- async def _map_entity(
270
- self,
271
- data: dict[str, Any] | tuple[dict[str, Any], str],
272
- raw_entity_mappings: dict[str, Any],
273
- items_to_parse_key: str | None,
274
- ) -> tuple[dict[str, str], dict[str, Any]]:
275
- if not items_to_parse_key:
276
- misconfigurations: dict[str, str] = {}
277
- data_to_search = data if isinstance(data, dict) else data[0]
278
- mapped_entity = await self._search_as_object(
279
- data_to_search, raw_entity_mappings, misconfigurations
280
- )
281
- return misconfigurations, mapped_entity
282
-
283
- modified_data: tuple[dict[str, Any], str | dict[str, Any]] = (
284
- data
285
- if isinstance(data, tuple)
286
- else (
287
- {items_to_parse_key: data[items_to_parse_key]},
288
- data,
289
- )
290
- )
291
-
292
- misconfigurations_item: dict[str, str] = {}
293
- misconfigurations_all: dict[str, str] = {}
294
- mapped_entity_item = await self._search_as_object(
295
- modified_data[0], raw_entity_mappings["item"], misconfigurations_item
296
- )
297
- if misconfigurations_item:
298
- filtered_item_mappings = self._filter_mappings_by_keys(
299
- raw_entity_mappings["item"], list(misconfigurations_item.keys())
300
- )
301
- raw_entity_mappings["all"] = self._deep_merge(
302
- raw_entity_mappings["all"], filtered_item_mappings
303
- )
304
- mapped_entity_all = await self._search_as_object(
305
- modified_data[1], raw_entity_mappings["all"], misconfigurations_all
306
- )
307
- mapped_entity_empty = await self._search_as_object(
308
- {}, raw_entity_mappings["empty"], misconfigurations_all
309
- )
310
- mapped_entity = self._deep_merge(mapped_entity_item, mapped_entity_all)
311
- mapped_entity = self._deep_merge(mapped_entity, mapped_entity_empty)
312
- return misconfigurations_all, mapped_entity
313
-
314
- async def _should_map_entity(
315
- self,
316
- data: dict[str, Any] | tuple[dict[str, Any], str],
317
- selector_query: str,
318
- items_to_parse_key: str | None,
319
- ) -> bool:
320
- if should_shortcut_no_input(selector_query):
321
- return await self._search_as_bool({}, selector_query)
322
- if isinstance(data, tuple):
323
- return await self._search_as_bool(
324
- data[0], selector_query
325
- ) or await self._search_as_bool(data[1], selector_query)
326
- if items_to_parse_key:
327
- return await self._search_as_bool(
328
- data[items_to_parse_key], selector_query
329
- ) or await self._search_as_bool(data, selector_query)
330
- return await self._search_as_bool(data, selector_query)
331
-
332
240
  async def _calculate_entity(
333
241
  self,
334
242
  data: dict[str, Any],
@@ -338,16 +246,9 @@ class JQEntityProcessor(BaseEntityProcessor):
338
246
  selector_query: str,
339
247
  parse_all: bool = False,
340
248
  ) -> tuple[list[MappedEntity], list[Exception]]:
341
- raw_data: list[dict[str, Any]] | list[tuple[dict[str, Any], str]] = [
342
- data.copy()
343
- ]
344
- items_to_parse_key = None
345
- if items_to_parse:
346
- items_to_parse_key = items_to_parse_name
347
- if not ocean.config.yield_items_to_parse:
348
- if data.get("file", {}).get("content", {}).get("path", None):
349
- with open(data["file"]["content"]["path"], "r") as f:
350
- data["file"]["content"] = json.loads(f.read())
249
+ raw_data = [data.copy()]
250
+ if not ocean.config.yield_items_to_parse:
251
+ if items_to_parse:
351
252
  items = await self._search(data, items_to_parse)
352
253
  if not isinstance(items, list):
353
254
  logger.warning(
@@ -355,28 +256,13 @@ class JQEntityProcessor(BaseEntityProcessor):
355
256
  f" Skipping..."
356
257
  )
357
258
  return [], []
358
- raw_all_payload_stringified = json.dumps(data)
359
- raw_data = [
360
- ({items_to_parse_name: item}, raw_all_payload_stringified)
361
- for item in items
362
- ]
363
- single_item_mappings, all_items_mappings, empty_items_mappings = (
364
- self._build_raw_entity_mappings(
365
- raw_entity_mappings, items_to_parse_name
366
- )
367
- )
368
- raw_entity_mappings = {
369
- "item": single_item_mappings,
370
- "all": all_items_mappings,
371
- "empty": empty_items_mappings,
372
- }
259
+ raw_data = [{items_to_parse_name: item, **data} for item in items]
373
260
 
374
261
  entities, errors = await gather_and_split_errors_from_results(
375
262
  [
376
263
  self._get_mapped_entity(
377
264
  raw,
378
265
  raw_entity_mappings,
379
- items_to_parse_key,
380
266
  selector_query,
381
267
  parse_all,
382
268
  )
@@ -389,201 +275,6 @@ class JQEntityProcessor(BaseEntityProcessor):
389
275
  )
390
276
  return entities, errors
391
277
 
392
- def _build_raw_entity_mappings(
393
- self, raw_entity_mappings: dict[str, Any], items_to_parse_name: str
394
- ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
395
- """Filter entity mappings to only include values that start with f'.{items_to_parse_name}'"""
396
- mappings: dict[InputEvaluationResult, dict[str, Any]] = {
397
- InputEvaluationResult.NONE: {},
398
- InputEvaluationResult.SINGLE: {},
399
- InputEvaluationResult.ALL: {},
400
- }
401
- pattern = f".{items_to_parse_name}"
402
- for key, value in raw_entity_mappings.items():
403
- if isinstance(value, str):
404
- # Direct string values (identifier, title, icon, blueprint, team)
405
- self.group_string_mapping_value(
406
- pattern,
407
- mappings,
408
- key,
409
- value,
410
- )
411
- elif isinstance(value, dict):
412
- # Complex objects (IngestSearchQuery for identifier/team, properties, relations)
413
- self.group_complex_mapping_value(
414
- pattern,
415
- mappings,
416
- key,
417
- value,
418
- )
419
- return (
420
- mappings[InputEvaluationResult.SINGLE],
421
- mappings[InputEvaluationResult.ALL],
422
- mappings[InputEvaluationResult.NONE],
423
- )
424
-
425
- def group_complex_mapping_value(
426
- self,
427
- pattern: str,
428
- mappings: dict[InputEvaluationResult, dict[str, Any]],
429
- key: str,
430
- value: dict[str, Any],
431
- ) -> None:
432
- mapping_dicts: dict[InputEvaluationResult, dict[str, Any]] = {
433
- InputEvaluationResult.SINGLE: {},
434
- InputEvaluationResult.ALL: {},
435
- InputEvaluationResult.NONE: {},
436
- }
437
- if key in ["properties", "relations"]:
438
- # For properties and relations, filter the dictionary values
439
- for dict_key, dict_value in value.items():
440
- if isinstance(dict_value, str):
441
- self.group_string_mapping_value(
442
- pattern,
443
- mapping_dicts,
444
- dict_key,
445
- dict_value,
446
- )
447
- elif isinstance(dict_value, dict):
448
- # Handle IngestSearchQuery objects
449
- self.group_search_query_mapping_value(
450
- pattern,
451
- mapping_dicts[InputEvaluationResult.SINGLE],
452
- mapping_dicts[InputEvaluationResult.ALL],
453
- dict_key,
454
- dict_value,
455
- )
456
- else:
457
- # For identifier/team IngestSearchQuery objects
458
- self.group_search_query_mapping_value(
459
- pattern,
460
- mapping_dicts[InputEvaluationResult.SINGLE],
461
- mapping_dicts[InputEvaluationResult.ALL],
462
- key,
463
- value,
464
- )
465
- if mapping_dicts[InputEvaluationResult.SINGLE]:
466
- mappings[InputEvaluationResult.SINGLE][key] = mapping_dicts[
467
- InputEvaluationResult.SINGLE
468
- ][key]
469
- if mapping_dicts[InputEvaluationResult.ALL]:
470
- mappings[InputEvaluationResult.ALL][key] = mapping_dicts[
471
- InputEvaluationResult.ALL
472
- ][key]
473
- if mapping_dicts[InputEvaluationResult.NONE]:
474
- mappings[InputEvaluationResult.NONE][key] = mapping_dicts[
475
- InputEvaluationResult.NONE
476
- ][key]
477
-
478
- def group_search_query_mapping_value(
479
- self,
480
- pattern: str,
481
- single_item_dict: dict[str, Any],
482
- all_item_dict: dict[str, Any],
483
- dict_key: str,
484
- dict_value: dict[str, Any],
485
- ) -> None:
486
- if self._should_keep_ingest_search_query(dict_value, pattern):
487
- single_item_dict[dict_key] = dict_value
488
- else:
489
- all_item_dict[dict_key] = dict_value
490
-
491
- def group_string_mapping_value(
492
- self,
493
- pattern: str,
494
- mappings: dict[InputEvaluationResult, dict[str, Any]],
495
- key: str,
496
- value: str,
497
- ) -> None:
498
- input_evaluation_result = evaluate_input(value, pattern)
499
- mappings[input_evaluation_result][key] = value
500
-
501
- def _should_keep_ingest_search_query(
502
- self, query_dict: dict[str, Any], pattern: str
503
- ) -> bool:
504
- """Check if an IngestSearchQuery should be kept based on its rules"""
505
- if "rules" not in query_dict:
506
- return False
507
-
508
- rules = query_dict["rules"]
509
- if not isinstance(rules, list):
510
- return False
511
-
512
- # Check if any rule contains a value starting with the pattern
513
- for rule in rules:
514
- if isinstance(rule, dict):
515
- if "value" in rule and isinstance(rule["value"], str):
516
- if pattern in rule["value"]:
517
- return True
518
- # Recursively check nested IngestSearchQuery objects
519
- elif "rules" in rule:
520
- if self._should_keep_ingest_search_query(rule, pattern):
521
- return True
522
- return False
523
-
524
- def _filter_mappings_by_keys(
525
- self, mappings: dict[str, Any], target_keys: list[str]
526
- ) -> dict[str, Any]:
527
- """
528
- Filter mappings to preserve structure with only the specified keys present.
529
- Recursively handles nested dictionaries and lists, searching for keys at any level.
530
- """
531
- if not target_keys:
532
- return {}
533
-
534
- filtered_mappings: dict[str, Any] = {}
535
-
536
- for key, value in mappings.items():
537
- filtered_value = self._process_mapping_value(key, value, target_keys)
538
-
539
- # Include if it's a direct match or contains nested target keys
540
- if key in target_keys or filtered_value:
541
- filtered_mappings[key] = filtered_value
542
-
543
- return filtered_mappings
544
-
545
- def _process_mapping_value(
546
- self, key: str, value: Any, target_keys: list[str]
547
- ) -> Any:
548
- """Process a single mapping value, handling different types recursively."""
549
- if isinstance(value, dict):
550
- # Recursively filter nested dictionary
551
- filtered_dict = self._filter_mappings_by_keys(value, target_keys)
552
- return filtered_dict if filtered_dict else None
553
- else:
554
- # Return simple values as-is
555
- return value if key in target_keys else None
556
-
557
- def _deep_merge(
558
- self, dict1: dict[str, Any], dict2: dict[str, Any]
559
- ) -> dict[str, Any]:
560
- """
561
- Deep merge two dictionaries, preserving nested structures.
562
- Values from dict2 override values from dict1 for the same keys.
563
- """
564
- result = dict1.copy()
565
-
566
- for key, value in dict2.items():
567
- if (
568
- key in result
569
- and isinstance(result[key], dict)
570
- and isinstance(value, dict)
571
- ):
572
- # Recursively merge nested dictionaries
573
- result[key] = self._deep_merge(result[key], value)
574
- elif (
575
- key in result
576
- and isinstance(result[key], list)
577
- and isinstance(value, list)
578
- ):
579
- # Merge lists by extending
580
- result[key].extend(value)
581
- else:
582
- # Override with value from dict2
583
- result[key] = value
584
-
585
- return result
586
-
587
278
  @staticmethod
588
279
  async def _send_examples(data: list[dict[str, Any]], kind: str) -> None:
589
280
  try:
@@ -638,10 +329,7 @@ class JQEntityProcessor(BaseEntityProcessor):
638
329
  and result.raw_data is not None
639
330
  ):
640
331
  examples_to_send.add_example(
641
- result.did_entity_pass_selector,
642
- self._get_raw_data_for_example(
643
- result.raw_data, mapping.port.items_to_parse_name
644
- ),
332
+ result.did_entity_pass_selector, result.raw_data
645
333
  )
646
334
 
647
335
  if result.entity.get("identifier") and result.entity.get("blueprint"):
@@ -667,13 +355,3 @@ class JQEntityProcessor(BaseEntityProcessor):
667
355
  errors,
668
356
  misconfigured_entity_keys=entity_misconfigurations,
669
357
  )
670
-
671
- def _get_raw_data_for_example(
672
- self,
673
- data: dict[str, Any] | tuple[dict[str, Any], str],
674
- items_to_parse_name: str,
675
- ) -> dict[str, Any]:
676
- if isinstance(data, tuple):
677
- raw_data = json.loads(data[1])
678
- return {items_to_parse_name: data[0], **raw_data}
679
- return data
@@ -29,7 +29,9 @@ class EntityMapping(BaseModel):
29
29
 
30
30
  @property
31
31
  def is_using_search_identifier(self) -> bool:
32
- return isinstance(self.identifier, dict)
32
+ return isinstance(self.identifier, dict) or isinstance(
33
+ self.identifier, IngestSearchQuery
34
+ )
33
35
 
34
36
 
35
37
  class MappingsConfig(BaseModel):
@@ -39,7 +41,7 @@ class MappingsConfig(BaseModel):
39
41
  class PortResourceConfig(BaseModel):
40
42
  entity: MappingsConfig
41
43
  items_to_parse: str | None = Field(alias="itemsToParse")
42
- items_to_parse_name: str = Field(alias="itemsToParseName", default="item")
44
+ items_to_parse_name: str | None = Field(alias="itemsToParseName", default="item")
43
45
 
44
46
 
45
47
  class Selector(BaseModel):
@@ -117,7 +117,7 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
117
117
  logger.info(
118
118
  f"Found async generator function for {resource_config.kind} name: {task.__qualname__}"
119
119
  )
120
- results.append(resync_generator_wrapper(task, resource_config.kind, resource_config.port.items_to_parse_name, resource_config.port.items_to_parse))
120
+ results.append(resync_generator_wrapper(task, resource_config.kind,resource_config.port.items_to_parse))
121
121
  else:
122
122
  logger.info(
123
123
  f"Found sync function for {resource_config.kind} name: {task.__qualname__}"
@@ -2,11 +2,12 @@ from contextlib import contextmanager
2
2
  from typing import Awaitable, Generator, Callable, cast
3
3
 
4
4
  from loguru import logger
5
+
5
6
  import asyncio
6
7
  import multiprocessing
7
- import re
8
- import json
8
+
9
9
  from port_ocean.core.handlers.entity_processor.jq_entity_processor import JQEntityProcessor
10
+ from port_ocean.core.handlers.port_app_config.models import ResourceConfig
10
11
  from port_ocean.core.ocean_types import (
11
12
  ASYNC_GENERATOR_RESYNC_TYPE,
12
13
  RAW_RESULT,
@@ -19,60 +20,11 @@ from port_ocean.exceptions.core import (
19
20
  OceanAbortException,
20
21
  KindNotImplementedException,
21
22
  )
22
- import os
23
+
23
24
  from port_ocean.utils.async_http import _http_client
24
25
  from port_ocean.clients.port.utils import _http_client as _port_http_client
25
26
  from port_ocean.helpers.metric.metric import MetricType, MetricPhase
26
27
  from port_ocean.context.ocean import ocean
27
- import subprocess
28
- import tempfile
29
- import stat
30
- import ijson
31
- from typing import Any, AsyncGenerator
32
-
33
- def _process_path_type_items(
34
- result: RAW_RESULT, items_to_parse: str | None = None
35
- ) -> RAW_RESULT:
36
- """
37
- Process items in the result array to check for "__type": "path" fields.
38
- If found, read the file contents and load them into a "content" field.
39
- Skip processing if we're on the items_to_parse branch.
40
- """
41
- if not isinstance(result, list):
42
- return result
43
-
44
- # Skip processing if we're on the items_to_parse branch
45
- if items_to_parse:
46
- return result
47
-
48
- processed_result = []
49
- for item in result:
50
- if isinstance(item, dict) and item.get("__type") == "path":
51
- try:
52
- # Read the file content and parse as JSON
53
- file_path = item.get("file", {}).get("content", {}).get("path")
54
- if file_path and os.path.exists(file_path):
55
- with open(file_path, "r") as f:
56
- content = json.loads(f.read())
57
- # Create a copy of the item with the content field
58
- processed_item = item.copy()
59
- processed_item["content"] = content
60
- processed_result.append(processed_item)
61
- else:
62
- # If file doesn't exist, keep the original item
63
- processed_result.append(item)
64
- except (json.JSONDecodeError, IOError, OSError) as e:
65
- logger.warning(
66
- f"Failed to read or parse file content for path "
67
- f"{item.get('file', {}).get('content', {}).get('path')}: {e}"
68
- )
69
- # Keep the original item if there's an error
70
- processed_result.append(item)
71
- else:
72
- # Keep non-path type items as is
73
- processed_result.append(item)
74
-
75
- return processed_result
76
28
 
77
29
  @contextmanager
78
30
  def resync_error_handling() -> Generator[None, None, None]:
@@ -95,12 +47,11 @@ async def resync_function_wrapper(
95
47
  ) -> RAW_RESULT:
96
48
  with resync_error_handling():
97
49
  results = await fn(kind)
98
- validated_results = validate_result(results)
99
- return _process_path_type_items(validated_results)
50
+ return validate_result(results)
100
51
 
101
52
 
102
53
  async def resync_generator_wrapper(
103
- fn: Callable[[str], ASYNC_GENERATOR_RESYNC_TYPE], kind: str, items_to_parse_name: str, items_to_parse: str | None = None
54
+ fn: Callable[[str], ASYNC_GENERATOR_RESYNC_TYPE], kind: str, items_to_parse: str | None = None
104
55
  ) -> ASYNC_GENERATOR_RESYNC_TYPE:
105
56
  generator = fn(kind)
106
57
  errors = []
@@ -110,23 +61,27 @@ async def resync_generator_wrapper(
110
61
  with resync_error_handling():
111
62
  result = await anext(generator)
112
63
  if not ocean.config.yield_items_to_parse:
113
- validated_result = validate_result(result)
114
- processed_result = _process_path_type_items(validated_result)
115
- yield processed_result
64
+ yield validate_result(result)
116
65
  else:
66
+ batch_size = ocean.config.yield_items_to_parse_batch_size
117
67
  if items_to_parse:
118
68
  for data in result:
119
- data_path: str | None = None
120
- if isinstance(data, dict) and data.get("file") is not None:
121
- content = data["file"].get("content") if isinstance(data["file"].get("content"), dict) else {}
122
- data_path = content.get("path", None)
123
- bulks = get_items_to_parse_bulks(data, data_path, items_to_parse, items_to_parse_name, data.get("__base_jq", ".file.content"))
124
- async for bulk in bulks:
125
- yield bulk
69
+ items = await cast(JQEntityProcessor, ocean.app.integration.entity_processor)._search(data, items_to_parse)
70
+ if not isinstance(items, list):
71
+ logger.warning(
72
+ f"Failed to parse items for JQ expression {items_to_parse}, Expected list but got {type(items)}."
73
+ f" Skipping..."
74
+ )
75
+ yield []
76
+ raw_data = [{"item": item, **data} for item in items]
77
+ while True:
78
+ raw_data_batch = raw_data[:batch_size]
79
+ yield raw_data_batch
80
+ raw_data = raw_data[batch_size:]
81
+ if len(raw_data) == 0:
82
+ break
126
83
  else:
127
- validated_result = validate_result(result)
128
- processed_result = _process_path_type_items(validated_result, items_to_parse)
129
- yield processed_result
84
+ yield validate_result(result)
130
85
  except OceanAbortException as error:
131
86
  errors.append(error)
132
87
  ocean.metrics.inc_metric(
@@ -146,104 +101,6 @@ def is_resource_supported(
146
101
  ) -> bool:
147
102
  return bool(resync_event_mapping[kind] or resync_event_mapping[None])
148
103
 
149
- def _validate_jq_expression(expression: str) -> None:
150
- """Validate jq expression to prevent command injection."""
151
- try:
152
- _ = cast(JQEntityProcessor, ocean.app.integration.entity_processor)._compile(expression)
153
- except Exception as e:
154
- raise ValueError(f"Invalid jq expression: {e}") from e
155
- # Basic validation - reject expressions that could be dangerous
156
- # Check for dangerous patterns (include, import, module)
157
- dangerous_patterns = ['include', 'import', 'module', 'env']
158
- for pattern in dangerous_patterns:
159
- # Use word boundary regex to match only complete words, not substrings
160
- if re.search(rf'\b{re.escape(pattern)}\b', expression):
161
- raise ValueError(f"Potentially dangerous pattern '{pattern}' found in jq expression")
162
-
163
- # Special handling for 'env' - block environment variable access
164
- if re.search(r'(?<!\w)\$ENV(?:\.)?', expression):
165
- raise ValueError("Environment variable access '$ENV.' found in jq expression")
166
- if re.search(r'\benv\.', expression):
167
- raise ValueError("Environment variable access 'env.' found in jq expression")
168
-
169
- def _create_secure_temp_file(suffix: str = ".json") -> str:
170
- """Create a secure temporary file with restricted permissions."""
171
- # Create temp directory if it doesn't exist
172
- temp_dir = "/tmp/ocean"
173
- os.makedirs(temp_dir, exist_ok=True)
174
-
175
- # Create temporary file with secure permissions
176
- fd, temp_path = tempfile.mkstemp(suffix=suffix, dir=temp_dir)
177
- try:
178
- # Set restrictive permissions (owner read/write only)
179
- os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
180
- return temp_path
181
- finally:
182
- os.close(fd)
183
-
184
- async def get_items_to_parse_bulks(raw_data: dict[Any, Any], data_path: str, items_to_parse: str, items_to_parse_name: str, base_jq: str) -> AsyncGenerator[list[dict[str, Any]], None]:
185
- # Validate inputs to prevent command injection
186
- _validate_jq_expression(items_to_parse)
187
- items_to_parse = items_to_parse.replace(base_jq, ".") if data_path else items_to_parse
188
-
189
- temp_data_path = None
190
- temp_output_path = None
191
-
192
- try:
193
- # Create secure temporary files
194
- if not data_path:
195
- raw_data_serialized = json.dumps(raw_data)
196
- temp_data_path = _create_secure_temp_file("_input.json")
197
- with open(temp_data_path, "w") as f:
198
- f.write(raw_data_serialized)
199
- data_path = temp_data_path
200
-
201
- temp_output_path = _create_secure_temp_file("_parsed.json")
202
-
203
- delete_target = items_to_parse.split('|', 1)[0].strip() if not items_to_parse.startswith('map(') else '.'
204
- base_jq_object_string = await _build_base_jq_object_string(raw_data, base_jq, delete_target)
205
-
206
- # Build jq expression safely
207
- jq_expression = f""". as $all
208
- | ($all | {items_to_parse}) as $items
209
- | $items
210
- | map({{{items_to_parse_name}: ., {base_jq_object_string}}})"""
211
-
212
- # Use subprocess with list arguments instead of shell=True
213
- jq_args = ["/bin/jq", jq_expression, data_path]
214
-
215
- with open(temp_output_path, "w") as output_file:
216
- result = subprocess.run(
217
- jq_args,
218
- stdout=output_file,
219
- stderr=subprocess.PIPE,
220
- text=True,
221
- check=False # Don't raise exception, handle errors manually
222
- )
223
-
224
- if result.returncode != 0:
225
- logger.error(f"Failed to parse items for JQ expression {items_to_parse}, error: {result.stderr}")
226
- yield []
227
- else:
228
- with open(temp_output_path, "r") as f:
229
- events_stream = get_events_as_a_stream(f, 'item', ocean.config.yield_items_to_parse_batch_size)
230
- for items_bulk in events_stream:
231
- yield items_bulk
232
-
233
- except ValueError as e:
234
- logger.error(f"Invalid jq expression: {e}")
235
- yield []
236
- except Exception as e:
237
- logger.error(f"Failed to parse items for JQ expression {items_to_parse}, error: {e}")
238
- yield []
239
- finally:
240
- # Cleanup temporary files
241
- for temp_path in [temp_data_path, temp_output_path]:
242
- if temp_path and os.path.exists(temp_path):
243
- try:
244
- os.remove(temp_path)
245
- except OSError as e:
246
- logger.warning(f"Failed to cleanup temporary file {temp_path}: {e}")
247
104
 
248
105
  def unsupported_kind_response(
249
106
  kind: str, available_resync_kinds: list[str]
@@ -251,44 +108,6 @@ def unsupported_kind_response(
251
108
  logger.error(f"Kind {kind} is not supported in this integration")
252
109
  return [], [KindNotImplementedException(kind, available_resync_kinds)]
253
110
 
254
- async def _build_base_jq_object_string(raw_data: dict[Any, Any], base_jq: str, delete_target: str) -> str:
255
- base_jq_object_before_parsing = await cast(JQEntityProcessor, ocean.app.integration.entity_processor)._search(raw_data, f"{base_jq} = {json.dumps("__all")}")
256
- base_jq_object_before_parsing_serialized = json.dumps(base_jq_object_before_parsing)
257
- base_jq_object_before_parsing_serialized = base_jq_object_before_parsing_serialized[1:-1] if len(base_jq_object_before_parsing_serialized) >= 2 else base_jq_object_before_parsing_serialized
258
- base_jq_object_before_parsing_serialized = base_jq_object_before_parsing_serialized.replace("\"__all\"", f"(($all | del({delete_target})) // {{}})")
259
- return base_jq_object_before_parsing_serialized
260
-
261
-
262
- def get_events_as_a_stream(
263
- stream: Any,
264
- target_items: str = "item",
265
- max_buffer_size_mb: int = 1
266
- ) -> Generator[list[dict[str, Any]], None, None]:
267
- events = ijson.sendable_list()
268
- coro = ijson.items_coro(events, target_items)
269
-
270
- # Convert MB to bytes for the buffer size
271
- buffer_size = max_buffer_size_mb * 1024 * 1024
272
-
273
- # Read chunks from the stream until exhausted
274
- while True:
275
- chunk = stream.read(buffer_size)
276
- if not chunk: # End of stream
277
- break
278
-
279
- # Convert string to bytes if necessary (for text mode files)
280
- if isinstance(chunk, str):
281
- chunk = chunk.encode('utf-8')
282
-
283
- coro.send(chunk)
284
- yield events
285
- del events[:]
286
- try:
287
- coro.close()
288
- finally:
289
- if events:
290
- yield events
291
- events[:] = []
292
111
 
293
112
  class ProcessWrapper(multiprocessing.Process):
294
113
  def __init__(self, *args, **kwargs):
@@ -315,34 +134,3 @@ def clear_http_client_context() -> None:
315
134
  _port_http_client.pop()
316
135
  except (RuntimeError, AttributeError):
317
136
  pass
318
-
319
- class _AiterReader:
320
- """
321
- Wraps an iterable of byte chunks (e.g., response.iter_bytes())
322
- and exposes a .read(n) method that ijson expects.
323
- """
324
- def __init__(self, iterable):
325
- self._iter = iter(iterable)
326
- self._buf = bytearray()
327
- self._eof = False
328
-
329
- def read(self, n=-1):
330
- # If n < 0, return everything until EOF
331
- if n is None or n < 0:
332
- chunks = [bytes(self._buf)]
333
- self._buf.clear()
334
- chunks.extend(self._iter) # drain the iterator
335
- return b"".join(chunks)
336
-
337
- # Fill buffer until we have n bytes or hit EOF
338
- while len(self._buf) < n and not self._eof:
339
- try:
340
- self._buf.extend(next(self._iter))
341
- except StopIteration:
342
- self._eof = True
343
- break
344
-
345
- # Serve up to n bytes
346
- out = bytes(self._buf[:n])
347
- del self._buf[:n]
348
- return out
@@ -4,7 +4,7 @@ import json
4
4
  from typing import Iterable, Any, TypeVar, Callable, Awaitable
5
5
 
6
6
  from loguru import logger
7
- from pydantic import parse_obj_as, ValidationError
7
+ from pydantic import BaseModel, parse_obj_as, ValidationError
8
8
 
9
9
 
10
10
  from port_ocean.clients.port.client import PortClient
@@ -79,6 +79,19 @@ async def gather_and_split_errors_from_results(
79
79
  return valid_items, errors
80
80
 
81
81
 
82
+ def _get_entity_key(entity: Entity) -> tuple[str, str]:
83
+ identifier = entity.identifier
84
+ if isinstance(identifier, BaseModel):
85
+ identifier = identifier.dict()
86
+
87
+ key_part = (
88
+ json.dumps(identifier, sort_keys=True)
89
+ if isinstance(identifier, dict)
90
+ else str(identifier)
91
+ )
92
+ return key_part, entity.blueprint
93
+
94
+
82
95
  def get_port_diff(before: Iterable[Entity], after: Iterable[Entity]) -> EntityPortDiff:
83
96
  before_dict = {}
84
97
  after_dict = {}
@@ -88,12 +101,10 @@ def get_port_diff(before: Iterable[Entity], after: Iterable[Entity]) -> EntityPo
88
101
 
89
102
  # Create dictionaries for before and after lists
90
103
  for entity in before:
91
- key = (entity.identifier, entity.blueprint)
92
- before_dict[key] = entity
104
+ before_dict[_get_entity_key(entity)] = entity
93
105
 
94
106
  for entity in after:
95
- key = (entity.identifier, entity.blueprint)
96
- after_dict[key] = entity
107
+ after_dict[_get_entity_key(entity)] = entity
97
108
 
98
109
  # Find created, modified, and deleted objects
99
110
  for key, obj in after_dict.items():
@@ -50,7 +50,7 @@ class TestJQEntityProcessor:
50
50
  raw_entity_mappings = {"foo": ".foo"}
51
51
  selector_query = '.foo == "bar"'
52
52
  result = await mocked_processor._get_mapped_entity(
53
- data, raw_entity_mappings, None, selector_query
53
+ data, raw_entity_mappings, selector_query
54
54
  )
55
55
  assert result.entity == {"foo": "bar"}
56
56
  assert result.did_entity_pass_selector is True
@@ -0,0 +1,164 @@
1
+ from typing import Any
2
+
3
+ from port_ocean.core.models import Entity
4
+ from port_ocean.core.utils.utils import get_port_diff
5
+
6
+
7
+ def create_test_entity(
8
+ identifier: str,
9
+ blueprint: str,
10
+ properties: dict[str, Any],
11
+ relations: dict[str, Any],
12
+ title: Any,
13
+ team: str | None | list[Any] = [],
14
+ ) -> Entity:
15
+ return Entity(
16
+ identifier=identifier,
17
+ blueprint=blueprint,
18
+ properties=properties,
19
+ relations=relations,
20
+ title=title,
21
+ team=team,
22
+ )
23
+
24
+
25
+ entity1 = create_test_entity(
26
+ "id1",
27
+ "bp1",
28
+ {"totalIssues": 123, "url": "https://test.atlassian.net/browse/test-29081"},
29
+ {"reporter": "id1", "project": "project_id"},
30
+ "",
31
+ "",
32
+ )
33
+ entity1_modified_properties = create_test_entity(
34
+ "id1",
35
+ "bp1",
36
+ {"totalIssues": 5, "url": "https://test.atlassian.net/browse/test-29081"},
37
+ {"reporter": "id1", "project": "project_id"},
38
+ "",
39
+ "",
40
+ )
41
+ entity2 = create_test_entity(
42
+ "id2",
43
+ "bp2",
44
+ {"totalIssues": 234, "url": "https://test.atlassian.net/browse/test-23451"},
45
+ {"reporter": "id2", "project": "project_id2"},
46
+ "",
47
+ "",
48
+ )
49
+ entity3 = create_test_entity(
50
+ "id3",
51
+ "bp3",
52
+ {"totalIssues": 20, "url": "https://test.atlassian.net/browse/test-542"},
53
+ {"reporter": "id3", "project": "project_id3"},
54
+ "",
55
+ "",
56
+ )
57
+
58
+
59
+ def test_get_port_diff_with_dictionary_identifier() -> None:
60
+ """
61
+ Test that get_port_diff handles dictionary identifiers by converting them to strings.
62
+ An entity with a dictionary identifier in 'before' and not in 'after' should be marked as deleted.
63
+ """
64
+ entity_with_dict_id = create_test_entity(
65
+ identifier={"rules": "some_id", "combinator": "some combinator"}, # type: ignore
66
+ blueprint="bp1",
67
+ properties={},
68
+ relations={},
69
+ title="test",
70
+ )
71
+ before = [entity_with_dict_id]
72
+ after: list[Entity] = []
73
+
74
+ diff = get_port_diff(before, after)
75
+
76
+ assert not diff.created
77
+ assert not diff.modified
78
+ assert len(diff.deleted) == 1
79
+ assert entity_with_dict_id in diff.deleted
80
+
81
+
82
+ def test_get_port_diff_no_changes() -> None:
83
+ """
84
+ Test get_port_diff with no changes between before and after.
85
+ Entities present in both should be in the 'modified' list.
86
+ """
87
+ before = [entity1, entity2]
88
+ after = [entity1, entity2]
89
+
90
+ diff = get_port_diff(before, after)
91
+
92
+ assert not diff.created
93
+ assert not diff.deleted
94
+ assert len(diff.modified) == 2
95
+ assert entity1 in diff.modified
96
+ assert entity2 in diff.modified
97
+
98
+
99
+ def test_get_port_diff_created_entities() -> None:
100
+ """
101
+ Test get_port_diff with only new entities.
102
+ """
103
+ before: list[Entity] = []
104
+ after = [entity1, entity2]
105
+
106
+ diff = get_port_diff(before, after)
107
+
108
+ assert not diff.modified
109
+ assert not diff.deleted
110
+ assert len(diff.created) == 2
111
+ assert entity1 in diff.created
112
+ assert entity2 in diff.created
113
+
114
+
115
+ def test_get_port_diff_deleted_entities() -> None:
116
+ """
117
+ Test get_port_diff with only deleted entities.
118
+ """
119
+ before = [entity1, entity2]
120
+ after: list[Entity] = []
121
+
122
+ diff = get_port_diff(before, after)
123
+
124
+ assert not diff.created
125
+ assert not diff.modified
126
+ assert len(diff.deleted) == 2
127
+ assert entity1 in diff.deleted
128
+ assert entity2 in diff.deleted
129
+
130
+
131
+ def test_get_port_diff_modified_entities() -> None:
132
+ """
133
+ Test get_port_diff with modified entities.
134
+ Entities with same identifier and blueprint are considered modified.
135
+ """
136
+ before = [entity1, entity2]
137
+ after = [entity1_modified_properties, entity2]
138
+
139
+ diff = get_port_diff(before, after)
140
+
141
+ assert not diff.created
142
+ assert not diff.deleted
143
+ assert len(diff.modified) == 2
144
+ assert entity1_modified_properties in diff.modified
145
+ assert entity2 in diff.modified
146
+
147
+
148
+ def test_get_port_diff_mixed_changes() -> None:
149
+ """
150
+ Test get_port_diff with a mix of created, modified, and deleted entities.
151
+ """
152
+ before = [entity1, entity2]
153
+ after = [entity1_modified_properties, entity3]
154
+
155
+ diff = get_port_diff(before, after)
156
+
157
+ assert len(diff.created) == 1
158
+ assert entity3 in diff.created
159
+
160
+ assert len(diff.modified) == 1
161
+ assert entity1_modified_properties in diff.modified
162
+
163
+ assert len(diff.deleted) == 1
164
+ assert entity2 in diff.deleted
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: port-ocean
3
- Version: 0.28.8
3
+ Version: 0.28.11
4
4
  Summary: Port Ocean is a CLI tool for managing your Port projects.
5
5
  Home-page: https://app.getport.io
6
6
  Keywords: ocean,port-ocean,port
@@ -1,9 +1,9 @@
1
- integrations/_infra/Dockerfile.Deb,sha256=ZqAg-p3GbLaneWS0sIcUDHp1FLwLoxHLvsKT5H8sCLc,2562
1
+ integrations/_infra/Dockerfile.Deb,sha256=QNyStzc0Zov1e3sejWna84yhrdOPO8Ogc-r_he3fYT4,2549
2
2
  integrations/_infra/Dockerfile.alpine,sha256=7E4Sb-8supsCcseerHwTkuzjHZoYcaHIyxiBZ-wewo0,3482
3
3
  integrations/_infra/Dockerfile.base.builder,sha256=ESe1PKC6itp_AuXawbLI75k1Kruny6NTANaTinxOgVs,743
4
4
  integrations/_infra/Dockerfile.base.runner,sha256=uAcs2IsxrAAUHGXt_qULA5INr-HFguf5a5fCKiqEzbY,384
5
5
  integrations/_infra/Dockerfile.dockerignore,sha256=CM1Fxt3I2AvSvObuUZRmy5BNLSGC7ylnbpWzFgD4cso,1163
6
- integrations/_infra/Dockerfile.local,sha256=yLkNs8AB1QMsSXyb2OOo0F8cPXeNF9bb2pzAt2d9fZ8,1663
6
+ integrations/_infra/Dockerfile.local,sha256=FFX9RvFqlaHvhUrRnnzUl0zQp2oKDFVRGkXJQPMQ7cI,1650
7
7
  integrations/_infra/Makefile,sha256=YgLKvuF_Dw4IA7X98Nus6zIW_3cJ60M1QFGs3imj5c4,2430
8
8
  integrations/_infra/README.md,sha256=ZtJFSMCTU5zTeM8ddRuW1ZL1ga8z7Ic2F3mxmgOSjgo,1195
9
9
  integrations/_infra/entry_local.sh,sha256=Sn2TexTEpruH2ixIAGsk-fZV6Y7pT3jd2Pi9TxBeFuw,633
@@ -61,7 +61,7 @@ port_ocean/clients/port/client.py,sha256=hBXgU0CDseN2F-vn20JqowfVkcd6oSVmYrjn6t4
61
61
  port_ocean/clients/port/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
62
  port_ocean/clients/port/mixins/blueprints.py,sha256=aMCG4zePsMSMjMLiGrU37h5z5_ElfMzTcTvqvOI5wXY,4683
63
63
  port_ocean/clients/port/mixins/entities.py,sha256=X2NqH00eK6TMJ3a3QEQRVQlKHzyj5l1FiPkIhonnbPg,24234
64
- port_ocean/clients/port/mixins/integrations.py,sha256=16RGnrEnToSkOgffUN1Q62J81Oo_8acvw-cxztuy5Ys,12037
64
+ port_ocean/clients/port/mixins/integrations.py,sha256=oBkO6OtRtos7KmjS-QUnudhrxHRoNOmaClVNnc1_EHU,12042
65
65
  port_ocean/clients/port/mixins/migrations.py,sha256=vdL_A_NNUogvzujyaRLIoZEu5vmKDY2BxTjoGP94YzI,1467
66
66
  port_ocean/clients/port/mixins/organization.py,sha256=A2cP5V49KnjoAXxjmnm_XGth4ftPSU0qURNfnyUyS_Y,1041
67
67
  port_ocean/clients/port/retry_transport.py,sha256=PtIZOAZ6V-ncpVysRUsPOgt8Sf01QLnTKB5YeKBxkJk,1861
@@ -101,12 +101,11 @@ port_ocean/core/handlers/entities_state_applier/port/get_related_entities.py,sha
101
101
  port_ocean/core/handlers/entities_state_applier/port/order_by_entities_dependencies.py,sha256=lyv6xKzhYfd6TioUgR3AVRSJqj7JpAaj1LxxU2xAqeo,1720
102
102
  port_ocean/core/handlers/entity_processor/__init__.py,sha256=FvFCunFg44wNQoqlybem9MthOs7p1Wawac87uSXz9U8,156
103
103
  port_ocean/core/handlers/entity_processor/base.py,sha256=PsnpNRqjHth9xwOvDRe7gKu8cjnVV0XGmTIHGvOelX0,1867
104
- port_ocean/core/handlers/entity_processor/jq_entity_processor.py,sha256=yMymDTiEvIvqA01yB08A6A1_cPH0Pnp1zs0O1v38RJQ,25730
105
- port_ocean/core/handlers/entity_processor/jq_input_evaluator.py,sha256=fcCt35pPi-Myv27ZI_HNhCoqvntU2sVWM2aGJ7yrkHQ,2300
104
+ port_ocean/core/handlers/entity_processor/jq_entity_processor.py,sha256=qvPMbIH1XRvaZ-TvW7lw9k4W27ZPCHcXGSdqnZ0wblw,12970
106
105
  port_ocean/core/handlers/port_app_config/__init__.py,sha256=8AAT5OthiVM7KCcM34iEgEeXtn2pRMrT4Dze5r1Ixbk,134
107
106
  port_ocean/core/handlers/port_app_config/api.py,sha256=r_Th66NEw38IpRdnXZcRvI8ACfvxW_A6V62WLwjWXlQ,1044
108
107
  port_ocean/core/handlers/port_app_config/base.py,sha256=Sup4-X_a7JGa27rMy_OgqGIjFHMlKBpKevicaK3AeHU,2919
109
- port_ocean/core/handlers/port_app_config/models.py,sha256=SMeId2M5Po3_NbJi1mGMduz3VaV9DY20O0tIqBAxEZw,3012
108
+ port_ocean/core/handlers/port_app_config/models.py,sha256=J-dfd9fEcTA77zJKL_Qd3bvg-v3B_-vZe06Mo-V5ptE,3091
110
109
  port_ocean/core/handlers/queue/__init__.py,sha256=yzgicE_jAR1wtljFKxgyG6j-HbLcG_Zze5qw1kkALUI,171
111
110
  port_ocean/core/handlers/queue/abstract_queue.py,sha256=SaivrYbqg8qsX6wtQlJZyxgcbdMD5B9NZG3byN9AvrI,782
112
111
  port_ocean/core/handlers/queue/group_queue.py,sha256=JvvJOwz9z_aI4CjPr7yQX-0rOgqLI5wMdxWk2x5x-34,4989
@@ -124,12 +123,12 @@ port_ocean/core/integrations/mixins/events.py,sha256=2L7P3Jhp8XBqddh2_o9Cn4N261n
124
123
  port_ocean/core/integrations/mixins/handler.py,sha256=mZ7-0UlG3LcrwJttFbMe-R4xcOU2H_g33tZar7PwTv8,3771
125
124
  port_ocean/core/integrations/mixins/live_events.py,sha256=zM24dhNc7uHx9XYZ6toVhDADPA90EnpOmZxgDegFZbA,4196
126
125
  port_ocean/core/integrations/mixins/sync.py,sha256=Vm_898pLKBwfVewtwouDWsXoxcOLicnAy6pzyqqk6U8,4053
127
- port_ocean/core/integrations/mixins/sync_raw.py,sha256=Zga3fSxALuXmAMKmIS0hZYWRe22lSGhiSVFWUCI4f1U,40972
128
- port_ocean/core/integrations/mixins/utils.py,sha256=aIIMCGb2_ezc5d8NEbKwh9cJ_E0C1VIkyqO8jVENH-Q,14120
126
+ port_ocean/core/integrations/mixins/sync_raw.py,sha256=49P9b4Fc5L3NUYmv0W2fzwJ5hariuDqQ0frURw-9o54,40929
127
+ port_ocean/core/integrations/mixins/utils.py,sha256=ytnFX7Lyv6N3CgBnOXxYaI1cRDq5Z4NDrVFiwE6bn-M,5250
129
128
  port_ocean/core/models.py,sha256=DNbKpStMINI2lIekKprTqBevqkw_wFuFayN19w1aDfQ,2893
130
129
  port_ocean/core/ocean_types.py,sha256=bkLlTd8XfJK6_JDl0eXUHfE_NygqgiInSMwJ4YJH01Q,1399
131
130
  port_ocean/core/utils/entity_topological_sorter.py,sha256=MDUjM6OuDy4Xj68o-7InNN0w1jqjxeDfeY8U02vySNI,3081
132
- port_ocean/core/utils/utils.py,sha256=XJ6ZZBR5hols19TcX4Bh49ygSNhPt3MLncLR-g41GTA,6858
131
+ port_ocean/core/utils/utils.py,sha256=6ySxua6JgVxcjESPL5MScdkpaUj5XR9srorGHHb0B2o,7157
133
132
  port_ocean/debug_cli.py,sha256=gHrv-Ey3cImKOcGZpjoHlo4pa_zfmyOl6TUM4o9VtcA,96
134
133
  port_ocean/exceptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
135
134
  port_ocean/exceptions/api.py,sha256=1JcA-H12lhSgolMEA6dM4JMbDrh9sYDcE7oydPSTuK8,649
@@ -171,7 +170,7 @@ port_ocean/tests/core/conftest.py,sha256=0Oql7R1iTbjPyNdUoO6M21IKknLwnCIgDRz2JQ7
171
170
  port_ocean/tests/core/defaults/test_common.py,sha256=sR7RqB3ZYV6Xn6NIg-c8k5K6JcGsYZ2SCe_PYX5vLYM,5560
172
171
  port_ocean/tests/core/event_listener/test_kafka.py,sha256=PH90qk2fvdrQOSZD2QrvkGy8w_WoYb_KHGnqJ6PLHAo,2681
173
172
  port_ocean/tests/core/handlers/entities_state_applier/test_applier.py,sha256=7XWgwUB9uVYRov4VbIz1A-7n2YLbHTTYT-4rKJxjB0A,10711
174
- port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py,sha256=JcDyC7bI4KqIcPpS2S3Hju0mNRfKIoPHdgKVCRBey_E,14105
173
+ port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py,sha256=TjSj8ssIqH23VJlO5PGovbudCqDbuE2-54iNQsD9K-I,14099
175
174
  port_ocean/tests/core/handlers/mixins/test_live_events.py,sha256=Sbv9IZAGQoZDhf27xDjMMVYxUSie9mHltDtxLSqckmM,12548
176
175
  port_ocean/tests/core/handlers/mixins/test_sync_raw.py,sha256=-Jd2rUG63fZM8LuyKtCp1tt4WEqO2m5woESjs1c91sU,44428
177
176
  port_ocean/tests/core/handlers/port_app_config/test_api.py,sha256=eJZ6SuFBLz71y4ca3DNqKag6d6HUjNJS0aqQPwiLMTI,1999
@@ -183,6 +182,7 @@ port_ocean/tests/core/handlers/webhook/test_processor_manager.py,sha256=16q-5Nag
183
182
  port_ocean/tests/core/handlers/webhook/test_webhook_event.py,sha256=oR4dEHLO65mp6rkfNfszZcfFoRZlB8ZWee4XetmsuIk,3181
184
183
  port_ocean/tests/core/test_utils.py,sha256=Z3kdhb5V7Svhcyy3EansdTpgHL36TL6erNtU-OPwAcI,2647
185
184
  port_ocean/tests/core/utils/test_entity_topological_sorter.py,sha256=zuq5WSPy_88PemG3mOUIHTxWMR_js1R7tOzUYlgBd68,3447
185
+ port_ocean/tests/core/utils/test_get_port_diff.py,sha256=YoQxAHZdX5nVpvrKV5Aox-jQ4w14AbfJVo-QK-ICAb8,4297
186
186
  port_ocean/tests/core/utils/test_resolve_entities_diff.py,sha256=nSB0H87Gk6iFw7RMq9YfiZtC_u6X20ao5vmvywP5HsE,17945
187
187
  port_ocean/tests/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
188
188
  port_ocean/tests/helpers/fake_port_api.py,sha256=9rtjC6iTQMfzWK6WipkDzzG0b1IIaRmvdJLOyV613vE,6479
@@ -209,8 +209,8 @@ port_ocean/utils/repeat.py,sha256=U2OeCkHPWXmRTVoPV-VcJRlQhcYqPWI5NfmPlb1JIbc,32
209
209
  port_ocean/utils/signal.py,sha256=J1sI-e_32VHP_VUa5bskLMFoJjJOAk5isrnewKDikUI,2125
210
210
  port_ocean/utils/time.py,sha256=pufAOH5ZQI7gXvOvJoQXZXZJV-Dqktoj9Qp9eiRwmJ4,1939
211
211
  port_ocean/version.py,sha256=UsuJdvdQlazzKGD3Hd5-U7N69STh8Dq9ggJzQFnu9fU,177
212
- port_ocean-0.28.8.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
213
- port_ocean-0.28.8.dist-info/METADATA,sha256=a0LUIWsaY3WN8PFHnIMqjp5XkyLlqo7_SH03tIdLug4,7015
214
- port_ocean-0.28.8.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
215
- port_ocean-0.28.8.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
216
- port_ocean-0.28.8.dist-info/RECORD,,
212
+ port_ocean-0.28.11.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
213
+ port_ocean-0.28.11.dist-info/METADATA,sha256=_DEW9DiFQmGDhG8llM9ln0bH4Bmg_rurx0SQ_FQSMPI,7016
214
+ port_ocean-0.28.11.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
215
+ port_ocean-0.28.11.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
216
+ port_ocean-0.28.11.dist-info/RECORD,,
@@ -1,69 +0,0 @@
1
- import re
2
- from enum import Enum
3
-
4
-
5
- class InputEvaluationResult(Enum):
6
- NONE = 1
7
- SINGLE = 2
8
- ALL = 3
9
-
10
-
11
- # Conservative allowlist: truly nullary jq expressions
12
- _ALLOWLIST_PATTERNS = [
13
- r"^\s*null\s*$", # null
14
- r"^\s*true\s*$", # true
15
- r"^\s*false\s*$", # false
16
- r"^\s*-?\d+(\.\d+)?\s*$", # number literal
17
- r'^\s*".*"\s*$', # string literal (simple heuristic)
18
- r"^\s*\[.*\]\s*$", # array literal (includes [])
19
- r"^\s*\{.*\}\s*$", # object literal (includes {})
20
- r"^\s*range\s*\(.*\)\s*$", # range(...)
21
- r"^\s*empty\s*$", # empty
22
- ]
23
-
24
- # Functions/filters that (even without ".") still require/assume input
25
- _INPUT_DEPENDENT_FUNCS = r"""
26
- \b(
27
- map|select|reverse|sort|sort_by|unique|unique_by|group_by|flatten|transpose|
28
- split|explode|join|add|length|has|in|index|indices|contains|
29
- paths|leaf_paths|keys|keys_unsorted|values|to_entries|with_entries|from_entries|
30
- del|delpaths|walk|reduce|foreach|input|inputs|limit|first|last|nth|
31
- while|until|recurse|recurse_down|bsearch|combinations|permutations
32
- )\b
33
- """
34
-
35
- _INPUT_DEPENDENT_RE = re.compile(_INPUT_DEPENDENT_FUNCS, re.VERBOSE)
36
-
37
-
38
- def should_shortcut_no_input(selector_query: str) -> bool:
39
- """
40
- Returns True if the jq expression can be executed without providing any JSON input.
41
- Conservative: requires NO '.' and must match a known nullary-safe pattern.
42
- """
43
- if "." in selector_query:
44
- return False # explicit JSON reference -> needs input
45
-
46
- # If it contains any known input-dependent functions, don't shortcut
47
- if _INPUT_DEPENDENT_RE.search(selector_query):
48
- return False
49
-
50
- # Allow only if it matches one of the nullary-safe patterns
51
- for pat in _ALLOWLIST_PATTERNS:
52
- if re.match(pat, selector_query):
53
- return True
54
-
55
- return False
56
-
57
-
58
- def evaluate_input(
59
- selector_query: str, single_item_key: str | None = None
60
- ) -> InputEvaluationResult:
61
- """
62
- Returns the input evaluation result for the jq expression.
63
- Conservative: requires NO '.' and must match a known nullary-safe pattern.
64
- """
65
- if should_shortcut_no_input(selector_query):
66
- return InputEvaluationResult.NONE
67
- if single_item_key and single_item_key in selector_query:
68
- return InputEvaluationResult.SINGLE
69
- return InputEvaluationResult.ALL