sefrone-api-e2e 1.3.6__tar.gz → 1.3.8__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 (36) hide show
  1. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/PKG-INFO +2 -1
  2. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/__init__.py +2 -0
  3. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/api_e2e_manager.py +16 -7
  4. sefrone_api_e2e-1.3.8/sefrone_api_e2e/runner_excel.py +215 -0
  5. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e.egg-info/PKG-INFO +2 -1
  6. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e.egg-info/SOURCES.txt +1 -0
  7. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e.egg-info/requires.txt +1 -0
  8. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/setup.py +3 -2
  9. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/README.md +0 -0
  10. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/0_check_features.yaml +0 -0
  11. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/1_normal_auth_flow.yaml +0 -0
  12. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/2_account_endpoints.yaml +0 -0
  13. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/3_admin_account_endpoints.yaml +0 -0
  14. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/4_workspace_endpoints.yaml +0 -0
  15. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/5_apikey_endpoints.yaml +0 -0
  16. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/6_project_endpoints.yaml +0 -0
  17. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/7_email_2fa_flow.yaml +0 -0
  18. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/8_temporary_passwords_flow.yaml +0 -0
  19. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Auth/9_project_account_access_control_flow.yaml +0 -0
  20. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Oidc/1_relying_party_admin.yaml +0 -0
  21. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Oidc/2_authorization_code_flow.yaml +0 -0
  22. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Oidc/3_disable_propagation_flow.yaml +0 -0
  23. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e/Scenarios/Oidc/4_multi_project_isolation.yaml +0 -0
  24. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/e2e_scenarios_manager.py +0 -0
  25. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/env_patch.py +0 -0
  26. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/json_mock_server.py +0 -0
  27. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_assets.py +0 -0
  28. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_csv.py +0 -0
  29. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_email_mock.py +0 -0
  30. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_rest_api.py +0 -0
  31. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_satim_mock.py +0 -0
  32. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/runner_webhook_mock.py +0 -0
  33. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e/step_runner.py +0 -0
  34. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e.egg-info/dependency_links.txt +0 -0
  35. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/sefrone_api_e2e.egg-info/top_level.txt +0 -0
  36. {sefrone_api_e2e-1.3.6 → sefrone_api_e2e-1.3.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sefrone_api_e2e
3
- Version: 1.3.6
3
+ Version: 1.3.8
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -12,6 +12,7 @@ Requires-Python: >=3.7
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: PyYAML>=6.0.1
14
14
  Requires-Dist: requests
15
+ Requires-Dist: openpyxl>=3.1.0
15
16
  Dynamic: author
16
17
  Dynamic: author-email
17
18
  Dynamic: classifier
@@ -6,6 +6,7 @@ from .runner_webhook_mock import WebhookMockRunner
6
6
  from .runner_assets import AssetsRunner
7
7
  from .runner_satim_mock import SatimMockRunner
8
8
  from .runner_csv import CsvRunner
9
+ from .runner_excel import ExcelRunner
9
10
  from .json_mock_server import JsonMockServer
10
11
  from .e2e_scenarios_manager import E2EScenariosManager
11
12
 
@@ -21,6 +22,7 @@ __all__ = [
21
22
  "AssetsRunner",
22
23
  "SatimMockRunner",
23
24
  "CsvRunner",
25
+ "ExcelRunner",
24
26
  "JsonMockServer",
25
27
  "E2EScenariosManager",
26
28
  ]
@@ -14,6 +14,7 @@ from .step_runner import StepRunner, RunContext
14
14
  from .runner_rest_api import RestApiRunner
15
15
  from .runner_assets import AssetsRunner
16
16
  from .runner_csv import CsvRunner
17
+ from .runner_excel import ExcelRunner
17
18
 
18
19
 
19
20
  class ScenarioSkippedException(Exception):
@@ -42,11 +43,12 @@ class ApiE2ETestsManager:
42
43
  ApiE2ETestsManager.global_store = {}
43
44
  ApiE2ETestsManager.scenario_results = []
44
45
 
45
- # RestApiRunner (default), AssetsRunner and CsvRunner are stateless/self-contained
46
- # (no external config needed), so they're always available without callers having to
47
- # construct and pass them in. Runners that need caller-supplied config (mail/webhook
48
- # mocks, etc.) still come in through the `runners` param.
49
- active_runners: List[StepRunner] = [RestApiRunner(), AssetsRunner(), CsvRunner()]
46
+ # RestApiRunner (default), AssetsRunner, CsvRunner and ExcelRunner are
47
+ # stateless/self-contained (no external config needed), so they're always
48
+ # available without callers having to construct and pass them in. Runners
49
+ # that need caller-supplied config (mail/webhook mocks, etc.) still come in
50
+ # through the `runners` param.
51
+ active_runners: List[StepRunner] = [RestApiRunner(), AssetsRunner(), CsvRunner(), ExcelRunner()]
50
52
  if runners:
51
53
  # make sure to distinct on runner name
52
54
  active_runners.extend(runners)
@@ -266,7 +268,9 @@ class ApiE2ETestsManager:
266
268
 
267
269
  Syntax: $date(<offset>[,<time>])
268
270
 
269
- offset: integer number of days relative to today (e.g. 0, +1, -2)
271
+ offset: integer number of days relative to today in UTC (e.g. 0, +1, -2) -- deliberately
272
+ UTC rather than the runner's local date, since the resolved string is always
273
+ stamped "Z" and compared against real UTC API timestamps.
270
274
  time: optional suffix controlling the time component
271
275
  'start' (default) → T00:00:00Z
272
276
  'end' → T23:59:59Z
@@ -290,7 +294,12 @@ class ApiE2ETestsManager:
290
294
  raise ValueError(
291
295
  f"$date() time suffix must be 'start' or 'end', got: '{time_part}'"
292
296
  )
293
- target = datetime.date.today() + datetime.timedelta(days=offset_days)
297
+ # Resolves against the UTC calendar date, not the local one -- the returned string is
298
+ # stamped "Z" (UTC) and compared against API timestamps that are real UTC (e.g.
299
+ # DateTime.UtcNow), so a local date here would drift for up to a full day depending on
300
+ # the runner's timezone, and briefly disagree with UTC around local midnight every day.
301
+ today_utc = datetime.datetime.now(datetime.timezone.utc).date()
302
+ target = today_utc + datetime.timedelta(days=offset_days)
294
303
  time_str = "T00:00:00Z" if time_part == "start" else "T23:59:59Z"
295
304
  return target.strftime("%Y-%m-%d") + time_str
296
305
 
@@ -0,0 +1,215 @@
1
+ import datetime
2
+ import os
3
+ from typing import Any, Dict, List, Set
4
+
5
+ from openpyxl import Workbook, load_workbook
6
+
7
+ from .step_runner import StepRunner, RunContext, StepResult
8
+
9
+
10
+ class ExcelRunner(StepRunner):
11
+ """
12
+ Runner for generating and reading .xlsx files during scenario execution, so a
13
+ batch-upload fixture (e.g. a tenant import file) can be built with values tied
14
+ to the scenario's own dynamically-created data (workspace, register, etc.)
15
+ instead of relying on a static file that silently drifts out of sync with
16
+ whatever the setup steps actually created, and so a downloaded/generated
17
+ workbook can be asserted on by structured row/column content.
18
+
19
+ This runner never makes HTTP requests itself -- pair a normal HTTP step's
20
+ ``files:`` upload (already supported by the default rest runner) with a
21
+ ``generate`` step to build the fixture first, or pair a ``save_file:``
22
+ download with a ``read`` step to assert on what came back.
23
+
24
+ Step syntax — generate (write an .xlsx fixture with specific content)::
25
+
26
+ - name: "Generate tenant batch fixture"
27
+ runner: excel
28
+ excel:
29
+ action: generate
30
+ path: "./e2e/tmp/tenants_batch_{$stored.seed_id}.xlsx"
31
+ sheet_name: "Tenants" # optional, default: "Sheet1"
32
+ headers: ["wilaya", "code caisse", "matricule locataire", "nom"]
33
+ rows:
34
+ - ["Alger", "{$stored.register_number}", "E2E-{$stored.seed_id}-001", "Benali"]
35
+
36
+ Step syntax — read (parse an existing/just-downloaded .xlsx file)::
37
+
38
+ - name: "Verify exported workbook content"
39
+ runner: excel
40
+ excel:
41
+ action: read # optional, default: read
42
+ path: "./e2e/tmp/export_{$stored.seed_id}.xlsx"
43
+ sheet_name: "Tenants" # optional, defaults to the first sheet
44
+ assertions:
45
+ - "{{excel.row_count}} == 3"
46
+ - "{{excel.headers}} include 'code caisse'"
47
+ - "{{excel.rows[0].nom}} == 'Benali'"
48
+ save:
49
+ first_row_name: "{{excel.rows[0].nom}}"
50
+
51
+ ``excel.rows`` in the read context is a list of dicts keyed by header name
52
+ (e.g. ``{{excel.rows[0].nom}}``); ``excel.raw_rows`` is the same data as
53
+ plain lists (positional access) for files with duplicate/blank headers.
54
+ Every cell value is read back as a **string** (matching CsvRunner), so
55
+ compare numeric-looking values against a quoted literal (``== '500'``, not
56
+ ``== 500``). Runner-generated files are removed during teardown, matching
57
+ AssetsRunner/CsvRunner; files a ``read`` step merely reads (e.g. ones saved
58
+ by an HTTP step) are left alone since this runner doesn't own them.
59
+ """
60
+
61
+ def __init__(self):
62
+ self._generated_paths: Set[str] = set()
63
+
64
+ @property
65
+ def name(self) -> str:
66
+ return "excel"
67
+
68
+ def handles(self, step: dict) -> bool:
69
+ return "excel" in step and "method" not in step
70
+
71
+ def execute(self, step: dict, ctx: RunContext) -> StepResult:
72
+ try:
73
+ spec = step.get("excel")
74
+ if not isinstance(spec, dict):
75
+ return StepResult.failed("excel step requires an 'excel' object")
76
+
77
+ action = str(spec.get("action", "read")).strip().lower()
78
+
79
+ if action == "read":
80
+ return self._read(step, spec, ctx)
81
+ if action == "generate":
82
+ return self._generate(spec, ctx)
83
+
84
+ return StepResult.failed(f"Unsupported excel action: '{action}'. Supported: 'read', 'generate'")
85
+ except AssertionError as e:
86
+ return StepResult.failed(str(e))
87
+ except Exception as e:
88
+ return StepResult.failed(str(e))
89
+
90
+ def teardown(self) -> None:
91
+ for path in list(self._generated_paths):
92
+ try:
93
+ os.remove(path)
94
+ print(f"[excel] Removed: {path}")
95
+ except FileNotFoundError:
96
+ pass
97
+ except Exception as e:
98
+ print(f"[excel] Failed removing '{path}': {e}")
99
+ self._generated_paths.clear()
100
+
101
+ # ------------------------------------------------------------------ #
102
+ # read
103
+ # ------------------------------------------------------------------ #
104
+
105
+ def _read(self, step: dict, spec: Dict[str, Any], ctx: RunContext) -> StepResult:
106
+ path = spec.get("path")
107
+ if not isinstance(path, str) or not path.strip():
108
+ return StepResult.failed("excel.path is required and must be a non-empty string")
109
+
110
+ resolved_path = os.path.normpath(ctx.substitute_stored(path))
111
+
112
+ if not os.path.exists(resolved_path):
113
+ return StepResult.failed(f"excel file not found: '{resolved_path}'")
114
+
115
+ sheet_name = spec.get("sheet_name")
116
+ workbook = load_workbook(filename=resolved_path, read_only=True, data_only=True)
117
+ try:
118
+ worksheet = workbook[ctx.substitute_stored(sheet_name)] if sheet_name else workbook.worksheets[0]
119
+ all_rows = [
120
+ [self._cell_to_str(cell) for cell in row]
121
+ for row in worksheet.iter_rows(values_only=True)
122
+ ]
123
+ finally:
124
+ workbook.close()
125
+
126
+ if not all_rows:
127
+ headers: List[str] = []
128
+ raw_rows: List[List[str]] = []
129
+ else:
130
+ headers, raw_rows = all_rows[0], all_rows[1:]
131
+
132
+ row_dicts = [dict(zip(headers, row)) for row in raw_rows]
133
+
134
+ template_context: Dict[str, Any] = {
135
+ "excel": {
136
+ "headers": headers,
137
+ "rows": row_dicts,
138
+ "raw_rows": raw_rows,
139
+ "row_count": len(row_dicts),
140
+ }
141
+ }
142
+
143
+ if "assertions" in step:
144
+ for expr in step["assertions"]:
145
+ try:
146
+ rendered_expr = ctx.substitute_stored(expr)
147
+ rendered = ctx.render_template(rendered_expr, template_context, add_quotes=True)
148
+ ctx.try_eval(rendered, rendered_expr)
149
+ except Exception as e:
150
+ raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{e}")
151
+
152
+ if "save" in step:
153
+ for key, path_expr in step["save"].items():
154
+ rendered_path = ctx.substitute_stored(str(path_expr))
155
+ rendered = ctx.render_template(rendered_path, template_context)
156
+ ctx.store_value(key, rendered)
157
+
158
+ return StepResult.passed()
159
+
160
+ # ------------------------------------------------------------------ #
161
+ # generate
162
+ # ------------------------------------------------------------------ #
163
+
164
+ def _generate(self, spec: Dict[str, Any], ctx: RunContext) -> StepResult:
165
+ path = spec.get("path")
166
+ headers = spec.get("headers")
167
+ rows = spec.get("rows", [])
168
+ sheet_name = spec.get("sheet_name", "Sheet1")
169
+
170
+ if not isinstance(path, str) or not path.strip():
171
+ return StepResult.failed("excel.path is required and must be a non-empty string")
172
+ if not isinstance(headers, list) or not headers:
173
+ return StepResult.failed("excel.headers is required and must be a non-empty list")
174
+ if not isinstance(rows, list):
175
+ return StepResult.failed("excel.rows must be a list of row lists")
176
+
177
+ resolved_path = os.path.normpath(ctx.substitute_stored(path))
178
+ parent_dir = os.path.dirname(resolved_path)
179
+ if parent_dir:
180
+ os.makedirs(parent_dir, exist_ok=True)
181
+
182
+ resolved_headers = [ctx.substitute_stored(str(h)) for h in headers]
183
+ resolved_rows = [[ctx.substitute_stored(str(cell)) for cell in row] for row in rows]
184
+
185
+ workbook = Workbook()
186
+ worksheet = workbook.active
187
+ worksheet.title = ctx.substitute_stored(str(sheet_name))
188
+ worksheet.append(resolved_headers)
189
+ for row in resolved_rows:
190
+ worksheet.append(row)
191
+ workbook.save(resolved_path)
192
+
193
+ self._generated_paths.add(resolved_path)
194
+ print(f"[excel] Generated: {resolved_path} ({len(resolved_rows)} row(s))")
195
+ return StepResult.passed()
196
+
197
+ # ------------------------------------------------------------------ #
198
+ # helpers
199
+ # ------------------------------------------------------------------ #
200
+
201
+ @staticmethod
202
+ def _cell_to_str(value: Any) -> str:
203
+ if value is None:
204
+ return ""
205
+ if isinstance(value, bool):
206
+ return str(value)
207
+ if isinstance(value, float):
208
+ # Avoid scientific notation / trailing ".0" for integral values, mirroring
209
+ # how the .NET ExcelFileParser formats numeric cells on the reading side.
210
+ if value.is_integer():
211
+ return str(int(value))
212
+ return repr(value)
213
+ if isinstance(value, (datetime.datetime, datetime.date)):
214
+ return value.isoformat()
215
+ return str(value).strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sefrone_api_e2e
3
- Version: 1.3.6
3
+ Version: 1.3.8
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -12,6 +12,7 @@ Requires-Python: >=3.7
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: PyYAML>=6.0.1
14
14
  Requires-Dist: requests
15
+ Requires-Dist: openpyxl>=3.1.0
15
16
  Dynamic: author
16
17
  Dynamic: author-email
17
18
  Dynamic: classifier
@@ -8,6 +8,7 @@ sefrone_api_e2e/json_mock_server.py
8
8
  sefrone_api_e2e/runner_assets.py
9
9
  sefrone_api_e2e/runner_csv.py
10
10
  sefrone_api_e2e/runner_email_mock.py
11
+ sefrone_api_e2e/runner_excel.py
11
12
  sefrone_api_e2e/runner_rest_api.py
12
13
  sefrone_api_e2e/runner_satim_mock.py
13
14
  sefrone_api_e2e/runner_webhook_mock.py
@@ -1,2 +1,3 @@
1
1
  PyYAML>=6.0.1
2
2
  requests
3
+ openpyxl>=3.1.0
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="sefrone_api_e2e",
8
- version="1.3.6",
8
+ version="1.3.8",
9
9
  author="Sefrone",
10
10
  author_email="contact@sefrone.com",
11
11
  description="A Python package to provide e2e testing helpers for sefrone API projects",
@@ -21,7 +21,8 @@ setup(
21
21
  python_requires=">=3.7",
22
22
  install_requires=[
23
23
  "PyYAML>=6.0.1",
24
- "requests"
24
+ "requests",
25
+ "openpyxl>=3.1.0"
25
26
  ],
26
27
  package_data={
27
28
  "sefrone_api_e2e": ["e2e/Scenarios/Auth/*.yaml", "e2e/Scenarios/Oidc/*.yaml"]