proctide 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.
proctide-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 proctide contributors
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,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: proctide
3
+ Version: 0.1.0
4
+ Summary: Run every process in your Procfile at once in one terminal, each output line prefixed with a colored process name; a clean Ctrl-C shuts them all down. Like foreman/overmind but zero-dependency and dual-runtime — no Ruby, no tmux.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/proctide-py
8
+ Project-URL: Repository, https://github.com/jjdoor/proctide-py
9
+ Project-URL: Issues, https://github.com/jjdoor/proctide-py/issues
10
+ Keywords: procfile,foreman,overmind,process,process-manager,concurrent,runner,dev,devtools,cli,monorepo
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 :: POSIX
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Unix
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # proctide
29
+
30
+ **Run every process in your `Procfile` together, in one terminal.** Each output
31
+ line is prefixed with a colored process name so you can tell `web` from `worker`
32
+ from `css` at a glance — and a single Ctrl-C takes the whole lot down cleanly.
33
+
34
+ Zero dependencies (pure Python stdlib). No Ruby. No tmux.
35
+
36
+ ```bash
37
+ pipx run proctide
38
+ ```
39
+
40
+ ```
41
+ web | listening on http://localhost:3000
42
+ worker | polling jobs queue…
43
+ css | rebuilt app.css in 42ms
44
+ web | GET / 200 11ms
45
+ worker | processed job #1841
46
+ ^C
47
+ proctide: SIGINT received, shutting down…
48
+ web | exited (signal SIGTERM)
49
+ worker | exited (signal SIGTERM)
50
+ css | exited (signal SIGTERM)
51
+ ```
52
+
53
+ ## Why another Procfile runner?
54
+
55
+ [foreman](https://github.com/ddollar/foreman) is the classic, but it's a **Ruby
56
+ gem** — one more runtime to install on a Node/Python box.
57
+ [overmind](https://github.com/DarthSim/overmind) is great but needs **tmux**.
58
+ [`concurrently`](https://www.npmjs.com/package/concurrently) is excellent and
59
+ mature — but it's an npm dependency, and you spell your processes out on the
60
+ command line instead of in a checked-in `Procfile`.
61
+
62
+ `proctide` is the small middle ground: a real `Procfile`, prefixed interleaved
63
+ output, clean shutdown — and **nothing to install but the tool itself**, on
64
+ whichever of Python or Node you already have.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pipx run proctide # no install, run on demand
70
+ pip install proctide # or install the `proctide` command
71
+ ```
72
+
73
+ There's an identical Node build too: `npx proctide` / `npm i -g proctide`
74
+ (see [proctide](https://github.com/jjdoor/proctide)). Both ports parse the same
75
+ Procfile and print the same prefix format — their pure cores are tested against
76
+ the same vectors.
77
+
78
+ ## The Procfile
79
+
80
+ One process per line, `name: command`. Blank lines and `#` comments are ignored.
81
+ The command runs through your shell, so pipes, `&&`, env vars, and globs all work.
82
+
83
+ ```Procfile
84
+ # Procfile
85
+ web: python server.py
86
+ worker: python worker.py
87
+ css: npx tailwindcss -i app.css -o public/app.css --watch
88
+ ```
89
+
90
+ ```bash
91
+ proctide # runs ./Procfile
92
+ proctide -f Procfile.dev # or point somewhere else
93
+ ```
94
+
95
+ ## Usage
96
+
97
+ ```bash
98
+ proctide [options]
99
+ ```
100
+
101
+ | Option | Description |
102
+ | --- | --- |
103
+ | `-f, --file <path>` | Procfile to read (default: `./Procfile`). |
104
+ | `--no-color` | Disable the colored name prefixes (e.g. when piping to a file). |
105
+ | `-h, --help` | Show help. |
106
+ | `-v, --version` | Print version. |
107
+
108
+ ### Behavior
109
+
110
+ - **Concurrent.** Every process starts at once; their stdout *and* stderr are
111
+ merged into one stream, each line tagged `name | …`. The name column is padded
112
+ to the longest process name so the `|` separators line up.
113
+ - **Clean shutdown.** Ctrl-C (SIGINT) — or SIGTERM — forwards SIGTERM to every
114
+ child's process group, waits briefly, then exits. A child that ignores SIGTERM
115
+ gets SIGKILL as a backstop.
116
+ - **One dies, all stop.** If any process exits on its own, the rest are shut down
117
+ too — that's the foreman/overmind contract for a dev stack.
118
+ - **Honest exit code.** `proctide` exits non-zero if any child exited non-zero,
119
+ so it behaves in CI and `Makefile`s.
120
+
121
+ ## Design notes
122
+
123
+ - **A pure core, an IO shell.** `parse_procfile`, `color_for`, and `prefix_line`
124
+ are pure functions — no spawn, no clock, no signals — which is what lets the
125
+ Python and Node ports be proven identical against one shared vector table. The
126
+ spawning/streaming/signal-forwarding lives in a separate runner module and is
127
+ covered by an integration smoke test instead (live output is nondeterministic).
128
+ - **Prefixes align by construction.** The name is padded to `width` *before* any
129
+ ANSI color is applied, so the escape codes never throw off the columns.
130
+ - **Zero dependencies.** Python uses `subprocess.Popen` + one reader thread per
131
+ process behind a shared lock so prefixed lines never interleave mid-line; Node
132
+ uses `child_process.spawn` + `readline`.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,109 @@
1
+ # proctide
2
+
3
+ **Run every process in your `Procfile` together, in one terminal.** Each output
4
+ line is prefixed with a colored process name so you can tell `web` from `worker`
5
+ from `css` at a glance — and a single Ctrl-C takes the whole lot down cleanly.
6
+
7
+ Zero dependencies (pure Python stdlib). No Ruby. No tmux.
8
+
9
+ ```bash
10
+ pipx run proctide
11
+ ```
12
+
13
+ ```
14
+ web | listening on http://localhost:3000
15
+ worker | polling jobs queue…
16
+ css | rebuilt app.css in 42ms
17
+ web | GET / 200 11ms
18
+ worker | processed job #1841
19
+ ^C
20
+ proctide: SIGINT received, shutting down…
21
+ web | exited (signal SIGTERM)
22
+ worker | exited (signal SIGTERM)
23
+ css | exited (signal SIGTERM)
24
+ ```
25
+
26
+ ## Why another Procfile runner?
27
+
28
+ [foreman](https://github.com/ddollar/foreman) is the classic, but it's a **Ruby
29
+ gem** — one more runtime to install on a Node/Python box.
30
+ [overmind](https://github.com/DarthSim/overmind) is great but needs **tmux**.
31
+ [`concurrently`](https://www.npmjs.com/package/concurrently) is excellent and
32
+ mature — but it's an npm dependency, and you spell your processes out on the
33
+ command line instead of in a checked-in `Procfile`.
34
+
35
+ `proctide` is the small middle ground: a real `Procfile`, prefixed interleaved
36
+ output, clean shutdown — and **nothing to install but the tool itself**, on
37
+ whichever of Python or Node you already have.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pipx run proctide # no install, run on demand
43
+ pip install proctide # or install the `proctide` command
44
+ ```
45
+
46
+ There's an identical Node build too: `npx proctide` / `npm i -g proctide`
47
+ (see [proctide](https://github.com/jjdoor/proctide)). Both ports parse the same
48
+ Procfile and print the same prefix format — their pure cores are tested against
49
+ the same vectors.
50
+
51
+ ## The Procfile
52
+
53
+ One process per line, `name: command`. Blank lines and `#` comments are ignored.
54
+ The command runs through your shell, so pipes, `&&`, env vars, and globs all work.
55
+
56
+ ```Procfile
57
+ # Procfile
58
+ web: python server.py
59
+ worker: python worker.py
60
+ css: npx tailwindcss -i app.css -o public/app.css --watch
61
+ ```
62
+
63
+ ```bash
64
+ proctide # runs ./Procfile
65
+ proctide -f Procfile.dev # or point somewhere else
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ ```bash
71
+ proctide [options]
72
+ ```
73
+
74
+ | Option | Description |
75
+ | --- | --- |
76
+ | `-f, --file <path>` | Procfile to read (default: `./Procfile`). |
77
+ | `--no-color` | Disable the colored name prefixes (e.g. when piping to a file). |
78
+ | `-h, --help` | Show help. |
79
+ | `-v, --version` | Print version. |
80
+
81
+ ### Behavior
82
+
83
+ - **Concurrent.** Every process starts at once; their stdout *and* stderr are
84
+ merged into one stream, each line tagged `name | …`. The name column is padded
85
+ to the longest process name so the `|` separators line up.
86
+ - **Clean shutdown.** Ctrl-C (SIGINT) — or SIGTERM — forwards SIGTERM to every
87
+ child's process group, waits briefly, then exits. A child that ignores SIGTERM
88
+ gets SIGKILL as a backstop.
89
+ - **One dies, all stop.** If any process exits on its own, the rest are shut down
90
+ too — that's the foreman/overmind contract for a dev stack.
91
+ - **Honest exit code.** `proctide` exits non-zero if any child exited non-zero,
92
+ so it behaves in CI and `Makefile`s.
93
+
94
+ ## Design notes
95
+
96
+ - **A pure core, an IO shell.** `parse_procfile`, `color_for`, and `prefix_line`
97
+ are pure functions — no spawn, no clock, no signals — which is what lets the
98
+ Python and Node ports be proven identical against one shared vector table. The
99
+ spawning/streaming/signal-forwarding lives in a separate runner module and is
100
+ covered by an integration smoke test instead (live output is nondeterministic).
101
+ - **Prefixes align by construction.** The name is padded to `width` *before* any
102
+ ANSI color is applied, so the escape codes never throw off the columns.
103
+ - **Zero dependencies.** Python uses `subprocess.Popen` + one reader thread per
104
+ process behind a shared lock so prefixed lines never interleave mid-line; Node
105
+ uses `child_process.spawn` + `readline`.
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "proctide"
7
+ version = "0.1.0"
8
+ description = "Run every process in your Procfile at once in one terminal, each output line prefixed with a colored process name; a clean Ctrl-C shuts them all down. Like foreman/overmind but zero-dependency and dual-runtime — no Ruby, no tmux."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["procfile", "foreman", "overmind", "process", "process-manager", "concurrent", "runner", "dev", "devtools", "cli", "monorepo"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: POSIX",
21
+ "Operating System :: MacOS",
22
+ "Operating System :: Unix",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Software Development :: Build Tools",
25
+ "Topic :: System :: Systems Administration",
26
+ "Topic :: Utilities",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/jjdoor/proctide-py"
32
+ Repository = "https://github.com/jjdoor/proctide-py"
33
+ Issues = "https://github.com/jjdoor/proctide-py/issues"
34
+
35
+ [project.scripts]
36
+ proctide = "proctide.cli:main"
37
+
38
+ [tool.setuptools]
39
+ package-dir = { "" = "src" }
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """proctide — run your Procfile's processes together, one colored prefix each."""
2
+
3
+ from .core import parse_procfile, color_for, prefix_line, PALETTE
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["parse_procfile", "color_for", "prefix_line", "PALETTE"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,108 @@
1
+ """proctide command-line interface — read a Procfile, hand it to the runner."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from . import core, runner
7
+
8
+ VERSION = "0.1.0"
9
+
10
+ _COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
11
+
12
+
13
+ def _c(code, s):
14
+ return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
15
+
16
+
17
+ def bold(s): return _c("1", s)
18
+ def dim(s): return _c("2", s)
19
+
20
+
21
+ HELP = f"""{bold('proctide')} — run your Procfile's processes together, one colored prefix each.
22
+
23
+ {bold('Usage')}
24
+ proctide [options] # runs ./Procfile
25
+ proctide -f Procfile.dev
26
+
27
+ proctide # web | … / worker | … / css | … interleaved
28
+ proctide --no-color | tee dev.log
29
+
30
+ {bold('Options')}
31
+ -f, --file <path> Procfile to read (default: ./Procfile)
32
+ --no-color disable colored name prefixes
33
+ -h, --help show this help
34
+ -v, --version print version
35
+
36
+ {bold('Procfile format')}
37
+ {dim('# one process per line: name: command')}
38
+ web: python server.py
39
+ worker: python worker.py
40
+ css: npx tailwindcss -i in.css -o out.css --watch
41
+
42
+ {bold('What it does')}
43
+ - spawns every process at once; each output line is prefixed {_c('36', 'web')} | … {_c('32', 'worker')} | …
44
+ - Ctrl-C sends SIGTERM to all children, waits, then exits
45
+ - exits non-zero if any process exited non-zero
46
+ - zero dependencies — like foreman, but no Ruby and no tmux
47
+ """
48
+
49
+
50
+ def die(msg):
51
+ sys.stderr.write(f"proctide: {msg}\n")
52
+ sys.exit(2)
53
+
54
+
55
+ def _value(argv, i):
56
+ if i + 1 >= len(argv):
57
+ die(f"{argv[i]} needs a value")
58
+ return argv[i + 1]
59
+
60
+
61
+ def parse_args(argv):
62
+ opts = {"file": "Procfile", "color": None}
63
+ i = 0
64
+ while i < len(argv):
65
+ a = argv[i]
66
+ if a in ("-f", "--file"):
67
+ opts["file"] = _value(argv, i); i += 2; continue
68
+ if a == "--color":
69
+ opts["color"] = True; i += 1; continue
70
+ if a == "--no-color":
71
+ opts["color"] = False; i += 1; continue
72
+ die(f'unknown option "{a}" (try --help)')
73
+ return opts
74
+
75
+
76
+ def main(argv=None):
77
+ argv = sys.argv[1:] if argv is None else argv
78
+ if "-h" in argv or "--help" in argv:
79
+ sys.stdout.write(HELP)
80
+ return 0
81
+ if "-v" in argv or "--version" in argv:
82
+ sys.stdout.write(VERSION + "\n")
83
+ return 0
84
+
85
+ opts = parse_args(argv)
86
+
87
+ if opts["color"] is None:
88
+ opts["color"] = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
89
+
90
+ try:
91
+ with open(opts["file"], "r", encoding="utf-8") as f:
92
+ text = f.read()
93
+ except FileNotFoundError:
94
+ die(f'no Procfile at "{os.path.abspath(opts["file"])}" (use -f to point elsewhere)')
95
+ except OSError as e:
96
+ die(f'could not read "{opts["file"]}": {e}')
97
+
98
+ entries, errors = core.parse_procfile(text)
99
+ for err in errors:
100
+ sys.stderr.write(dim(f'proctide: ignoring {opts["file"]}:{err["line"]} — {err["reason"]}') + "\n")
101
+ if not entries:
102
+ die(f'no runnable processes found in "{opts["file"]}"')
103
+
104
+ return runner.run(entries, color=opts["color"])
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ """proctide core — pure Procfile parsing & line-prefix formatting.
2
+
3
+ No spawn, no fs, no clock, no signals, no streams.
4
+
5
+ The whole tool is one idea: run every process in a Procfile at once in a single
6
+ terminal, and tag each line of output with a colored, padded process name so you
7
+ can tell ``web`` from ``worker`` from ``css`` at a glance — then a clean Ctrl-C
8
+ takes the whole lot down. foreman does this in Ruby; overmind needs tmux.
9
+ proctide is zero-dependency and ships for both Node and Python.
10
+
11
+ Everything in this file is a pure function of its arguments, so this port and
12
+ the Node one are checked against the exact same input -> output vectors
13
+ (tests/test_core.py and test/core.test.js share one VECTORS table). The live
14
+ runner — concurrent spawn, interleaved streams, signal forwarding — is
15
+ inherently nondeterministic and lives in runner.py, behavior-aligned but not
16
+ vector-tested.
17
+ """
18
+
19
+ # Deterministic ANSI foreground palette, cycled by process index. Picked to be
20
+ # readable on a dark terminal and distinct from each other. Codes are shared
21
+ # verbatim with the Node port. (No red — red reads as "error".)
22
+ PALETTE = ["36", "32", "33", "34", "35", "96", "92", "93"]
23
+
24
+
25
+ def parse_procfile(text):
26
+ """Parse Procfile text into an ordered list of ``{"name", "cmd"}`` dicts.
27
+
28
+ Format (foreman-compatible subset): one process per line, ``name: command``.
29
+ - blank lines and lines whose first non-space char is ``#`` are skipped
30
+ - the name is everything before the FIRST colon; the command is the rest,
31
+ so a command may freely contain extra colons (``sh -c 'date: now'``)
32
+ - name and command are both trimmed
33
+ - a line with no colon, an empty name, or an empty command is ignored
34
+ (it cannot be run) and recorded in the returned ``errors`` list
35
+
36
+ Returns ``(entries, errors)`` where ``entries`` is a list of dicts and
37
+ ``errors`` is a list of ``{"line", "text", "reason"}`` dicts. Mirrors the
38
+ Node port, whose array carries the same errors as an attached property.
39
+ """
40
+ entries = []
41
+ errors = []
42
+ lines = str(text).split("\n")
43
+ for i, raw in enumerate(lines):
44
+ # Match Node's /\r?\n/ split: drop a trailing CR if present.
45
+ if raw.endswith("\r"):
46
+ raw = raw[:-1]
47
+ line_no = i + 1
48
+ stripped = raw.strip()
49
+ if stripped == "" or stripped[0] == "#":
50
+ continue
51
+ colon = raw.find(":")
52
+ if colon == -1:
53
+ errors.append({"line": line_no, "text": stripped, "reason": 'no colon (expected "name: command")'})
54
+ continue
55
+ name = raw[:colon].strip()
56
+ cmd = raw[colon + 1:].strip()
57
+ if name == "":
58
+ errors.append({"line": line_no, "text": stripped, "reason": "empty process name"})
59
+ continue
60
+ if cmd == "":
61
+ errors.append({"line": line_no, "text": stripped, "reason": "empty command"})
62
+ continue
63
+ entries.append({"name": name, "cmd": cmd})
64
+ return entries, errors
65
+
66
+
67
+ def color_for(index):
68
+ """Deterministic ANSI color code for the Nth process, cycling the palette.
69
+
70
+ Negative indices wrap too (defensive; the runner only ever passes >= 0).
71
+ """
72
+ n = len(PALETTE)
73
+ i = (int(index) % n + n) % n
74
+ return PALETTE[i]
75
+
76
+
77
+ def prefix_line(name, line, width, color_code, use_color):
78
+ """Format one line of a child's output for our stdout::
79
+
80
+ web | listening on :3000
81
+
82
+ The name is right-padded to ``width`` (the longest process name, so the
83
+ ``|`` separators line up) and, when ``use_color``, wrapped in the given ANSI
84
+ code. Padding is added BEFORE coloring so the visible columns stay aligned
85
+ and the escape sequences never count toward width. Pure: no globals, no IO.
86
+ """
87
+ padded = str(name).ljust(width)
88
+ label = f"\x1b[{color_code}m{padded}\x1b[0m" if use_color else padded
89
+ return f"{label} | {line}"
@@ -0,0 +1,148 @@
1
+ """proctide runner — the IO half: spawn every process concurrently, prefix each
2
+ line of their interleaved stdout/stderr, forward signals, and report exits.
3
+
4
+ This module is deliberately NOT vector-tested: live interleaved process output is
5
+ nondeterministic. It is behavior-aligned with the Node port (same Procfile ->
6
+ same prefix format) and exercised by the integration smoke test instead. All the
7
+ deterministic string work lives in core.py.
8
+ """
9
+
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+
17
+ from . import core
18
+
19
+
20
+ def run(entries, color=False, out=None):
21
+ """Run a list of ``{"name", "cmd"}`` entries concurrently.
22
+
23
+ Returns the process exit code to use (non-zero if any child exited non-zero).
24
+ """
25
+ if out is None:
26
+ out = sys.stdout
27
+ if not entries:
28
+ return 0
29
+
30
+ width = max(len(e["name"]) for e in entries)
31
+ lock = threading.Lock() # serialize writes so prefixed lines never tear
32
+ state = {"worst": 0, "shutting_down": False}
33
+ procs = [] # list of (entry, color_code, Popen)
34
+ posix = os.name == "posix"
35
+
36
+ def write(s):
37
+ with lock:
38
+ out.write(s)
39
+ out.flush()
40
+
41
+ def dim(s):
42
+ return f"\x1b[2m{s}\x1b[0m" if color else s
43
+
44
+ def emit(name, color_code, line):
45
+ write(core.prefix_line(name, line, width, color_code, color) + "\n")
46
+
47
+ def signal_proc(proc, sig):
48
+ if proc.poll() is not None:
49
+ return
50
+ # Signal the whole process group (start_new_session=True made the child a
51
+ # group leader), so a `sh -c 'sleep 30'` takes its `sleep` grandchild
52
+ # down too instead of orphaning it. Fall back to the direct child.
53
+ try:
54
+ if posix:
55
+ os.killpg(proc.pid, sig)
56
+ else:
57
+ proc.send_signal(sig)
58
+ except (ProcessLookupError, OSError):
59
+ try:
60
+ proc.send_signal(sig)
61
+ except (ProcessLookupError, OSError):
62
+ pass
63
+
64
+ def shutdown(sig=signal.SIGTERM):
65
+ if state["shutting_down"]:
66
+ return
67
+ state["shutting_down"] = True
68
+ for _e, _c, p in procs:
69
+ signal_proc(p, sig)
70
+
71
+ def reader(entry, color_code, proc):
72
+ # One thread per process; bufsize=1 + text gives us line-at-a-time reads.
73
+ for line in proc.stdout:
74
+ emit(entry["name"], color_code, line.rstrip("\n"))
75
+ proc.wait()
76
+ code = proc.returncode
77
+ if code is not None and code < 0:
78
+ shown = f"signal {signal.Signals(-code).name}"
79
+ else:
80
+ shown = f"code {code}"
81
+ write(dim(core.prefix_line(entry["name"], f"exited ({shown})", width, color_code, False)) + "\n")
82
+ with lock:
83
+ if code and code > state["worst"]:
84
+ state["worst"] = code
85
+ # A process that *crashes* on its own (non-zero, or killed by a signal we
86
+ # didn't send) tears the rest down too — the foreman/overmind contract
87
+ # for a dev stack. But a clean exit (code 0) is just a one-shot step
88
+ # finishing; it must NOT kill the long-running siblings, and the reader
89
+ # above has already drained this child's pipe to EOF before we get here.
90
+ if not state["shutting_down"] and code not in (0, None):
91
+ shutdown(signal.SIGTERM)
92
+
93
+ # ---- spawn everything ----
94
+ threads = []
95
+ for idx, entry in enumerate(entries):
96
+ color_code = core.color_for(idx)
97
+ popen_kwargs = dict(
98
+ shell=True,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.STDOUT,
101
+ bufsize=1,
102
+ universal_newlines=True,
103
+ )
104
+ if posix:
105
+ popen_kwargs["start_new_session"] = True # own process group
106
+ try:
107
+ proc = subprocess.Popen(entry["cmd"], **popen_kwargs)
108
+ except OSError as e:
109
+ emit(entry["name"], color_code, f"proctide: failed to start: {e}")
110
+ with lock:
111
+ if state["worst"] == 0:
112
+ state["worst"] = 1
113
+ continue
114
+ procs.append((entry, color_code, proc))
115
+ t = threading.Thread(target=reader, args=(entry, color_code, proc), daemon=True)
116
+ threads.append(t)
117
+ t.start()
118
+
119
+ # ---- forward signals from the foreground ----
120
+ def handle(signum, _frame):
121
+ name = signal.Signals(signum).name
122
+ sys.stderr.write(dim(f"\nproctide: {name} received, shutting down…") + "\n")
123
+ sys.stderr.flush()
124
+ shutdown(signal.SIGTERM)
125
+
126
+ old_int = signal.signal(signal.SIGINT, handle)
127
+ old_term = signal.signal(signal.SIGTERM, handle)
128
+
129
+ try:
130
+ for t in threads:
131
+ t.join()
132
+ # Backstop: if anything is still alive (ignored SIGTERM), SIGKILL it.
133
+ deadline = time.time() + 5
134
+ for _e, _c, p in procs:
135
+ remaining = max(0.0, deadline - time.time())
136
+ try:
137
+ p.wait(timeout=remaining)
138
+ except subprocess.TimeoutExpired:
139
+ signal_proc(p, signal.SIGKILL)
140
+ try:
141
+ p.wait(timeout=2)
142
+ except subprocess.TimeoutExpired:
143
+ pass
144
+ finally:
145
+ signal.signal(signal.SIGINT, old_int)
146
+ signal.signal(signal.SIGTERM, old_term)
147
+
148
+ return state["worst"]
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: proctide
3
+ Version: 0.1.0
4
+ Summary: Run every process in your Procfile at once in one terminal, each output line prefixed with a colored process name; a clean Ctrl-C shuts them all down. Like foreman/overmind but zero-dependency and dual-runtime — no Ruby, no tmux.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/proctide-py
8
+ Project-URL: Repository, https://github.com/jjdoor/proctide-py
9
+ Project-URL: Issues, https://github.com/jjdoor/proctide-py/issues
10
+ Keywords: procfile,foreman,overmind,process,process-manager,concurrent,runner,dev,devtools,cli,monorepo
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 :: POSIX
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Unix
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # proctide
29
+
30
+ **Run every process in your `Procfile` together, in one terminal.** Each output
31
+ line is prefixed with a colored process name so you can tell `web` from `worker`
32
+ from `css` at a glance — and a single Ctrl-C takes the whole lot down cleanly.
33
+
34
+ Zero dependencies (pure Python stdlib). No Ruby. No tmux.
35
+
36
+ ```bash
37
+ pipx run proctide
38
+ ```
39
+
40
+ ```
41
+ web | listening on http://localhost:3000
42
+ worker | polling jobs queue…
43
+ css | rebuilt app.css in 42ms
44
+ web | GET / 200 11ms
45
+ worker | processed job #1841
46
+ ^C
47
+ proctide: SIGINT received, shutting down…
48
+ web | exited (signal SIGTERM)
49
+ worker | exited (signal SIGTERM)
50
+ css | exited (signal SIGTERM)
51
+ ```
52
+
53
+ ## Why another Procfile runner?
54
+
55
+ [foreman](https://github.com/ddollar/foreman) is the classic, but it's a **Ruby
56
+ gem** — one more runtime to install on a Node/Python box.
57
+ [overmind](https://github.com/DarthSim/overmind) is great but needs **tmux**.
58
+ [`concurrently`](https://www.npmjs.com/package/concurrently) is excellent and
59
+ mature — but it's an npm dependency, and you spell your processes out on the
60
+ command line instead of in a checked-in `Procfile`.
61
+
62
+ `proctide` is the small middle ground: a real `Procfile`, prefixed interleaved
63
+ output, clean shutdown — and **nothing to install but the tool itself**, on
64
+ whichever of Python or Node you already have.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pipx run proctide # no install, run on demand
70
+ pip install proctide # or install the `proctide` command
71
+ ```
72
+
73
+ There's an identical Node build too: `npx proctide` / `npm i -g proctide`
74
+ (see [proctide](https://github.com/jjdoor/proctide)). Both ports parse the same
75
+ Procfile and print the same prefix format — their pure cores are tested against
76
+ the same vectors.
77
+
78
+ ## The Procfile
79
+
80
+ One process per line, `name: command`. Blank lines and `#` comments are ignored.
81
+ The command runs through your shell, so pipes, `&&`, env vars, and globs all work.
82
+
83
+ ```Procfile
84
+ # Procfile
85
+ web: python server.py
86
+ worker: python worker.py
87
+ css: npx tailwindcss -i app.css -o public/app.css --watch
88
+ ```
89
+
90
+ ```bash
91
+ proctide # runs ./Procfile
92
+ proctide -f Procfile.dev # or point somewhere else
93
+ ```
94
+
95
+ ## Usage
96
+
97
+ ```bash
98
+ proctide [options]
99
+ ```
100
+
101
+ | Option | Description |
102
+ | --- | --- |
103
+ | `-f, --file <path>` | Procfile to read (default: `./Procfile`). |
104
+ | `--no-color` | Disable the colored name prefixes (e.g. when piping to a file). |
105
+ | `-h, --help` | Show help. |
106
+ | `-v, --version` | Print version. |
107
+
108
+ ### Behavior
109
+
110
+ - **Concurrent.** Every process starts at once; their stdout *and* stderr are
111
+ merged into one stream, each line tagged `name | …`. The name column is padded
112
+ to the longest process name so the `|` separators line up.
113
+ - **Clean shutdown.** Ctrl-C (SIGINT) — or SIGTERM — forwards SIGTERM to every
114
+ child's process group, waits briefly, then exits. A child that ignores SIGTERM
115
+ gets SIGKILL as a backstop.
116
+ - **One dies, all stop.** If any process exits on its own, the rest are shut down
117
+ too — that's the foreman/overmind contract for a dev stack.
118
+ - **Honest exit code.** `proctide` exits non-zero if any child exited non-zero,
119
+ so it behaves in CI and `Makefile`s.
120
+
121
+ ## Design notes
122
+
123
+ - **A pure core, an IO shell.** `parse_procfile`, `color_for`, and `prefix_line`
124
+ are pure functions — no spawn, no clock, no signals — which is what lets the
125
+ Python and Node ports be proven identical against one shared vector table. The
126
+ spawning/streaming/signal-forwarding lives in a separate runner module and is
127
+ covered by an integration smoke test instead (live output is nondeterministic).
128
+ - **Prefixes align by construction.** The name is padded to `width` *before* any
129
+ ANSI color is applied, so the escape codes never throw off the columns.
130
+ - **Zero dependencies.** Python uses `subprocess.Popen` + one reader thread per
131
+ process behind a shared lock so prefixed lines never interleave mid-line; Node
132
+ uses `child_process.spawn` + `readline`.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/proctide/__init__.py
5
+ src/proctide/__main__.py
6
+ src/proctide/cli.py
7
+ src/proctide/core.py
8
+ src/proctide/runner.py
9
+ src/proctide.egg-info/PKG-INFO
10
+ src/proctide.egg-info/SOURCES.txt
11
+ src/proctide.egg-info/dependency_links.txt
12
+ src/proctide.egg-info/entry_points.txt
13
+ src/proctide.egg-info/top_level.txt
14
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proctide = proctide.cli:main
@@ -0,0 +1 @@
1
+ proctide
@@ -0,0 +1,108 @@
1
+ import pytest
2
+
3
+ from proctide.core import parse_procfile, color_for, prefix_line, PALETTE
4
+
5
+ ESC = "\x1b"
6
+
7
+ # ===================================================================
8
+ # SHARED VECTORS. The Node port (test/core.test.js) runs the exact same
9
+ # three tables, so both languages are proven to agree byte-for-byte on
10
+ # the pure functions. (The live runner is nondeterministic and gets a
11
+ # separate integration smoke test, not vectors.)
12
+ # ===================================================================
13
+
14
+ # parse_procfile: text -> (entries, errors)
15
+ PARSE_VECTORS = [
16
+ (
17
+ "basic two processes",
18
+ "web: node server.js\nworker: node worker.js",
19
+ [{"name": "web", "cmd": "node server.js"}, {"name": "worker", "cmd": "node worker.js"}],
20
+ [],
21
+ ),
22
+ (
23
+ "skips blanks and # comments",
24
+ "# my Procfile\n\nweb: rackup\n\n # indented comment\njob: ./run\n",
25
+ [{"name": "web", "cmd": "rackup"}, {"name": "job", "cmd": "./run"}],
26
+ [],
27
+ ),
28
+ (
29
+ "trims name and command whitespace",
30
+ " web : node server.js ",
31
+ [{"name": "web", "cmd": "node server.js"}],
32
+ [],
33
+ ),
34
+ (
35
+ "command may contain extra colons",
36
+ "clock: sh -c 'echo time: now; sleep 1'",
37
+ [{"name": "clock", "cmd": "sh -c 'echo time: now; sleep 1'"}],
38
+ [],
39
+ ),
40
+ (
41
+ "line without a colon is an error",
42
+ "web: ok\nthis has no colon\njob: ok2",
43
+ [{"name": "web", "cmd": "ok"}, {"name": "job", "cmd": "ok2"}],
44
+ [{"line": 2, "text": "this has no colon", "reason": 'no colon (expected "name: command")'}],
45
+ ),
46
+ (
47
+ "empty name and empty command are errors",
48
+ ": nameless\nweb: \nworker: real",
49
+ [{"name": "worker", "cmd": "real"}],
50
+ [
51
+ {"line": 1, "text": ": nameless", "reason": "empty process name"},
52
+ {"line": 2, "text": "web:", "reason": "empty command"},
53
+ ],
54
+ ),
55
+ (
56
+ "empty input yields no entries",
57
+ "",
58
+ [],
59
+ [],
60
+ ),
61
+ ]
62
+
63
+ # color_for: index -> ANSI code (deterministic palette cycling).
64
+ COLOR_VECTORS = [
65
+ (0, "36"),
66
+ (1, "32"),
67
+ (2, "33"),
68
+ (3, "34"),
69
+ (4, "35"),
70
+ (5, "96"),
71
+ (6, "92"),
72
+ (7, "93"),
73
+ (8, "36"), # wraps
74
+ (9, "32"),
75
+ ]
76
+
77
+ # prefix_line: (name, line, width, color_code, use_color) -> string
78
+ PREFIX_VECTORS = [
79
+ ("pads name to width, no color", ("web", "listening on :3000", 6, "36", False), "web | listening on :3000"),
80
+ ("longer name, no padding needed", ("worker", "job done", 6, "32", False), "worker | job done"),
81
+ ("name longer than width is not truncated", ("database", "ready", 3, "33", False), "database | ready"),
82
+ ("color wraps the padded name only", ("web", "up", 6, "36", True), f"{ESC}[36mweb {ESC}[0m | up"),
83
+ ("empty line still gets a prefix", ("web", "", 3, "34", False), "web | "),
84
+ ("width zero pads nothing", ("x", "hi", 0, "35", False), "x | hi"),
85
+ ]
86
+
87
+
88
+ @pytest.mark.parametrize("name,raw,entries,errors", PARSE_VECTORS, ids=[v[0] for v in PARSE_VECTORS])
89
+ def test_parse_procfile(name, raw, entries, errors):
90
+ got_entries, got_errors = parse_procfile(raw)
91
+ assert got_entries == entries
92
+ assert got_errors == errors
93
+
94
+
95
+ @pytest.mark.parametrize("index,expected", COLOR_VECTORS, ids=[str(v[0]) for v in COLOR_VECTORS])
96
+ def test_color_for(index, expected):
97
+ assert color_for(index) == expected
98
+
99
+
100
+ @pytest.mark.parametrize("name,args,expected", PREFIX_VECTORS, ids=[v[0] for v in PREFIX_VECTORS])
101
+ def test_prefix_line(name, args, expected):
102
+ assert prefix_line(*args) == expected
103
+
104
+
105
+ def test_palette_has_no_red():
106
+ # red reads as "error"; keep it out of the process palette
107
+ assert "31" not in PALETTE
108
+ assert "91" not in PALETTE