waitfor-cli 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.
waitfor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """waitfor - Wait for conditions in automation and scripts."""
2
+
3
+ __version__ = "0.1.0"
waitfor/cli.py ADDED
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env python3
2
+ """waitfor - Wait for conditions (port, file, URL, process) in automation scripts."""
3
+
4
+ import click
5
+ import socket
6
+ import time
7
+ import os
8
+ import sys
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Optional, Callable, Any, Dict
13
+ from urllib.request import urlopen, Request
14
+ from urllib.error import URLError, HTTPError
15
+
16
+
17
+ def wait_loop(
18
+ check_fn: Callable[[], bool],
19
+ condition_name: str,
20
+ timeout: int,
21
+ interval: float,
22
+ quiet: bool,
23
+ json_output: bool,
24
+ invert: bool = False
25
+ ) -> Dict[str, Any]:
26
+ """Generic wait loop. Returns result dict."""
27
+ start = time.time()
28
+ attempts = 0
29
+
30
+ while True:
31
+ attempts += 1
32
+ elapsed = time.time() - start
33
+
34
+ try:
35
+ result = check_fn()
36
+ # If invert, we want the check to be False
37
+ success = (not result) if invert else result
38
+ except Exception as e:
39
+ if not quiet and not json_output:
40
+ click.echo(f" Check error: {e}", err=True)
41
+ success = invert # Error counts as False, which is success if inverted
42
+
43
+ if success:
44
+ result_data = {
45
+ "success": True,
46
+ "condition": condition_name,
47
+ "elapsed_seconds": round(elapsed, 2),
48
+ "attempts": attempts
49
+ }
50
+ if json_output:
51
+ click.echo(json.dumps(result_data))
52
+ elif not quiet:
53
+ click.echo(f"✓ {condition_name} (took {elapsed:.1f}s, {attempts} attempts)")
54
+ return result_data
55
+
56
+ if timeout > 0 and elapsed >= timeout:
57
+ result_data = {
58
+ "success": False,
59
+ "condition": condition_name,
60
+ "elapsed_seconds": round(elapsed, 2),
61
+ "attempts": attempts,
62
+ "error": f"Timeout after {timeout}s"
63
+ }
64
+ if json_output:
65
+ click.echo(json.dumps(result_data))
66
+ elif not quiet:
67
+ click.echo(f"✗ Timeout waiting for {condition_name}", err=True)
68
+ return result_data
69
+
70
+ if not quiet and not json_output:
71
+ click.echo(f" Waiting for {condition_name}... ({elapsed:.0f}s)", err=True)
72
+
73
+ time.sleep(interval)
74
+
75
+
76
+ @click.group()
77
+ @click.version_option()
78
+ def cli():
79
+ """Wait for conditions in automation scripts.
80
+
81
+ Useful for waiting on services, files, or processes before continuing.
82
+
83
+ Examples:
84
+
85
+ waitfor port 8080
86
+
87
+ waitfor file /tmp/ready.flag
88
+
89
+ waitfor url http://localhost:3000/health
90
+
91
+ waitfor process nginx
92
+ """
93
+ pass
94
+
95
+
96
+ @cli.command("port")
97
+ @click.argument("port", type=int)
98
+ @click.option("-h", "--host", default="localhost", help="Host to check")
99
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
100
+ @click.option("-i", "--interval", default=1.0, help="Check interval in seconds")
101
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
102
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
103
+ @click.option("--closed", is_flag=True, help="Wait for port to be CLOSED instead")
104
+ def wait_port(port: int, host: str, timeout: int, interval: float, quiet: bool, json_output: bool, closed: bool):
105
+ """Wait for a TCP port to be open (or closed with --closed)."""
106
+
107
+ def check_port():
108
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
109
+ sock.settimeout(1)
110
+ try:
111
+ result = sock.connect_ex((host, port))
112
+ return result == 0
113
+ finally:
114
+ sock.close()
115
+
116
+ action = "closed" if closed else "open"
117
+ condition = f"port {port} on {host} to be {action}"
118
+ result = wait_loop(check_port, condition, timeout, interval, quiet, json_output, invert=closed)
119
+ sys.exit(0 if result["success"] else 1)
120
+
121
+
122
+ @cli.command("file")
123
+ @click.argument("path")
124
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
125
+ @click.option("-i", "--interval", default=1.0, help="Check interval in seconds")
126
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
127
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
128
+ @click.option("--gone", is_flag=True, help="Wait for file to be GONE instead")
129
+ @click.option("--contains", "content", help="Wait for file to contain this string")
130
+ def wait_file(path: str, timeout: int, interval: float, quiet: bool, json_output: bool, gone: bool, content: Optional[str]):
131
+ """Wait for a file to exist (or be gone with --gone)."""
132
+
133
+ def check_file():
134
+ p = Path(path)
135
+ if not p.exists():
136
+ return False
137
+ if content:
138
+ try:
139
+ return content in p.read_text()
140
+ except:
141
+ return False
142
+ return True
143
+
144
+ if content:
145
+ condition = f"file {path} to contain '{content}'"
146
+ elif gone:
147
+ condition = f"file {path} to be gone"
148
+ else:
149
+ condition = f"file {path} to exist"
150
+
151
+ result = wait_loop(check_file, condition, timeout, interval, quiet, json_output, invert=gone)
152
+ sys.exit(0 if result["success"] else 1)
153
+
154
+
155
+ @cli.command("url")
156
+ @click.argument("url")
157
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
158
+ @click.option("-i", "--interval", default=2.0, help="Check interval in seconds")
159
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
160
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
161
+ @click.option("--status", type=int, help="Expected HTTP status code (default: any 2xx)")
162
+ @click.option("--contains", "content", help="Response must contain this string")
163
+ def wait_url(url: str, timeout: int, interval: float, quiet: bool, json_output: bool, status: Optional[int], content: Optional[str]):
164
+ """Wait for a URL to respond successfully."""
165
+
166
+ def check_url():
167
+ try:
168
+ req = Request(url, headers={"User-Agent": "waitfor/1.0"})
169
+ resp = urlopen(req, timeout=5)
170
+ code = resp.getcode()
171
+
172
+ if status and code != status:
173
+ return False
174
+ elif not status and not (200 <= code < 300):
175
+ return False
176
+
177
+ if content:
178
+ body = resp.read().decode("utf-8", errors="ignore")
179
+ return content in body
180
+
181
+ return True
182
+ except (URLError, HTTPError):
183
+ return False
184
+
185
+ condition = f"URL {url} to respond"
186
+ if status:
187
+ condition += f" with status {status}"
188
+ if content:
189
+ condition += f" containing '{content}'"
190
+
191
+ result = wait_loop(check_url, condition, timeout, interval, quiet, json_output)
192
+ sys.exit(0 if result["success"] else 1)
193
+
194
+
195
+ @cli.command("process")
196
+ @click.argument("name")
197
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
198
+ @click.option("-i", "--interval", default=1.0, help="Check interval in seconds")
199
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
200
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
201
+ @click.option("--gone", is_flag=True, help="Wait for process to be GONE instead")
202
+ def wait_process(name: str, timeout: int, interval: float, quiet: bool, json_output: bool, gone: bool):
203
+ """Wait for a process to be running (or gone with --gone)."""
204
+
205
+ def check_process():
206
+ try:
207
+ # Use pgrep for cross-platform process detection
208
+ result = subprocess.run(
209
+ ["pgrep", "-x", name],
210
+ capture_output=True,
211
+ timeout=5
212
+ )
213
+ return result.returncode == 0
214
+ except:
215
+ # Fallback: check ps output
216
+ try:
217
+ result = subprocess.run(
218
+ ["ps", "aux"],
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=5
222
+ )
223
+ return name in result.stdout
224
+ except:
225
+ return False
226
+
227
+ action = "to stop" if gone else "to start"
228
+ condition = f"process '{name}' {action}"
229
+ result = wait_loop(check_process, condition, timeout, interval, quiet, json_output, invert=gone)
230
+ sys.exit(0 if result["success"] else 1)
231
+
232
+
233
+ @cli.command("command")
234
+ @click.argument("cmd")
235
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
236
+ @click.option("-i", "--interval", default=2.0, help="Check interval in seconds")
237
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
238
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
239
+ @click.option("--shell/--no-shell", default=True, help="Run through shell")
240
+ def wait_command(cmd: str, timeout: int, interval: float, quiet: bool, json_output: bool, shell: bool):
241
+ """Wait for a command to exit with status 0."""
242
+
243
+ def check_command():
244
+ try:
245
+ result = subprocess.run(
246
+ cmd,
247
+ shell=shell,
248
+ capture_output=True,
249
+ timeout=10
250
+ )
251
+ return result.returncode == 0
252
+ except:
253
+ return False
254
+
255
+ condition = f"command '{cmd}' to succeed"
256
+ result = wait_loop(check_command, condition, timeout, interval, quiet, json_output)
257
+ sys.exit(0 if result["success"] else 1)
258
+
259
+
260
+ @cli.command("tcp")
261
+ @click.argument("host")
262
+ @click.argument("port", type=int)
263
+ @click.option("-t", "--timeout", default=30, help="Timeout in seconds (0=forever)")
264
+ @click.option("-i", "--interval", default=1.0, help="Check interval in seconds")
265
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress output")
266
+ @click.option("--json", "json_output", is_flag=True, help="JSON output")
267
+ def wait_tcp(host: str, port: int, timeout: int, interval: float, quiet: bool, json_output: bool):
268
+ """Wait for a remote TCP connection (host + port)."""
269
+
270
+ def check_tcp():
271
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
272
+ sock.settimeout(2)
273
+ try:
274
+ result = sock.connect_ex((host, port))
275
+ return result == 0
276
+ finally:
277
+ sock.close()
278
+
279
+ condition = f"TCP connection to {host}:{port}"
280
+ result = wait_loop(check_tcp, condition, timeout, interval, quiet, json_output)
281
+ sys.exit(0 if result["success"] else 1)
282
+
283
+
284
+ def main():
285
+ cli()
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: waitfor-cli
3
+ Version: 0.1.0
4
+ Summary: Wait for conditions (port, file, URL, process) in automation scripts
5
+ Author-email: Marcus <marcus.builds.things@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/waitfor
8
+ Project-URL: Repository, https://github.com/marcusbuildsthings-droid/waitfor
9
+ Project-URL: Issues, https://github.com/marcusbuildsthings-droid/waitfor/issues
10
+ Keywords: cli,automation,wait,port,process,devops,scripting
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: click>=8.0
29
+ Dynamic: license-file
30
+
31
+ # waitfor
32
+
33
+ Wait for conditions in automation scripts. Check for ports, files, URLs, or processes before continuing.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install waitfor-cli
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Wait for a port to be open
44
+
45
+ ```bash
46
+ # Wait for port 8080 on localhost
47
+ waitfor port 8080
48
+
49
+ # Wait for port on remote host
50
+ waitfor port 5432 -h db.example.com
51
+
52
+ # Wait for port to be CLOSED
53
+ waitfor port 8080 --closed
54
+ ```
55
+
56
+ ### Wait for a file
57
+
58
+ ```bash
59
+ # Wait for file to exist
60
+ waitfor file /tmp/ready.flag
61
+
62
+ # Wait for file to be gone
63
+ waitfor file /var/run/app.pid --gone
64
+
65
+ # Wait for file to contain specific content
66
+ waitfor file /var/log/app.log --contains "Server started"
67
+ ```
68
+
69
+ ### Wait for a URL to respond
70
+
71
+ ```bash
72
+ # Wait for health endpoint
73
+ waitfor url http://localhost:3000/health
74
+
75
+ # Wait for specific status code
76
+ waitfor url http://api.example.com/status --status 200
77
+
78
+ # Wait for response to contain string
79
+ waitfor url http://localhost:8080/ready --contains "ok"
80
+ ```
81
+
82
+ ### Wait for a process
83
+
84
+ ```bash
85
+ # Wait for process to start
86
+ waitfor process nginx
87
+
88
+ # Wait for process to stop
89
+ waitfor process nginx --gone
90
+ ```
91
+
92
+ ### Wait for a command to succeed
93
+
94
+ ```bash
95
+ # Wait for docker container to be healthy
96
+ waitfor command "docker inspect --format='{{.State.Health.Status}}' myapp | grep healthy"
97
+
98
+ # Wait for database to accept connections
99
+ waitfor command "pg_isready -h localhost"
100
+ ```
101
+
102
+ ### Wait for remote TCP connection
103
+
104
+ ```bash
105
+ # Wait for database to be reachable
106
+ waitfor tcp db.example.com 5432
107
+
108
+ # Wait for Redis
109
+ waitfor tcp localhost 6379
110
+ ```
111
+
112
+ ## Options
113
+
114
+ All commands support these common options:
115
+
116
+ | Option | Description | Default |
117
+ |--------|-------------|---------|
118
+ | `-t, --timeout` | Timeout in seconds (0 = forever) | 30 |
119
+ | `-i, --interval` | Check interval in seconds | 1.0-2.0 |
120
+ | `-q, --quiet` | Suppress output | off |
121
+ | `--json` | JSON output | off |
122
+
123
+ ## Exit Codes
124
+
125
+ - `0` - Condition met
126
+ - `1` - Timeout or condition not met
127
+
128
+ ## JSON Output
129
+
130
+ ```bash
131
+ $ waitfor port 8080 --json
132
+ {"success": true, "condition": "port 8080 on localhost to be open", "elapsed_seconds": 0.01, "attempts": 1}
133
+ ```
134
+
135
+ ## Examples
136
+
137
+ ### Wait for services before running tests
138
+
139
+ ```bash
140
+ #!/bin/bash
141
+ waitfor port 5432 -t 60 && \
142
+ waitfor port 6379 -t 30 && \
143
+ waitfor url http://localhost:8080/health -t 60 && \
144
+ pytest
145
+ ```
146
+
147
+ ### Deployment script
148
+
149
+ ```bash
150
+ #!/bin/bash
151
+ docker-compose up -d
152
+
153
+ # Wait for all services
154
+ waitfor port 80 -t 120
155
+ waitfor url http://localhost/api/health --contains "healthy" -t 60
156
+
157
+ echo "Deployment complete!"
158
+ ```
159
+
160
+ ### Wait for build artifact
161
+
162
+ ```bash
163
+ # Start build in background
164
+ make build &
165
+
166
+ # Wait for output
167
+ waitfor file ./dist/app.js -t 300
168
+ ```
169
+
170
+ ## For AI Agents
171
+
172
+ See [SKILL.md](SKILL.md) for agent-optimized documentation.
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,8 @@
1
+ waitfor/__init__.py,sha256=Ck2AXo82-T6hZI5nIR9HGIBfXAT-iULduLwyHotaV90,86
2
+ waitfor/cli.py,sha256=9swfOCo-KnotMofHz2JlegTy8A2HDJVzA01hnJjA18k,10447
3
+ waitfor_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
4
+ waitfor_cli-0.1.0.dist-info/METADATA,sha256=zeagwZc_fyKSpJCaM8BzfpWRPXpU8e4O2yCUbIyOlug,4015
5
+ waitfor_cli-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ waitfor_cli-0.1.0.dist-info/entry_points.txt,sha256=UB6yg40JW5YcC8VtL8Ucqq0LOuvBEM6AUVZ4L6TAbqs,45
7
+ waitfor_cli-0.1.0.dist-info/top_level.txt,sha256=CZo7W0FVVs1rRLRxwZNeooeRORiEHQdz21OYVUoMpWI,8
8
+ waitfor_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ waitfor = waitfor.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ waitfor