pam-python 0.2.18__py3-none-any.whl → 0.2.20__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.
pam/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.18"
3
+ __version__ = "0.2.20"
@@ -127,6 +127,7 @@ class DataInputV2:
127
127
  datasets: tuple[DataInputV2Dataset, ...]
128
128
  run_complete: bool
129
129
  next: str
130
+ missing_contacts: DataInputV2File | None = None
130
131
 
131
132
  def iter_population_rows(self) -> Iterator[dict[str, str]]:
132
133
  """Stream the population file for this customer-aligned logical page."""
@@ -210,6 +211,15 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
210
211
  if contact_count > max_contacts_per_batch:
211
212
  raise ValueError("V2 population contact_count exceeds configured batch maximum")
212
213
 
214
+ missing_contacts = None
215
+ if "missing_contacts" in manifest:
216
+ missing_contacts = _parse_and_verify_file(
217
+ manifest["missing_contacts"],
218
+ files_by_name,
219
+ declared_names,
220
+ population_format,
221
+ )
222
+
213
223
  raw_datasets = manifest.get("datasets")
214
224
  if not isinstance(raw_datasets, list) or len(raw_datasets) > V2_MAX_DATASETS:
215
225
  raise ValueError("V2 manifest datasets must be a list of at most five items")
@@ -283,6 +293,7 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
283
293
  max_contacts_per_batch=max_contacts_per_batch,
284
294
  time_window=time_window,
285
295
  population=population_file,
296
+ missing_contacts=missing_contacts,
286
297
  datasets=tuple(datasets),
287
298
  run_complete=_required_bool(manifest, "run_complete"),
288
299
  next=_required_optional_string(manifest, "next"),
pam/reports/store.py CHANGED
@@ -6,7 +6,7 @@ import sqlite3
6
6
  import threading
7
7
  from datetime import datetime, timezone
8
8
  from pathlib import Path
9
- from typing import Any, Iterable, List, Optional, Set
9
+ from typing import Any, Iterable, List, Mapping, Optional, Set
10
10
 
11
11
  from pam.datetime_utils import format_pam_datetime
12
12
  from pam.reports.errors import (
@@ -157,9 +157,12 @@ class ReportStore:
157
157
  granularity: str,
158
158
  columns: Iterable[Field],
159
159
  temporal_mode: Optional[TemporalMode] = None,
160
+ row_order: Optional[Mapping[str, Iterable[Any]]] = None,
160
161
  ) -> TableReport:
161
162
  """Declare a typed Table report."""
162
- return TableReport.declare(self, key, granularity, columns, temporal_mode)
163
+ return TableReport.declare(
164
+ self, key, granularity, columns, temporal_mode, row_order
165
+ )
163
166
 
164
167
  def metric(
165
168
  self,
pam/reports/table.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """Typed Table report public API."""
2
2
 
3
- from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
3
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
4
4
 
5
+ from pam.reports.axis import normalize_value_order
5
6
  from pam.reports.definition import ReportDefinition
6
7
  from pam.reports.errors import ReportStoreValidationError
7
8
  from pam.reports.fields import Dimension, Field, Measure
@@ -22,6 +23,7 @@ class TableReport:
22
23
  report,
23
24
  granularity: str,
24
25
  columns: Sequence[Field],
26
+ row_order: Mapping[str, Tuple[Any, ...]],
25
27
  ):
26
28
  self._report = report
27
29
  self.key = report.key
@@ -33,6 +35,7 @@ class TableReport:
33
35
  self._measures = tuple(
34
36
  column for column in self.columns if isinstance(column, Measure)
35
37
  )
38
+ self.row_order = dict(row_order)
36
39
 
37
40
  @classmethod
38
41
  def declare(
@@ -42,6 +45,7 @@ class TableReport:
42
45
  granularity: str,
43
46
  columns: Iterable[Field],
44
47
  temporal_mode: Optional[TemporalMode] = None,
48
+ row_order: Optional[Mapping[str, Iterable[Any]]] = None,
45
49
  ):
46
50
  if isinstance(columns, (str, bytes)):
47
51
  raise ReportStoreValidationError("columns must contain field declarations")
@@ -71,12 +75,21 @@ class TableReport:
71
75
  normalized_temporal_mode = normalize_temporal_mode(
72
76
  temporal_mode, granularity, measures, "Table"
73
77
  )
78
+ normalized_row_order = cls._normalize_row_order(
79
+ normalized_columns, row_order
80
+ )
74
81
 
