beam-lan 0.2.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.
beam/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """beam - move a project folder between two laptops over the local network.
2
+
3
+ import beam
4
+
5
+ beam.send_project("~/thesis", code="ab3f9c") # laptop A
6
+ beam.receive_project(code="ab3f9c") # laptop B
7
+ """
8
+
9
+ from ._files import DEFAULT_EXCLUDES
10
+ from ._protocol import DEFAULT_PORT, BeamError, find_sender, lan_ip
11
+ from .receiver import receive_project
12
+ from .sender import send_project
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ __all__ = [
17
+ "send_project",
18
+ "receive_project",
19
+ "find_sender",
20
+ "lan_ip",
21
+ "BeamError",
22
+ "DEFAULT_EXCLUDES",
23
+ "DEFAULT_PORT",
24
+ "__version__",
25
+ ]
beam/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m beam`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
beam/_files.py ADDED
@@ -0,0 +1,137 @@
1
+ """Walking the project tree, exclude rules, hashing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import os
8
+ import sys
9
+ import tarfile
10
+ from pathlib import Path
11
+
12
+ from ._protocol import MANIFEST_NAME
13
+
14
+ HASH_CHUNK = 1 << 20
15
+
16
+ # Things you almost never want to carry between machines: virtualenvs are
17
+ # platform-specific, caches are regenerable, .git is better cloned.
18
+ DEFAULT_EXCLUDES = [
19
+ ".git",
20
+ ".hg",
21
+ ".svn",
22
+ "__pycache__",
23
+ "*.pyc",
24
+ "*.pyo",
25
+ ".venv",
26
+ "venv",
27
+ "env",
28
+ ".env",
29
+ ".ipynb_checkpoints",
30
+ ".mypy_cache",
31
+ ".pytest_cache",
32
+ ".ruff_cache",
33
+ ".tox",
34
+ "*.egg-info",
35
+ "node_modules",
36
+ ".DS_Store",
37
+ "Thumbs.db",
38
+ MANIFEST_NAME,
39
+ ]
40
+
41
+ # tarfile only learned about extraction filters in 3.12.
42
+ EXTRACT_KW = {"filter": "data"} if sys.version_info >= (3, 12) else {}
43
+
44
+
45
+ def human(n: float) -> str:
46
+ for unit in ("B", "KB", "MB", "GB"):
47
+ if n < 1024 or unit == "GB":
48
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
49
+ n /= 1024
50
+ return f"{n:.1f} GB"
51
+
52
+
53
+ def excluded(rel: Path, patterns) -> bool:
54
+ text = rel.as_posix()
55
+ for pat in patterns:
56
+ pat = pat.rstrip("/")
57
+ if fnmatch.fnmatch(rel.name, pat):
58
+ return True
59
+ if any(fnmatch.fnmatch(part, pat) for part in rel.parts):
60
+ return True
61
+ if fnmatch.fnmatch(text, pat):
62
+ return True
63
+ return False
64
+
65
+
66
+ def read_ignore_file(root: Path) -> list:
67
+ """Read .beamignore if present. Simple glob patterns, one per line."""
68
+ path = root / ".beamignore"
69
+ if not path.is_file():
70
+ return []
71
+ out = []
72
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
73
+ line = line.strip()
74
+ if line and not line.startswith("#"):
75
+ out.append(line)
76
+ return out
77
+
78
+
79
+ def sha256_file(path: Path) -> str:
80
+ digest = hashlib.sha256()
81
+ with path.open("rb") as fh:
82
+ for block in iter(lambda: fh.read(HASH_CHUNK), b""):
83
+ digest.update(block)
84
+ return digest.hexdigest()
85
+
86
+
87
+ def walk(root: Path, patterns, max_bytes=None):
88
+ """Return ([(abs_path, rel_path, size)], [(rel_path, size)]) - kept, skipped."""
89
+ found, skipped = [], []
90
+ for dirpath, dirnames, filenames in os.walk(root):
91
+ here = Path(dirpath)
92
+ rel_dir = here.relative_to(root)
93
+ dirnames[:] = sorted(d for d in dirnames if not excluded(rel_dir / d, patterns))
94
+ for name in sorted(filenames):
95
+ rel = rel_dir / name
96
+ if excluded(rel, patterns):
97
+ continue
98
+ abs_path = here / name
99
+ if abs_path.is_symlink() and not abs_path.exists():
100
+ continue # broken symlink
101
+ try:
102
+ size = abs_path.stat().st_size
103
+ except OSError:
104
+ continue
105
+ if max_bytes is not None and size > max_bytes:
106
+ skipped.append((rel, size))
107
+ continue
108
+ found.append((abs_path, rel, size))
109
+ return found, skipped
110
+
111
+
112
+ def member_is_safe(name: str) -> bool:
113
+ path = Path(name)
114
+ return not path.is_absolute() and ".." not in path.parts
115
+
116
+
117
+ def safe_extract(tar: tarfile.TarFile, member: tarfile.TarInfo, dest: Path) -> None:
118
+ if not member_is_safe(member.name):
119
+ raise ValueError(f"unsafe path in stream: {member.name!r}")
120
+ if (member.islnk() or member.issym()) and not member_is_safe(member.linkname):
121
+ raise ValueError(f"unsafe link target in stream: {member.linkname!r}")
122
+ tar.extract(member, dest, **EXTRACT_KW)
123
+
124
+
125
+ class BytesReader:
126
+ """File-like wrapper so tar.addfile can consume an in-memory manifest."""
127
+
128
+ def __init__(self, data: bytes):
129
+ self.data = data
130
+ self.pos = 0
131
+
132
+ def read(self, n=-1) -> bytes:
133
+ if n is None or n < 0:
134
+ n = len(self.data) - self.pos
135
+ chunk = self.data[self.pos : self.pos + n]
136
+ self.pos += len(chunk)
137
+ return chunk
beam/_protocol.py ADDED
@@ -0,0 +1,162 @@
1
+ """Wire protocol: framing, errors, and network helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import socket
8
+ import struct
9
+ import threading
10
+ import time
11
+
12
+ PROTOCOL = 1
13
+ DEFAULT_PORT = 8765
14
+ DISCOVERY_PORT = 8766
15
+ MANIFEST_NAME = ".beam-manifest.json"
16
+ MAX_HEADER = 64 * 1024 * 1024
17
+
18
+
19
+ class BeamError(RuntimeError):
20
+ """The other side refused us, or the stream was malformed."""
21
+
22
+
23
+ def send_msg(sock: socket.socket, obj: dict) -> None:
24
+ raw = json.dumps(obj).encode("utf-8")
25
+ sock.sendall(struct.pack("!I", len(raw)) + raw)
26
+
27
+
28
+ def recv_exact(sock: socket.socket, n: int) -> bytes:
29
+ buf = bytearray()
30
+ while len(buf) < n:
31
+ chunk = sock.recv(n - len(buf))
32
+ if not chunk:
33
+ raise BeamError("connection closed mid-message")
34
+ buf += chunk
35
+ return bytes(buf)
36
+
37
+
38
+ def recv_msg(sock: socket.socket) -> dict:
39
+ (length,) = struct.unpack("!I", recv_exact(sock, 4))
40
+ if length > MAX_HEADER:
41
+ raise BeamError("refusing an absurdly large header")
42
+ return json.loads(recv_exact(sock, length).decode("utf-8"))
43
+
44
+
45
+ def lan_ip() -> str:
46
+ """Best guess at this machine's address on the local network."""
47
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
48
+ try:
49
+ sock.connect(("8.8.8.8", 80)) # no packets are actually sent
50
+ return sock.getsockname()[0]
51
+ except OSError:
52
+ return "127.0.0.1"
53
+ finally:
54
+ sock.close()
55
+
56
+
57
+ # --- discovery ---------------------------------------------------------------
58
+ #
59
+ # The receiver only knows the code. It broadcasts a UDP query carrying a hash
60
+ # of the code; the sender with that code answers with its TCP port, and the
61
+ # reply's source address tells the receiver where to connect.
62
+
63
+
64
+ def code_tag(code: str) -> str:
65
+ return hashlib.sha256(f"beam:{code}".encode()).hexdigest()[:16]
66
+
67
+
68
+ def _local_ipv4s() -> set[str]:
69
+ ips = {lan_ip()}
70
+ try:
71
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
72
+ ips.add(info[4][0])
73
+ except OSError:
74
+ pass
75
+ return {ip for ip in ips if not ip.startswith("127.")}
76
+
77
+
78
+ def _broadcast_targets() -> list[str]:
79
+ targets = ["255.255.255.255", "127.0.0.1"]
80
+ # Windows often sends 255.255.255.255 out of one adapter only, so also hit
81
+ # each adapter's subnet broadcast (assuming the usual home /24).
82
+ for ip in _local_ipv4s():
83
+ targets.append(ip.rsplit(".", 1)[0] + ".255")
84
+ return targets
85
+
86
+
87
+ class DiscoveryResponder:
88
+ """Answers discovery queries for one code until stopped."""
89
+
90
+ def __init__(
91
+ self, code: str, port: int, name: str, udp_port: int = DISCOVERY_PORT
92
+ ):
93
+ self.tag = code_tag(code)
94
+ self.reply = json.dumps(
95
+ {"beam": PROTOCOL, "id": self.tag, "port": port, "name": name}
96
+ ).encode("utf-8")
97
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
98
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
99
+ self.sock.bind(("", udp_port))
100
+ self.sock.settimeout(0.5)
101
+ self._stop = threading.Event()
102
+ self._thread = threading.Thread(target=self._run, daemon=True)
103
+
104
+ def start(self) -> DiscoveryResponder:
105
+ self._thread.start()
106
+ return self
107
+
108
+ def _run(self) -> None:
109
+ while not self._stop.is_set():
110
+ try:
111
+ data, addr = self.sock.recvfrom(2048)
112
+ query = json.loads(data.decode("utf-8"))
113
+ except (OSError, ValueError): # timeout, ICMP reset, junk
114
+ continue
115
+ if isinstance(query, dict) and query.get("find") == self.tag:
116
+ try:
117
+ self.sock.sendto(self.reply, addr)
118
+ except OSError:
119
+ pass
120
+
121
+ def stop(self) -> None:
122
+ self._stop.set()
123
+ self._thread.join(timeout=2)
124
+ self.sock.close()
125
+
126
+
127
+ def find_sender(
128
+ code: str, timeout: float = 15.0, udp_port: int = DISCOVERY_PORT
129
+ ) -> tuple[str, int, str]:
130
+ """Locate the sender holding ``code`` on the LAN. Returns (host, port, name)."""
131
+ tag = code_tag(code)
132
+ query = json.dumps({"beam": PROTOCOL, "find": tag}).encode("utf-8")
133
+ targets = _broadcast_targets()
134
+
135
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
136
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
137
+ sock.bind(("", 0))
138
+ sock.settimeout(0.3)
139
+ deadline = time.monotonic() + timeout
140
+ next_ping = 0.0
141
+ try:
142
+ while time.monotonic() < deadline:
143
+ if time.monotonic() >= next_ping:
144
+ for target in targets:
145
+ try:
146
+ sock.sendto(query, (target, udp_port))
147
+ except OSError:
148
+ pass
149
+ next_ping = time.monotonic() + 1.0
150
+ try:
151
+ data, addr = sock.recvfrom(2048)
152
+ reply = json.loads(data.decode("utf-8"))
153
+ except (OSError, ValueError):
154
+ continue
155
+ if isinstance(reply, dict) and reply.get("id") == tag:
156
+ return addr[0], int(reply["port"]), str(reply.get("name", ""))
157
+ finally:
158
+ sock.close()
159
+ raise BeamError(
160
+ f"no sender with code {code!r} found on this network after {timeout:.0f}s "
161
+ "(same Wi-Fi? firewall allowing Python? or pass --host IP)"
162
+ )
beam/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command line interface for beam."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from ._protocol import DEFAULT_PORT, BeamError
10
+ from .receiver import receive_project
11
+ from .sender import send_project
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="beam", description="Move a project folder between two laptops."
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"beam {__version__}")
19
+ sub = parser.add_subparsers(dest="cmd", required=True)
20
+
21
+ send = sub.add_parser("send", help="serve a folder to one receiver")
22
+ send.add_argument("path", help="project folder to send")
23
+ send.add_argument("--port", type=int, default=DEFAULT_PORT)
24
+ send.add_argument("--code", help="access code (random if omitted)")
25
+ send.add_argument(
26
+ "--exclude",
27
+ action="append",
28
+ default=[],
29
+ metavar="GLOB",
30
+ help="extra pattern to skip (repeatable)",
31
+ )
32
+ send.add_argument(
33
+ "--all",
34
+ action="store_true",
35
+ help="do not apply the default excludes (.git, venv, __pycache__, ...)",
36
+ )
37
+ send.add_argument(
38
+ "--max-size",
39
+ type=float,
40
+ metavar="MB",
41
+ help="skip files larger than this many MB",
42
+ )
43
+ send.add_argument(
44
+ "--no-hash",
45
+ action="store_true",
46
+ help="skip SHA-256 hashing (faster on huge datasets, size-check only)",
47
+ )
48
+ send.add_argument("--fast", action="store_true", help="compress less, send sooner")
49
+
50
+ recv = sub.add_parser("recv", help="pull a folder from a sender")
51
+ recv.add_argument("code", help="the code shown by the sender")
52
+ recv.add_argument("--host", help="sender's IP (or IP:port); skips auto-discovery")
53
+ recv.add_argument("--port", type=int, default=DEFAULT_PORT)
54
+ recv.add_argument(
55
+ "--wait",
56
+ type=float,
57
+ default=15,
58
+ metavar="SEC",
59
+ help="how long to search the network for the sender",
60
+ )
61
+ recv.add_argument("--out", default=".", help="where to put the folder")
62
+ recv.add_argument("--name", help="rename the folder on arrival")
63
+ recv.add_argument(
64
+ "--force", action="store_true", help="write into a non-empty folder"
65
+ )
66
+ recv.add_argument("--no-verify", action="store_true")
67
+ return parser
68
+
69
+
70
+ def main(argv=None) -> int:
71
+ args = build_parser().parse_args(argv)
72
+ try:
73
+ if args.cmd == "send":
74
+ send_project(
75
+ args.path,
76
+ port=args.port,
77
+ code=args.code,
78
+ excludes=[] if args.all else None,
79
+ extra_excludes=args.exclude,
80
+ hash_files=not args.no_hash,
81
+ max_size_mb=args.max_size,
82
+ compresslevel=1 if args.fast else 6,
83
+ )
84
+ else:
85
+ receive_project(
86
+ args.host,
87
+ port=args.port,
88
+ code=args.code,
89
+ wait=args.wait,
90
+ out=args.out,
91
+ name=args.name,
92
+ overwrite=args.force,
93
+ verify=not args.no_verify,
94
+ )
95
+ except (BeamError, OSError, NotADirectoryError) as exc:
96
+ print(f"\nbeam: {exc}", file=sys.stderr)
97
+ return 1
98
+ except KeyboardInterrupt:
99
+ return 130
100
+ return 0
beam/py.typed ADDED
File without changes
beam/receiver.py ADDED
@@ -0,0 +1,132 @@
1
+ """Pulling a project folder from a sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import sys
8
+ import tarfile
9
+ from pathlib import Path
10
+
11
+ from ._files import human, safe_extract, sha256_file
12
+ from ._protocol import (
13
+ DEFAULT_PORT,
14
+ MANIFEST_NAME,
15
+ PROTOCOL,
16
+ BeamError,
17
+ find_sender,
18
+ recv_msg,
19
+ send_msg,
20
+ )
21
+
22
+
23
+ def receive_project(
24
+ host: str | None = None,
25
+ port: int = DEFAULT_PORT,
26
+ code: str = "",
27
+ out=".",
28
+ name: str | None = None,
29
+ overwrite: bool = False,
30
+ verify: bool = True,
31
+ quiet: bool = False,
32
+ wait: float = 15.0,
33
+ ) -> Path:
34
+ """Pull a project from a sender. Returns the folder it was written to.
35
+
36
+ Leave ``host`` as None to find the sender on the LAN by its code.
37
+ """
38
+
39
+ def say(*args):
40
+ if not quiet:
41
+ print(*args, file=sys.stderr, flush=True)
42
+
43
+ if host is None:
44
+ if not code:
45
+ raise BeamError("need the code shown by the sender")
46
+ say(f"\n looking for the sender with code {code} ...")
47
+ host, port, _ = find_sender(code, timeout=wait)
48
+ say(f" found it at {host}:{port}")
49
+ elif ":" in host and host.count(":") == 1:
50
+ host, _, raw_port = host.partition(":")
51
+ port = int(raw_port)
52
+
53
+ sock = socket.create_connection((host, port), timeout=30)
54
+ try:
55
+ send_msg(sock, {"protocol": PROTOCOL, "code": code})
56
+ reply = recv_msg(sock)
57
+ if not reply.get("ok"):
58
+ raise BeamError(f"sender refused us: {reply.get('error', 'unknown')}")
59
+
60
+ total = reply.get("bytes", 0)
61
+ count = reply.get("count", 0)
62
+ dest = Path(out).expanduser().resolve() / (name or reply["name"])
63
+ if dest.exists() and any(dest.iterdir()) and not overwrite:
64
+ raise BeamError(
65
+ f"{dest} already exists and is not empty "
66
+ f"(pass overwrite=True or --force to write into it anyway)"
67
+ )
68
+ dest.mkdir(parents=True, exist_ok=True)
69
+
70
+ say(f"\n receiving {reply['name']} ({count} files, {human(total)})")
71
+ say(f" into {dest}")
72
+
73
+ sock.settimeout(None)
74
+ manifest = None
75
+ done = 0
76
+ stream = sock.makefile("rb")
77
+ with tarfile.open(fileobj=stream, mode="r|gz") as tar:
78
+ for member in tar:
79
+ try:
80
+ safe_extract(tar, member, dest)
81
+ except ValueError as exc:
82
+ raise BeamError(str(exc)) from None
83
+ if member.name == MANIFEST_NAME:
84
+ manifest = json.loads(
85
+ (dest / MANIFEST_NAME).read_text(encoding="utf-8")
86
+ )
87
+ (dest / MANIFEST_NAME).unlink()
88
+ continue
89
+ done += 1
90
+ if not quiet:
91
+ print(
92
+ f"\r {done}/{count or '?'} {member.name[:52]:<52}",
93
+ end="",
94
+ file=sys.stderr,
95
+ flush=True,
96
+ )
97
+ say("")
98
+
99
+ verified, mismatched = 0, []
100
+ if verify and manifest:
101
+ say(" verifying ...")
102
+ for rel, meta in manifest["files"].items():
103
+ target = dest / rel
104
+ if not target.is_file():
105
+ mismatched.append(f"{rel} (missing)")
106
+ elif meta["sha256"] is None:
107
+ if target.stat().st_size != meta["size"]:
108
+ mismatched.append(f"{rel} (size differs)")
109
+ else:
110
+ verified += 1
111
+ elif sha256_file(target) != meta["sha256"]:
112
+ mismatched.append(f"{rel} (checksum differs)")
113
+ else:
114
+ verified += 1
115
+
116
+ try:
117
+ send_msg(
118
+ sock, {"ok": True, "verified": verified, "mismatched": len(mismatched)}
119
+ )
120
+ except OSError:
121
+ pass
122
+
123
+ if mismatched:
124
+ say(f" {len(mismatched)} file(s) did not verify:")
125
+ for item in mismatched[:10]:
126
+ say(f" {item}")
127
+ raise BeamError("transfer completed with verification failures")
128
+
129
+ say(f" done - {verified or done} files in {dest}")
130
+ return dest
131
+ finally:
132
+ sock.close()
beam/sender.py ADDED
@@ -0,0 +1,223 @@
1
+ """Serving a project folder to one receiver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import json
7
+ import secrets
8
+ import socket
9
+ import sys
10
+ import tarfile
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from ._files import (
15
+ DEFAULT_EXCLUDES,
16
+ BytesReader,
17
+ human,
18
+ read_ignore_file,
19
+ sha256_file,
20
+ walk,
21
+ )
22
+ from ._protocol import (
23
+ DEFAULT_PORT,
24
+ DISCOVERY_PORT,
25
+ MANIFEST_NAME,
26
+ PROTOCOL,
27
+ BeamError,
28
+ DiscoveryResponder,
29
+ lan_ip,
30
+ recv_msg,
31
+ send_msg,
32
+ )
33
+
34
+
35
+ class SocketWriter:
36
+ """Minimal write-only file object so tarfile can stream into a socket."""
37
+
38
+ def __init__(self, sock: socket.socket):
39
+ self.sock = sock
40
+
41
+ def write(self, data: bytes) -> int:
42
+ self.sock.sendall(data)
43
+ return len(data)
44
+
45
+ def flush(self) -> None:
46
+ pass
47
+
48
+
49
+ def send_project(
50
+ path,
51
+ port: int = DEFAULT_PORT,
52
+ code: str | None = None,
53
+ excludes=None,
54
+ extra_excludes=(),
55
+ hash_files: bool = True,
56
+ max_size_mb: float | None = None,
57
+ compresslevel: int = 6,
58
+ quiet: bool = False,
59
+ on_ready=None,
60
+ discoverable: bool = True,
61
+ discovery_port: int = DISCOVERY_PORT,
62
+ ) -> str:
63
+ """Serve one folder to one receiver, then stop.
64
+
65
+ Pass ``port=0`` to bind an ephemeral port; ``on_ready`` is then called with
66
+ ``{"host", "port", "code", "files", "bytes"}`` once the socket is listening.
67
+ With ``discoverable`` the sender answers LAN broadcasts, so the receiver
68
+ only needs the code.
69
+ Returns the access code.
70
+ """
71
+ root = Path(path).expanduser().resolve()
72
+ if not root.is_dir():
73
+ raise NotADirectoryError(f"{root} is not a directory")
74
+
75
+ patterns = list(DEFAULT_EXCLUDES if excludes is None else excludes)
76
+ patterns += list(extra_excludes)
77
+ patterns += read_ignore_file(root)
78
+
79
+ max_bytes = int(max_size_mb * 1024 * 1024) if max_size_mb else None
80
+ files, skipped = walk(root, patterns, max_bytes)
81
+ if not files:
82
+ raise BeamError(f"nothing to send from {root} after applying excludes")
83
+
84
+ total = sum(size for _, _, size in files)
85
+ code = code or secrets.token_hex(3)
86
+
87
+ def say(*args):
88
+ if not quiet:
89
+ print(*args, file=sys.stderr, flush=True)
90
+
91
+ say(f"\n project : {root.name} ({len(files)} files, {human(total)})")
92
+ biggest = sorted(files, key=lambda f: -f[2])[:3]
93
+ if biggest and biggest[0][2] > 10 * 1024 * 1024:
94
+ say(" largest : " + ", ".join(f"{r} ({human(s)})" for _, r, s in biggest))
95
+ for rel, size in skipped:
96
+ say(f" skipped : {rel} ({human(size)}, over --max-size)")
97
+
98
+ manifest = {"name": root.name, "protocol": PROTOCOL, "files": {}}
99
+ if hash_files:
100
+ say(" hashing ...")
101
+ for abs_path, rel, size in files:
102
+ manifest["files"][rel.as_posix()] = {
103
+ "size": size,
104
+ "sha256": sha256_file(abs_path),
105
+ }
106
+ else:
107
+ for _, rel, size in files:
108
+ manifest["files"][rel.as_posix()] = {"size": size, "sha256": None}
109
+
110
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
111
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
112
+ server.bind(("0.0.0.0", port))
113
+ server.listen(1)
114
+ bound_port = server.getsockname()[1]
115
+
116
+ responder = None
117
+ if discoverable:
118
+ try:
119
+ responder = DiscoveryResponder(
120
+ code, bound_port, root.name, discovery_port
121
+ ).start()
122
+ except OSError as exc:
123
+ say(f" (auto-discovery unavailable: {exc}; receiver needs --host)")
124
+
125
+ if on_ready is not None:
126
+ on_ready(
127
+ {
128
+ "host": lan_ip(),
129
+ "port": bound_port,
130
+ "code": code,
131
+ "files": len(files),
132
+ "bytes": total,
133
+ }
134
+ )
135
+
136
+ say(f"\n code : {code}")
137
+ say("\n on the other laptop, run:\n")
138
+ if responder is not None:
139
+ say(f" beam recv {code}\n")
140
+ say(f" (if it is not found: --host {lan_ip()}:{bound_port})\n")
141
+ else:
142
+ say(f" beam recv {code} --host {lan_ip()}:{bound_port}\n")
143
+ say(" waiting (Ctrl-C to cancel) ...")
144
+
145
+ try:
146
+ while True:
147
+ conn, addr = server.accept()
148
+ try:
149
+ conn.settimeout(30)
150
+ hello = recv_msg(conn)
151
+ if hello.get("protocol") != PROTOCOL:
152
+ send_msg(conn, {"ok": False, "error": "protocol mismatch"})
153
+ continue
154
+ if not secrets.compare_digest(str(hello.get("code", "")), code):
155
+ send_msg(conn, {"ok": False, "error": "bad code"})
156
+ say(f" refused {addr[0]} (wrong code)")
157
+ continue
158
+
159
+ say(f" sending to {addr[0]} ...")
160
+ send_msg(
161
+ conn,
162
+ {
163
+ "ok": True,
164
+ "name": root.name,
165
+ "count": len(files),
166
+ "bytes": total,
167
+ "hashed": hash_files,
168
+ },
169
+ )
170
+
171
+ conn.settimeout(None)
172
+ _stream(conn, manifest, files, total, compresslevel, quiet)
173
+
174
+ conn.shutdown(socket.SHUT_WR)
175
+ conn.settimeout(600)
176
+ try:
177
+ ack = recv_msg(conn)
178
+ bad = ack.get("mismatched", 0)
179
+ if bad:
180
+ say(f" done, but {bad} file(s) failed verification")
181
+ else:
182
+ say(f" done - {ack.get('verified', len(files))} files landed")
183
+ except (BeamError, OSError):
184
+ say(" stream finished (no confirmation from the receiver)")
185
+ return code
186
+ finally:
187
+ conn.close()
188
+ except KeyboardInterrupt:
189
+ say("\n cancelled")
190
+ return code
191
+ finally:
192
+ if responder is not None:
193
+ responder.stop()
194
+ server.close()
195
+
196
+
197
+ def _stream(conn, manifest, files, total, compresslevel, quiet):
198
+ sent = 0
199
+ writer = SocketWriter(conn)
200
+ # tarfile's "w|gz" only accepts compresslevel from 3.12, so gzip ourselves.
201
+ with gzip.GzipFile(
202
+ fileobj=writer, mode="wb", compresslevel=compresslevel, mtime=0
203
+ ) as gz, tarfile.open(fileobj=gz, mode="w|") as tar:
204
+ blob = json.dumps(manifest).encode("utf-8")
205
+ info = tarfile.TarInfo(MANIFEST_NAME)
206
+ info.size = len(blob)
207
+ info.mtime = int(time.time())
208
+ tar.addfile(info, BytesReader(blob))
209
+
210
+ for abs_path, rel, size in files:
211
+ tar.add(abs_path, arcname=rel.as_posix(), recursive=False)
212
+ sent += size
213
+ if not quiet:
214
+ pct = 100 * sent / total if total else 100
215
+ print(
216
+ f"\r {pct:5.1f}% {human(sent)} / {human(total)}"
217
+ f" {str(rel)[:48]:<48}",
218
+ end="",
219
+ file=sys.stderr,
220
+ flush=True,
221
+ )
222
+ if not quiet:
223
+ print("", file=sys.stderr, flush=True)
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.5
2
+ Name: beam-lan
3
+ Version: 0.2.0
4
+ Summary: Move a project folder from one laptop to another over the local network.
5
+ Project-URL: Homepage, https://github.com/NYLONXD/beam
6
+ Project-URL: Source, https://github.com/NYLONXD/beam
7
+ Project-URL: Issues, https://github.com/NYLONXD/beam/issues
8
+ Project-URL: Changelog, https://github.com/NYLONXD/beam/blob/main/CHANGELOG.md
9
+ Author-email: Nylonxd <Himanshujha202005@gmail.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Nylonxd
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: cli,file-transfer,lan,local-network,project,sync,tar
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Environment :: Console
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3 :: Only
40
+ Classifier: Programming Language :: Python :: 3.9
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Programming Language :: Python :: 3.14
46
+ Classifier: Topic :: Communications :: File Sharing
47
+ Classifier: Topic :: System :: Archiving
48
+ Classifier: Typing :: Typed
49
+ Requires-Python: >=3.9
50
+ Provides-Extra: dev
51
+ Requires-Dist: build; extra == 'dev'
52
+ Requires-Dist: pytest>=7; extra == 'dev'
53
+ Requires-Dist: ruff>=0.4; extra == 'dev'
54
+ Requires-Dist: twine; extra == 'dev'
55
+ Description-Content-Type: text/markdown
56
+
57
+ # beam
58
+
59
+ Move a project folder from one laptop to another over the local network.
60
+ Pure Python standard library, no runtime dependencies, works on Windows,
61
+ macOS and Linux (Python 3.9+).
62
+
63
+ ```bash
64
+ # laptop A
65
+ beam send ~/thesis
66
+
67
+ # laptop B
68
+ beam recv ab3f9c
69
+ ```
70
+
71
+ ## Install
72
+
73
+ Both laptops need it:
74
+
75
+ ```bash
76
+ pip install beam-lan
77
+ ```
78
+
79
+ `pipx install beam-lan` also works and keeps it out of your other environments.
80
+
81
+ If your shell says `beam` is not a recognised command, pip put the script in a
82
+ folder that is not on your `PATH` (pip prints a warning naming it). Either add
83
+ that folder to `PATH`, or run `python -m beam` instead of `beam`.
84
+
85
+ ## Use it
86
+
87
+ On the laptop that has the project:
88
+
89
+ ```bash
90
+ beam send ~/thesis
91
+ ```
92
+
93
+ It prints a short one-time code. Send it to the other laptop however you like
94
+ (chat, email, read it out), then run there:
95
+
96
+ ```bash
97
+ beam recv ab3f9c
98
+ ```
99
+
100
+ The receiver finds the sender on the local network by itself, so no IP address
101
+ is needed. Both machines must be on the same network.
102
+
103
+ ## As a library
104
+
105
+ ```python
106
+ import beam
107
+
108
+ beam.send_project("~/thesis", code="ab3f9c") # laptop A
109
+ beam.receive_project(code="ab3f9c", out="~/work") # laptop B
110
+ ```
111
+
112
+ `receive_project` also takes `host=` to skip discovery. `send_project` accepts
113
+ `port=0` to bind a free port, and an `on_ready` callback that receives the bound
114
+ host, port and code once it is listening. `beam.find_sender(code)` returns the
115
+ `(host, port, name)` of a sender without transferring anything.
116
+
117
+ ## What it does
118
+
119
+ - Skips `.venv`, `__pycache__`, `.git`, `node_modules` and similar by default.
120
+ Copying a virtualenv between machines mostly produces broken paths; rebuild
121
+ it from `requirements.txt` instead.
122
+ - Hashes every file before sending and re-hashes after extraction, so
123
+ corruption is reported instead of discovered weeks later.
124
+ - Streams a gzipped tar straight through the socket, so no temporary archive
125
+ is written on either side.
126
+ - A `.beamignore` file in the project root adds per-project glob patterns, one
127
+ per line (`#` starts a comment).
128
+
129
+ ## Options
130
+
131
+ `beam send PATH`
132
+
133
+ | Flag | Effect |
134
+ | --- | --- |
135
+ | `--code CODE` | choose the code instead of a random one |
136
+ | `--port PORT` | TCP port to listen on (default 8765) |
137
+ | `--exclude GLOB` | extra pattern to skip, repeatable |
138
+ | `--all` | ignore the default exclude list |
139
+ | `--max-size MB` | skip files larger than this |
140
+ | `--no-hash` | size-check only, faster on large datasets |
141
+ | `--fast` | lower compression, starts sooner |
142
+
143
+ `beam recv CODE`
144
+
145
+ | Flag | Effect |
146
+ | --- | --- |
147
+ | `--host IP[:PORT]` | connect directly instead of searching the network |
148
+ | `--wait SEC` | how long to search for the sender (default 15) |
149
+ | `--out DIR` | where to write the folder (default: current directory) |
150
+ | `--name NAME` | rename the folder on arrival |
151
+ | `--force` | write into a non-empty destination |
152
+ | `--no-verify` | skip the checksum pass after extraction |
153
+
154
+ ## Troubleshooting
155
+
156
+ **`no sender with code ... found`**
157
+
158
+ - Check both laptops are on the same Wi-Fi or wired network. Guest and office
159
+ networks often isolate devices from each other or drop broadcasts.
160
+ - On Windows, allow Python through the firewall when it asks on the sending
161
+ laptop. If you dismissed the prompt, allow `python.exe` for private networks
162
+ in *Windows Defender Firewall → Allow an app*.
163
+ - Skip discovery and connect directly using the address the sender prints:
164
+ `beam recv ab3f9c --host 192.168.1.42:8765`.
165
+
166
+ Discovery uses UDP port 8766 and the transfer uses TCP port 8765.
167
+
168
+ ## Limits
169
+
170
+ The connection is plain TCP with no encryption. The code stops casual grabs on
171
+ a shared network; it is not security. Do not use it on networks you do not
172
+ trust. For repeated syncing rather than a one-time move, `rsync -av
173
+ --exclude=.venv` or a private Git remote is a better fit.
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ pip install -e ".[dev]"
179
+ pytest
180
+ ruff check .
181
+ python -m build
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,13 @@
1
+ beam/__init__.py,sha256=AdrY0aofFucPBQu3CQJUF2OeK0tbhe8qu5KoGw_My4k,604
2
+ beam/__main__.py,sha256=sb1IUiXLoJIijr7a5yvn-WneuOFJwMabUYWdRZ_w01k,110
3
+ beam/_files.py,sha256=lOCCSumTWKvZOI6mwL8LDsPclc8z1HMJy4RTauDbRlY,3971
4
+ beam/_protocol.py,sha256=tcTb6MBabOqlfjj0vryuYheHg3R5eKcd_rJaaEmUcH4,5291
5
+ beam/cli.py,sha256=CRaqKSnXt2hzB1nuJZU9Ue8rqgu_t3iK1Y9kollJSt0,3333
6
+ beam/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ beam/receiver.py,sha256=91AVHZFY4iYgFgMsFXoNq9e5eBkjZ_q0y9_mIVFYs3Q,4298
8
+ beam/sender.py,sha256=rE4Mazt5tIEzdlw4VWwEe8pQJ8T6KzKGZnhtpCYa0PA,7309
9
+ beam_lan-0.2.0.dist-info/METADATA,sha256=7VG0W-fg4CSecoSVkTvxqndMj45TilEEUNfVutR2y8s,6647
10
+ beam_lan-0.2.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
11
+ beam_lan-0.2.0.dist-info/entry_points.txt,sha256=NirM3NEKoQGPNnhFejnt55vLIWaML_j3jeFRiKxnPZs,39
12
+ beam_lan-0.2.0.dist-info/licenses/LICENSE,sha256=3A8WSslFz-PVcI-VtCHlpm609451pJkCN_YUIpiBQSA,1085
13
+ beam_lan-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ beam = beam.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nylonxd
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.