beam-lan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
beam/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """beam - zip a project and send it to another laptop, anywhere.
2
+
3
+ import beam
4
+
5
+ code = beam.send("D:/projects/my_app", exclude=[".mp4", ".log"]) # laptop A
6
+ beam.receive(code) # laptop B
7
+
8
+ beam.send("D:/projects/my_app", lan=True) # direct, same Wi-Fi only
9
+ beam.pack("D:/projects/my_app") # only make my_app.zip
10
+
11
+ The zip carries a start.bat that installs what the project needs and runs it.
12
+ """
13
+
14
+ __version__ = "0.1.0" # before the imports: _cloud reads it for its User-Agent
15
+
16
+ from ._files import DEFAULT_EXCLUDES # noqa: E402
17
+ from ._protocol import DEFAULT_PORT, BeamError, find_sender, lan_ip # noqa: E402
18
+ from .packer import pack # noqa: E402
19
+ from .transfer import receive, send # noqa: E402
20
+
21
+ __all__ = [
22
+ "send",
23
+ "receive",
24
+ "pack",
25
+ "find_sender",
26
+ "lan_ip",
27
+ "BeamError",
28
+ "DEFAULT_EXCLUDES",
29
+ "DEFAULT_PORT",
30
+ "__version__",
31
+ ]
beam/_cloud.py ADDED
@@ -0,0 +1,187 @@
1
+ """Free temporary file hosts that carry the encrypted zip between laptops.
2
+
3
+ Each host is tried in order. The code starts with the host's letter, so the
4
+ receiver knows where to download from.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ import urllib.error
13
+ import urllib.request
14
+
15
+ from . import __version__
16
+ from ._protocol import BeamError
17
+
18
+ USER_AGENT = f"beam-lan/{__version__} (+https://pypi.org/project/beam-lan/)"
19
+ UPLOAD_NAME = "beam.bin"
20
+ BLOCK = 1 << 16
21
+
22
+
23
+ class _Host:
24
+ letter = ""
25
+ label = ""
26
+ max_bytes = 0
27
+ keeps = ""
28
+
29
+ def upload(self, path, progress) -> str:
30
+ """Upload ``path`` and return the host's id for it."""
31
+ raise NotImplementedError
32
+
33
+ def download_request(self, file_id) -> urllib.request.Request:
34
+ raise NotImplementedError
35
+
36
+
37
+ class TempSh(_Host):
38
+ letter, label, max_bytes, keeps = "t", "temp.sh", 4 * 1024**3, "3 days"
39
+
40
+ def upload(self, path, progress):
41
+ url = _post_file("https://temp.sh/upload", "file", path, progress).strip()
42
+ # https://temp.sh/<id>/beam.bin
43
+ parts = url.rstrip("/").split("/")
44
+ if len(parts) < 2 or not url.startswith("https://temp.sh/"):
45
+ raise BeamError(f"temp.sh gave an unexpected reply: {url[:100]!r}")
46
+ return parts[-2]
47
+
48
+ def download_request(self, file_id):
49
+ # temp.sh serves the file itself only to a POST; a GET returns a page.
50
+ return urllib.request.Request(
51
+ f"https://temp.sh/{file_id}/{UPLOAD_NAME}", data=b"", method="POST"
52
+ )
53
+
54
+
55
+ class Uguu(_Host):
56
+ letter, label, max_bytes, keeps = "u", "uguu.se", 128 * 1024**2, "3 hours"
57
+
58
+ def upload(self, path, progress):
59
+ reply = _post_file("https://uguu.se/upload", "files[]", path, progress)
60
+ try:
61
+ url = json.loads(reply)["files"][0]["url"]
62
+ except (ValueError, KeyError, IndexError, TypeError):
63
+ raise BeamError(
64
+ f"uguu.se gave an unexpected reply: {reply[:100]!r}"
65
+ ) from None
66
+ # https://<server>.uguu.se/<name>.bin - the server letter varies
67
+ prefix = "https://"
68
+ server, _, name = url[len(prefix) :].partition(".uguu.se/")
69
+ if not url.startswith(prefix) or not name.endswith(".bin") or "/" in name:
70
+ raise BeamError(f"uguu.se gave an unexpected reply: {url[:100]!r}")
71
+ return f"{server}.{name[: -len('.bin')]}"
72
+
73
+ def download_request(self, file_id):
74
+ server, _, name = file_id.partition(".")
75
+ return urllib.request.Request(f"https://{server}.uguu.se/{name}.bin")
76
+
77
+
78
+ HOSTS = [TempSh(), Uguu()]
79
+ BY_LETTER = {host.letter: host for host in HOSTS}
80
+
81
+
82
+ class _MultipartBody:
83
+ """File-like multipart/form-data body, streamed from disk with progress."""
84
+
85
+ def __init__(self, field, path, progress):
86
+ self.boundary = "beam" + secrets.token_hex(16)
87
+ self.head = (
88
+ f"--{self.boundary}\r\n"
89
+ f'Content-Disposition: form-data; name="{field}"; '
90
+ f'filename="{UPLOAD_NAME}"\r\n'
91
+ "Content-Type: application/octet-stream\r\n\r\n"
92
+ ).encode()
93
+ self.tail = f"\r\n--{self.boundary}--\r\n".encode()
94
+ self.size = os.path.getsize(path)
95
+ self.length = len(self.head) + self.size + len(self.tail)
96
+ self.fh = open(path, "rb")
97
+ self.stage = 0
98
+ self.sent = 0
99
+ self.progress = progress
100
+
101
+ def read(self, n=-1):
102
+ if self.stage == 0:
103
+ self.stage = 1
104
+ return self.head
105
+ if self.stage == 1:
106
+ data = self.fh.read(BLOCK if n is None or n < 0 else n)
107
+ if data:
108
+ self.sent += len(data)
109
+ if self.progress:
110
+ self.progress(self.sent, self.size)
111
+ return data
112
+ self.stage = 2
113
+ return self.tail
114
+ return b""
115
+
116
+ def close(self):
117
+ self.fh.close()
118
+
119
+
120
+ def _post_file(url, field, path, progress) -> str:
121
+ body = _MultipartBody(field, path, progress)
122
+ request = urllib.request.Request(
123
+ url,
124
+ data=body,
125
+ method="POST",
126
+ headers={
127
+ "Content-Type": f"multipart/form-data; boundary={body.boundary}",
128
+ "Content-Length": str(body.length),
129
+ "User-Agent": USER_AGENT,
130
+ },
131
+ )
132
+ try:
133
+ with urllib.request.urlopen(request, timeout=120) as resp:
134
+ return resp.read(1 << 16).decode("utf-8", "replace")
135
+ finally:
136
+ body.close()
137
+
138
+
139
+ def upload(path, progress=None, say=None) -> tuple[str, str, str]:
140
+ """Upload to the first host that takes it. Returns (letter, file_id, keeps)."""
141
+ size = os.path.getsize(path)
142
+ errors = []
143
+ for host in HOSTS:
144
+ if size > host.max_bytes:
145
+ errors.append(f"{host.label}: file is over its size limit")
146
+ continue
147
+ if say:
148
+ say(f" uploading to {host.label} ...")
149
+ try:
150
+ return host.letter, host.upload(path, progress), host.keeps
151
+ except (OSError, BeamError) as exc: # URLError is an OSError
152
+ errors.append(f"{host.label}: {exc}")
153
+ if say:
154
+ say(f"\n {host.label} failed ({exc}), trying the next one")
155
+ raise BeamError("upload failed on every host:\n " + "\n ".join(errors))
156
+
157
+
158
+ def download(letter, file_id, dst, progress=None) -> None:
159
+ host = BY_LETTER.get(letter)
160
+ if host is None:
161
+ raise BeamError(
162
+ "this code is not valid (unknown host); check it was copied fully"
163
+ )
164
+ request = host.download_request(file_id)
165
+ request.add_header("User-Agent", USER_AGENT)
166
+ try:
167
+ with urllib.request.urlopen(request, timeout=120) as resp:
168
+ total = int(resp.headers.get("Content-Length") or 0)
169
+ done = 0
170
+ with open(dst, "wb") as fh:
171
+ for block in iter(lambda: resp.read(BLOCK), b""):
172
+ fh.write(block)
173
+ done += len(block)
174
+ if progress:
175
+ progress(done, total)
176
+ except urllib.error.HTTPError as exc:
177
+ if exc.code in (404, 410):
178
+ raise BeamError(
179
+ f"nothing found for this code on {host.label}: it has expired "
180
+ f"(kept for {host.keeps}) or was mistyped"
181
+ ) from None
182
+ raise BeamError(f"download failed: {host.label} said {exc.code}") from None
183
+ except urllib.error.URLError as exc:
184
+ raise BeamError(
185
+ f"could not reach {host.label} ({exc.reason}); "
186
+ "check the internet connection"
187
+ ) from None
beam/_crypto.py ADDED
@@ -0,0 +1,93 @@
1
+ """Encrypting a file before it leaves the laptop, and decrypting it on arrival.
2
+
3
+ AES-256-GCM over 1 MiB chunks. Each chunk's nonce carries its index and a
4
+ "last chunk" flag, so reordered, dropped or truncated chunks fail to decrypt.
5
+ The key comes from the secret half of the code, which never reaches the host.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import os
14
+ import struct
15
+
16
+ from cryptography.exceptions import InvalidTag
17
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
+
19
+ from ._protocol import BeamError
20
+
21
+ MAGIC = b"BEAM\x01"
22
+ CHUNK = 1 << 20
23
+
24
+
25
+ def _key(secret: str, salt: bytes) -> bytes:
26
+ return hmac.new(secret.encode(), b"beam-v1" + salt, hashlib.sha256).digest()
27
+
28
+
29
+ def _nonce(prefix: bytes, index: int, last: bool) -> bytes:
30
+ return prefix + struct.pack("!IB", index, int(last))
31
+
32
+
33
+ def encrypt_file(src, dst, secret: str, meta: dict, progress=None) -> None:
34
+ """Write ``src`` to ``dst`` encrypted; ``meta`` (e.g. the filename) rides along."""
35
+ salt, prefix = os.urandom(16), os.urandom(7)
36
+ aes = AESGCM(_key(secret, salt))
37
+ total = os.path.getsize(src)
38
+ done = 0
39
+ with open(src, "rb") as fin, open(dst, "wb") as fout:
40
+ fout.write(MAGIC + salt + prefix)
41
+ blob = aes.encrypt(_nonce(prefix, 0, False), json.dumps(meta).encode(), None)
42
+ fout.write(struct.pack("!BI", 0, len(blob)) + blob)
43
+ index = 1
44
+ block = fin.read(CHUNK)
45
+ while True:
46
+ nxt = fin.read(CHUNK) if len(block) == CHUNK else b""
47
+ last = not nxt
48
+ blob = aes.encrypt(_nonce(prefix, index, last), block, None)
49
+ fout.write(struct.pack("!BI", int(last), len(blob)) + blob)
50
+ done += len(block)
51
+ if progress:
52
+ progress(done, total)
53
+ if last:
54
+ break
55
+ block, index = nxt, index + 1
56
+
57
+
58
+ def decrypt_file(src, dst, secret: str) -> dict:
59
+ """Decrypt ``src`` into ``dst``; return the metadata stored with it."""
60
+ wrong = BeamError("could not decrypt: wrong code, or the file was damaged")
61
+ with open(src, "rb") as fin:
62
+ head = fin.read(len(MAGIC) + 16 + 7)
63
+ if not head.startswith(MAGIC) or len(head) < len(MAGIC) + 23:
64
+ raise BeamError("this download is not a beam file (expired or wrong code?)")
65
+ salt, prefix = head[5:21], head[21:28]
66
+ aes = AESGCM(_key(secret, salt))
67
+
68
+ def chunk(index):
69
+ raw = fin.read(5)
70
+ if len(raw) < 5:
71
+ raise BeamError("download is incomplete; try again")
72
+ last, size = struct.unpack("!BI", raw)
73
+ if size > CHUNK + 16:
74
+ raise wrong
75
+ data = fin.read(size)
76
+ try:
77
+ return bool(last), aes.decrypt(
78
+ _nonce(prefix, index, bool(last)), data, None
79
+ )
80
+ except InvalidTag:
81
+ raise wrong from None
82
+
83
+ _, meta_raw = chunk(0)
84
+ meta = json.loads(meta_raw)
85
+ with open(dst, "wb") as fout:
86
+ index, last = 1, False
87
+ while not last:
88
+ last, data = chunk(index)
89
+ fout.write(data)
90
+ index += 1
91
+ if fin.read(1):
92
+ raise wrong
93
+ return meta
beam/_files.py ADDED
@@ -0,0 +1,125 @@
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
+ from pathlib import Path
9
+
10
+ HASH_CHUNK = 1 << 20
11
+
12
+ # Things you almost never want to carry between machines: virtualenvs are
13
+ # platform-specific, caches are regenerable, .git is better cloned, .env
14
+ # usually holds secrets.
15
+ DEFAULT_EXCLUDES = [
16
+ ".git",
17
+ ".hg",
18
+ ".svn",
19
+ "__pycache__",
20
+ "*.pyc",
21
+ "*.pyo",
22
+ ".venv",
23
+ "venv",
24
+ "env",
25
+ ".env",
26
+ ".ipynb_checkpoints",
27
+ ".mypy_cache",
28
+ ".pytest_cache",
29
+ ".ruff_cache",
30
+ ".tox",
31
+ "*.egg-info",
32
+ "node_modules",
33
+ ".DS_Store",
34
+ "Thumbs.db",
35
+ ]
36
+
37
+
38
+ def human(n: float) -> str:
39
+ for unit in ("B", "KB", "MB", "GB"):
40
+ if n < 1024 or unit == "GB":
41
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
42
+ n /= 1024
43
+ return f"{n:.1f} GB"
44
+
45
+
46
+ def normalize_excludes(exclude) -> list[str]:
47
+ """Turn user-friendly excludes into glob patterns.
48
+
49
+ ".mp4", "mp4" and "*.mp4" all skip mp4 files. A bare name like "data" or
50
+ ".cache" also skips a file or folder with exactly that name.
51
+ """
52
+ if exclude is None:
53
+ return []
54
+ if isinstance(exclude, (str, os.PathLike)):
55
+ exclude = [exclude]
56
+ patterns = []
57
+ for item in exclude:
58
+ item = str(item).strip().replace("\\", "/")
59
+ if not item:
60
+ continue
61
+ if any(ch in item for ch in "*?[/"):
62
+ patterns.append(item)
63
+ else:
64
+ patterns.append(item)
65
+ patterns.append("*." + item.lstrip("."))
66
+ return patterns
67
+
68
+
69
+ def excluded(rel: Path, patterns) -> bool:
70
+ text = rel.as_posix()
71
+ for pat in patterns:
72
+ pat = pat.rstrip("/")
73
+ if fnmatch.fnmatch(rel.name, pat):
74
+ return True
75
+ if any(fnmatch.fnmatch(part, pat) for part in rel.parts):
76
+ return True
77
+ if fnmatch.fnmatch(text, pat):
78
+ return True
79
+ return False
80
+
81
+
82
+ def read_ignore_file(root: Path) -> list:
83
+ """Read .beamignore if present. Simple glob patterns, one per line."""
84
+ path = root / ".beamignore"
85
+ if not path.is_file():
86
+ return []
87
+ out = []
88
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
89
+ line = line.strip()
90
+ if line and not line.startswith("#"):
91
+ out.append(line)
92
+ return out
93
+
94
+
95
+ def sha256_file(path: Path) -> str:
96
+ digest = hashlib.sha256()
97
+ with path.open("rb") as fh:
98
+ for block in iter(lambda: fh.read(HASH_CHUNK), b""):
99
+ digest.update(block)
100
+ return digest.hexdigest()
101
+
102
+
103
+ def walk(root: Path, patterns, max_bytes=None):
104
+ """Return ([(abs_path, rel_path, size)], [(rel_path, size)]) - kept, skipped."""
105
+ found, skipped = [], []
106
+ for dirpath, dirnames, filenames in os.walk(root):
107
+ here = Path(dirpath)
108
+ rel_dir = here.relative_to(root)
109
+ dirnames[:] = sorted(d for d in dirnames if not excluded(rel_dir / d, patterns))
110
+ for name in sorted(filenames):
111
+ rel = rel_dir / name
112
+ if excluded(rel, patterns):
113
+ continue
114
+ abs_path = here / name
115
+ if abs_path.is_symlink() and not abs_path.exists():
116
+ continue # broken symlink
117
+ try:
118
+ size = abs_path.stat().st_size
119
+ except OSError:
120
+ continue
121
+ if max_bytes is not None and size > max_bytes:
122
+ skipped.append((rel, size))
123
+ continue
124
+ found.append((abs_path, rel, size))
125
+ return found, skipped
beam/_protocol.py ADDED
@@ -0,0 +1,161 @@
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 = 2
13
+ DEFAULT_PORT = 8765
14
+ DISCOVERY_PORT = 8766
15
+ MAX_HEADER = 1024 * 1024
16
+
17
+
18
+ class BeamError(RuntimeError):
19
+ """The other side refused us, or the stream was malformed."""
20
+
21
+
22
+ def send_msg(sock: socket.socket, obj: dict) -> None:
23
+ raw = json.dumps(obj).encode("utf-8")
24
+ sock.sendall(struct.pack("!I", len(raw)) + raw)
25
+
26
+
27
+ def recv_exact(sock: socket.socket, n: int) -> bytes:
28
+ buf = bytearray()
29
+ while len(buf) < n:
30
+ chunk = sock.recv(n - len(buf))
31
+ if not chunk:
32
+ raise BeamError("connection closed mid-message")
33
+ buf += chunk
34
+ return bytes(buf)
35
+
36
+
37
+ def recv_msg(sock: socket.socket) -> dict:
38
+ (length,) = struct.unpack("!I", recv_exact(sock, 4))
39
+ if length > MAX_HEADER:
40
+ raise BeamError("refusing an absurdly large header")
41
+ return json.loads(recv_exact(sock, length).decode("utf-8"))
42
+
43
+
44
+ def lan_ip() -> str:
45
+ """Best guess at this machine's address on the local network."""
46
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
47
+ try:
48
+ sock.connect(("8.8.8.8", 80)) # no packets are actually sent
49
+ return sock.getsockname()[0]
50
+ except OSError:
51
+ return "127.0.0.1"
52
+ finally:
53
+ sock.close()
54
+
55
+
56
+ # --- discovery ---------------------------------------------------------------
57
+ #
58
+ # The receiver only knows the code. It broadcasts a UDP query carrying a hash
59
+ # of the code; the sender with that code answers with its TCP port, and the
60
+ # reply's source address tells the receiver where to connect.
61
+
62
+
63
+ def code_tag(code: str) -> str:
64
+ return hashlib.sha256(f"beam:{code}".encode()).hexdigest()[:16]
65
+
66
+
67
+ def _local_ipv4s() -> set[str]:
68
+ ips = {lan_ip()}
69
+ try:
70
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
71
+ ips.add(info[4][0])
72
+ except OSError:
73
+ pass
74
+ return {ip for ip in ips if not ip.startswith("127.")}
75
+
76
+
77
+ def _broadcast_targets() -> list[str]:
78
+ targets = ["255.255.255.255", "127.0.0.1"]
79
+ # Windows often sends 255.255.255.255 out of one adapter only, so also hit
80
+ # each adapter's subnet broadcast (assuming the usual home /24).
81
+ for ip in _local_ipv4s():
82
+ targets.append(ip.rsplit(".", 1)[0] + ".255")
83
+ return targets
84
+
85
+
86
+ class DiscoveryResponder:
87
+ """Answers discovery queries for one code until stopped."""
88
+
89
+ def __init__(
90
+ self, code: str, port: int, name: str, udp_port: int = DISCOVERY_PORT
91
+ ):
92
+ self.tag = code_tag(code)
93
+ self.reply = json.dumps(
94
+ {"beam": PROTOCOL, "id": self.tag, "port": port, "name": name}
95
+ ).encode("utf-8")
96
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
97
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
98
+ self.sock.bind(("", udp_port))
99
+ self.sock.settimeout(0.5)
100
+ self._stop = threading.Event()
101
+ self._thread = threading.Thread(target=self._run, daemon=True)
102
+
103
+ def start(self) -> DiscoveryResponder:
104
+ self._thread.start()
105
+ return self
106
+
107
+ def _run(self) -> None:
108
+ while not self._stop.is_set():
109
+ try:
110
+ data, addr = self.sock.recvfrom(2048)
111
+ query = json.loads(data.decode("utf-8"))
112
+ except (OSError, ValueError): # timeout, ICMP reset, junk
113
+ continue
114
+ if isinstance(query, dict) and query.get("find") == self.tag:
115
+ try:
116
+ self.sock.sendto(self.reply, addr)
117
+ except OSError:
118
+ pass
119
+
120
+ def stop(self) -> None:
121
+ self._stop.set()
122
+ self._thread.join(timeout=2)
123
+ self.sock.close()
124
+
125
+
126
+ def find_sender(
127
+ code: str, timeout: float = 15.0, udp_port: int = DISCOVERY_PORT
128
+ ) -> tuple[str, int, str]:
129
+ """Locate the sender holding ``code`` on the LAN. Returns (host, port, name)."""
130
+ tag = code_tag(code)
131
+ query = json.dumps({"beam": PROTOCOL, "find": tag}).encode("utf-8")
132
+ targets = _broadcast_targets()
133
+
134
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
135
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
136
+ sock.bind(("", 0))
137
+ sock.settimeout(0.3)
138
+ deadline = time.monotonic() + timeout
139
+ next_ping = 0.0
140
+ try:
141
+ while time.monotonic() < deadline:
142
+ if time.monotonic() >= next_ping:
143
+ for target in targets:
144
+ try:
145
+ sock.sendto(query, (target, udp_port))
146
+ except OSError:
147
+ pass
148
+ next_ping = time.monotonic() + 1.0
149
+ try:
150
+ data, addr = sock.recvfrom(2048)
151
+ reply = json.loads(data.decode("utf-8"))
152
+ except (OSError, ValueError):
153
+ continue
154
+ if isinstance(reply, dict) and reply.get("id") == tag:
155
+ return addr[0], int(reply["port"]), str(reply.get("name", ""))
156
+ finally:
157
+ sock.close()
158
+ raise BeamError(
159
+ f"no sender with code {code!r} found on this network after {timeout:.0f}s "
160
+ "(same Wi-Fi? firewall allowing Python? or pass host=IP)"
161
+ )