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 +25 -0
- beam/__main__.py +6 -0
- beam/_files.py +137 -0
- beam/_protocol.py +162 -0
- beam/cli.py +100 -0
- beam/py.typed +0 -0
- beam/receiver.py +132 -0
- beam/sender.py +223 -0
- beam_lan-0.2.0.dist-info/METADATA +186 -0
- beam_lan-0.2.0.dist-info/RECORD +13 -0
- beam_lan-0.2.0.dist-info/WHEEL +4 -0
- beam_lan-0.2.0.dist-info/entry_points.txt +2 -0
- beam_lan-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|