75
82
  definition = ReportDefinition.create(
76
83
  report_type="table",
77
84
  contract_version=TABLE_CONTRACT_VERSION,
78
85
  fields=normalized_columns,
79
86
  binding={"columns": [column.name for column in normalized_columns]},
87
+ options={
88
+ "row_order": {
89
+ name: list(values)
90
+ for name, values in normalized_row_order.items()
91
+ }
92
+ } if normalized_row_order else None,
80
93
  temporal_mode=normalized_temporal_mode,
81
94
  )
82
95
  report = store._declare_typed_report(
@@ -86,7 +99,40 @@ class TableReport:
86
99
  granularity=granularity,
87
100
  definition_json=definition.to_json(),
88
101
  )
89
- return cls(report, granularity, normalized_columns)
102
+ return cls(report, granularity, normalized_columns, normalized_row_order)
103
+
104
+ @staticmethod
105
+ def _normalize_row_order(
106
+ columns: Sequence[Field],
107
+ row_order: Optional[Mapping[str, Iterable[Any]]],
108
+ ) -> Dict[str, Tuple[Any, ...]]:
109
+ if row_order is None:
110
+ return {}
111
+ if not isinstance(row_order, Mapping) or not row_order:
112
+ raise ReportStoreValidationError(
113
+ "Table row_order must be a non-empty mapping of dimension names to values"
114
+ )
115
+ dimensions = {
116
+ column.name: column
117
+ for column in columns
118
+ if isinstance(column, Dimension)
119
+ }
120
+ unknown = set(row_order) - set(dimensions)
121
+ if unknown:
122
+ raise ReportStoreValidationError(
123
+ f"Table row_order contains unknown dimension fields: {sorted(unknown)}"
124
+ )
125
+ normalized = {}
126
+ for name, values in row_order.items():
127
+ order = normalize_value_order(
128
+ "Table", f"row_order['{name}']", dimensions[name], values
129
+ )
130
+ if order is None:
131
+ raise ReportStoreValidationError(
132
+ f"Table row_order['{name}'] must be non-empty"
133
+ )
134
+ normalized[name] = order
135
+ return normalized
90
136
 
