rapidpro-api 0.1.0__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 (65) hide show
  1. rapidpro_api/__init__.py +0 -0
  2. rapidpro_api/contact_processors.py +343 -0
  3. rapidpro_api/contact_processors_pl.py +106 -0
  4. rapidpro_api/datasets/BaseDataset.py +551 -0
  5. rapidpro_api/datasets/ContactsDataset.py +31 -0
  6. rapidpro_api/datasets/DeltaLakeDataset.py +270 -0
  7. rapidpro_api/datasets/FieldsDataset.py +31 -0
  8. rapidpro_api/datasets/FlowsDataset.py +31 -0
  9. rapidpro_api/datasets/GroupsDataset.py +31 -0
  10. rapidpro_api/datasets/MessagesDataset.py +31 -0
  11. rapidpro_api/datasets/RawDataset.py +649 -0
  12. rapidpro_api/datasets/RunsDataset.py +32 -0
  13. rapidpro_api/datasets/__init__.py +8 -0
  14. rapidpro_api/field_processors.py +34 -0
  15. rapidpro_api/flow_processors.py +140 -0
  16. rapidpro_api/group_processors.py +101 -0
  17. rapidpro_api/message_processors.py +110 -0
  18. rapidpro_api/run_processors_pl.py +87 -0
  19. rapidpro_api/stats/__init__.py +5 -0
  20. rapidpro_api/stats/download_stats.py +112 -0
  21. rapidpro_api/stats/download_stats_list.py +115 -0
  22. rapidpro_api/stats/process_stats.py +318 -0
  23. rapidpro_api/stats/process_stats_list.py +266 -0
  24. rapidpro_api/stats/stats.py +643 -0
  25. rapidpro_api/telegram.py +37 -0
  26. rapidpro_api/time_utils.py +224 -0
  27. rapidpro_api/ureport_processors_pl.py +331 -0
  28. rapidpro_api/utils.py +72 -0
  29. rapidpro_api/validators.py +123 -0
  30. rapidpro_api/version.py +8 -0
  31. rapidpro_api/workspaces.py +278 -0
  32. rapidpro_api-0.1.0.dist-info/METADATA +95 -0
  33. rapidpro_api-0.1.0.dist-info/RECORD +65 -0
  34. rapidpro_api-0.1.0.dist-info/WHEEL +5 -0
  35. rapidpro_api-0.1.0.dist-info/top_level.txt +2 -0
  36. tests/__init__.py +0 -0
  37. tests/datasets/__init__.py +0 -0
  38. tests/datasets/test_base_dataset.py +528 -0
  39. tests/datasets/test_contacts_dataset.py +27 -0
  40. tests/datasets/test_fields_dataset.py +27 -0
  41. tests/datasets/test_flows_dataset.py +27 -0
  42. tests/datasets/test_groups_dataset.py +27 -0
  43. tests/datasets/test_messages_dataset.py +27 -0
  44. tests/datasets/test_raw_dataset.py +400 -0
  45. tests/datasets/test_runs_dataset.py +27 -0
  46. tests/stats/__init__.py +0 -0
  47. tests/stats/test_download_stats.py +186 -0
  48. tests/stats/test_download_stats_list.py +366 -0
  49. tests/stats/test_process_stats.py +0 -0
  50. tests/stats/test_process_stats_list.py +531 -0
  51. tests/stats/test_stats.py +711 -0
  52. tests/test_contact_processors.py +199 -0
  53. tests/test_contact_processors_pl.py +188 -0
  54. tests/test_field_processors.py +101 -0
  55. tests/test_flow_processor.py +90 -0
  56. tests/test_group_processors.py +190 -0
  57. tests/test_message_processors.py +129 -0
  58. tests/test_run_processors_pl.py +248 -0
  59. tests/test_telegram.py +61 -0
  60. tests/test_time_utils.py +318 -0
  61. tests/test_ureport_processors_pl.py +198 -0
  62. tests/test_utils.py +182 -0
  63. tests/test_validators.py +127 -0
  64. tests/test_version.py +20 -0
  65. tests/test_workspaces.py +576 -0
