lotus-shell 26.10.4__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.
@@ -0,0 +1,4 @@
1
+ from .shell import Shell
2
+ def main():
3
+ shell = Shell()
4
+ shell.go()
@@ -0,0 +1,158 @@
1
+ import inspect as ins
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import shutil
6
+ import ast
7
+
8
+ from .config import Context
9
+ from pathlib import Path
10
+
11
+
12
+ def cd(args: list[str], ctx: Context) -> int:
13
+ place = args[0] if args else "~"
14
+ try:
15
+ os.chdir(os.path.expanduser(place))
16
+ except OSError as e:
17
+ print(f"cd: {place}: {e.strerror}")
18
+ return 127
19
+ path(args, ctx)
20
+ return 0
21
+
22
+ def test() -> None:
23
+ while True:
24
+ continue
25
+
26
+ def path(args, ctx: Context):
27
+ cwd = Path.cwd()
28
+ home = Path.home()
29
+ print(f"you're somewhere around: {cwd}")
30
+ if cwd == home:
31
+ print("and that is.. home, as far as i can tell.")
32
+ elif cwd.is_relative_to(home):
33
+ print(f"home is {len(cwd.relative_to(home).parts)} dirs away")
34
+ else:
35
+ print("and that is.. nowhere near home, as far as i can tell.")
36
+ return 0
37
+
38
+ def help(args, ctx: Context):
39
+ print("help? you want help?\nhere's some help: type things in the box and i will make it do things for you")
40
+ return 0
41
+
42
+ def editconfig(args, ctx: Context):
43
+ editor = os.environ.get("EDITOR", "nano")
44
+ config = os.path.expanduser("~/.config/lotus/config.py")
45
+ subprocess.run([editor, config], check=False)
46
+ return 0
47
+
48
+ def command(args: list[str], ctx: Context):
49
+ try:
50
+ subprocess.run(args, check=False)
51
+ return 0
52
+ except IndexError:
53
+ return 1
54
+ def exit(args, ctx: Context):
55
+ sys.exit(0)
56
+
57
+ def listdir(args: list[str], ctx: Context):
58
+ path = Path(args[0]).expanduser() if args else Path.cwd()
59
+
60
+ raw = os.listdir(path)
61
+
62
+ print(f"Directory listing of: {path.resolve()}")
63
+ print("-------------")
64
+
65
+ for item in raw:
66
+ print(item)
67
+
68
+ print("-------------")
69
+
70
+ return 0
71
+
72
+ def inspect(args: list[str], ctx: Context):
73
+ if len(args) < 1:
74
+ print("Please provide a command!")
75
+ return 2
76
+ print(f"Specified command name: {args[0]}")
77
+ for arg in args[1:]:
78
+ print(f"Argument: {arg}")
79
+ exec = shutil.which(args[0])
80
+ print(f"Does command exist: {exec or 'not found'}")
81
+ if args[0] in ctx.config.aliases:
82
+ print("Is alias: True")
83
+ print(f"Alias points to: {ctx.config.aliases.get(args[0])}")
84
+ else:
85
+ print("Is alias = False")
86
+ return 0
87
+
88
+ def config(args: list[str], ctx: Context) -> int:
89
+ configuration = ctx.config
90
+
91
+ # config
92
+ if not args:
93
+ for name in dir(configuration):
94
+ if name.startswith("_"):
95
+ continue
96
+
97
+ value = getattr(configuration, name)
98
+
99
+ if not callable(value):
100
+ print(f"{name} = {value}")
101
+ return 0
102
+
103
+ name = args[0]
104
+ key = f"_{name}"
105
+
106
+ # config <name>
107
+ if not hasattr(configuration, key):
108
+ print(f"config: '{name}' doesn't exist")
109
+ return 127
110
+
111
+ if len(args) == 1:
112
+ print(f"{name} = {getattr(configuration, name)}")
113
+ return 0
114
+
115
+ # config <name> <value>
116
+ raw_value = " ".join(args[1:])
117
+
118
+ try:
119
+ value = ast.literal_eval(raw_value)
120
+ except (ValueError, SyntaxError):
121
+ if raw_value.startswith("[") and raw_value.endswith("]"):
122
+ value = [
123
+ item.strip().strip("\"'")
124
+ for item in raw_value[1:-1].split(",")
125
+ ]
126
+ else:
127
+ value = raw_value
128
+
129
+ setattr(configuration, key, value)
130
+
131
+ print(f"{name} = {getattr(configuration, name)}")
132
+ return 0
133
+
134
+ def type(args: list[str], ctx: Context):
135
+ if not args:
136
+ print("type: expected a command")
137
+ return 2
138
+
139
+ name = args[0]
140
+
141
+ if name in ctx.config.aliases:
142
+ print(f"{name} is an alias for {ctx.config.aliases[name]}")
143
+ elif name in ctx.info.BUILTINS:
144
+ print(f"{name} is a lotus builtin")
145
+ elif shutil.which(name):
146
+ print(f"{name} is {shutil.which(name)}")
147
+ else:
148
+ print(f"{name}: not found")
149
+ return 127
150
+ return 0
151
+
152
+ def _get():
153
+ return {
154
+ name: func
155
+ for name, func in globals().items()
156
+ if ins.isfunction(func)
157
+ and not name.startswith("_")
158
+ }
lotus_shell/config.py ADDED
@@ -0,0 +1,103 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ VERSION = "26.10.4"
7
+
8
+ class Context:
9
+ def __init__(self, config, info):
10
+ self.config = config
11
+ self.info = info
12
+
13
+ class Config:
14
+ def __init__(self):
15
+ self.config_path: Path = Path("~/.config/lotus").expanduser().resolve()
16
+ self.default_config = """
17
+ # WARNING
18
+ # The config does NOT check if a value is missing.
19
+ # This means that if you are to remove a value, it will error out before the shell can load.
20
+ # This is being patched.
21
+ # Please keep all existing entries.
22
+
23
+
24
+ # Create the config object
25
+ lotus = Config()
26
+
27
+ # Lotus version. Do not modify.
28
+ lotus.version = "26.10.4"
29
+
30
+ # Wanna know what's happening?
31
+ lotus.verbose = False
32
+
33
+ # Would you like the dynamic prompt?
34
+ # True: Dynamic, False: Classic
35
+ # Dynamic: -- Home -- 12:00 >
36
+ # Classic user@hostname:directory >
37
+ lotus.dynamic = True
38
+
39
+ # What should we show for root and user as the indicator?
40
+ # (root first, then user)
41
+ lotus.indicator = ["#", "$"]
42
+
43
+ # Can I show you an advert on startup?
44
+ lotus.advert = True
45
+
46
+ # What do you want Lotus to pretend you typed? (aliases, prefix builtins with b-)
47
+ lotus.aliases = {
48
+ "la": "ls -la",
49
+ "ls": "b-listdir",
50
+ "dir": "b-listdir",
51
+ }
52
+
53
+ # This function runs on startup!
54
+ def on_start():
55
+ pass
56
+ """
57
+ if not self.config_path.exists():
58
+ self.config_path.mkdir(parents=True)
59
+ self.defaults()
60
+
61
+ def defaults(self):
62
+ with open(self.config_path / "config.py", "w") as f:
63
+ f.write(self.default_config)
64
+
65
+ def load(self):
66
+ config_file = self.config_path / "config.py"
67
+
68
+ spec = importlib.util.spec_from_file_location(
69
+ "lotus.config",
70
+ config_file,
71
+ )
72
+
73
+ if spec is None or spec.loader is None:
74
+ raise ImportError(f"Could not load config: {config_file}")
75
+
76
+ config_module = importlib.util.module_from_spec(spec)
77
+
78
+ config_module.Config = Config # pyright: ignore[reportAttributeAccessIssue]
79
+
80
+ spec.loader.exec_module(config_module)
81
+
82
+ config = config_module.lotus.__dict__
83
+
84
+ on_start: Callable | None = getattr(config_module, "on_start", None)
85
+ if config.get("version") != VERSION:
86
+ print("your config is outdated. ")
87
+ res = input(f"do you want to update it to {VERSION}? [y/N] ")
88
+ if res.lower() == "n" or not res:
89
+ print(f"cannot load outdated config, loading default for version {VERSION}")
90
+ namespace = {
91
+ "Config": Config,
92
+ }
93
+
94
+ exec(self.default_config, namespace)
95
+
96
+ config = namespace["lotus"].__dict__
97
+ else:
98
+ print(f"updating config to version {VERSION}")
99
+ self.defaults()
100
+ print("config updated.\nrestart the shell.")
101
+ sys.exit()
102
+
103
+ return config, on_start # pyright: ignore[reportAny]
lotus_shell/init.py ADDED
@@ -0,0 +1,157 @@
1
+ from typing import Callable
2
+ from pathlib import Path
3
+
4
+ from .config import Config, Context
5
+ from .builtins import _get
6
+
7
+ import os
8
+ import socket
9
+ import importlib
10
+ import shutil
11
+ import subprocess
12
+
13
+ class CTX(Context):
14
+ def __init__(self, config, info):
15
+ super().__init__(config, info)
16
+
17
+ class Configuration:
18
+ def __init__(self, config, on_start, config_path):
19
+ # lotus.indicator
20
+ if not config.get("indicator"):
21
+ config["indicator"] = ["#", "$"]
22
+ self._indicators: list[str] = config.get("indicator")
23
+ self._advert: bool = config.get("advert")
24
+ self._aliases: dict[str, str] = config.get("aliases")
25
+ self._verbose: bool = config.get("verbose")
26
+ self._dynamic: bool = config.get("dynamic")
27
+ self._on_start: Callable = on_start
28
+ self.config_path = config_path
29
+ @property
30
+ def indicators(self):
31
+ return self._indicators
32
+ @property
33
+ def advert(self):
34
+ return self._advert
35
+ @property
36
+ def aliases(self):
37
+ return self._aliases
38
+ @property
39
+ def verbose(self):
40
+ return self._verbose
41
+ @property
42
+ def dynamic_shell(self):
43
+ return self._dynamic
44
+
45
+
46
+
47
+
48
+ class Information:
49
+ def __init__(self):
50
+ self.user: str | None = os.environ.get("USER") or os.environ.get("USERNAME")
51
+ self.host: str = socket.gethostname()
52
+ self.home = Path.home()
53
+ self.builtins: dict[str,Callable] = _get()
54
+ @property
55
+ def username(self):
56
+ return self.user
57
+ @property
58
+ def hostname(self):
59
+ return self.host
60
+ @property
61
+ def BUILTINS(self):
62
+ return self.builtins
63
+ @property
64
+ def cwd(self) -> str:
65
+ path = Path.cwd()
66
+
67
+ if path == self.home:
68
+ return "~"
69
+
70
+ try:
71
+ parts = list(path.relative_to(self.home).parts)
72
+ except ValueError:
73
+ parts = list(path.parts)
74
+
75
+ if not parts:
76
+ return "~"
77
+
78
+ width = shutil.get_terminal_size().columns
79
+ available = max(width // 3, 20)
80
+
81
+ if len("/".join(parts)) <= available:
82
+ return "/".join(parts)
83
+
84
+ # Shorten parent directories first.
85
+ for i in range(len(parts) - 1):
86
+ while len("/".join(parts)) > available and len(parts[i]) > 1:
87
+ parts[i] = parts[i][:-1]
88
+
89
+ # If we're still too long, progressively shorten deeper parents.
90
+ while len("/".join(parts)) > available:
91
+ changed = False
92
+
93
+ for i in range(len(parts) - 1):
94
+ if len(parts[i]) > 1:
95
+ parts[i] = parts[i][:-1]
96
+ changed = True
97
+
98
+ if len("/".join(parts)) <= available:
99
+ break
100
+
101
+ if not changed:
102
+ break
103
+
104
+ return "/".join(parts)
105
+ @property
106
+ def git(self) -> str | None:
107
+ result = subprocess.run(
108
+ ["git", "status", "--porcelain", "--branch"],
109
+ capture_output=True,
110
+ text=True,
111
+ )
112
+
113
+ if result.returncode != 0:
114
+ return None
115
+
116
+ lines = result.stdout.splitlines()
117
+ branch = lines[0].removeprefix("## ").split("...")[0]
118
+
119
+ unstaged = 0
120
+ staged = 0
121
+
122
+ for line in lines[1:]:
123
+ if line[0] != " ":
124
+ staged += 1
125
+
126
+ if line[1] != " ":
127
+ unstaged += 1
128
+
129
+ def indicator(symbol: str, count: int) -> str:
130
+ if count == 0:
131
+ return ""
132
+
133
+ if count <= 4:
134
+ return symbol * count
135
+
136
+ return f"{symbol}×{count}"
137
+
138
+ status = (
139
+ indicator("!", unstaged)
140
+ + indicator("*", staged)
141
+ )
142
+
143
+ if "ahead" in lines[0]:
144
+ status += "^"
145
+
146
+ return f"{branch}{status}"
147
+
148
+
149
+ def load_config():
150
+ unloaded = Config()
151
+ loaded, on_start = unloaded.load()
152
+
153
+ if on_start is not None and not callable(on_start):
154
+ raise TypeError("on_start isn't callable")
155
+
156
+ configuration = Configuration(loaded, on_start, unloaded.config_path)
157
+ return configuration
lotus_shell/shell.py ADDED
@@ -0,0 +1,126 @@
1
+ import os
2
+ from pathlib import Path
3
+ from prompt_toolkit import PromptSession
4
+ from prompt_toolkit.history import FileHistory
5
+ from datetime import datetime
6
+ import subprocess
7
+ import shlex
8
+ import importlib
9
+ import time
10
+
11
+ from .init import Configuration, Information, CTX, load_config
12
+ from .utils import open_invite
13
+ from . import builtins
14
+
15
+
16
+ class Shell:
17
+ def __init__(self):
18
+ status = self.status
19
+ print("shell [WAIT]")
20
+ self.config: Configuration = load_config()
21
+ status("config [OK]")
22
+ self.info: Information = Information()
23
+ status("info [OK]")
24
+ self.info.BUILTINS["reload"] = self.reload
25
+ status("reload inject [OK]")
26
+
27
+ def reload(self, args: list[str], ctx: CTX):
28
+ importlib.reload(builtins)
29
+ self.__init__()
30
+
31
+ def status(self, message: str):
32
+ if self.config.verbose:
33
+ print(message)
34
+ time.sleep(0.1)
35
+
36
+ def go(self):
37
+ status = self.status
38
+ # doing the ad
39
+ status("ad [WAIT]")
40
+ if self.config.advert:
41
+ print("wait, pause, you should probably join this cool amazing funny super duper best discord server!\nyou can turn this off by setting advert to false in your config!")
42
+ res = input("would you like to join the server? [y/N]: ")
43
+ if res.lower() == "n" or not res:
44
+ status("ad [FAIL]")
45
+ else:
46
+ open_invite("f9GsbvRadg")
47
+ status("ad [OK]")
48
+ else:
49
+ status("ad [FAIL]")
50
+ status("history [WAIT]" )
51
+ history = self.config.config_path / "history"
52
+ status("file init [OK]")
53
+ history.touch(exist_ok=True)
54
+ status("file creation [OK]")
55
+ session = PromptSession(
56
+ history=FileHistory(str(history)),
57
+ )
58
+ status("prompter'd'session creation [OK]")
59
+ status("history [OK]")
60
+ if self.config._on_start:
61
+ status("on_start [WAIT]")
62
+ self.config._on_start()
63
+ status("on_start [OK]")
64
+ print("do you wanna build a snowman..? (shell [OK])")
65
+ code = 0
66
+ while True:
67
+ try:
68
+ try:
69
+ indicator = (
70
+ self.config.indicators[0]
71
+ if self.info.username == "root"
72
+ else self.config.indicators[1]
73
+ )
74
+
75
+ if self.config.dynamic_shell:
76
+ git = self.info.git
77
+ time = datetime.now().strftime("%H:%M")
78
+ if Path.cwd().resolve() == Path.home():
79
+ prompt = f"-- Home -- {time} > "
80
+ elif git:
81
+ prompt = f"-- {Path.cwd().name} -- ({git}) -- {time} > "
82
+ else:
83
+ prompt = f"-- {Path.cwd().name} -- {time} > "
84
+ else:
85
+ prompt = f"{self.info.username}@{self.info.hostname}:{self.info.cwd} > "
86
+ cmd = shlex.split(session.prompt(prompt))
87
+ except ValueError as e:
88
+ print(f"what is this? ({e})")
89
+ status("syntax error [ERROR]")
90
+ continue
91
+
92
+ if not cmd:
93
+ print("what do i do with NOTHING?")
94
+ status("no text given [WARN]")
95
+ continue
96
+
97
+ try:
98
+ if cmd[0] in self.info.BUILTINS:
99
+ code=self.info.BUILTINS[cmd[0]](cmd[1:], CTX(self.config, self.info))
100
+ continue
101
+ if cmd[0] in self.config.aliases:
102
+ cmd = shlex.split(self.config.aliases[cmd[0]]) + cmd[1:]
103
+ if cmd and cmd[0].startswith("b-"):
104
+ name = cmd[0][2:]
105
+ if name in self.info.BUILTINS:
106
+ self.info.BUILTINS[name](cmd[1:], CTX(self.config, self.info))
107
+ else:
108
+ print(f"{name}: no such builtin")
109
+ continue
110
+ except TypeError:
111
+ print("fatal: a builtin didn't take it's arguments\nit's refusing to move to ram now..\ncan we get a crane for this guy?")
112
+ status("builtin has no positional argument slot [FATAL]")
113
+ continue
114
+ result=subprocess.run(cmd, check=False)
115
+ code = result.returncode
116
+ except FileNotFoundError:
117
+ print(f"{cmd[0]}: command not found\nshall i put the missing posters up?")
118
+ status("ErrorNo2/WinError2 [ERROR]")
119
+ continue
120
+ except EOFError:
121
+ status("shutdown [OK]")
122
+ print("\nokay, bye..")
123
+ break
124
+ except KeyboardInterrupt:
125
+ status("caught KeyboardInterrupt [WARN]")
126
+ print("\nlotus: the keyboard did WHAT now!?")
lotus_shell/utils.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import socket
8
+ import struct
9
+ import subprocess
10
+ import sys
11
+ import webbrowser
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from collections.abc import Callable, Iterable, Iterator, Sequence
15
+ from typing import Optional
16
+
17
+ __all__ = ["open_invite", "parse_code", "InviteError", "InviteResult", "METHODS"]
18
+ __version__ = "0.1.0"
19
+
20
+ log = logging.getLogger("discord_invite")
21
+
22
+ METHODS = ("ipc", "client", "opener", "web")
23
+
24
+ CLIENT_BINS = ("vesktop", "equibop", "legcord", "webcord", "vencord",
25
+ "discord", "discord-ptb", "discord-canary", "Discord")
26
+ FLATPAK_IDS = ("dev.vencord.Vesktop", "com.discordapp.Discord",
27
+ "app.legcord.Legcord", "io.github.equicord.equibop")
28
+
29
+
30
+ class InviteError(RuntimeError):
31
+ """Raised when every requested method failed. `.attempted` lists them."""
32
+
33
+ def __init__(self, message: str, attempted: Sequence[str] = ()):
34
+ super().__init__(message)
35
+ self.attempted = tuple(attempted)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class InviteResult:
40
+ method: str
41
+ code: str
42
+ url: str
43
+ attempted: tuple = field(default_factory=tuple)
44
+
45
+
46
+ def parse_code(invite: str) -> str:
47
+ """Accept a full link (discord.gg/…, discord.com/invite/…) or a bare code."""
48
+ code = invite.strip().split("?")[0].split("#")[0].rstrip("/").rsplit("/", 1)[-1]
49
+ if not code or not all(c.isalnum() or c in "-_" for c in code):
50
+ raise ValueError(f"Can't parse invite: {invite!r}")
51
+ return code
52
+
53
+
54
+ def _run(cmd: Sequence[str], timeout: float = 10) -> tuple[int, str]:
55
+ try:
56
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
57
+ return r.returncode, r.stdout.strip()
58
+ except (OSError, subprocess.SubprocessError) as e:
59
+ log.debug("run failed %s: %s", cmd, e)
60
+ return 1, ""
61
+
62
+
63
+ def _spawn(cmd: Sequence[str]) -> bool:
64
+ """Launch detached: never blocks, survives the calling process."""
65
+ kwargs = {}
66
+ if sys.platform == "win32":
67
+ kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED | NEW_GROUP
68
+ else:
69
+ kwargs["start_new_session"] = True
70
+ try:
71
+ subprocess.Popen(list(cmd), stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
73
+ **kwargs)
74
+ log.debug("spawned %s", cmd)
75
+ return True
76
+ except OSError as e:
77
+ log.debug("spawn failed %s: %s", cmd, e)
78
+ return False
79
+
80
+ def _ipc_sockets() -> Iterator[Path]:
81
+ if sys.platform == "win32":
82
+ return
83
+ base = (os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR")
84
+ or f"/run/user/{os.getuid()}")
85
+ roots = [Path(base), Path(base, "app/com.discordapp.Discord"),
86
+ Path(base, "app/dev.vencord.Vesktop"), Path(base, "snap.discord")]
87
+ for root in roots:
88
+ for i in range(10):
89
+ p = root / f"discord-ipc-{i}"
90
+ if p.exists():
91
+ yield p
92
+
93
+
94
+ def _frame(op: int, payload: dict) -> bytes:
95
+ data = json.dumps(payload).encode()
96
+ return struct.pack("<II", op, len(data)) + data
97
+
98
+
99
+ def _recv_exact(sock: socket.socket, n: int) -> bytes:
100
+ buf = b""
101
+ while len(buf) < n:
102
+ chunk = sock.recv(n - len(buf))
103
+ if not chunk:
104
+ break
105
+ buf += chunk
106
+ return buf
107
+
108
+
109
+ def _read_frame(sock: socket.socket) -> tuple[Optional[int], dict]:
110
+ head = _recv_exact(sock, 8)
111
+ if len(head) < 8:
112
+ return None, {}
113
+ op, length = struct.unpack("<II", head)
114
+ body = _recv_exact(sock, length)
115
+ try:
116
+ return op, json.loads(body)
117
+ except ValueError:
118
+ return op, {}
119
+
120
+
121
+ def _via_ipc(code: str, url: str) -> bool:
122
+ for path in _ipc_sockets():
123
+ log.debug("trying IPC socket %s", path)
124
+ try:
125
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
126
+ s.settimeout(3)
127
+ s.connect(str(path))
128
+ s.sendall(_frame(0, {"v": 1, "client_id": "1234567890123456789"}))
129
+ op, msg = _read_frame(s)
130
+ if op != 1 or msg.get("evt") == "ERROR":
131
+ log.debug("handshake rejected: %s %s", op, msg)
132
+ continue
133
+ s.sendall(_frame(1, {"cmd": "INVITE_BROWSER",
134
+ "args": {"code": code},
135
+ "nonce": "invite-1"}))
136
+ op, msg = _read_frame(s)
137
+ if op == 1 and msg.get("evt") != "ERROR":
138
+ return True
139
+ log.debug("invite rejected: %s %s", op, msg)
140
+ except (OSError, ValueError) as e:
141
+ log.debug("IPC error on %s: %s", path, e)
142
+ return False
143
+
144
+
145
+ def _handler_binary() -> Optional[str]:
146
+ if not shutil.which("xdg-mime"):
147
+ return None
148
+ _, out = _run(["xdg-mime", "query", "default", "x-scheme-handler/discord"])
149
+ name = out[:-8] if out.endswith(".desktop") else out
150
+ return name or None
151
+
152
+
153
+ def _via_client(code: str, url: str) -> bool:
154
+ if sys.platform == "win32":
155
+ local = os.environ.get("LOCALAPPDATA")
156
+ if local:
157
+ for v in ("Discord", "DiscordPTB", "DiscordCanary"):
158
+ upd = Path(local, v, "Update.exe")
159
+ if upd.exists():
160
+ exe = {"Discord": "Discord.exe", "DiscordPTB": "DiscordPTB.exe",
161
+ "DiscordCanary": "DiscordCanary.exe"}[v]
162
+ return _spawn([str(upd), "--processStart", exe, "--process-start-args", url])
163
+ return False
164
+ if sys.platform == "darwin":
165
+ for app in ("Vesktop", "Discord", "Discord PTB", "Discord Canary", "Legcord"):
166
+ if _run(["open", "-a", app, url])[0] == 0:
167
+ return True
168
+ return False
169
+
170
+ ordered: list[str] = []
171
+ h = _handler_binary()
172
+ if h:
173
+ ordered.append(h)
174
+ ordered += [b for b in CLIENT_BINS if b != h]
175
+ for b in ordered:
176
+ if shutil.which(b) and _spawn([b, url]):
177
+ return True
178
+ if shutil.which("flatpak"):
179
+ _, out = _run(["flatpak", "list", "--app", "--columns=application"])
180
+ installed = out.split()
181
+ for fid in FLATPAK_IDS:
182
+ if fid in installed and _spawn(["flatpak", "run", fid, url]):
183
+ return True
184
+ return False
185
+
186
+
187
+ def _via_opener(code: str, url: str) -> bool:
188
+ if sys.platform == "win32":
189
+ try:
190
+ os.startfile(url) # type: ignore[attr-defined]
191
+ return True
192
+ except OSError as e:
193
+ log.debug("startfile failed: %s", e)
194
+ return False
195
+ if sys.platform == "darwin":
196
+ return _run(["open", url])[0] == 0
197
+ # gio uses GLib's handler lookup and doesn't fall back to a browser the
198
+ # way xdg-open does.
199
+ for cmd in (["gio", "open", url], ["xdg-open", url]):
200
+ if shutil.which(cmd[0]) and _run(cmd)[0] == 0:
201
+ return True
202
+ return False
203
+
204
+
205
+ def _via_web(code: str, url: str) -> bool:
206
+ web = f"https://discord.gg/{code}"
207
+ if sys.platform.startswith("linux") and shutil.which("xdg-open"):
208
+ return _run(["xdg-open", web])[0] == 0
209
+ return webbrowser.open(web)
210
+
211
+
212
+ _IMPL: dict[str, Callable[[str, str], bool]] = {
213
+ "ipc": _via_ipc,
214
+ "client": _via_client,
215
+ "opener": _via_opener,
216
+ "web": _via_web,
217
+ }
218
+
219
+
220
+ def open_invite(invite: str, *,
221
+ methods: Optional[Iterable[str]] = None) -> InviteResult:
222
+ """Open `invite` in the desktop client.
223
+
224
+ Args:
225
+ invite: full invite URL or bare code.
226
+ methods: ordered subset of METHODS to try. Default is all of them,
227
+ most seamless first. Pass e.g. ("ipc", "client") to forbid the
228
+ browser fallback.
229
+
230
+ Raises:
231
+ ValueError: the invite couldn't be parsed.
232
+ InviteError: every requested method failed.
233
+ """
234
+ code = parse_code(invite)
235
+ url = f"discord://-/invite/{code}"
236
+ order = tuple(methods) if methods is not None else METHODS
237
+
238
+ unknown = [m for m in order if m not in _IMPL]
239
+ if unknown:
240
+ raise ValueError(f"Unknown method(s) {unknown}; choose from {METHODS}")
241
+
242
+ attempted: list[str] = []
243
+ for name in order:
244
+ attempted.append(name)
245
+ log.debug("trying method %s", name)
246
+ try:
247
+ ok = _IMPL[name](code, url)
248
+ except Exception: # a broken method must never stop the fallthrough
249
+ log.debug("method %s raised", name, exc_info=True)
250
+ ok = False
251
+ if ok:
252
+ return InviteResult(name, code, url, tuple(attempted))
253
+ raise InviteError(f"No method worked (tried: {', '.join(attempted)})", attempted)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: lotus-shell
3
+ Version: 26.10.4
4
+ Summary: Add your description here
5
+ Author: Stormzady
6
+ Author-email: Stormzady <Stormzady@noreply.codeberg.org>
7
+ Requires-Dist: prompt-toolkit>=3.0.53
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
@@ -0,0 +1,10 @@
1
+ lotus_shell/__init__.py,sha256=xPtJgOtxABYHKT1NWHurmU6KYZsihICNd7mryMy4dTg,72
2
+ lotus_shell/builtins.py,sha256=t3AqyPpA7EwUFsTF7qTgWLPIKuE6Xf3jZfr9b5x6hsU,4015
3
+ lotus_shell/config.py,sha256=_yqxg2N1HAyRU8mowXkRxhVzND1bGSNaapihvJUGCC0,2949
4
+ lotus_shell/init.py,sha256=DYCCDOSITRywcDzX-5Og4k0Jxf_cWe8Obmq4SGcTMX8,4066
5
+ lotus_shell/shell.py,sha256=3WCcjPWwnZkkLoIjrTd2_GQYahteMdSigI1UWeof19Y,5012
6
+ lotus_shell/utils.py,sha256=ENZxgFfKIwVWVneawaTNiEGdOcQFcjSR_lk63DTQ2WY,8594
7
+ lotus_shell-26.10.4.dist-info/WHEEL,sha256=766qEDcnZQDL36MOdWBQ0gowd8OjUWAOwMNC7Vgvxts,81
8
+ lotus_shell-26.10.4.dist-info/entry_points.txt,sha256=5U4LG9-hA_w3-cEDUkYsmhcLQV-Ex4aE-rzEhrLOH3k,38
9
+ lotus_shell-26.10.4.dist-info/METADATA,sha256=Wc-gGFPePwunP5pomTPMfv_nUpRSS9me23fdM-Hejuk,270
10
+ lotus_shell-26.10.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.20
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ lotus = lotus:main
3
+