shellphone 0.0.1__tar.gz → 0.0.2__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.
@@ -6,3 +6,4 @@ __pycache__/
6
6
  .access.env
7
7
  .venv/
8
8
  uv.lock
9
+ out/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellphone
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: A chat room for you and your coding agents, on your own computer.
5
5
  Project-URL: Homepage, https://github.com/woltspace/shellphone
6
6
  Project-URL: Issues, https://github.com/woltspace/shellphone/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shellphone"
3
- version = "0.0.1"
3
+ version = "0.0.2"
4
4
  description = "A chat room for you and your coding agents, on your own computer."
5
5
  readme = "shellphone/README.md"
6
6
  requires-python = ">=3.10"
@@ -793,7 +793,10 @@ def code_ref():
793
793
  """The commit this room runs, so strangers' agents fetch exactly this version (not a branch that can move)."""
794
794
  if os.environ.get("SHELLPHONE_REF"):
795
795
  return os.environ["SHELLPHONE_REF"]
796
- got = subprocess.run(["git", "-C", str(HERE), "rev-parse", "HEAD"], capture_output=True, text=True)
796
+ try:
797
+ got = subprocess.run(["git", "-C", str(HERE), "rev-parse", "HEAD"], capture_output=True, text=True)
798
+ except OSError: # no git on this computer
799
+ return "main"
797
800
  return got.stdout.strip() if got.returncode == 0 else "main"
798
801
 
799
802
 
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
 
12
12
  HERE = Path(__file__).resolve().parent
13
13
  PREFIX = "shellphone-room-"
14
+ THROWAWAY = "throwaway.pid" # only in a room made to be deleted: the pid of the command that owns it
14
15
  HOST_EMAIL = "host@shellphone.local" # the room keys people by email; a local room has one person, the host
15
16
  CONNECTOR = ("wait", "post", "history", "working", "run", "voice", "version")
16
17
 
@@ -48,10 +49,12 @@ def alive(pid):
48
49
 
49
50
 
50
51
  def sweep():
51
- """A room that crashed couldn't delete itself: remove what such rooms left behind. Rooms still open are left alone."""
52
+ """A throwaway room that crashed couldn't delete itself: remove what such rooms left behind. Only folders this
53
+ command made as throwaway carry the marker; a kept room never does, wherever it is and whatever it's called.
54
+ Rooms still open are left alone."""
52
55
  for d in Path(tempfile.gettempdir()).glob(PREFIX + "*"):
53
56
  try:
54
- pid = int((d / "host.pid").read_text())
57
+ pid = int((d / THROWAWAY).read_text())
55
58
  except (OSError, ValueError):
56
59
  continue # not clearly ours to delete
57
60
  if not alive(pid):
@@ -77,10 +80,12 @@ def room(a):
77
80
  os.chmod(data, 0o700)
78
81
  else:
79
82
  data = Path(tempfile.mkdtemp(prefix=PREFIX)) # mode 700
80
- (data / "host.pid").write_text(str(os.getpid()))
83
+ (data / THROWAWAY).write_text(str(os.getpid()))
81
84
  port = free_port(a.port)
82
85
  url = f"http://127.0.0.1:{port}"
83
86
  os.environ.update(SHELLPHONE_DATA=str(data), SHELLPHONE_URL=url) # read by room.py when it loads
87
+ if (HERE / "room.py").is_file(): # installed as a package: there's no checkout to ask git about (and maybe no git)
88
+ os.environ.setdefault("SHELLPHONE_REF", "main")
84
89
  r = part("room")
85
90
  with contextlib.redirect_stdout(io.StringIO()):
86
91
  r.add_person(HOST_EMAIL, a.name)
@@ -92,9 +97,15 @@ def room(a):
92
97
  server = r.http.server.ThreadingHTTPServer(("127.0.0.1", port), r.Handler)
93
98
  server.daemon_threads = True
94
99
 