@@ -0,0 +1,643 @@
1
+ import time
2
+ from typing import Dict, Any, Optional, Union
3
+
4
+ class Stats:
5
+ """
6
+ A statistics collection class for tracking timers, counters, ratios, and attributes.
7
+
8
+ This class provides functionality to:
9
+ - Track execution times with named timers
10
+ - Count events with optional units
11
+ - Calculate ratios between timers/counters
12
+ - Store arbitrary attributes
13
+ - Export all data as a flat dictionary
14
+
15
+ Examples:
16
+ >>> stats = Stats()
17
+
18
+ # Timer usage
19
+ >>> stats.start_timer("processing")
20
+ >>> # ... do some work ...
21
+ >>> stats.end_timer("processing")
22
+
23
+ # Cumulative timer usage
24
+ >>> stats.start_cumulative_timer("data_load")
25
+ >>> # ... load some data ...
26
+ >>> stats.end_cumulative_timer("data_load")
27
+ >>> # ... do other work ...
28
+ >>> stats.start_cumulative_timer("data_load")
29
+ >>> # ... load even more data ...
30
+ >>> accumulated_time = stats.end_cumulative_timer("data_load")
31
+
32
+ # Counter usage
33
+ >>> stats.count("items_processed", 10, "items")
34
+ >>> stats.count("items_processed", 5) # adds to existing
35
+
36
+ # Ratio calculation
37
+ >>> stats.ratio("success_rate", "successful_items", "total_items")
38
+
39
+ # Attributes
40
+ >>> stats.add_attribute("version", "1.0.0")
41
+
42
+ # Export all data
43
+ >>> data = stats.to_dict()
44
+ """
45
+
46
+ def __init__(self):
47
+ self._timers = {} # {name: {'start': time, 'end': time, 'elapsed': duration}}
48
+ self._cumulative_timers = {} # {name: {'start': time, 'accumulated': total_duration}}
49
+ self._counters = {} # {name: {'value': amount, 'unit': unit}}
50
+ self._ratios = {} # {name: {'numerator': name, 'denominator': name, 'value': ratio}}
51
+ self._attributes = {} # {name: value}
52
+ self._names_used = set() # Track all names to ensure uniqueness
53
+
54
+ def set_timer(self, name: str) -> None:
55
+ """
56
+ Defines a new timer with the given name, but does not start it.
57
+ This is useful for pre-defining timers before starting them, for example if you use them
58
+ for a ratio (which requires numerator and denominator to be pre-defined).
59
+
60
+ Args:
61
+ name: Unique name for the timer
62
+ Raises:
63
+ ValueError: If the timer name is already used by a timer or counter
64
+ Examples:
65
+ >>> stats = Stats()
66
+ >>> stats.set_timer("api_call")
67
+ >>> stats.start_timer("api_call")
68
+ >>> # ... do some work ...
69
+ >>> elapsed = stats.end_timer("api_call")
70
+
71
+ """
72
+ if name in self._names_used:
73
+ raise ValueError(f"Name '{name}' is already in use")
74
+
75
+ self._names_used.add(name)
76
+ self._timers[name] = {
77
+ 'start': None,
78
+ 'end': None,
79
+ 'elapsed': 0.0
80
+ }
81
+
82
+ def start_timer(self, name: str) -> None:
83
+ """
84
+ Start a timer with the given name.
85
+
86
+ Args:
87
+ name: Unique name for the timer
88
+
89
+ Raises:
90
+ ValueError: If the name is already used by a timer or counter
91
+
92
+ Examples:
93
+ >>> stats = Stats()
94
+ >>> stats.start_timer("api_call")
95
+ """
96
+ if name in self._names_used and name not in self._timers:
97
+ raise ValueError(f"Name '{name}' is already in use")
98
+
99
+ current_time = time.time()
100
+ self._timers[name] = {
101
+ 'start': current_time,
102
+ 'end': None,
103
+ 'elapsed': 0.0
104
+ }
105
+ self._names_used.add(name)
106
+
107
+ def stop_timer(self, name: str) -> float:
108
+ """
109
+ Alias for end_timer to stop a timer and get elapsed time.
110
+ """
111
+ return self.end_timer(name)
112
+
113
+ def end_timer(self, name: str) -> float:
114
+ """
115
+ End a timer and calculate elapsed time.
116
+
117
+ Args:
118
+ name: Name of the timer to end
119
+
120
+ Returns:
121
+ Elapsed time in seconds
122
+
123
+ Raises:
124
+ ValueError: If timer doesn't exist or wasn't started
125
+
126
+ Examples:
127
+ >>> stats = Stats()
128
+ >>> stats.start_timer("task")
129
+ >>> elapsed = stats.end_timer("task")
130
+ """
131
+ if name not in self._timers:
132
+ raise ValueError(f"Timer '{name}' does not exist")
133
+
134
+ timer = self._timers[name]
135
+ if timer['start'] is None:
136
+ raise ValueError(f"Timer '{name}' was not started")
137
+
138
+ current_time = time.time()
139
+ elapsed = current_time - timer['start']
140
+
141
+ timer['end'] = current_time
142
+ timer['elapsed'] = elapsed
143
+
144
+ return elapsed
145
+
146
+
147
+ def set_cumulative_timer(self, name: str) -> None:
148
+ """
149
+ Defines a new cumulative timer with the given name, but does not start it.
150
+ This is useful for pre-defining timers before starting them, for example if you use them
151
+ for a ratio (which requires numerator and denominator to be pre-defined).
152
+
153
+ Args:
154
+ name: Unique name for the cumulative timer
155
+ Raises:
156
+ ValueError: If the timer name is already used by a timer or counter
157
+ Examples:
158
+ >>> stats = Stats()
159
+ >>> stats.set_cumulative_timer("data_load")
160
+ >>> stats.start_cumulative_timer("data_load")
161
+ >>> # ... load some data ...
162
+ >>> stats.end_cumulative_timer("data_load")
163
+ >>> # ... do other work ...
164
+ >>> stats.start_cumulative_timer("data_load")
165
+ >>> # ... load even more data ...
166
+ >>> accumulated_time = stats.end_cumulative_timer("data_load")
167
+
168
+ """
169
+ if name in self._names_used:
170
+ raise ValueError(f"Name '{name}' is already in use")
171
+ self._names_used.add(name)
172
+ self._cumulative_timers[name] = {
173
+ 'start': None,
174
+ 'accumulated': 0.0 # Initialize elapsed to 0 for accumulation
175
+ }
176
+
177
+
178
+ def start_cumulative_timer(self, name: str ) -> None:
179
+ """
180
+ Start an cumulative timer. This allows multiple start-end cycles to accumulate total time.
181
+
182
+ Args:
183
+ name: Unique name for the cumulative timer
184
+ Raises:
185
+ ValueError: If the name is already used by a timer or counter
186
+ Examples:
187
+ >>> stats = Stats()
188
+ >>> stats.start_cumulative_timer("data_processing")
189
+ >>> # ... do some work ...
190
+ >>> stats.end_cumulative_timer("data_processing")
191
+ >>> # ... do more work ...
192
+ >>> stats.start_cumulative_timer("data_processing")
193
+ >>> # ... do even more work ...
194
+ >>> stats.end_cumulative_timer("data_processing")
195
+ """
196
+ if name in self._names_used and name not in self._cumulative_timers:
197
+ raise ValueError(f"Name '{name}' is already in use")
198
+
199
+ current_time = time.time()
200
+ if name not in self._cumulative_timers:
201
+ self._cumulative_timers[name] = {
202
+ 'start': current_time,
203
+ 'accumulated': 0.0 # Initialize elapsed to 0 for accumulation
204
+ }
205
+ self._names_used.add(name)
206
+ else:
207
+ # if timer not started
208
+ if self._cumulative_timers[name]['start'] is None:
209
+ self._cumulative_timers[name]['start'] = current_time
210
+ return
211
+
212
+ def get_elapsed_time(self, name: str) -> float:
213
+ """
214
+ Get the elapsed time for a timer. The timer can be either cumulative or "normal".
215
+ if the timer is still running, returns the elapsed time up to now (does not stop the timer).
216
+ if the timer was ended, returns the total elapsed time.
217
+
218
+ Args:
219
+ name: Name of the timer
220
+
221
+ Returns:
222
+ Elapsed time in seconds
223
+
224
+ Raises:
225
+ ValueError: If timer doesn't exist.
226
+
227
+ Examples:
228
+ >>> stats = Stats()
229
+ >>> # for a normal timer
230
+ >>> stats.start_timer("task")
231
+ >>> # ... do some work ...
232
+ >>> stats.get_elapsed_time("task") # returns elapsed time so far
233
+ >>> # ... do some more work ...
234
+ >>> stats.end_timer("task")
235
+ >>> elapsed = stats.get_elapsed_time("task")
236
+ >>> # for a cumulative timer
237
+ >>> stats.start_cumulative_timer("data_load")
238
+ >>> # some time passes...
239
+ >>> elapsed = stats.get_elapsed_time("data_load")
240
+ """
241
+ if (name not in self._timers) and (name not in self._cumulative_timers):
242
+ raise ValueError(f"Timer '{name}' does not exist")
243
+
244
+ if name in self._cumulative_timers:
245
+ return self._get_elapsed_for_cumulative_timer(name)
246
+ return self._get_elapsed_for_timer(name)
247
+
248
+ def _get_elapsed_for_timer(self, name:str) -> float:
249
+ """Helper method to get elapsed time for a normal timer.
250
+ NOTE: Does not check if timer exists.
251
+ Args:
252
+ name: Name of the timer
253
+ Returns:
254
+ Elapsed time in seconds since start, or 0 if not started
255
+ """
256
+ timer = self._timers[name]
257
+ if timer['start'] is None:
258
+ return 0.0 # Timer was never started
259
+
260
+ if timer['elapsed'] != 0.0: # timer completed -> return elapsed
261
+ return timer['elapsed']
262
+ # if timer not ended, calculate elapsed time up to now
263
+ if timer['end'] is None:
264
+ return time.time() - timer['start']
265
+ else:
266
+ return timer['end'] - timer['start']
267
+
268
+
269
+ def _get_elapsed_for_cumulative_timer(self, name: str) -> float:
270
+ """Helper method to get elapsed time for a cumulative timer.
271
+ NOTE: Does not check if timer exists.
272
+ Args:
273
+ name: Name of the cumulative timer
274
+ Returns:
275
+ Elapsed time in seconds since last start, or 0 if not running
276
+ """
277
+ timer = self._cumulative_timers[name]
278
+ if timer['start'] is None:
279
+ return 0.0 # Timer not running
280
+ return time.time() - timer['start']
281
+
282
+ def get_accumulated_time(self, name: str) -> float:
283
+ """Helper method to get total accumulated time for a cumulative timer.
284
+ NOTE: Does not check if timer exists.
285
+ Args:
286
+ name: Name of the cumulative timer
287
+ Returns:
288
+ Total accumulated time in seconds
289
+ """
290
+ elapsed = self._get_elapsed_for_cumulative_timer(name)
291
+ return self._cumulative_timers[name]['accumulated'] + elapsed
292
+
293
+
294
+ def end_cumulative_timer(self, name: str, return_elapsed=False) -> float:
295
+ """
296
+ End an cumulative timer and add to total elapsed time.
297
+
298
+ Args:
299
+ name: Name of the cumulative timer to end
300
+ return_elapsed: If True, returns the elapsed time for this session only. If False
301
+ (default), returns the total accumulated time.
302
+ Returns:
303
+ Total accumulated time or this session elapsed time if return_elapsed is True
304
+ Raises:
305
+ ValueError: If timer doesn't exist or wasn't started
306
+ Examples:
307
+ >>> stats = Stats()
308
+ >>> stats.start_cumulative_timer("data_processing")
309
+ >>> # ... do some work (10 seconds)...
310
+ >>> total_elapsed = stats.end_cumulative_timer("data_processing")
311
+ >>> # total_elapsed is 10 seconds
312
+ >>> #... do more work ...
313
+ >>> stats.start_cumulative_timer("data_processing")
314
+ >>> # ... do more work (5 seconds)...
315
+ >>> session_elapsed = stats.end_cumulative_timer("data_processing", return_elapsed=True)
316
+ >>> # session_elapsed is 5 seconds
317
+ >>> total = stats.get_accumulated_time("data_processing")
318
+ >>> # total is 15 seconds
319
+
320
+ """
321
+ if name not in self._cumulative_timers:
322
+ raise ValueError(f"Timer '{name}' does not exist")
323
+ elapsed = self._get_elapsed_for_cumulative_timer(name)
324
+ self._cumulative_timers[name]['accumulated'] = self._cumulative_timers[name]['accumulated'] + elapsed
325
+ self._cumulative_timers[name]['start'] = None # Reset start to None to indicate it's not running
326
+ return elapsed if return_elapsed else self._cumulative_timers[name]['accumulated']
327
+
328
+
329
+ def count(self, counter_name: str, amount: Union[int, float], unit: Optional[str] = None) -> None:
330
+ """
331
+ Add to a counter or create a new one.
332
+
333
+ Args:
334
+ counter_name: Unique name for the counter
335
+ amount: Amount to add to the counter
336
+ unit: Optional unit description
337
+
338
+ Raises:
339
+ ValueError: If the name is already used by a timer
340
+
341
+ Examples:
342
+ >>> stats = Stats()
343
+ >>> stats.count("requests", 1, "requests")
344
+ >>> stats.count("requests", 5, "requests") # now total is 6
345
+ >>> stats.count("bytes", 1024, "bytes")
346
+ """
347
+ if counter_name in self._timers:
348
+ raise ValueError(f"Name '{counter_name}' is already used by a timer")
349
+
350
+ if counter_name not in self._counters:
351
+ self._counters[counter_name] = {'value': 0, 'unit': unit}
352
+ self._names_used.add(counter_name)
353
+
354
+ self._counters[counter_name]['value'] += amount
355
+
356
+ # Update unit if provided
357
+ if unit is not None:
358
+ self._counters[counter_name]['unit'] = unit
359
+
360
+ def get_counter_value(self, counter_name: str) -> Union[int, float]:
361
+ """
362
+ Get the current value of a counter.
363
+
364
+ Args:
365
+ counter_name: Name of the counter
366
+
367
+ Returns:
368
+ Current counter value
369
+
370
+ Raises:
371
+ ValueError: If counter doesn't exist
372
+
373
+ Examples:
374
+ >>> stats = Stats()
375
+ >>> stats.count("items", 10)
376
+ >>> value = stats.get_counter_value("items") # 10
377
+ """
378
+ if counter_name not in self._counters:
379
+ raise ValueError(f"Counter '{counter_name}' does not exist")
380
+
381
+ return self._counters[counter_name]['value']
382
+
383
+ def get_counter_unit(self, counter_name: str) -> Optional[str]:
384
+ """
385
+ Get the unit of a counter.
386
+
387
+ Args:
388
+ counter_name: Name of the counter
389
+
390
+ Returns:
391
+ Counter unit or None if not set
392
+
393
+ Raises:
394
+ ValueError: If counter doesn't exist
395
+ Examples:
396
+ >>> stats = Stats()
397
+ >>> stats.count("items", 10, "pieces")
398
+ >>> unit = stats.get_counter_unit("items") # "pieces"
399
+ """
400
+ if counter_name not in self._counters:
401
+ raise ValueError(f"Counter '{counter_name}' does not exist")
402
+
403
+ return self._counters[counter_name]['unit']
404
+
405
+ def set_counter_unit(self, counter_name: str, unit: str) -> None:
406
+ """
407
+ Set or update the unit of a counter.
408
+
409
+ Args:
410
+ counter_name: Name of the counter
411
+ unit: Unit description to set for the counter
412
+ Raises:
413
+ ValueError: If counter doesn't exist
414
+ Examples:
415
+ >>> stats = Stats()
416
+ >>> stats.count("items", 10)
417
+ >>> stats.set_counter_unit("items", "pieces")
418
+ >>> unit = stats.get_counter_unit("items") # "pieces"
419
+ """
420
+ if counter_name not in self._counters:
421
+ raise ValueError(f"Counter '{counter_name}' does not exist")
422
+
423
+ self._counters[counter_name]['unit'] = unit
424
+
425
+
426
+ def set_ratio(self, ratio_name: str, numerator: str, denominator: str) -> None:
427
+ """
428
+ Add a ratio definition between two timers or counters.
429
+ The ratio is not calculated until requested.
430
+ Before using a ratio, ensure that both numerator and denominator timers/counters
431
+ have been defined.
432
+
433
+ Args:
434
+ ratio_name: Name for this ratio
435
+ numerator: Name of timer/counter to use as numerator
436
+ denominator: Name of timer/counter to use as denominator
437
+
438
+ Raises:
439
+ ValueError: If numerator or denominator don't exist
440
+
441
+ Examples:
442
+ >>> stats = Stats()
443
+ >>> stats.count("success", 80)
444
+ >>> stats.count("total", 100)
445
+ >>> stats.set_ratio("success_rate", "success", "total")
446
+ >>> #
447
+ >>> # with timers, you can do:
448
+ >>> stats.start_timer("num")
449
+ >>> stats.end_timer("den")
450
+ >>> stats.set_ratio("ratio", "num", "den")
451
+ >>> #
452
+ >>> # or set them first
453
+ >>> stats.set_timer("num")
454
+ >>> stats.set_timer("den")
455
+ >>> stats.set_ratio("ratio", "num", "den")
456
+ >>> #
457
+ >>> # then to calculate the ratio:
458
+ >>> ratio = stats.calculate_ratio("ratio")
459
+ """
460
+ if numerator not in self._timers and numerator not in self._counters and numerator not in self._cumulative_timers:
461
+ raise ValueError(f"Numerator '{numerator}' not found in timers or counters")
462
+
463
+ if denominator not in self._timers and denominator not in self._counters and denominator not in self._cumulative_timers:
464
+ raise ValueError(f"Denominator '{denominator}' not found in timers or counters")
465
+
466
+ self._ratios[ratio_name] = {
467
+ 'numerator': numerator,
468
+ 'denominator': denominator,
469
+ 'value': None
470
+ }
471
+
472
+
473
+ def calculate_ratio(self, ratio_name: str) -> float:
474
+ """
475
+ Calculate the value of a previously defined ratio.
476
+
477
+ Args:
478
+ ratio_name: Name of the ratio to calculate
479
+
480
+ Returns:
481
+ Calculated ratio value
482
+
483
+ Raises:
484
+ ValueError: If ratio doesn't exist, timer not completed, or denominator is zero
485
+
486
+ Examples:
487
+ >>> stats = Stats()
488
+ >>> stats.count("success", 80)
489
+ >>> stats.count("total", 100)
490
+ >>> stats.add_ratio("success_rate", "success", "total")
491
+ >>> ratio = stats.calculate_ratio("success_rate") # 0.8
492
+ """
493
+ if ratio_name not in self._ratios:
494
+ raise ValueError(f"Ratio '{ratio_name}' does not exist")
495
+
496
+ ratio_def = self._ratios[ratio_name]
497
+ numerator = ratio_def['numerator']
498
+ denominator = ratio_def['denominator']
499
+
500
+ def get_value(name: str) -> float:
501
+ if name in self._timers:
502
+ timer = self._timers[name]
503
+ return self.get_elapsed_time(name)
504
+ elif name in self._counters:
505
+ return self._counters[name]['value']
506
+ elif name in self._cumulative_timers:
507
+ return self.get_accumulated_time(name)
508
+ else:
509
+ raise ValueError(f"'{name}' not found in timers or counters")
510
+
511
+ num_value = get_value(numerator)
512
+ den_value = get_value(denominator)
513
+
514
+ if den_value == 0:
515
+ return 0.0
516
+ #raise ValueError(f"Denominator '{denominator}' cannot be zero")
517
+
518
+ ratio_value = num_value / den_value
519
+ self._ratios[ratio_name]['value'] = ratio_value
520
+
521
+ return ratio_value
522
+
523
+
524
+ def set_attribute(self, name: str, value: str) -> None:
525
+ """
526
+ Add an attribute (key-value pair).
527
+ If the attribute already exists, it will be overwritten.
528
+ Args:
529
+ name: Attribute name
530
+ value: Attribute value
531
+
532
+ Examples:
533
+ >>> stats = Stats()
534
+ >>> stats.add_attribute("version", "1.2.3")
535
+ >>> stats.add_attribute("environment", "production")
536
+ """
537
+ self._attributes[name] = value
538
+
539
+
540
+ def to_dict(self) -> Dict[str, Any]:
541
+ """
542
+ Export all statistics as a flat dictionary.
543
+
544
+ For time accumulators, returns the total accumulated time. If the timer is currently running,
545
+ it adds the elapsed time since it was started to the accumulated time.
546
+
547
+ Returns:
548
+ Dictionary containing all timers, counters, ratios, and attributes
549
+
550
+ Examples:
551
+ >>> stats = Stats()
552
+ >>> stats.start_timer("task")
553
+ >>> stats.end_timer("task")
554
+ >>> stats.count("items", 5, "pieces")
555
+ >>> stats.add_attribute("version", "1.0")
556
+ >>> data = stats.to_dict()
557
+ >>> # Returns: {
558
+ >>> # 'start_task_time': 1234567890.123,
559
+ >>> # 'end_task_time': 1234567891.456,
560
+ >>> # 'items_count': 5,
561
+ >>> # 'items_count_unit': 'pieces',
562
+ >>> # 'version': '1.0'
563
+ >>> # }
564
+ """
565
+ result = {}
566
+
567
+ # Add timer data
568
+ for name, timer in self._timers.items():
569
+ if timer['start'] is not None:
570
+ result[f'start_{name}'] = timer['start']
571
+ result[f'start_{name}_date'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timer['start']))
572
+
573
+ if timer['end'] is None:
574
+ result[f'end_{name}'] = time.time()
575
+ # convert to iso datetime string in UTC
576
+ result[f'end_{name}_date'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(result[f'end_{name}']))
577
+ else:
578
+ result[f'end_{name}'] = timer['end']
579
+ result[f'end_{name}_date'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timer['end']))
580
+ result[f'{name}_elapsed'] = self.get_elapsed_time(name)
581
+
582
+ # Add counter data
583
+ for name, counter in self._counters.items():
584
+ result[f'{name}'] = counter['value']
585
+ result[f'{name}_unit'] = counter['unit'] or "unit"
586
+
587
+ # Add ratio data
588
+ for name, ratio in self._ratios.items():
589
+ result[f'{name}'] = self.calculate_ratio(name)
590
+ # Add unit as combination of numerator and denominator units if available
591
+ num_unit = self._get_unit_for_name(ratio['numerator'])
592
+ den_unit = self._get_unit_for_name(ratio['denominator'])
593
+ if num_unit and den_unit and num_unit == den_unit:
594
+ result[f'{name}_unit'] = ""
595
+ else:
596
+ result[f'{name}_unit'] = f"{num_unit}s/{den_unit}"
597
+
598
+ for name, timer in self._cumulative_timers.items():
599
+ # if timer started, add elapsed time to accumulated
600
+ result[f'{name}'] = self.get_accumulated_time(name)
601
+ # Add attributes
602
+ result.update(self._attributes)
603
+ return result
604
+
605
+ def _get_unit_for_name(self, name: str) -> Optional[str]:
606
+ """Helper method to get unit for a timer or counter name."""
607
+ if name in self._counters:
608
+ return self._counters[name]['unit'] or "unit"
609
+ if name in self._timers:
610
+ return "second"
611
+ if name in self._cumulative_timers:
612
+ return "second"
613
+ return None
614
+
615
+
616
+ def __str__(self) -> str:
617
+ """
618
+ Return a human-readable string representation of all statistics.
619
+
620
+ Returns:
621
+ Formatted string containing all timers, counters, ratios, and attributes
622
+
623
+ Examples:
624
+ >>> stats = Stats()
625
+ >>> stats.start_timer("task")
626
+ >>> stats.end_timer("task")
627
+ >>> stats.count("items", 5, "pieces")
628
+ >>> stats.set_attribute("version", "1.0")
629
+ >>> print(stats)
630
+ """
631
+ data = self.to_dict()
632
+ if not data:
633
+ return "Stats: (empty)"
634
+
635
+ lines = ["Stats:"]
636
+ for key, value in sorted(data.items()):
637
+ if isinstance(value, float):
638
+ formatted_value = f"{value:.4f}"
639
+ else:
640
+ formatted_value = str(value)
641
+ lines.append(f" {key}: {formatted_value}")
642
+
643
+ return "\n".join(lines)
@@ -0,0 +1,37 @@
1
+ # -*- coding: utf-8 -*-
2
+ import logging
3
+ import os
4
+ import requests
5
+
6
+
7
+
8
+ def message(text: str) -> bool:
9
+ """
10
+ Sends a message to a Telegram chat using the bot token and chat ID from environment variables.
11
+ Expects the following environment variables to be set:
12
+ - TELEGRAM_BOT_TOKEN: The token of the Telegram bot.
13
+ - TELEGRAM_CHAT_ID: The chat ID where the message will be sent.
14
+
15
+ Args:
16
+ text (str): The message to send.
17
+
18
+ Returns:
19
+ bool: True if the message was sent successfully, False otherwise.
20
+ """
21
+ logging.info(text)
22
+ bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
23
+ chat_id = os.getenv("TELEGRAM_CHAT_ID")
24
+ if not bot_token or not chat_id:
25
+ return False
26
+
27
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
28
+ payload = {
29
+ "chat_id": chat_id,
30
+ "text": text
31
+ }
32
+
33
+ try:
34
+ response = requests.post(url, data=payload, timeout=10)
35
+ return response.status_code == 200 and response.json().get("ok", False)
36
+ except Exception:
37
+ return False