91
137
  def append_rows(
92
138
  self,
@@ -241,4 +287,10 @@ class TableReport:
241
287
  raise ReportStoreValidationError(
242
288
  f"Report '{self.key}' row {index} must contain a dimension value"
243
289
  )
290
+ for name, values in self.row_order.items():
291
+ if dimensions.get(name) not in values:
292
+ raise ReportStoreValidationError(
293
+ f"Table row {index} value '{dimensions.get(name)}' is not declared "
294
+ f"in row_order['{name}']"
295
+ )
244
296
  return normalized
pam/task_manager.py CHANGED
@@ -9,6 +9,7 @@ import math
9
9
  import os
10
10
  import time
11
11
  import threading
12
+ import uuid
12
13
  from typing import Dict, TYPE_CHECKING
13
14
  from pam.utils import log
14
15
  from pam.api import API
@@ -453,34 +454,31 @@ class TaskManager(ITaskManager):
453
454
  return response
454
455
 
455
456
  def service_upload_report(self, service: Service, file_path):
456
- """
457
- Uploads a report file asynchronously and logs the response.
458
- """
459
- endpoint = service.request.response_api
460
-
461
- def handle_report_response(response):
462
- """Logs the response after the report upload completes."""
463
- if response is None:
464
- log(f"Response from report upload to {endpoint}: None")
465
- return
466
- try:
467
- response_data = response.json()
468
- except ValueError:
469
- response_data = response.text
470
- log(f"Response from report upload to {endpoint}: {response_data}")
471
-
472
- def upload_wrapper():
473
- """Wrapper for the report upload to handle response logging."""
474
- response = self.api.http_upload(
475
- endpoint,
476
- file_path,
477
- {"token": service.request.token},
478
- )
479
- handle_report_response(response)
480
-
481
- log(f"Uploading Report to: {endpoint}")
482
- http_thread = threading.Thread(target=upload_wrapper)
483
- http_thread.start()
457
+ """Wait for PAM to enqueue a legacy report; propagate delivery failures."""
458
+ trace_id = uuid.uuid4().hex
459
+ started = time.monotonic()
460
+ log(f"report_delivery_started trace_id={trace_id} service={service.request.service_name} "
461
+ f"report_file={Path(file_path).name}")
462
+ response = self.api.http_upload(
463
+ service.request.response_api,
464
+ file_path,
465
+ {"token": service.request.token, "report_delivery_id": trace_id},
466
+ )
467
+ status = response.status_code if response is not None else None
468
+ if response is None or not 200 <= status < 300:
469
+ log(f"report_delivery_failed trace_id={trace_id} http_status={status}")
470
+ raise RuntimeError(f"Report delivery failed trace_id={trace_id} http_status={status}")
471
+ try:
472
+ body = response.json()
473
+ except ValueError:
474
+ body = {}
475
+ stage = body.get("stage", "accepted_unconfirmed") if isinstance(body, dict) else "accepted_unconfirmed"
476
+ log(f"report_delivery_response trace_id={trace_id} http_status={status} "
477
+ f"stage={stage} elapsed_s={time.monotonic() - started:.3f}")
478
+ # Older PAM deployments only acknowledge receipt. Do not report that as
479
+ # successful publication: the new endpoint explicitly confirms enqueue.
480
+ if not isinstance(body, dict) or body.get("report_delivery_id") != trace_id or stage != "tracker_queued":
481
+ raise RuntimeError(f"Report delivery was not confirmed; update PAM first trace_id={trace_id}")
484
482
 
485
483
  def service_publish_report_pointer_v2(
486
484
  self,
@@ -680,3 +680,13 @@ Prefer streaming/chunked/incremental processing whenever possible.
680
680
  - Treat Polars lazy queries as the default compute path.
681
681
  - Use Pandas only when the selected service template or an integration requires it.
682
682
  - Prefer streaming or incremental durable steps when a query cannot stay in memory.
683
+
684
+ ### Missing contacts in V2 input
685
+
686
+ `data.missing_contacts` is an optional verified `DataInputV2File` with one `id`
687
+ column, using the same CSV or Parquet format as population. Read it with
688
+ `scan_polars()` or `read_pandas()`; `iter_csv_rows()` is CSV-only. Older
689
+ manifests expose `None`, while a present zero-row file is valid. These IDs are
690
+ excluded from population and configured datasets. Their absence does not prove
691
+ deletion; plugin developers decide whether and how to handle them. Read the
692
+ file before page cleanup. Do not treat it as a configured dataset position.
@@ -399,6 +399,7 @@ table = store.table(
399
399
  Dimension("branch", label="สาขา", value_type="string"),
400
400
  Measure("members", label="สมาชิก", value_type="integer", unit="คน"),
401
401
  ],
402
+ row_order={"branch": ["กรุงเทพฯ", "เชียงใหม่", "รวม"]},
402
403
  )
403
404
 
404
405
  table.append_rows(
@@ -413,6 +414,8 @@ table.append_rows(
413
414
  - Required column ต้องมีครบและค่าต้องตรงชนิด
414
415
  - Row ใน Report เดียวกันห้ามมี generated identity ซ้ำ
415
416
  - CMS ใช้ลำดับ column จาก definition ไม่ใช่ row แรก
417
+ - `row_order` กำหนดลำดับค่าของ Dimension ได้ โดยค่าที่เขียนต้องอยู่ในรายการทั้งหมด
418
+ เช่น วาง `รวม` เป็นตัวสุดท้ายเพื่อให้อยู่ล่างสุดเสมอ
416
419
  - Partition เป็น metadata สำหรับ range/order ไม่ถูกเพิ่มเป็น Day/Month column พิเศษ
417
420
  - `remove_rows` รับเฉพาะ Dimension identity; Measure ไม่ใช่ identity
418
421
 
@@ -558,11 +558,17 @@ table = store.table(
558
558
  unit="THB",
559
559
  ),
560
560
  ],
561
+ row_order={
562
+ "branch": ["Bangkok", "Chiang Mai", "Total"],
563
+ },
561
564
  )
562
565
  ```
563
566
 
564
567
  Column order is the declaration order. At least one dimension is required. By
565
- default, all dimension values form the stable row identity.
568
+ default, all dimension values form the stable row identity. Optional `row_order`
569
+ maps Dimension names to their complete display order. Every written value for an
570
+ ordered Dimension must appear in that list; place values such as `Total` last when
571
+ they must remain at the bottom of the table.
566
572
 
567
573
  ## Writing rows
568
574
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: Pam Python Library
5
5
  Author-email: Narongrit Kanhanoi <narongrit@pams.ai>
6
6
  Project-URL: Homepage, https://github.com/heart/pam-python
@@ -1,4 +1,4 @@
1
- pam/__init__.py,sha256=EPwLYPy_XyrD-Pj5t3E9m73WOd0PDLIEJ299GVK-UJg,57
1
+ pam/__init__.py,sha256=KdW64FpMxu2aAx-3gs199l6f7BdAIK_OkLhszvC_MM0,57
2
2
  pam/api.py,sha256=nfsq9OLHutitRMqwkoi3mD0gZ3EfMUzN2rwmS9gpVpY,8198
3
3
  pam/cli.py,sha256=Ip4kYNbZ49WAkcMN4SuNc2qXxDeagOzJwE7GSb-BYlE,13831
4
4
  pam/datetime_utils.py,sha256=sDFxNRR46FsO61p-EzhmIRS6ARnfMPRquKee_RcQ_3E,664
@@ -9,12 +9,12 @@ pam/result_batch_uploader.py,sha256=xw4b-q7i52CoCjvQtQ8EgfBrrqAl1zEl6sTO-wIvFPc,
9
9
  pam/server.py,sha256=auok-4Ls3fTnRS-_sQHt0SHQIOLW1NPwk8B_tmmZwI0,8191
10
10
  pam/service.py,sha256=nH8dupZWFr8-Ab00S9UJvaJfqNd-_6qvKMQMzTiWibc,9416
11
11
  pam/sqlite.py,sha256=0bEUnb17V_Z6N6u843MYyGQnigaAuMx6FUckeWifn8I,2585
12
- pam/task_manager.py,sha256=iNXm5gFwl8ybijd36RedZpkj7J9TEF6ZRLTpReHgPQs,20681
12
+ pam/task_manager.py,sha256=psO6RRd_n-PgEGp0OwOfuHG-TMOYrq4YajKwhv9PqZU,21188
13
13
  pam/temp_file_utils.py,sha256=noMyF4SZ17AWtZcRkZtNtdObRWzykAMxeFr-WkOEQi4,9450
14
14
  pam/tester_task.py,sha256=SKRIut-kksIVWSMwzquqi7MxrZyTO6x2ggqlnRsVJQ4,11967
15
15
  pam/utils.py,sha256=-pfnQ00Ib-IXvrMz4S5lTlO36PtSaJoQOhwXpwet8h4,1744
16
16
  pam/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- pam/models/data_input_v2.py,sha256=0mkZHPjb0RLmc-v4W5Su8_bAw-3U5qL-YU1yF5Z-oCI,17536
17
+ pam/models/data_input_v2.py,sha256=XiLLOPziIo016dC-OfDUgASLkn6oU09HRCcsvNx3Rhg,17888
18
18
  pam/models/request_command.py,sha256=6QBJyQfu75bMfhqBUILE1chvhjRdZ8WHWiBbX3k8K5s,14211
19
19
  pam/models/sqlite_download.py,sha256=7JMWwLp_5LSCMR5Ppk1RgbgcOaX_2LmRFrJiADAnIfs,678
20
20
  pam/reports/__init__.py,sha256=vdAzsUN0bmYPGjqKveg9HjNWe_MnBZj3b42TsDBE6_w,2368
@@ -44,16 +44,16 @@ pam/reports/record.py,sha256=ttFKu97794CFzNgwXOdD6SU0gozqwPcm2WiOdfSOGQs,5287
44
44
  pam/reports/report.py,sha256=yZDjuLhkQ-1BdN51imKtFUnXuQjLOt3Ia4Z0hh7BxBQ,1229
45
45
  pam/reports/scatter.py,sha256=0VgX0Uu3C5_W5WWAdnjcmOHNe3CXKK3CHNneGEAgvrI,4127
46
46
  pam/reports/schema.py,sha256=qYOzlRWIUzIMjE-x4HYCuobdlEve5ylPEt-9r6kPO00,1231
47
- pam/reports/store.py,sha256=aH90Kb4zZ4U_NT9ltMH2YNQRnJcycEr-okMV5WD92Bs,25756
48
- pam/reports/table.py,sha256=n0FEIOw2hlrrcHqYwXZVo8r2XkgzVN0EqQ9zaaJBarc,8760
47
+ pam/reports/store.py,sha256=BZSarCtGRlHnG_MiA2z19TsftcffCJUQ4TjccHyIIDw,25863
48
+ pam/reports/table.py,sha256=33kx8T_rB_TwFsTTYKOxABJGClWAJYpJ08EijR5c5Dw,10877
49
49
  pam/reports/temporal.py,sha256=s3aEjoJ9c5hMI4NDaStqdDP7PksNgXlDf0Xfp55VSPc,1806
50
50
  pam/reports/transport.py,sha256=iDm63RQnPfhCJ1zFbr1uAVYHz3ondNfp2scz21JAnL8,2111
51
51
  pam/reports/treemap.py,sha256=W_VwxbiRMSvkNpY2z1HfbZtGKWXNr6FdbVdI0grVesw,4251
52
52
  pam/templates/buildcmd/pamb,sha256=58GCN59ysvHGNhPvTD3nkF4aLg-kM1WfWmH7CL3eQVo,530
53
53
  pam/templates/buildcmd/pamb-base.sh,sha256=Q0DkbeHtPx33PIjc68EeI7ZIwBOLlwcSmPhpxALXxVY,518
54
- pam/templates/init/AGENT.md,sha256=yOtdr5aSsqPbjh7gWbyfoFYnCZ7opVSF9ol0LbONCTw,27030
55
- pam/templates/init/REPORTS-th.md,sha256=tKL8za7nxIG6jxBPzwqK00nfDdh8xe-lM34ueAU4WAQ,35752
56
- pam/templates/init/REPORTS.md,sha256=7yYbC2A7A92_NZloozJSkJaFL-bIddrrgcsSDpuIWaA,49561
54
+ pam/templates/init/AGENT.md,sha256=36J7Si-C7n_als111-iRaH6-iQbqFtdFpN0JTGwNUA4,27601
55
+ pam/templates/init/REPORTS-th.md,sha256=VdGUFvxU0Zl6pYKgFHIC6U8pmQJcz4JFM7_QYbcXblc,36196
56
+ pam/templates/init/REPORTS.md,sha256=LCMXW1SLRpOz2yWYM5Pp_B7H90E_X_aQ9vXYvHhEJgc,49868
57
57
  pam/templates/init/dockerignore.tmpl,sha256=Ht1eCgQLqalEwpUjih0FQavzhYJbT2jH-FP4btM3960,52
58
58
  pam/templates/init/gitignore.tmpl,sha256=xi8KBrPKSCPiuwtKP8xsF4shQbYowh1_LGW3fKbl09M,917
59
59
  pam/templates/init/main.tmpl,sha256=CtrwBpk5610OGa0KscawpfcDuAciRfpnqQvGydvD0KI,260
@@ -73,9 +73,9 @@ pam/templates/service/pandas/service_class.tmpl,sha256=qaq9tqRhxogWQlqi09dbX5vLu
73
73
  pam/templates/service/polars/functions.tmpl,sha256=7Qlwsd5Q1vdkOvXHtlltjb2oS-i7JuNKPor2iNDSsQg,701
74
74
  pam/templates/service/polars/service.test.tmpl,sha256=nC6VoauZMYy_UTTXxxergfWbXvUD_VWwpM7-7A3aKqE,6881
75
75
  pam/templates/service/polars/service_class.tmpl,sha256=jMGeruXduu0eBB7FPDEkZu8b4mSIO3uRt5TiDuNq5T4,3688
76
- pam_python-0.2.18.dist-info/licenses/LICENSE.txt,sha256=yeHD2-gzrkkET2ZkMe22WyUGpDjlwTrqA4POb0FoZi4,1138
77
- pam_python-0.2.18.dist-info/METADATA,sha256=bLVH-U_jGsSiAvNfdFKInKbSyVhLWuw9IUcPJm3GNvA,10116
78
- pam_python-0.2.18.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
79
- pam_python-0.2.18.dist-info/entry_points.txt,sha256=xqYpsIqvNlO0j8elZc02bDwR-12dLK9FU2B1ACsLYxw,37
80
- pam_python-0.2.18.dist-info/top_level.txt,sha256=0EOjbyc3hQyzjhn6iyMgsEseqA66Xz0p27iBN7G7W1w,4
81
- pam_python-0.2.18.dist-info/RECORD,,
76
+ pam_python-0.2.20.dist-info/licenses/LICENSE.txt,sha256=yeHD2-gzrkkET2ZkMe22WyUGpDjlwTrqA4POb0FoZi4,1138
77
+ pam_python-0.2.20.dist-info/METADATA,sha256=l8sFUyXDsXS9GU9GVnhN9DcIRolP9POSLkXYR0-hkVI,10116
78
+ pam_python-0.2.20.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
79
+ pam_python-0.2.20.dist-info/entry_points.txt,sha256=xqYpsIqvNlO0j8elZc02bDwR-12dLK9FU2B1ACsLYxw,37
80
+ pam_python-0.2.20.dist-info/top_level.txt,sha256=0EOjbyc3hQyzjhn6iyMgsEseqA66Xz0p27iBN7G7W1w,4
81
+ pam_python-0.2.20.dist-info/RECORD,,