95
- def stop(*_):
96
- raise KeyboardInterrupt
100
+ closing = []
101
+
102
+ def stop(*_): # the first stop signal closes the room; any that follow do nothing, so closing always finishes
103
+ if not closing:
104
+ closing.append(True)
105
+ raise KeyboardInterrupt
97
106
  signal.signal(signal.SIGTERM, stop)
107
+ if signal.getsignal(signal.SIGINT) is not signal.SIG_IGN: # started in the background, Ctrl-C isn't ours to take
108
+ signal.signal(signal.SIGINT, stop)
98
109
  print(f"""Shell Phone room is open, on this computer only.
99
110
 
100
111
  Open the chat: {url}/?k={token}
@@ -111,7 +122,6 @@ Press Ctrl-C to close the room. """ + (f"It's kept in {data}." if a.keep else "E
111
122
  if r.CON is not None:
112
123
  r.CON.close()
113
124
  if a.keep:
114
- (data / "host.pid").unlink(missing_ok=True)
115
125
  print(f"\nRoom closed. It's kept in {data}.")
116
126
  else:
117
127
  shutil.rmtree(data, ignore_errors=True)
@@ -1,25 +1,26 @@
1
1
  """The front door, end to end on one computer: open a room, log in with the link, bring an agent, talk, close.
2
2
 
3
- python3 -m unittest discover tests
3
+ python3 -m unittest discover tests # the code in this folder
4
+ SHELLPHONE_CLI=/some/venv/bin/shellphone python3 -m unittest discover tests # an installed package, e.g. a built wheel
4
5
  """
5
- import http.cookiejar, json, os, re, signal, subprocess, sys, tempfile, time, unittest, urllib.error, urllib.request
6
+ import http.cookiejar, json, os, re, shlex, signal, subprocess, sys, tempfile, time, unittest, urllib.error, urllib.request
6
7
  from pathlib import Path
7
8
 
8
9
  REPO = Path(__file__).resolve().parent.parent
9
10
  sys.path.insert(0, str(REPO))
10
- CLI = [sys.executable, "-m", "shellphone.cli"]
11
+ INSTALLED = os.environ.get("SHELLPHONE_CLI") # test this command (an installed package) instead of the code in this folder
12
+ CLI = shlex.split(INSTALLED) if INSTALLED else [sys.executable, "-m", "shellphone.cli"]
13
+ ENV = {**os.environ, "PYTHONUNBUFFERED": "1", **({} if INSTALLED else {"PYTHONPATH": str(REPO)})}
11
14
 
12
15
 
13
16
  def run(*args, cwd, stdin=None):
14
- return subprocess.run([*CLI, *args], cwd=cwd, input=stdin, capture_output=True, text=True, timeout=30,
15
- env={**os.environ, "PYTHONPATH": str(REPO)})
17
+ return subprocess.run([*CLI, *args], cwd=cwd, input=stdin, capture_output=True, text=True, timeout=30, env=ENV)
16
18
 
17
19
 
18
20
  class Room:
19
21
  """A room in its own process, as `shellphone room` starts it."""
20
- def __init__(self, *args):
21
- self.proc = subprocess.Popen([*CLI, "room", "--port", "0", *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
22
- env={**os.environ, "PYTHONPATH": str(REPO), "PYTHONUNBUFFERED": "1"})
22
+ def __init__(self, *args, env=None):
23
+ self.proc = subprocess.Popen([*CLI, "room", "--port", "0", *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env or ENV)
23
24
  self.banner = ""
24
25
  while "Press Ctrl-C" not in self.banner:
25
26
  line = self.proc.stdout.readline()
@@ -59,14 +60,14 @@ class FrontDoor(unittest.TestCase):
59
60
  r.proc.communicate()
60
61
  self.tmp.cleanup()
61
62
 
62
- def room(self, *args):
63
- self.rooms.append(Room(*args))
63
+ def room(self, *args, **kw):
64
+ self.rooms.append(Room(*args, **kw))
64
65
  return self.rooms[-1]
65
66
 
66
67
  def data_dir(self, room):
67
68
  """Where the room keeps its data, found from outside: the temp folder holding this room's pid."""
68
69
  for d in Path(tempfile.gettempdir()).glob("shellphone-room-*"):
69
- if (d / "host.pid").is_file() and (d / "host.pid").read_text() == str(room.proc.pid):
70
+ if (d / "throwaway.pid").is_file() and (d / "throwaway.pid").read_text() == str(room.proc.pid):
70
71
  return d
71
72
 
72
73
  def test_the_link_logs_the_host_in_and_nothing_else_does(self):
@@ -142,6 +143,11 @@ class FrontDoor(unittest.TestCase):
142
143
  self.assertNotEqual(got.returncode, 0, link)
143
144
  self.assertFalse((work / "shellphone").exists(), link)
144
145
 
146
+ def test_a_room_opens_on_a_computer_with_no_git(self):
147
+ r = self.room(env={**ENV, "PATH": self.tmp.name}) # nothing on the path at all
148
+ self.assertEqual(r.browser.open(r.open).status, 200)
149
+ self.assertIn("Room closed and deleted.", r.close())
150
+
145
151
  def test_closing_deletes_the_room(self):
146
152
  for sig in (signal.SIGINT, signal.SIGTERM):
147
153
  r = self.room()
@@ -151,6 +157,18 @@ class FrontDoor(unittest.TestCase):
151
157
  self.assertIn("Room closed and deleted.", r.close(sig))
152
158
  self.assertFalse(data.exists())
153
159
 
160
+ def test_a_second_stop_signal_cant_interrupt_the_closing(self):
161
+ for burst in ([signal.SIGINT] * 6, [signal.SIGTERM] * 6, [signal.SIGTERM, signal.SIGINT] * 3):
162
+ r = self.room()
163
+ r.browser.open(r.open)
164
+ data = self.data_dir(r)
165
+ for sig in burst: # a double Ctrl-C, or a stop sent to a whole process group
166
+ r.proc.send_signal(sig)
167
+ out = r.proc.communicate(timeout=10)[0]
168
+ self.assertIn("Room closed and deleted.", out)
169
+ self.assertNotIn("Traceback", out)
170
+ self.assertFalse(data.exists())
171
+
154
172
  def test_keep_keeps_the_room_and_changes_the_links(self):
155
173
  kept = Path(self.tmp.name) / "kept"
156
174
  r = self.room("--keep", str(kept))
@@ -166,6 +184,21 @@ class FrontDoor(unittest.TestCase):
166
184
  stale = again.url + "/#" + old.split("#")[1] # last run's agent link no longer opens the room
167
185
  self.assertNotEqual(run("join", stale, "--name", "helper", cwd=Path(self.tmp.name)).returncode, 0)
168
186
 
187
+ def test_a_kept_room_is_never_swept_even_when_it_looks_like_a_throwaway_one(self):
188
+ kept = Path(tempfile.mkdtemp(prefix="shellphone-room-saved-")) # the worst place and name to keep a room
189
+ self.addCleanup(__import__("shutil").rmtree, kept, True)
190
+ r = self.room("--keep", str(kept))
191
+ r.browser.open(r.open)
192
+ r.browser.open(urllib.request.Request(r.url + "/api/messages", data=json.dumps({"text": "keep me"}).encode(),
193
+ headers={"Content-Type": "application/json"}))
194
+ r.proc.kill() # a crash: nothing gets tidied
195
+ r.proc.communicate()
196
+ self.room() # another room starts and sweeps
197
+ self.assertTrue((kept / "room.db").is_file())
198
+ again = self.room("--keep", str(kept)) # and the kept room opens again with what was said
199
+ again.browser.open(again.open)
200
+ self.assertIn("keep me", [m.get("text") for m in again.state()["messages"]])
201
+
169
202
  def test_a_crashed_rooms_leftovers_are_swept_and_an_open_room_is_left_alone(self):
170
203
  open_room = self.room()
171
204
  crashed = self.room()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes