Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
599ec58
Merge pull request #15 from Rurusachi/ffx
Omnises Nov 6, 2025
724f60b
ugly and basic but it works
Omnises Nov 7, 2025
a39de30
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Nov 7, 2025
b2ce112
idk
Omnises Nov 9, 2025
a748da4
Merge branch 'ffx' into ffx
Rurusachi Nov 13, 2025
db56e26
Core: Make .apworlds importable using importlib (without force-import…
NewSoupVi Jan 5, 2026
d7f0ab6
Merge pull request #19 from Rurusachi/ffx
Omnises Jan 8, 2026
8e87966
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Jan 8, 2026
875765e
PyCharm: Fix name of apworld builder run config (#5824)
benny-dreamly Jan 9, 2026
69e8307
Multiserver: remove dead code (#5831)
gerbiljames Jan 11, 2026
8fea40e
descriptive location names
Omnises Jan 13, 2026
139ce63
second pass
Omnises Jan 13, 2026
f186b29
guado zero, zu in rules
Omnises Jan 13, 2026
de148bb
incorporating Zu, adding pre post region
Omnises Jan 15, 2026
42bc402
spread out region ids. fixed duplicate location.
Omnises Jan 15, 2026
c575fa6
Merge branch 'ffx' into ffx
Omnises Jan 15, 2026
f294148
fix dupe 2
Omnises Jan 15, 2026
d56bb42
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Jan 15, 2026
53b87bd
oops forgot to update region to first visit
Omnises Jan 15, 2026
7514e87
idk man
Omnises Jan 15, 2026
d8c17e7
weird extra spaces on comments
Omnises Jan 15, 2026
713ba93
will this make generation happy
Omnises Jan 15, 2026
68af2d7
bleh
Omnises Jan 15, 2026
b92803e
Core: replace the eval in OptionsCreator.py (#5828)
Mysteryem Jan 15, 2026
0554bf4
Satisfactory: Fix typo in GoalSelection possible values description c…
budak7273 Jan 15, 2026
cda54e0
WebHost: Fix world sorting in /tutorial/ (#5785)
remyjette Jan 15, 2026
763be0d
mobile edit 1
Omnises Jan 17, 2026
31792a9
mobile edit 2
Omnises Jan 17, 2026
363dd27
Merge branch 'ffx' into ffx
Omnises Jan 17, 2026
8dee460
customserver: don't set last_activity that will be overwritten later …
black-sliver Jan 17, 2026
5899920
Factorio: fix inverted condition in victory requirements (#5647)
lepideble Jan 18, 2026
b81e1a2
The Messenger: Fix lambda capture issue in add_closed_portal_reqs (#5…
Mysteryem Jan 18, 2026
62dfeac
Super Mario Land 2: Fix Goal Logic (#5781)
Scrungip Jan 18, 2026
983936a
TUNIC: Fix region for the grass by the West Garden portal (#5784)
ScipioWright Jan 18, 2026
a4e4ce1
Core: Change image link to relative (#5802)
duckboycool Jan 18, 2026
20c10e3
Shapez: Change image links to relative (#5803)
duckboycool Jan 18, 2026
a035ac5
Noita: Fix filling Shop Item locations without updating item.location…
Mysteryem Jan 18, 2026
c1b858b
Core: Add `.apignore` format to not include files in APWorld Builder …
duckboycool Jan 18, 2026
16760d6
Merge branch 'ffx' into ffx
Omnises Jan 19, 2026
13830ff
Timespinner: Align Lantern Logic (#5562)
TriumphantBass Jan 19, 2026
e092589
compatibility with capturesanity and stuff
Omnises Jan 19, 2026
3227cbd
sandy cheeks
Omnises Jan 19, 2026
6877bc4
shinra
Omnises Jan 19, 2026
b8311a6
FFMQ: Update link to upstream rando (#5838)
wildham0 Jan 19, 2026
9f71fe7
SC2: fix supreme logic hole (#5768)
MatthewMarinets Jan 19, 2026
092a9dc
Core: fix bug with missing help text (#5632)
beauxq Jan 19, 2026
e1322df
APQuest: Explain game_name and supports_uri more in components.py (#5…
NewSoupVi Jan 19, 2026
646a52a
LADX: no pickle (#5849)
Berserker66 Jan 19, 2026
76e0619
Core: Bump version from 0.6.6 to 0.6.7 (#5851)
black-sliver Jan 19, 2026
5932160
Docs: add dev FAQ for 'should I start with the APWorld or the client?…
Ixrec Jan 20, 2026
220248d
Docs: define and explain the trade-off of "local" vs "remote" items (…
Ixrec Jan 20, 2026
c7db213
Docs: explicitly document why get_filler_item_name may return non-IC.…
Ixrec Jan 20, 2026
ddd0834
Docs: Show that Data is optional for bounces #5794
nicholassaylor Jan 20, 2026
8f261bb
Core: Add Pymem to requirements.txt (#5855)
NewSoupVi Jan 20, 2026
f96c729
add regions for primers in multiple locations
Omnises Jan 20, 2026
52b2e4d
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Jan 20, 2026
e9a9ead
new regions for captures in multiple locations
Omnises Jan 20, 2026
361ac2e
Merge pull request #21 from Rurusachi/ffx
Omnises Jan 20, 2026
238fe28
Merge branch 'ffx' into catch
Omnises Jan 20, 2026
6c6a18d
Merge pull request #22 from Omnises/catch
Omnises Jan 20, 2026
9515032
clarified the regions are for captures
Omnises Jan 21, 2026
bd80236
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Jan 21, 2026
bbc65f5
fixed duplicate entry
Omnises Jan 21, 2026
6aead51
fixed empty reference in regions
Omnises Jan 21, 2026
02c4993
a
Omnises Jan 21, 2026
94492c4
Super Mario 64: Add painting passability as items (#5294)
duh102 Jan 21, 2026
aa3614a
LADX: fix improved additional warps (#5858)
threeandthreee Jan 23, 2026
520253e
ModuleUpdate: Add explicit error when above max supported version (#5…
duckboycool Jan 26, 2026
65ef35f
Core: Give clearer error message for invalid .apworld zip (#5871)
duckboycool Jan 27, 2026
a6740e7
KH2: Deathlink and ingame item popups (#5206)
JaredWeakStrike Jan 28, 2026
9b42145
Doc: WebHost: update readme and style guide (#4853)
black-sliver Jan 28, 2026
c5d67dd
Docs: Explain building a single world with Build APWorlds component (…
duckboycool Jan 31, 2026
5f073c2
Doc: Reword required python version for AP (#5822)
cjmang Jan 31, 2026
8662433
FFMQ: Fix Collect/Remove Asymmetry (#5253)
Exempt-Medic Jan 31, 2026
c47687d
TLOZ: Move completion condition to be before set_rules is complete (#…
Rosalie-A Jan 31, 2026
77e5f37
AHIT: Add option to shuffle Battle of the Birds director tokens and t…
CookieCat45 Jan 31, 2026
8095f92
[WebHost Docs] Updated and clarified new tracker endpoitns and misc f…
Quasky Jan 31, 2026
6ee02fc
Docs (DS3): Fix the documentation for the Simple Early Bosses option …
nex3 Feb 1, 2026
c835bff
Docs: KH1 more troubleshooting and clearer nomenclature (#5872)
Omnises Feb 1, 2026
4cb5189
Fix, OptionsCreator: export options on Linux (#5774)
black-sliver Feb 1, 2026
3b1971b
Core: Fix some typing errors (#4995)
duckboycool Feb 2, 2026
3da9d53
tiny name tweak
Omnises Feb 3, 2026
8bbbe39
voice trap
Omnises Feb 3, 2026
b8cdaae
Merge pull request #23 from Rurusachi/ffx
Omnises Feb 3, 2026
f9adbd3
Merge branch 'ffx' into catch
Omnises Feb 3, 2026
f7faf9a
Merge pull request #24 from Omnises/catch
Omnises Feb 3, 2026
0ede1ee
more small stuff
Omnises Feb 3, 2026
7dcc1d6
Merge branch 'ffx' of https://github.com/Omnises/Archipelago into ffx
Omnises Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Output Logs/
/datapackage
/datapackage_export.json
/custom_worlds
# stubgen output
/out/

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
2 changes: 1 addition & 1 deletion .run/Build APWorld.run.xml → .run/Build APWorlds.run.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
Expand Down
25 changes: 11 additions & 14 deletions Generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from Utils import parse_yamls, version_tuple, __version__, tuplize_version


def mystery_argparse(argv: list[str] | None = None):
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
from settings import get_settings
settings = get_settings()
defaults = settings.generator
Expand Down Expand Up @@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
args.plando = PlandoOptions.from_option_string(args.plando)

return args

Expand Down Expand Up @@ -135,7 +135,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)

except Exception as e:
logging.exception(f"Exception reading weights in file {fname}")
player_errors.append(
Expand Down Expand Up @@ -205,7 +205,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
yaml[category_name][key] = option

settings_cache: dict[str, tuple[argparse.Namespace, ...]] = {fname: None for fname in weights_cache}
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
if args.sameoptions:
for fname, yamls in weights_cache.items():
try:
Expand All @@ -225,7 +225,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
player_path_cache: dict[int, str] = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
name_counter: Counter[str] = Counter()
args.player_options = {}

player = 1
Expand All @@ -241,13 +241,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
try:
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
# Invariant: settings_cache[path] and weights_cache[path] have the same length
settingsObject: argparse.Namespace = (
settings_cache[path][doc_index]
if settings_cache[path]
else roll_settings(yaml, args.plando)
)

for k, v in vars(settingsObject).items():
cached = settings_cache[path]
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))

for k, v in vars(settings_object).items():
if v is not None:
try:
getattr(args, k)[player] = v
Expand Down Expand Up @@ -365,7 +362,7 @@ def get_value(self, key, args, kwargs):
return kwargs.get(key, "{" + key + "}")


def handle_name(name: str, player: int, name_counter: Counter):
def handle_name(name: str, player: int, name_counter: Counter[str]):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
Expand Down Expand Up @@ -503,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
return weights


def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
try:
if option_key in game_weights:
if not option.supports_weighting:
Expand Down
9 changes: 5 additions & 4 deletions ModuleUpdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
import warnings


if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
"Official 3.11.9 through 3.13.x is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 11, 0):
elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")

# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(
Expand Down
11 changes: 4 additions & 7 deletions MultiServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -917,12 +917,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =


async def on_client_connected(ctx: Context, client: Client):
players = []
for team, clients in ctx.clients.items():
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
Expand Down Expand Up @@ -1370,7 +1364,10 @@ def get_help_text(self) -> str:
argname += "=" + parameter.default
argtext += argname
argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
method_doc = inspect.getdoc(method)
if method_doc is None:
method_doc = "(missing help text)"
doctext = "\n ".join(method_doc.split("\n"))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s

Expand Down
19 changes: 11 additions & 8 deletions Options.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,9 @@ def __ge__(self, other: typing.Union[Choice, int, str]):

class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int]
value: str | int

def __init__(self, value: typing.Union[str, int]):
def __init__(self, value: str | int):
assert isinstance(value, str) or isinstance(value, int), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
Expand All @@ -546,7 +546,7 @@ def from_text(cls, text: str) -> TextChoice:
return cls(text)

@classmethod
def get_option_name(cls, value: T) -> str:
def get_option_name(cls, value: str | int) -> str:
if isinstance(value, str):
return value
return super().get_option_name(value)
Expand Down Expand Up @@ -891,7 +891,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()


class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
supports_weighting = False
Expand All @@ -906,7 +906,8 @@ def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")

def get_option_name(self, value):
@classmethod
def get_option_name(cls, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())

def __getitem__(self, item: str) -> typing.Any:
Expand Down Expand Up @@ -986,7 +987,8 @@ def from_any(cls, data: typing.Any):
return cls(data)
return cls.from_text(str(data))

def get_option_name(self, value):
@classmethod
def get_option_name(cls, value):
return ", ".join(map(str, value))

def __contains__(self, item):
Expand All @@ -1011,7 +1013,8 @@ def from_any(cls, data: typing.Any):
return cls(data)
return cls.from_text(str(data))

def get_option_name(self, value):
@classmethod
def get_option_name(cls, value):
return ", ".join(sorted(value))

def __contains__(self, item):
Expand Down Expand Up @@ -1656,7 +1659,7 @@ def __iter__(self) -> typing.Iterator[PlandoItem]:
def __len__(self) -> int:
return len(self.value)


class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
Expand Down
62 changes: 42 additions & 20 deletions OptionsCreator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
ToggleButton, MarkupDropdown, ResizableTextField)
from kivy.clock import Clock
from kivy.uix.behaviors.button import ButtonBehavior
from kivymd.uix.behaviors import RotateBehavior
from kivymd.uix.anchorlayout import MDAnchorLayout
Expand Down Expand Up @@ -269,34 +270,53 @@ def __init__(self):
self.options = {}
super().__init__()

def export_options(self, button: Widget):
if 0 < len(self.name_input.text) < 17 and self.current_game:
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
@staticmethod
def show_result_snack(text: str) -> None:
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()

def on_export_result(self, text: str | None) -> None:
self.container.disabled = False
if text is not None:
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)

def export_options_background(self, options: dict[str, typing.Any]) -> None:
try:
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
except Exception:
self.on_export_result("Could not open dialog. Already open?")
raise

if not file_name:
self.on_export_result(None) # No file selected. No need to show a message for this.
return

try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
self.on_export_result("File saved successfully.")
except Exception:
self.on_export_result("Could not save file.")
raise

def export_options(self, button: Widget) -> None:
if 0 < len(self.name_input.text) < 17 and self.current_game:
import threading
options = {
"name": self.name_input.text,
"description": f"YAML generated by Archipelago {Utils.__version__}.",
"game": self.current_game,
self.current_game: {k: check_random(v) for k, v in self.options.items()}
}
try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
except FileNotFoundError:
MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
self.container.disabled = True
elif not self.name_input.text:
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
self.show_result_snack("Name must not be empty.")
elif not self.current_game:
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
self.show_result_snack("You must select a game to play.")
else:
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
self.show_result_snack("Name cannot be longer than 16 characters.")

def create_range(self, option: typing.Type[Range], name: str):
def update_text(range_box: VisualRange):
Expand Down Expand Up @@ -509,8 +529,10 @@ def randomize_option(instance: Widget, value: str):
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric() or self.options[name] in ("True", "False"):
self.options[name] = eval(self.options[name])
if self.options[name].isnumeric():
self.options[name] = int(self.options[name])
elif self.options[name] in ("True", "False"):
self.options[name] = self.options[name] == "True"

base_object = instance.parent.parent
label_object = instance.parent
Expand Down
38 changes: 28 additions & 10 deletions Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec

try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
Expand All @@ -48,7 +49,7 @@ def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)


__version__ = "0.6.6"
__version__ = "0.6.7"
version_tuple = tuplize_version(__version__)

is_linux = sys.platform.startswith("linux")
Expand Down Expand Up @@ -387,6 +388,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
logging.debug(f"Could not store data package: {e}")


def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
try:
with open(filename) as ignore_file:
return GitIgnoreSpec.from_lines(ignore_file)
except FileNotFoundError:
return None


def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
Expand Down Expand Up @@ -802,29 +811,32 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
try:
return tkinter.filedialog.askopenfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()


def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")

def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None

if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)

# fall back to tk
try:
Expand All @@ -847,8 +859,14 @@ def run(*args: str):
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
try:
return tkinter.filedialog.asksaveasfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()


def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
Expand Down
Loading
Loading