sfq 0.0.35__tar.gz → 0.0.36__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {sfq-0.0.35 → sfq-0.0.36}/PKG-INFO +1 -1
  2. {sfq-0.0.35 → sfq-0.0.36}/pyproject.toml +1 -1
  3. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/__init__.py +20 -4
  4. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/http_client.py +1 -1
  5. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/utils.py +125 -0
  6. sfq-0.0.36/tests/html/test_complex_nested.html +1 -0
  7. sfq-0.0.36/tests/html/test_complex_nested_styled.html +1 -0
  8. sfq-0.0.36/tests/html/test_empty_list.html +1 -0
  9. sfq-0.0.36/tests/html/test_empty_list_styled.html +1 -0
  10. sfq-0.0.36/tests/html/test_int_float_bool.html +1 -0
  11. sfq-0.0.36/tests/html/test_int_float_bool_styled.html +1 -0
  12. sfq-0.0.36/tests/html/test_list_value.html +1 -0
  13. sfq-0.0.36/tests/html/test_list_value_styled.html +1 -0
  14. sfq-0.0.36/tests/html/test_multiple_dicts.html +1 -0
  15. sfq-0.0.36/tests/html/test_multiple_dicts_styled.html +1 -0
  16. sfq-0.0.36/tests/html/test_nested_dict.html +1 -0
  17. sfq-0.0.36/tests/html/test_nested_dict_styled.html +1 -0
  18. sfq-0.0.36/tests/html/test_none_value.html +1 -0
  19. sfq-0.0.36/tests/html/test_none_value_styled.html +1 -0
  20. sfq-0.0.36/tests/html/test_other_types.html +1 -0
  21. sfq-0.0.36/tests/html/test_other_types_styled.html +1 -0
  22. sfq-0.0.36/tests/html/test_sample_report.html +1 -0
  23. sfq-0.0.36/tests/html/test_sample_report_styled.html +1 -0
  24. sfq-0.0.36/tests/html/test_single_flat_dict.html +1 -0
  25. sfq-0.0.36/tests/html/test_single_flat_dict_styled.html +1 -0
  26. sfq-0.0.36/tests/html/test_typecastable_keys_bool.html +1 -0
  27. sfq-0.0.36/tests/html/test_typecastable_keys_bool_styled.html +1 -0
  28. sfq-0.0.36/tests/html/test_typecastable_keys_float.html +1 -0
  29. sfq-0.0.36/tests/html/test_typecastable_keys_float_styled.html +1 -0
  30. sfq-0.0.36/tests/html/test_typecastable_keys_int.html +1 -0
  31. sfq-0.0.36/tests/html/test_typecastable_keys_int_styled.html +1 -0
  32. sfq-0.0.36/tests/test_records_to_html.py +45 -0
  33. sfq-0.0.36/tests/test_utils_html_table.py +190 -0
  34. {sfq-0.0.35 → sfq-0.0.36}/uv.lock +1 -1
  35. {sfq-0.0.35 → sfq-0.0.36}/.github/workflows/publish.yml +0 -0
  36. {sfq-0.0.35 → sfq-0.0.36}/.gitignore +0 -0
  37. {sfq-0.0.35 → sfq-0.0.36}/.python-version +0 -0
  38. {sfq-0.0.35 → sfq-0.0.36}/README.md +0 -0
  39. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/_cometd.py +0 -0
  40. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/auth.py +0 -0
  41. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/crud.py +0 -0
  42. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/debug_cleanup.py +0 -0
  43. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/exceptions.py +0 -0
  44. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/py.typed +0 -0
  45. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/query.py +0 -0
  46. {sfq-0.0.35 → sfq-0.0.36}/src/sfq/soap.py +0 -0
  47. {sfq-0.0.35 → sfq-0.0.36}/tests/test_auth.py +0 -0
  48. {sfq-0.0.35 → sfq-0.0.36}/tests/test_cdelete.py +0 -0
  49. {sfq-0.0.35 → sfq-0.0.36}/tests/test_compatibility.py +0 -0
  50. {sfq-0.0.35 → sfq-0.0.36}/tests/test_cquery.py +0 -0
  51. {sfq-0.0.35 → sfq-0.0.36}/tests/test_create.py +0 -0
  52. {sfq-0.0.35 → sfq-0.0.36}/tests/test_crud.py +0 -0
  53. {sfq-0.0.35 → sfq-0.0.36}/tests/test_crud_e2e.py +0 -0
  54. {sfq-0.0.35 → sfq-0.0.36}/tests/test_cupdate.py +0 -0
  55. {sfq-0.0.35 → sfq-0.0.36}/tests/test_debug_cleanup_e2e.py +0 -0
  56. {sfq-0.0.35 → sfq-0.0.36}/tests/test_debug_cleanup_unit.py +0 -0
  57. {sfq-0.0.35 → sfq-0.0.36}/tests/test_http_client.py +0 -0
  58. {sfq-0.0.35 → sfq-0.0.36}/tests/test_limits_api.py +0 -0
  59. {sfq-0.0.35 → sfq-0.0.36}/tests/test_log_trace_redact.py +0 -0
  60. {sfq-0.0.35 → sfq-0.0.36}/tests/test_open_frontdoor.py +0 -0
  61. {sfq-0.0.35 → sfq-0.0.36}/tests/test_query.py +0 -0
  62. {sfq-0.0.35 → sfq-0.0.36}/tests/test_query_client.py +0 -0
  63. {sfq-0.0.35 → sfq-0.0.36}/tests/test_query_e2e.py +0 -0
  64. {sfq-0.0.35 → sfq-0.0.36}/tests/test_query_integration.py +0 -0
  65. {sfq-0.0.35 → sfq-0.0.36}/tests/test_soap.py +0 -0
  66. {sfq-0.0.35 → sfq-0.0.36}/tests/test_soap_batch_operation.py +0 -0
  67. {sfq-0.0.35 → sfq-0.0.36}/tests/test_static_resources.py +0 -0
  68. {sfq-0.0.35 → sfq-0.0.36}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.35
