skipper-pytest 0.1.0__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.
@@ -0,0 +1,50 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ *.egg
11
+ .eggs/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # uv
19
+ .uv/
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ .tox/
26
+ .nox/
27
+
28
+ # Type checking
29
+ .mypy_cache/
30
+
31
+ # IDE
32
+ .idea/
33
+ .vscode/
34
+ *.swp
35
+ *.swo
36
+
37
+ # Temp files
38
+ *.tmp
39
+ /tmp/
40
+
41
+ # Credentials (never commit)
42
+ service-account-skipper-bot.json
43
+ *.json.bak
44
+
45
+ # Environment
46
+ .env
47
+ .env.local
48
+
49
+ # macOS
50
+ .DS_Store
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: skipper-pytest
3
+ Version: 0.1.0
4
+ Summary: pytest plugin for Skipper test-gating via Google Spreadsheet
5
+ Project-URL: Homepage, https://github.com/get-skipper/skipper-python
6
+ Project-URL: Repository, https://github.com/get-skipper/skipper-python
7
+ License: MIT
8
+ Keywords: google-sheets,pytest,skipper,test-gating,testing
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: pytest>=7.0
17
+ Requires-Dist: skipper-core>=0.1.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # skipper-pytest
21
+
22
+ pytest plugin for [Skipper](https://github.com/get-skipper/skipper-python) test-gating via Google Spreadsheet.
23
+
24
+ Tests are automatically skipped when their `disabledUntil` date is in the future — no changes to test code required.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install skipper-pytest
30
+ ```
31
+
32
+ ## Setup
33
+
34
+ Add to `tests/conftest.py`:
35
+
36
+ ```python
37
+ from skipper_pytest import configure_skipper
38
+ from skipper_core import SkipperConfig, FileCredentials
39
+
40
+ configure_skipper(SkipperConfig(
41
+ spreadsheet_id="YOUR_SPREADSHEET_ID",
42
+ credentials=FileCredentials("./service-account-skipper-bot.json"),
43
+ sheet_name="skipper-python",
44
+ ))
45
+ ```
46
+
47
+ Or configure entirely via environment variables:
48
+
49
+ ```bash
50
+ SKIPPER_SPREADSHEET_ID=... \
51
+ SKIPPER_CREDENTIALS_FILE=./service-account-skipper-bot.json \
52
+ SKIPPER_SHEET_NAME=skipper-python \
53
+ pytest
54
+ ```
55
+
56
+ ## Test ID Format
57
+
58
+ `tests/test_auth.py > ClassName > test_method_name`
59
+
60
+ ## Sync Mode
61
+
62
+ Set `SKIPPER_MODE=sync` to have Skipper reconcile the spreadsheet after the test run (adds new test IDs, removes stale ones).
63
+
64
+ See the [root README](../../README.md) for full documentation.
@@ -0,0 +1,45 @@
1
+ # skipper-pytest
2
+
3
+ pytest plugin for [Skipper](https://github.com/get-skipper/skipper-python) test-gating via Google Spreadsheet.
4
+
5
+ Tests are automatically skipped when their `disabledUntil` date is in the future — no changes to test code required.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install skipper-pytest
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ Add to `tests/conftest.py`:
16
+
17
+ ```python
18
+ from skipper_pytest import configure_skipper
19
+ from skipper_core import SkipperConfig, FileCredentials
20
+
21
+ configure_skipper(SkipperConfig(
22
+ spreadsheet_id="YOUR_SPREADSHEET_ID",
23
+ credentials=FileCredentials("./service-account-skipper-bot.json"),
24
+ sheet_name="skipper-python",
25
+ ))
26
+ ```
27
+
28
+ Or configure entirely via environment variables:
29
+
30
+ ```bash
31
+ SKIPPER_SPREADSHEET_ID=... \
32
+ SKIPPER_CREDENTIALS_FILE=./service-account-skipper-bot.json \
33
+ SKIPPER_SHEET_NAME=skipper-python \
34
+ pytest
35
+ ```
36
+
37
+ ## Test ID Format
38
+
39
+ `tests/test_auth.py > ClassName > test_method_name`
40
+
41
+ ## Sync Mode
42
+
43
+ Set `SKIPPER_MODE=sync` to have Skipper reconcile the spreadsheet after the test run (adds new test IDs, removes stale ones).
44
+
45
+ See the [root README](../../README.md) for full documentation.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "skipper-pytest"
7
+ version = "0.1.0"
8
+ description = "pytest plugin for Skipper test-gating via Google Spreadsheet"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ keywords = ["testing", "test-gating", "google-sheets", "skipper", "pytest"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Framework :: Pytest",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Testing",
20
+ ]
21
+ dependencies = [
22
+ "skipper-core>=0.1.0",
23
+ "pytest>=7.0",
24
+ ]
25
+
26
+ [project.entry-points."pytest11"]
27
+ skipper = "skipper_pytest.plugin"
28
+
29
+ [tool.uv.sources]
30
+ skipper-core = { workspace = true }
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/get-skipper/skipper-python"
34
+ Repository = "https://github.com/get-skipper/skipper-python"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/skipper_pytest"]
@@ -0,0 +1,5 @@
1
+ """skipper-pytest — pytest plugin for Skipper test-gating via Google Spreadsheet."""
2
+
3
+ from .plugin import configure_skipper
4
+
5
+ __all__ = ["configure_skipper"]
@@ -0,0 +1,208 @@
1
+ """skipper-pytest — pytest plugin for Skipper test-gating."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+
8
+ import pytest
9
+ from skipper_core import (
10
+ CacheManager,
11
+ SkipperConfig,
12
+ SkipperResolver,
13
+ build_test_id,
14
+ mode_from_env,
15
+ )
16
+ from skipper_core.writer import SheetsWriter
17
+
18
+ # ── Module-level state ────────────────────────────────────────────────────────
19
+ _resolver: SkipperResolver | None = None
20
+ _cache_dir: str | None = None
21
+ _discovered_lock = threading.Lock()
22
+ _discovered_ids: list[str] = []
23
+ _config: SkipperConfig | None = None
24
+ _cache_manager = CacheManager()
25
+
26
+ # ── Public API ────────────────────────────────────────────────────────────────
27
+
28
+
29
+ def configure_skipper(config: SkipperConfig) -> None:
30
+ """Call this from conftest.py to register a SkipperConfig with the plugin.
31
+
32
+ Example::
33
+
34
+ # tests/conftest.py
35
+ from skipper_pytest import configure_skipper
36
+ from skipper_core import SkipperConfig, FileCredentials
37
+
38
+ configure_skipper(SkipperConfig(
39
+ spreadsheet_id="YOUR_SPREADSHEET_ID",
40
+ credentials=FileCredentials("./service-account-skipper-bot.json"),
41
+ sheet_name="skipper-python",
42
+ ))
43
+ """
44
+ global _config
45
+ _config = config
46
+
47
+
48
+ # ── pytest hooks ──────────────────────────────────────────────────────────────
49
+
50
+
51
+ def pytest_configure(config: pytest.Config) -> None:
52
+ """Initialize the resolver (or rehydrate from cache for xdist workers)."""
53
+ global _resolver, _cache_dir, _discovered_ids
54
+
55
+ _discovered_ids = []
56
+
57
+ # xdist worker: rehydrate from cache file written by the controller.
58
+ cache_file = os.getenv("SKIPPER_CACHE_FILE")
59
+ if cache_file and _is_xdist_worker(config):
60
+ data = _cache_manager.read_resolver_cache(cache_file)
61
+ _resolver = SkipperResolver.from_marshal_cache(data)
62
+ return
63
+
64
+ # Main process (or no xdist): initialize from spreadsheet if config was provided.
65
+ # Config may not be set yet (set_config is called in conftest.py which runs after
66
+ # pytest_configure for plugins). We defer actual initialization to pytest_sessionstart.
67
+
68
+
69
+ def pytest_sessionstart(session: pytest.Session) -> None:
70
+ """Initialize the resolver once config is available (called after conftest.py)."""
71
+ global _resolver, _cache_dir
72
+
73
+ if _resolver is not None:
74
+ return # already rehydrated (xdist worker)
75
+
76
+ cfg = _config
77
+ if cfg is None:
78
+ # Check env vars as fallback for minimal config.
79
+ cfg = _config_from_env()
80
+ if cfg is None:
81
+ return # Skipper not configured — run all tests normally.
82
+
83
+ if _is_xdist_worker(session.config):
84
+ return # Worker rehydration handled in pytest_configure.
85
+
86
+ resolver = SkipperResolver(cfg)
87
+ resolver.initialize()
88
+ _resolver = resolver
89
+
90
+ data = resolver.marshal_cache()
91
+ cache_dir = _cache_manager.write_resolver_cache(data)
92
+ _cache_dir = cache_dir
93
+ os.environ["SKIPPER_CACHE_FILE"] = os.path.join(cache_dir, "cache.json")
94
+ os.environ["SKIPPER_DISCOVERED_DIR"] = cache_dir
95
+
96
+
97
+ def pytest_runtest_setup(item: pytest.Item) -> None:
98
+ """Skip the test if Skipper says it is disabled; record the test ID for sync."""
99
+ if _resolver is None:
100
+ return
101
+
102
+ test_id = _test_id_from_item(item)
103
+
104
+ with _discovered_lock:
105
+ _discovered_ids.append(test_id)
106
+
107
+ # Also write to the shared dir if running under xdist.
108
+ discovered_dir = os.getenv("SKIPPER_DISCOVERED_DIR")
109
+ if discovered_dir and _is_xdist_worker(item.config):
110
+ _cache_manager.write_discovered_ids(discovered_dir, [test_id])
111
+
112
+ if not _resolver.is_test_enabled(test_id):
113
+ until = _resolver.get_disabled_until(test_id)
114
+ msg = "[skipper] Test disabled"
115
+ if until is not None:
116
+ msg += f" until {until.strftime('%Y-%m-%d')}"
117
+ pytest.skip(msg)
118
+
119
+
120
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
121
+ """In sync mode, write discovered IDs and reconcile the spreadsheet."""
122
+ if _resolver is None or _is_xdist_worker(session.config):
123
+ return
124
+
125
+ cfg = _config or _config_from_env()
126
+ if cfg is None or mode_from_env().value != "sync":
127
+ _cleanup()
128
+ return
129
+
130
+ # Flush in-memory discovered IDs to the shared dir.
131
+ if _cache_dir:
132
+ with _discovered_lock:
133
+ ids = list(_discovered_ids)
134
+ if ids:
135
+ _cache_manager.write_discovered_ids(_cache_dir, ids)
136
+
137
+ # Merge all workers' files.
138
+ all_ids = _cache_manager.merge_discovered_ids(_cache_dir)
139
+ else:
140
+ with _discovered_lock:
141
+ all_ids = list(_discovered_ids)
142
+
143
+ writer = SheetsWriter(cfg)
144
+ writer.sync(all_ids)
145
+
146
+ _cleanup()
147
+
148
+
149
+ # ── Helpers ───────────────────────────────────────────────────────────────────
150
+
151
+
152
+ def _test_id_from_item(item: pytest.Item) -> str:
153
+ """Build a stable Skipper test ID from a pytest Item."""
154
+ try:
155
+ # item.fspath gives the absolute path; build_test_id makes it relative.
156
+ file_path = str(item.fspath)
157
+ except Exception:
158
+ file_path = item.nodeid.split("::")[0]
159
+
160
+ # nodeid is "path/to/test.py::ClassName::test_name[param]"
161
+ parts = item.nodeid.split("::")
162
+ title_parts = parts[1:] # drop the file path component
163
+
164
+ # Strip parametrize suffixes like "[param]" for lookup so that
165
+ # test_login[chrome] and test_login[firefox] map to the same spreadsheet row.
166
+ title_parts = [_strip_param(p) for p in title_parts]
167
+
168
+ return build_test_id(file_path, title_parts)
169
+
170
+
171
+ def _strip_param(name: str) -> str:
172
+ bracket = name.find("[")
173
+ return name[:bracket] if bracket >= 0 else name
174
+
175
+
176
+ def _is_xdist_worker(config: pytest.Config) -> bool:
177
+ return hasattr(config, "workerinput")
178
+
179
+
180
+ def _config_from_env() -> SkipperConfig | None:
181
+ """Build a minimal SkipperConfig from environment variables."""
182
+ from skipper_core import Base64Credentials, Credentials, FileCredentials
183
+
184
+ spreadsheet_id = os.getenv("SKIPPER_SPREADSHEET_ID")
185
+ if not spreadsheet_id:
186
+ return None
187
+
188
+ creds_file = os.getenv("SKIPPER_CREDENTIALS_FILE")
189
+ creds_b64 = os.getenv("GOOGLE_CREDS_B64")
190
+
191
+ credentials: Credentials
192
+ if creds_file:
193
+ credentials = FileCredentials(path=creds_file)
194
+ elif creds_b64:
195
+ credentials = Base64Credentials(encoded=creds_b64)
196
+ else:
197
+ return None
198
+
199
+ return SkipperConfig(
200
+ spreadsheet_id=spreadsheet_id,
201
+ credentials=credentials,
202
+ sheet_name=os.getenv("SKIPPER_SHEET_NAME") or None,
203
+ )
204
+
205
+
206
+ def _cleanup() -> None:
207
+ if _cache_dir:
208
+ _cache_manager.cleanup(_cache_dir)