3
+ Version: 0.0.36
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.35"
3
+ version = "0.0.36"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -24,7 +24,7 @@ from .exceptions import (
24
24
  from .http_client import HTTPClient
25
25
  from .query import QueryClient
26
26
  from .soap import SOAPClient
27
- from .utils import get_logger
27
+ from .utils import get_logger, records_to_html_table
28
28
  from .debug_cleanup import DebugCleanup
29
29
 
30
30
  # Define public API for documentation tools
@@ -43,7 +43,7 @@ __all__ = [
43
43
  "__version__",
44
44
  ]
45
45
 
46
- __version__ = "0.0.35"
46
+ __version__ = "0.0.36"
47
47
  """
48
48
  ### `__version__`
49
49
 
@@ -67,7 +67,7 @@ class SFAuth:
67
67
  access_token: Optional[str] = None,
68
68
  token_expiration_time: Optional[float] = None,
69
69
  token_lifetime: int = 15 * 60,
70
- user_agent: str = "sfq/0.0.35",
70
+ user_agent: str = "sfq/0.0.36",
71
71
  sforce_client: str = "_auto",
72
72
  proxy: str = "_auto",
73
73
  ) -> None:
@@ -132,7 +132,7 @@ class SFAuth:
132
132
  self._debug_cleanup = DebugCleanup(sf_auth=self)
133
133
 
134
134
  # Store version information
135
- self.__version__ = "0.0.35"
135
+ self.__version__ = "0.0.36"
136
136
  """
137
137
  ### `__version__`
138
138
 
@@ -561,3 +561,19 @@ class SFAuth:
561
561
  sid = quote(self.access_token, safe="")
562
562
  frontdoor_url = f"{self.instance_url}/secur/frontdoor.jsp?sid={sid}"
563
563
  webbrowser.open(frontdoor_url)
564
+
565
+ def records_to_html_table(
566
+ self,
567
+ items: List[Dict[str, Any]],
568
+ styled: bool = False,
569
+ ) -> str:
570
+ """
571
+ Convert a list of dictionaries to an HTML table.
572
+
573
+ :param items: List of dictionaries to convert.
574
+ :param styled: If True, apply basic CSS styles to the table.
575
+ :return: HTML string representing the table.
576
+ """
577
+ if "records" in items:
578
+ items = items["records"]
579
+ return records_to_html_table(items, styled=styled)
@@ -28,7 +28,7 @@ class HTTPClient:
28
28
  def __init__(
29
29
  self,
30
30
  auth_manager: AuthManager,
31
- user_agent: str = "sfq/0.0.35",
31
+ user_agent: str = "sfq/0.0.36",
32
32
  sforce_client: str = "_auto",
33
33
  high_api_usage_threshold: int = 80,
34
34
  ) -> None:
@@ -9,6 +9,7 @@ sensitive data redaction functionality.
9
9
  import json
10
10
  import logging
11
11
  import re
12
+ from html import escape
12
13
  from typing import Any, Dict, List, Tuple, Union
13
14
 
14
15
  # Custom TRACE logging level
@@ -194,3 +195,127 @@ def extract_org_and_user_ids(token_id_url: str) -> Tuple[str, str]:
194
195
  return org_id, user_id
195
196
  except (IndexError, AttributeError):
196
197
  raise ValueError(f"Invalid token ID URL format: {token_id_url}")
198
+
199
+
200
+ def dicts_to_html_table(
201
+ items: List[Dict[str, Any]], styled: bool = False
202
+ ) -> str:
203
+ """
204
+ Convert a list of dictionaries to a compact HTML table.
205
+
206
+ :param items: List of dictionaries to convert
207
+ :param styled: If True, apply minimal inline CSS for compact styling
208
+ :return: HTML string for a table with one column per key.
209
+ :raises ValueError: If input is not a list of dictionaries, or if keys are invalid types.
210
+ """
211
+ if not isinstance(items, list):
212
+ raise ValueError("Input must be a list of dictionaries.")
213
+
214
+ def render_value(val: Any) -> str:
215
+ if val is None:
216
+ return ""
217
+ if isinstance(val, (int, float, str, bool)):
218
+ return escape(str(val))
219
+ if isinstance(val, list):
220
+ return (
221
+ "<ul>"
222
+ + "".join(f"<li>{render_value(item)}</li>" for item in val)
223
+ + "</ul>"
224
+ )
225
+ if isinstance(val, dict):
226
+ return dicts_to_html_table([val], styled=styled)
227
+ try:
228
+ dumped = json.dumps(val, default=str)
229
+ if dumped.startswith('"') and dumped.endswith('"'):
230
+ dumped = dumped[1:-1]
231
+ return escape(dumped)
232
+ except Exception:
233
+ return escape(str(val))
234
+
235
+ # Preserve key order by first appearance
236
+ columns: List[str] = []
237
+ seen = set()
238
+ for i, d in enumerate(items):
239
+ if not isinstance(d, dict):
240
+ raise ValueError(f"Element at index {i} is not a dictionary.")
241
+ for k in d.keys():
242
+ k_str = (
243
+ k
244
+ if isinstance(k, str)
245
+ else str(k)
246
+ if isinstance(k, (int, float, bool))
247
+ else None
248
+ )
249
+ if k_str is None:
250
+ raise ValueError(f"Dictionary at index {i} has a non-string key: {k!r}")
251
+ if k_str not in seen:
252
+ columns.append(k_str)
253
+ seen.add(k_str)
254
+
255
+ # Build table
256
+ def get_value_by_str_key(d: Dict[Any, Any], col: str) -> Any:
257
+ for k in d.keys():
258
+ k_str = (
259
+ k
260
+ if isinstance(k, str)
261
+ else str(k)
262
+ if isinstance(k, (int, float, bool))
263
+ else None
264
+ )
265
+ if k_str == col:
266
+ return d[k]
267
+ return ""
268
+
269
+ if styled:
270
+ table_style = (
271
+ "border-collapse:collapse;font-size:12px;line-height:1.2;"
272
+ "margin:0;padding:0;width:auto;"
273
+ )
274
+ td_style = "border:1px solid #ccc;padding:2px 6px;vertical-align:top;"
275
+ th_style = (
276
+ "border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;"
277
+ )
278
+ if columns:
279
+ html = [f'<table style="{table_style}"><thead><tr>']
280
+ html.extend(f'<th style="{th_style}">{escape(col)}</th>' for col in columns)
281
+ html.append("</tr></thead><tbody>")
282
+ for d in items:
283
+ html.append("<tr>")
284
+ html.extend(
285
+ f'<td style="{td_style}">{render_value(get_value_by_str_key(d, col))}</td>'
286
+ for col in columns
287
+ )
288
+ html.append("</tr>")
289
+ html.append("</tbody></table>")
290
+ else:
291
+ html = [
292
+ f'<table style="{table_style}"><thead></thead><tbody></tbody></table>'
293
+ ]
294
+ else:
295
+ if columns:
296
+ html = ["<table><thead><tr>"]
297
+ html.extend(f"<th>{escape(col)}</th>" for col in columns)
298
+ html.append("</tr></thead><tbody>")
299
+ for d in items:
300
+ html.append("<tr>")
301
+ html.extend(
302
+ f"<td>{render_value(get_value_by_str_key(d, col))}</td>"
303
+ for col in columns
304
+ )
305
+ html.append("</tr>")
306
+ html.append("</tbody></table>")
307
+ else:
308
+ html = ["<table><thead></thead><tbody></tbody></table>"]
309
+
310
+ return "".join(html)
311
+
312
+ def records_to_html_table(records: List[Dict[str, Any]], styled: bool = False) -> str:
313
+ """Convert a list of records to an HTML table."""
314
+ # really we don't want anything associated with "attributes"
315
+ normalized_records = []
316
+ for record in records:
317
+ if not isinstance(record, dict):
318
+ raise ValueError(f"Record is not a dictionary: {record!r}")
319
+ record.pop("attributes", None)
320
+ normalized_records.append(record)
321
+ return dicts_to_html_table(normalized_records, styled=styled)
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>a</th></tr></thead><tbody><tr><td><ul><li>1</li><li><table><thead><tr><th>b</th></tr></thead><tbody><tr><td><ul><li>2</li><li>3</li></ul></td></tr></tbody></table></li><li></li></ul></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">a</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"><ul><li>1</li><li><table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">b</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"><ul><li>2</li><li>3</li></ul></td></tr></tbody></table></li><li></li></ul></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead></thead><tbody></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead></thead><tbody></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>i</th><th>f</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2.5</td><td>True</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">i</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">f</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">b</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">1</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">2.5</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">True</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>hello</th></tr></thead><tbody><tr><td><ul><li>bob</li><li>sally</li></ul></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">hello</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"><ul><li>bob</li><li>sally</li></ul></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>x</th><th>z</th></tr></thead><tbody><tr><td>y</td><td></td></tr><tr><td></td><td>w</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">x</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">z</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">y</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td></tr><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">w</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>outer</th></tr></thead><tbody><tr><td><table><thead><tr><th>inner</th></tr></thead><tbody><tr><td>value</td></tr></tbody></table></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">outer</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"><table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">inner</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">value</td></tr></tbody></table></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>foo</th></tr></thead><tbody><tr><td></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">foo</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>custom</th></tr></thead><tbody><tr><td>custom_str</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">custom</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">custom_str</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>name</th><th>date</th><th>status</th></tr></thead><tbody><tr><td>Sample Report</td><td>2023-01-01</td><td>Completed</td></tr><tr><td>Sample Report 2</td><td>2023-01-02</td><td>In Progress</td></tr><tr><td>Sample Report 3</td><td>2023-01-03</td><td>Not Started</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">name</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">date</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">status</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">Sample Report</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">2023-01-01</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">Completed</td></tr><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">Sample Report 2</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">2023-01-02</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">In Progress</td></tr><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">Sample Report 3</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">2023-01-03</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">Not Started</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>a</th><th>c</th></tr></thead><tbody><tr><td>b</td><td>d</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">a</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">c</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">b</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">d</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>True</th><th>False</th></tr></thead><tbody><tr><td>truthy</td><td></td></tr><tr><td></td><td>falsey</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">True</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">False</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">truthy</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td></tr><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">falsey</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>2.5</th></tr></thead><tbody><tr><td>two point five</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">2.5</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">two point five</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table><thead><tr><th>1</th></tr></thead><tbody><tr><td>one</td></tr></tbody></table>
@@ -0,0 +1 @@
1
+ <table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">1</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">one</td></tr></tbody></table>
@@ -0,0 +1,45 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from sfq import SFAuth
6
+
7
+
8
+ @pytest.fixture(scope="module")
9
+ def sf_instance():
10
+ required_env_vars = [
11
+ "SF_INSTANCE_URL",
12
+ "SF_CLIENT_ID",
13
+ "SF_CLIENT_SECRET",
14
+ "SF_REFRESH_TOKEN",
15
+ ]
16
+
17
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
18
+ if missing_vars:
19
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
20
+
21
+ sf = SFAuth(
22
+ instance_url=os.getenv("SF_INSTANCE_URL"),
23
+ client_id=os.getenv("SF_CLIENT_ID"),
24
+ client_secret=os.getenv("SF_CLIENT_SECRET").strip(),
25
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
26
+ )
27
+ return sf
28
+
29
+
30
+ def test_html_table_conversion(sf_instance):
31
+ """
32
+ Test the HTML table conversion utility.
33
+ """
34
+ records = sf_instance.query("SELECT Id,NamespacePrefix FROM Organization LIMIT 1")
35
+
36
+ plain_html_table = sf_instance.records_to_html_table(records, styled=False)
37
+ styled_html_table = sf_instance.records_to_html_table(records, styled=True)
38
+
39
+ assert isinstance(plain_html_table, str)
40
+ assert "<table>" in plain_html_table
41
+ assert "<tr>" in plain_html_table
42
+ assert "<td>" in plain_html_table
43
+ org_id = sf_instance.org_id
44
+ assert f"<table><thead><tr><th>Id</th><th>NamespacePrefix</th></tr></thead><tbody><tr><td>{org_id}</td><td></td></tr></tbody></table>" == plain_html_table
45
+ assert f'<table style="border-collapse:collapse;font-size:12px;line-height:1.2;margin:0;padding:0;width:auto;"><thead><tr><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">Id</th><th style="border:1px solid #ccc;padding:2px 6px;background:#f8f8f8;font-weight:bold;">NamespacePrefix</th></tr></thead><tbody><tr><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;">{org_id}</td><td style="border:1px solid #ccc;padding:2px 6px;vertical-align:top;"></td></tr></tbody></table>' == styled_html_table
@@ -0,0 +1,190 @@
1
+ import pytest
2
+
3
+ from sfq.utils import dicts_to_html_table
4
+
5
+
6
+ def test_typecastable_keys():
7
+ # int key
8
+ data_int = [{1: "one"}]
9
+ with open(
10
+ "./tests/html/test_typecastable_keys_int.html", "r", encoding="utf-8"
11
+ ) as f:
12
+ expected = f.read()
13
+ with open(
14
+ "./tests/html/test_typecastable_keys_int_styled.html", "r", encoding="utf-8"
15
+ ) as f:
16
+ expected_styled = f.read()
17
+ assert dicts_to_html_table(data_int) == expected
18
+ assert dicts_to_html_table(data_int, styled=True) == expected_styled
19
+
20
+ # float key
21
+ data_float = [{2.5: "two point five"}]
22
+ with open(
23
+ "./tests/html/test_typecastable_keys_float.html", "r", encoding="utf-8"
24
+ ) as f:
25
+ expected = f.read()
26
+ with open(
27
+ "./tests/html/test_typecastable_keys_float_styled.html", "r", encoding="utf-8"
28
+ ) as f:
29
+ expected_styled = f.read()
30
+ assert dicts_to_html_table(data_float) == expected
31
+ assert dicts_to_html_table(data_float, styled=True) == expected_styled
32
+
33
+ # bool key
34
+ data_bool = [{True: "truthy"}, {False: "falsey"}]
35
+ with open(
36
+ "./tests/html/test_typecastable_keys_bool.html", "r", encoding="utf-8"
37
+ ) as f:
38
+ expected = f.read()
39
+ with open(
40
+ "./tests/html/test_typecastable_keys_bool_styled.html", "r", encoding="utf-8"
41
+ ) as f:
42
+ expected_styled = f.read()
43
+ assert dicts_to_html_table(data_bool) == expected
44
+ assert dicts_to_html_table(data_bool, styled=True) == expected_styled
45
+
46
+
47
+ def test_empty_list():
48
+ with open("./tests/html/test_empty_list.html", "r", encoding="utf-8") as f:
49
+ expected = f.read()
50
+ with open("./tests/html/test_empty_list_styled.html", "r", encoding="utf-8") as f:
51
+ expected_styled = f.read()
52
+ assert dicts_to_html_table([]) == expected
53
+ assert dicts_to_html_table([], styled=True) == expected_styled
54
+
55
+
56
+ def test_single_flat_dict():
57
+ data = [{"a": "b", "c": "d"}]
58
+
59
+ with open("./tests/html/test_single_flat_dict.html", "r", encoding="utf-8") as f:
60
+ expected = f.read()
61
+ with open(
62
+ "./tests/html/test_single_flat_dict_styled.html", "r", encoding="utf-8"
63
+ ) as f:
64
+ expected_styled = f.read()
65
+ assert dicts_to_html_table(data) == expected
66
+ assert dicts_to_html_table(data, styled=True) == expected_styled
67
+
68
+
69
+ def test_multiple_dicts():
70
+ data = [{"x": "y"}, {"z": "w"}]
71
+
72
+ with open("./tests/html/test_multiple_dicts.html", "r", encoding="utf-8") as f:
73
+ expected = f.read()
74
+ with open(
75
+ "./tests/html/test_multiple_dicts_styled.html", "r", encoding="utf-8"
76
+ ) as f:
77
+ expected_styled = f.read()
78
+ assert dicts_to_html_table(data) == expected
79
+ assert dicts_to_html_table(data, styled=True) == expected_styled
80
+
81
+
82
+ def test_none_value():
83
+ data = [{"foo": None}]
84
+
85
+ with open("./tests/html/test_none_value.html", "r", encoding="utf-8") as f:
86
+ expected = f.read()
87
+ with open("./tests/html/test_none_value_styled.html", "r", encoding="utf-8") as f:
88
+ expected_styled = f.read()
89
+ assert dicts_to_html_table(data) == expected
90
+ assert dicts_to_html_table(data, styled=True) == expected_styled
91
+
92
+
93
+ def test_int_float_bool():
94
+ data = [{"i": 1, "f": 2.5, "b": True}]
95
+
96
+ with open("./tests/html/test_int_float_bool.html", "r", encoding="utf-8") as f:
97
+ expected = f.read()
98
+ with open(
99
+ "./tests/html/test_int_float_bool_styled.html", "r", encoding="utf-8"
100
+ ) as f:
101
+ expected_styled = f.read()
102
+ assert dicts_to_html_table(data) == expected
103
+ assert dicts_to_html_table(data, styled=True) == expected_styled
104
+
105
+
106
+ def test_list_value():
107
+ data = [{"hello": ["bob", "sally"]}]
108
+ with open("./tests/html/test_list_value.html", "r", encoding="utf-8") as f:
109
+ expected = f.read()
110
+ with open("./tests/html/test_list_value_styled.html", "r", encoding="utf-8") as f:
111
+ expected_styled = f.read()
112
+ assert dicts_to_html_table(data) == expected
113
+ assert dicts_to_html_table(data, styled=True) == expected_styled
114
+
115
+
116
+ def test_nested_dict():
117
+ data = [{"outer": {"inner": "value"}}]
118
+ data = [{"outer": {"inner": "value"}}]
119
+ with open("./tests/html/test_nested_dict.html", "r", encoding="utf-8") as f:
120
+ expected = f.read()
121
+ with open("./tests/html/test_nested_dict_styled.html", "r", encoding="utf-8") as f:
122
+ expected_styled = f.read()
123
+ assert dicts_to_html_table(data) == expected
124
+ assert dicts_to_html_table(data, styled=True) == expected_styled
125
+
126
+
127
+ def test_complex_nested():
128
+ data = [{"a": [1, {"b": [2, 3]}, None]}]
129
+ data = [{"a": [1, {"b": [2, 3]}, None]}]
130
+ with open("./tests/html/test_complex_nested.html", "r", encoding="utf-8") as f:
131
+ expected = f.read()
132
+ with open(
133
+ "./tests/html/test_complex_nested_styled.html", "r", encoding="utf-8"
134
+ ) as f:
135
+ expected_styled = f.read()
136
+ assert dicts_to_html_table(data) == expected
137
+ assert dicts_to_html_table(data, styled=True) == expected_styled
138
+
139
+
140
+ def test_invalid_input_type():
141
+ # Not printing for error cases
142
+ with pytest.raises(ValueError):
143
+ dicts_to_html_table("not a list")
144
+ with pytest.raises(ValueError):
145
+ dicts_to_html_table(["not a dict"])
146
+ # Only non-typecastable keys should raise ValueError
147
+ with pytest.raises(ValueError):
148
+ dicts_to_html_table([{(1, 2): "value"}])
149
+ with pytest.raises(ValueError):
150
+ dicts_to_html_table([{object(): "value"}])
151
+ # Also test styled param does not affect error
152
+ with pytest.raises(ValueError):
153
+ dicts_to_html_table("not a list", styled=True)
154
+ with pytest.raises(ValueError):
155
+ dicts_to_html_table(["not a dict"], styled=True)
156
+ with pytest.raises(ValueError):
157
+ dicts_to_html_table([{(1, 2): "value"}], styled=True)
158
+ with pytest.raises(ValueError):
159
+ dicts_to_html_table([{object(): "value"}], styled=True)
160
+
161
+
162
+ def test_other_types():
163
+ class Custom:
164
+ def __str__(self):
165
+ return "custom_str"
166
+
167
+ data = [{"custom": Custom()}]
168
+ with open("./tests/html/test_other_types.html", "r", encoding="utf-8") as f:
169
+ expected = f.read()
170
+ with open("./tests/html/test_other_types_styled.html", "r", encoding="utf-8") as f:
171
+ expected_styled = f.read()
172
+ assert dicts_to_html_table(data) == expected
173
+ assert dicts_to_html_table(data, styled=True) == expected_styled
174
+
175
+
176
+ def test_sample_report():
177
+ data = [
178
+ {"name": "Sample Report", "date": "2023-01-01", "status": "Completed"},
179
+ {"name": "Sample Report 2", "date": "2023-01-02", "status": "In Progress"},
180
+ {"name": "Sample Report 3", "date": "2023-01-03", "status": "Not Started"},
181
+ ]
182
+ expected = "<table><tbody><tr><td>name</td><td>Sample Report</td></tr><tr><td>date</td><td>2023-01-01</td></tr><tr><td>status</td><td>Completed</td></tr></tbody></table>"
183
+ with open("./tests/html/test_sample_report.html", "r", encoding="utf-8") as f:
184
+ expected = f.read()
185
+ with open(
186
+ "./tests/html/test_sample_report_styled.html", "r", encoding="utf-8"
187
+ ) as f:
188
+ expected_styled = f.read()
189
+ assert dicts_to_html_table(data) == expected
190
+ assert dicts_to_html_table(data, styled=True) == expected_styled
@@ -3,5 +3,5 @@ requires-python = ">=3.9"
3
3
 
4
4
  [[package]]
5
5
  name = "sfq"
6
- version = "0.0.35"
6
+ version = "0.0.36"
7
7
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes