5 | {% endblock %}
6 |
7 |
--------------------------------------------------------------------------------
/readme.rst:
--------------------------------------------------------------------------------
1 | PyLNP: A launcher for Dwarf Fortress
2 | #####################################
3 |
4 | PyLNP has a variety of useful features to manage settings and configure the base
5 | game. It can also manage, configure, install, and run a wide variety of
6 | related content - from graphics to color schemes, and utility programs to
7 | content-replacing mods.
8 |
9 | The full documentation can be found at `./docs/index.rst`, or online at https://pylnp.birdiesoft.dk/docs/.
10 |
--------------------------------------------------------------------------------
/README.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 | Page Redirection
12 |
13 |
14 | Follow this link to the documentation, or view online at https://pylnp.birdiesoft.dk/docs/.
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
16 | Page Redirection
17 |
18 |
19 | Follow this link to the documentation.
20 |
21 |
--------------------------------------------------------------------------------
/COPYING.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014, Michael Madsen
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any
4 | purpose with or without fee is hereby granted, provided that the above
5 | copyright notice and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13 | PERFORMANCE OF THIS SOFTWARE.
14 |
15 |
--------------------------------------------------------------------------------
/launch.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """This file is used to launch the program."""
4 |
5 | import os
6 | import sys
7 |
8 | from core import lnp
9 |
10 | sys.path.insert(0, os.path.dirname(__file__))
11 | # pylint: disable=redefined-builtin
12 | __package__ = ""
13 |
14 | try:
15 | lnp.PyLNP()
16 | except SystemExit:
17 | raise
18 | except Exception:
19 | import traceback
20 | message = ''.join(traceback.format_exception(*sys.exc_info()))
21 | # Log exception to stderr if possible
22 | try:
23 | print(message, file=sys.stderr)
24 | except Exception:
25 | pass
26 |
27 | # Also show error in Tkinter message box if possible
28 | try:
29 | from tkinter import messagebox
30 | messagebox.showerror(message=message)
31 | except Exception:
32 | pass
33 |
--------------------------------------------------------------------------------
/PyLNP.json:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | ["Savegame folder","/data/save"],
4 | ["Utilities folder","LNP/Utilities"],
5 | ["Graphics folder","LNP/Graphics"],
6 | ["-","-"],
7 | ["Main folder",""],
8 | ["LNP folder","LNP"],
9 | ["Dwarf Fortress folder",""],
10 | ["Init folder","/data/init"]
11 | ],
12 | "links": [
13 | ["DF Homepage","https://www.bay12games.com/dwarves/"],
14 | ["DF Wiki","https://dwarffortresswiki.org/"],
15 | ["DF Forums","http://www.bay12forums.com/smf/"]
16 | ],
17 | "to_import": [
18 | ["text_prepend", "/gamelog.txt"],
19 | ["text_prepend", "/ss_fix.log"],
20 | ["text_prepend", "/dfhack.history"],
21 | ["copy_add", "/data/save"],
22 | ["copy_add", "/soundsense"]
23 | ],
24 | "hideUtilityPath": false,
25 | "hideUtilityExt": false,
26 | "updates": {
27 | "updateMethod": ""
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tkgui/tab.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import
4 | """Base class for notebook tabs for the TKinter GUI."""
5 |
6 | from tkinter import BOTH, TOP, Y
7 | from tkinter.ttk import Frame
8 |
9 |
10 | # pylint: disable=unused-argument
11 | class Tab(Frame):
12 | """Base class for notebook tabs for the TKinter GUI."""
13 | def __init__(self, parent, *args, **kwargs):
14 | super().__init__()
15 | self.parent = parent
16 | self.pack(side=TOP, fill=BOTH, expand=Y)
17 | self.create_variables()
18 | self.create_controls()
19 | self.read_data()
20 |
21 | def create_variables(self):
22 | """
23 | Creates all TKinter variables needed by this tab.
24 | Overridden in child classes.
25 | """
26 |
27 | def read_data(self):
28 | """Reads all external data needed. Overridden in child classes."""
29 |
30 | def create_controls(self):
31 | """Creates all controls for this tab. Overriden in child classes."""
32 |
--------------------------------------------------------------------------------
/core/embarks.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Embark profile management."""
4 |
5 | import os
6 |
7 | from . import helpers, log, paths
8 | from .dfraw import DFRaw
9 |
10 |
11 | def read_embarks():
12 | """Returns a list of embark profiles."""
13 | return tuple(sorted([
14 | os.path.basename(o) for o in helpers.get_text_files(
15 | paths.get('embarks'))]))
16 |
17 |
18 | def install_embarks(files):
19 | """
20 | Installs a list of embark profiles.
21 |
22 | Args:
23 | files: list of files to install.
24 | """
25 | with DFRaw.open(paths.get('init', 'embark_profiles.txt'), 'wt') as out:
26 | log.i('Installing embark profiles: ' + str(files))
27 | for f in files:
28 | embark = DFRaw.read(paths.get('embarks', f))
29 | out.write(embark + "\n\n")
30 |
31 |
32 | def get_installed_files():
33 | """Returns the names of the currently installed embark profiles."""
34 | files = helpers.get_text_files(paths.get('embarks'))
35 | current = paths.get('init', 'embark_profiles.txt')
36 | result = helpers.detect_installed_files(current, files)
37 | return [os.path.basename(r) for r in result]
38 |
--------------------------------------------------------------------------------
/tkgui/tkhelpers.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """
4 | Static utility methods that are needed in several parts of the TkGui module.
5 | """
6 |
7 | from tkinter import messagebox
8 |
9 | from core import baselines, download
10 | from core.lnp import lnp
11 |
12 |
13 | def check_vanilla_raws():
14 | """Validates status of vanilla raws are ready."""
15 | if not download.get_queue('baselines').empty():
16 | return False
17 | raw_status = baselines.find_vanilla_raws()
18 | if raw_status is None:
19 | messagebox.showerror(
20 | message='Your Dwarf Fortress version could not be detected '
21 | 'accurately, which is necessary to process this request.'
22 | '\n\nYou will need to restore the file "release notes.txt" in '
23 | 'order to use this launcher feature.', title='Cannot continue')
24 | return False
25 | if raw_status is False:
26 | if lnp.userconfig.get_bool('downloadBaselines'):
27 | messagebox.showinfo(
28 | message='A copy of Dwarf Fortress needs to be '
29 | 'downloaded in order to use this. The download is '
30 | 'currently in progress.\n\nPlease note: You '
31 | 'will need to retry the action after the download '
32 | 'completes.', title='Download required')
33 | return False
34 | return True
35 |
--------------------------------------------------------------------------------
/core/paths.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Path management."""
4 |
5 | import os
6 |
7 | from . import log
8 |
9 | __paths = {}
10 |
11 |
12 | def _identify_folder_name(base, name):
13 | """
14 | Allows folder names to be lowercase on case-sensitive systems.
15 | Returns "base/name" where name is lowercase if the lower case version
16 | exists and the standard case version does not.
17 |
18 | Args:
19 | base: the path containing the desired folder.
20 | name: the standard case name of the desired folder.
21 | """
22 | normal = os.path.join(base, name)
23 | lower = os.path.join(base, name.lower())
24 | if os.path.isdir(lower) and not os.path.isdir(normal):
25 | return lower
26 | return normal
27 |
28 |
29 | def register(name, *path_elms, **kwargs):
30 | """Registers a path constructed by under .
31 | If multiple path elements are given, the last
32 | element will undergo case correction (see _identify_folder_name).
33 |
34 | Args:
35 | name: a registered name corresponding to some path segment
36 | path_elems: path elements, which will be joined to the root path
37 | allow_create: If True, the registered path will be created if it
38 | does not already exist. Defaults to True.
39 | """
40 | if len(path_elms) > 1:
41 | __paths[name] = _identify_folder_name(os.path.join(
42 | *path_elms[:-1]), path_elms[-1])
43 | else:
44 | __paths[name] = path_elms[0]
45 | log.i('Registering path %s as %s', name, __paths[name])
46 | if kwargs.get('allow_create', True) and not os.path.exists(__paths[name]):
47 | os.makedirs(__paths[name])
48 |
49 |
50 | def get(name, *paths):
51 | """Returns the path registered under , or an empty string if
52 | is not known."""
53 | try:
54 | base = __paths[name]
55 | except KeyError:
56 | base = ''
57 | return os.path.join(base, *paths)
58 |
59 |
60 | def clear():
61 | """Clears the path cache."""
62 | __paths.clear()
63 |
--------------------------------------------------------------------------------
/docs/developer.rst:
--------------------------------------------------------------------------------
1 | PyLNP Developer Reference
2 | #########################
3 |
4 | This document will describe PyLNP from a developer's perspective.
5 |
6 | Licensing
7 | =========
8 | PyLNP is licensed under the ISC license (see ``COPYING.txt``), which essentially
9 | allows you to modify and distribute changes as you see fit. (This only
10 | applies to the launcher. Any bundled utilities, graphics packs, etc. have
11 | their own licenses; refer to those projects separately.)
12 |
13 | Acquiring the source code
14 | =========================
15 | The source code is available at `Github `.
16 |
17 | If you wish to contribute to PyLNP development, a pull request will be much appreciated.
18 |
19 | Coding guidelines
20 | =================
21 | - All source files must start with the following preamble (followed by a blank line for separation)::
22 |
23 | #!/usr/bin/env python
24 | # -*- coding: utf-8 -*-
25 | """"""
26 |
27 | - As a rule of thumb, `PEP 8 ` should be followed. *In particular*, `pylint` should return no errors or warnings when run on the source code (using the provided `pylintrc` file). Any and all messages generated by `pylint` must either be fixed or explicitly suppressed on a case-by-case basis. Formatting-related messages (line length, indentation, etc.) must *always* be fixed. Examples of valid suppressions:
28 |
29 | - PyInstaller requires a certain package to appear in an `import` statement in order to create working binaries, but the statement doesn't need to actually run at any point in time. Here, it is valid to place the `import` statement in an `if False:` block and suppress the resulting `using-constant-test` message.
30 | - Inner functions defined for the purpose of callbacks are permitted to not have docstrings, but the warning for this must be suppressed for the individual function.
31 |
32 | - PyLNP is divided into two main components: `core`, which is the main application, and `tkgui`, which represents the UI. The UI *must only* perform non-UI work by calling into functions of the `core` library. This is to simplify implementation of alternative user interfaces. Where possible, `core` methods should not call into the UI; in cases where this is unavoidable, the method must be documented in the `UI` class defined in `lnp.py`.
33 |
34 | List of Modules
35 | ===============
36 |
37 | .. toctree::
38 | :maxdepth: 1
39 | :glob:
40 |
41 | core/*
42 | tkgui/*
43 |
--------------------------------------------------------------------------------
/tkgui/binding.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Handles control binding for the TKinter GUI."""
4 |
5 | from tkinter import END
6 | from tkinter.ttk import Entry
7 |
8 | __controls = {}
9 | __lnp = None
10 | __ui = None
11 |
12 |
13 | def init(lnp, ui):
14 | """Connect to LNP and TkGui instances."""
15 | # pylint:disable=global-statement
16 | global __lnp, __ui
17 | __lnp = lnp
18 | __ui = ui
19 | __controls.clear()
20 |
21 |
22 | def bind(control, option, update_func=None):
23 | """Binds a control to an option."""
24 |
25 | if option not in __controls:
26 | __controls[option] = []
27 |
28 | if update_func:
29 | value = (control, update_func)
30 | else:
31 | value = control
32 | __controls[option].append(value)
33 |
34 |
35 | def version_has_option(field):
36 | """Returns True if the current DF version has the provided field."""
37 | o = field
38 | if not isinstance(field, str):
39 | o = field[0]
40 | return __lnp.settings.version_has_option(o)
41 |
42 |
43 | def get(field):
44 | """
45 | Returns the value of the control known as .
46 | If multiple controls are bound, the earliest binding is used.
47 | """
48 | return __controls[field][0].get()
49 |
50 |
51 | def update():
52 | """Updates configuration displays (buttons, etc.)."""
53 | def disabled_change_entry(*args, **kwargs): # pylint: disable=unused-argument
54 | """Prevents entry change callbacks from being processed."""
55 |
56 | old_change_entry = __ui.change_entry
57 | __ui.change_entry = disabled_change_entry
58 | for key, option in __controls.items():
59 | try:
60 | k = key
61 | if not isinstance(k, str):
62 | k = key[0]
63 | value = getattr(__lnp.settings, k)
64 | except KeyError:
65 | value = ''
66 | for entry in option:
67 | if hasattr(entry, '__iter__'):
68 | # Allow (control, func) tuples, etc. to customize value
69 | control = entry[0]
70 | value = entry[1](value)
71 | else:
72 | control = entry
73 | if isinstance(control, Entry):
74 | control.delete(0, END)
75 | control.insert(0, value)
76 | else:
77 | control["text"] = (
78 | control["text"].split(':')[0] + ': '
79 | + str(value))
80 | __ui.change_entry = old_change_entry
81 |
82 | # vim:expandtab
83 |
--------------------------------------------------------------------------------
/docs/static/switcher.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | 'use strict';
3 |
4 | // Parses versions in URL segments like:
5 | // "3", "dev", "release/2.7" or "3.6rc2"
6 | var version_regexs = [
7 | '(?:\\d)',
8 | '(?:\\d\\.\\d[\\w\\d\\.]*)',
9 | '(?:dev)',
10 | '(?:release/\\d.\\d[\\x\\d\\.]*)'];
11 |
12 | var all_versions = {
13 | 'dev': 'dev',
14 | '0.13': '0.13',
15 | '0.12c': '0.12c',
16 | };
17 |
18 | function build_version_select(current_version, current_release) {
19 | var buf = ['');
30 | return buf.join('');
31 | }
32 |
33 | function navigate_to_first_existing(urls) {
34 | // Navigate to the first existing URL in urls.
35 | var url = urls.shift();
36 | if (urls.length == 0) {
37 | window.location.href = url;
38 | return;
39 | }
40 | $.ajax({
41 | url: url,
42 | success: function() {
43 | window.location.href = url;
44 | },
45 | error: function() {
46 | navigate_to_first_existing(urls);
47 | }
48 | });
49 | }
50 |
51 | function on_version_switch() {
52 | var selected_version = $(this).children('option:selected').attr('value') + '/';
53 | var url = window.location.href;
54 | var current_version = version_segment_in_url(url);
55 | var new_url = url.replace('/' + current_version,
56 | '/' + selected_version);
57 | if (new_url != url) {
58 | navigate_to_first_existing([
59 | new_url,
60 | url.replace('/' + current_version,
61 | '/' + selected_version)
62 | ]);
63 | }
64 | }
65 |
66 |
67 | // Returns the path segment of the version as a string, like '3.6/'
68 | // or '' if not found.
69 | function version_segment_in_url(url) {
70 | var version_segment = '(?:(?:' + version_regexs.join('|') + ')/)';
71 | var version_regexp = '/(' + version_segment + ')';
72 | var match = url.match(version_regexp);
73 | if (match !== null)
74 | return match[1];
75 | return ''
76 | }
77 |
78 | $(document).ready(function() {
79 | var release = DOCUMENTATION_OPTIONS.VERSION;
80 | var version = release;
81 | var version_select = build_version_select(version, release);
82 |
83 | $('.version-placeholder').html(version_select);
84 | $('.version-placeholder select').bind('change', on_version_switch);
85 | });
86 | })();
87 |
88 |
--------------------------------------------------------------------------------
/tkgui/layout.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init
4 | """Layout helpers for the TKinter GUI."""
5 |
6 | from . import controls
7 |
8 |
9 | class GridLayouter(object):
10 | """Class to automate grid layouts."""
11 | def __init__(self, cols, pad=(0, 0)):
12 | """
13 | Constructor for GridLayouter.
14 |
15 | Args:
16 | cols: number of columns for the grid.
17 | pad: the amount (x, y) of padding between elements
18 | """
19 | self.cols = cols
20 | self.controls = []
21 | self.used = []
22 | try:
23 | self.pad = (int(pad), int(pad))
24 | except TypeError: # not an int; assume tuple
25 | self.pad = pad
26 |
27 | def add(self, control, span=1, **opts):
28 | """
29 | Adds a control to the grid.
30 |
31 | Args:
32 | control: the control to add.
33 | span: the number of columns to span (defaults to 1).
34 | opts: extra options for the grid layout.
35 | """
36 | if control is controls.fake_control:
37 | return
38 | self.controls.append((control, span, opts))
39 | self.layout()
40 |
41 | def layout(self):
42 | """Applies layout to the added controls."""
43 | cells_used = 0
44 | max_index = len(self.controls) - 1
45 | for i, c in enumerate(self.controls):
46 | c = list(c)
47 | while True:
48 | row = cells_used // self.cols
49 | col = cells_used % self.cols
50 | if (row, col) not in self.used:
51 | break
52 | cells_used += 1
53 |
54 | padx = 0 if col == 0 else (self.pad[0], 0)
55 | pady = 0 if row == 0 else (self.pad[1], 0)
56 |
57 | if ((i == max_index and col != self.cols - 1) or (
58 | i < max_index
59 | and col + c[1] + self.controls[i + 1][1] > self.cols)):
60 | # Pad colspan if last control, or next control won't fit
61 | colspan = self.cols - col
62 | for n in range(col + 1, self.cols):
63 | if (row, n) in self.used:
64 | colspan = n - col
65 | break
66 | c[1] = colspan
67 |
68 | c[0].grid(
69 | row=row, column=col, sticky="nsew", columnspan=c[1], padx=padx,
70 | pady=pady, **c[2])
71 | if 'rowspan' in c[2]:
72 | for n in range(1, c[2]['rowspan'] + 1):
73 | self.used.append((row + n, col))
74 | cells_used += c[1]
75 |
--------------------------------------------------------------------------------
/.hgtags:
--------------------------------------------------------------------------------
1 | f585d2edeb011edc65ec4acabd61d32020f30699 0.1
2 | 0f81a4c9ac0531b5eb727e50b9a44a2504ac61df 0.2
3 | 0f81a4c9ac0531b5eb727e50b9a44a2504ac61df 0.2
4 | ed8da97fc286be2f5aa312b5f010cd078d33f411 0.2
5 | 7bb6b0f3eacd0bd34850c671dd4b0dbe6b7d6875 0.3
6 | 7bb6b0f3eacd0bd34850c671dd4b0dbe6b7d6875 0.3
7 | b659e76b9fad151adcd68ff999e17faed87f91b6 0.3
8 | b659e76b9fad151adcd68ff999e17faed87f91b6 0.3
9 | 24d3573354804de05acdb2658ece1db8848544c6 0.3
10 | baac1e9ba968ab545beb01ed5410430092e30167 0.4
11 | 74be5f566b0daa30e5f33ded57e0ee6d7b7d411c 0.5
12 | 19e70c903455254da0ce8c68e21ccf51b294a6e7 0.5.1
13 | fe60b7abcd8bf17179cd22dec5956b9826fa40ed 0.5.2
14 | c3dbee512700b7d081930f29b617d1be925785ff 0.6
15 | 68bfd106be8c303fc42cb8ae5f69471c8f1d7147 0.7
16 | 066e76d6aec3b8108e2b5fc7a1631e88cc4811e9 0.7.1
17 | de100756dab84d6be45d6cee6e1fe738b4d3e072 0.8
18 | 9b689b63c38b9389b40af75b7817f7edb0d27d1a 0.8a
19 | 246a60c96c5b0f3276cb18806749ab03e12bf265 0.8b
20 | f76f9eda5213ad277690bd28f47ca93cc3ba7acf 0.9
21 | 0b234231cd979ffe3973f04c45e8f1eec77386f4 0.9.1
22 | aaf223fc7ab84a04812be7e83d5d29e7a3342d8f 0.9.1a
23 | 9c35c852d4cd9840ce6c312d373d5242130d081e 0.9.2
24 | 9c35c852d4cd9840ce6c312d373d5242130d081e 0.9.2
25 | 6b119d9af8ca58feb9f1bb3d71a05ecf45b0d238 0.9.2
26 | 373edd61071f11336373901fe6a50eaf81fae539 0.9.2a
27 | 8f7d25bfaf25539a618291f19da8603ee6d226eb 0.9.3
28 | 0ddce7f4e3df4e3801f933388057f8adb2614767 0.9.4
29 | 7a8681a430f0d91007dbb5e3b888bcefffb340ff 0.9.5
30 | 6e32f68645bd5c0f6559d6fe1ff4637a5e81f05b 0.9.5a
31 | 75dbda09a431f56eda7db430c119b0745dcf8ac6 0.9.6
32 | b5a749292cc4b1f59b46bcc8f921d3dcb2feaacb 0.10
33 | 5d9da7b71045f0e0beec719776625d8e9c36232b 0.10a
34 | 3a1cf73201bb56967483721334b38fb1071e46a7 0.10b
35 | 80d760b59cc55440ee1575b7364c070939970f82 0.10c
36 | 652e55aebafe822f9d4ff2c7960114db6db62f0d 0.10d
37 | 427519e67058d9ac2a6444586e5178ebc7d9c366 0.10e
38 | 427519e67058d9ac2a6444586e5178ebc7d9c366 0.10e
39 | 28e9c79af2879c0fb780a39d96e1ec30ff35e17a 0.10e
40 | 2a486b049ceb77f410462d8c47c63199f80fcbfe 0.10f
41 | 338ec9f62c919ef3601f1df7313b5d028a3a26ab 0.11
42 | 338ec9f62c919ef3601f1df7313b5d028a3a26ab 0.11
43 | 4145af6bbe21761ac24e5f9e70b66b0bf2eeab37 0.11
44 | 793accf034e73af3e6f389009817ca0a869c2374 0.12
45 | e5ee63fda86383b7ea525246b5146e586ac79d67 0.12a
46 | b399ba23d6c227b7150c823e5d587f54feaf4510 0.12b
47 | 0bb39798fe8f85312b32186b453398862b040e6d 0.12c
48 | 5a681f0dd4929b4bb3363e7e671b5c00f7e70e45 0.13
49 | e86355507a529529eb245a3cdc8f2ab52ec29885 0.13a
50 | a45542d6e7fdbe0c2bc24ab5bba99d2b8faafdbb 0.13b
51 | 8b0477460243e1c69ca8a3d3d560061cbe0be5f6 0.14
52 | cf7126bc39684d16b2fd5aa9bf60f1d79d182378 0.14a
53 | 926437db6a1714bfdf9c51482c640ceef55e1acf 0.14b
54 | b4805d4b4e81f9b8f7759fb356fe0c75d824f97e 0.14c
55 | a6a2758323918a867325c29e12f283e1a53e751d 0.14d
56 | d482fb0ccf48054718e1b00faf5e4fcc9c8370ca 0.14e-pre1
57 |
--------------------------------------------------------------------------------
/core/errorlog.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Framework for logging errors."""
4 |
5 | import sys
6 |
7 | from . import paths
8 |
9 |
10 | class CaptureStream(object):
11 | """ Redirects output to a file-like object to an internal list as well as a
12 | file."""
13 | def __init__(self, name, add_header=False, tee=True):
14 | """
15 | Constructor for CaptureStream. Call redirect() to start redirection.
16 |
17 | Args:
18 | name: the name of the sys stream to capture (e.g. 'stdout' for
19 | sys.stdout)
20 | add_header: if True, extra information will be printed to the file
21 | when it is initially written to. The text contains the PyLNP
22 | version number, the OS it's running on, and whether it's a
23 | compiled executable.
24 | tee: if True, forward writing to the original stream after
25 | capturing. If False, the redirected stream is not used.
26 | """
27 | self.softspace = 0
28 | self.lines = []
29 | self.name = name
30 | self.add_header = add_header
31 | self.tee = tee
32 | self.stream = getattr(sys, name)
33 | self.outfile = None
34 |
35 | def write(self, string):
36 | """
37 | Writes a string to the captured stream.
38 |
39 | Args:
40 | string: The string to write.
41 | """
42 | self.lines.append(string)
43 | if not self.outfile:
44 | # TODO: See if it's possible to use a with statment here
45 | # pylint: disable=consider-using-with
46 | self.outfile = open(
47 | paths.get('root', self.name + '.txt'), 'w', encoding='utf-8')
48 | # pylint: enable=consider-using-with
49 | if self.add_header:
50 | from .lnp import VERSION, lnp
51 | self.outfile.write(
52 | "Running PyLNP {} (OS: {}, Compiled: {})\n".format(
53 | VERSION, lnp.os, lnp.os == lnp.bundle))
54 | self.outfile.write(string)
55 | self.flush()
56 | if self.tee:
57 | return self.stream.write(string)
58 | return None
59 |
60 | def flush(self):
61 | """Flushes the output file."""
62 | if self.outfile is not None:
63 | self.outfile.flush()
64 |
65 | def hook(self):
66 | """Replaces the named stream with the redirected stream."""
67 | setattr(sys, self.name, self)
68 |
69 | def unhook(self):
70 | """Restores the original stream object."""
71 | setattr(sys, self.name, self.stream)
72 |
73 |
74 | def start():
75 | """Starts redirection of stdout and stderr."""
76 | out.hook()
77 | err.hook()
78 |
79 |
80 | def stop():
81 | """Stops redirection of stdout and stderr."""
82 | out.unhook()
83 | err.unhook()
84 |
85 |
86 | out = CaptureStream('stdout')
87 | err = CaptureStream('stderr', True)
88 |
89 | # vim:expandtab
90 |
--------------------------------------------------------------------------------
/core/helpers.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Helper functions."""
4 |
5 | import glob
6 | import os
7 | import platform
8 | import sys
9 |
10 | from . import log
11 | from .dfraw import DFRaw
12 |
13 |
14 | def get_text_files(directory):
15 | """
16 | Returns a list of .txt files in .
17 | Excludes all filenames beginning with "readme" (case-insensitive).
18 |
19 | Args:
20 | directory: the directory to search.
21 | """
22 | temp = glob.glob(os.path.join(directory, '*.txt'))
23 | result = []
24 | for f in temp:
25 | if not os.path.basename(f).lower().startswith('readme'):
26 | result.append(f)
27 | return result
28 |
29 |
30 | def detect_installed_file(current_file, test_files):
31 | """Returns the file in which is contained in
32 | , or "Unknown"."""
33 | try:
34 | current = DFRaw.read(current_file)
35 | for f in test_files:
36 | tested = DFRaw.read(f)
37 | if tested.endswith('\n'):
38 | tested = tested[:-1]
39 | if tested in current:
40 | return f
41 | except IOError:
42 | pass
43 | return "Unknown"
44 |
45 |
46 | def detect_installed_files(current_file, test_files):
47 | """Returns a list of files in that are contained in
48 | ."""
49 | if not os.path.isfile(current_file):
50 | log.d('Nothing installed in nonexistent file {}'.format(current_file))
51 | return []
52 | installed = []
53 | try:
54 | current = DFRaw.read(current_file)
55 | for f in test_files:
56 | try:
57 | tested = DFRaw.read(f)
58 | if tested.endswith('\n'):
59 | tested = tested[:-1]
60 | if tested in current:
61 | installed.append(f)
62 | except IOError:
63 | log.e('Cannot tell if {} is installed; read failed'.format(f))
64 | except IOError:
65 | log.e('Cannot check installs in {}; read failed'.format(current_file))
66 | return installed
67 |
68 |
69 | def get_resource(filename):
70 | """
71 | If running in a bundle, this will point to the place internal
72 | resources are located; if running the script directly,
73 | no modification takes place.
74 |
75 | Args:
76 | filename (str): the ordinary path to the resource
77 |
78 | Returns:
79 | (str): Path for bundled filename
80 | """
81 | from .lnp import lnp
82 | if lnp.bundle == 'osx':
83 | # file is inside application bundle on OS X
84 | return os.path.join(os.path.dirname(sys.executable), filename)
85 | if lnp.bundle in ['win', 'linux']:
86 | # file is inside executable on Linux and Windows
87 | # pylint: disable=protected-access, no-member
88 | return os.path.join(sys._MEIPASS, filename)
89 | return os.path.abspath(filename)
90 |
91 |
92 | def os_is_64bit():
93 | """Returns true if running on a 64-bit OS."""
94 | return platform.machine().endswith('64')
95 |
96 |
97 | def key_from_underscore_prefixed_string(s):
98 | """Converts a string to a key such that strings prefixed with an underscore
99 | will be sorted before other strings."""
100 | return not s.startswith('_'), s
101 |
--------------------------------------------------------------------------------
/core/manifest.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Manages content manifests for graphics, mods, and utilities."""
4 |
5 | import os
6 |
7 | from . import json_config, paths
8 | from .lnp import lnp
9 |
10 |
11 | def get_cfg(content_type, item):
12 | """Returns a JSONConfiguration object for the given item.
13 |
14 | **Manifest format:**
15 |
16 | The manifest is a dictionary of values, which can be saved as manifest.json
17 | in the top level of the content folder. Content is as below, except
18 | that True or False should not be capitalised. Whitespace is irrelevant.
19 | Unused lines can be left out of the file.
20 |
21 | 'title' and 'tooltip' control presentation in the list for that kind of
22 | content. Both should be strings. Title is the name in the list; tooltip
23 | is the hovertext - linebreaks are inserted with ``\\n``, since it must be
24 | one ine in the manifest file.
25 |
26 | 'folder_prefix' controls what the name of the graphics pack's folder must
27 | begin with.
28 |
29 | 'author' and 'version' are strings for the author and version of the
30 | content. Both are for information only at this stage.
31 |
32 | 'df_min_version', 'df_max_version', and 'df_incompatible_versions' allow
33 | you to specify versions of DF with which the content is incompatible.
34 | Versions are strings of numbers, of the format '0.40.24'. Min and max are
35 | the lowest and highest compatible versions; anything outside that range has
36 | the content hidden. If they are not set, they assume all earlier or later
37 | versions are compatible. incompatible_versions is a list of specific
38 | versions which are incompatible, for when the range alone is insufficient.
39 |
40 | 'needs_dfhack' is a boolean value, and should only be True if the content
41 | does not function *at all* without DFHack. Partial requirements can be
42 | explained to the user with the 'tooltip' field.
43 |
44 | Args:
45 | content_type: 'graphics', 'mods', or 'utilities'
46 | item: content identifier path segment, such that
47 | the full path is ``'LNP/content_type/item/*'``
48 |
49 | Returns:
50 | core.json_config.JSONConfiguration: manifest object
51 | """
52 | default_config = {
53 | 'author': '',
54 | 'content_version': '',
55 | 'df_min_version': '',
56 | 'df_max_version': '',
57 | 'df_incompatible_versions': [],
58 | 'needs_dfhack': False,
59 | 'title': '',
60 | 'folder_prefix': '',
61 | 'tooltip': ''
62 | }
63 | if content_type == 'utilities':
64 | default_config.update({
65 | 'win_exe': '',
66 | 'osx_exe': '',
67 | 'linux_exe': '',
68 | 'launch_with_terminal': False,
69 | 'readme': '',
70 | })
71 | manifest = paths.get(content_type, item, 'manifest.json')
72 | return json_config.JSONConfiguration(manifest, default_config, warn=False)
73 |
74 |
75 | def exists(content_type, item):
76 | """Returns a bool, that the given item has a manifest.
77 | Used before calling get_cfg if logging a warning isn't required."""
78 | return os.path.isfile(paths.get(content_type, item, 'manifest.json'))
79 |
80 |
81 | def is_compatible(content_type, item, ver=''):
82 | """Boolean compatibility rating; True unless explicitly incompatible."""
83 | if not exists(content_type, item):
84 | return True
85 | if not ver:
86 | ver = lnp.df_info.version
87 | cfg = get_cfg(content_type, item)
88 | df_min_version = cfg.get_string('df_min_version')
89 | df_max_version = cfg.get_string('df_max_version')
90 | return not any([
91 | ver < df_min_version,
92 | (ver > df_max_version and df_max_version),
93 | ver in cfg.get_list('incompatible_df_versions'),
94 | cfg.get_bool('needs_dfhack') and 'dfhack' not in lnp.df_info.variations
95 | ])
96 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | PyLNP: A launcher for Dwarf Fortress
2 | #####################################
3 |
4 | PyLNP has a variety of useful features to manage settings and configure the base
5 | game. It can also manage, configure, install, and run a wide variety of
6 | related content - from graphics to color schemes, and utility programs to
7 | content-replacing mods.
8 |
9 | It forms the core of various bundles for new, lazy, or impatient players -
10 | such as the old "Lazy Newb Pack". While this project is just the launcher,
11 | you can download a complete bundle for Windows, OSX, or Linux put together by members of
12 | the community. See https://dwarffortresswiki.org/Lazy_Newb_Pack for current links to these.
13 |
14 | If you have a question that is not answered here, go ahead and ask it in the
15 | `Bay12 forum thread for PyLNP `_.
16 |
17 |
18 | .. contents::
19 |
20 |
21 | Documentation
22 | =============
23 | .. toctree::
24 | :maxdepth: 1
25 |
26 | content
27 | developer
28 | building
29 |
30 | Indices and tables
31 | ==================
32 | * :ref:`genindex`
33 | * :ref:`modindex`
34 | * :ref:`search`
35 |
36 |
37 | Usage Instructions
38 | ==================
39 | There's not much to it: run the launcher (see below) then click the buttons
40 | to configure your DF install, change settings or graphics, merge and install
41 | mods, run utility programs, start DF, and more.
42 |
43 | This section should probably be larger, but it should be clear how things
44 | work from the tooltips if you hover over a button. If not, ask in the forum
45 | thread linked above! The customisation section below, intended for advanced
46 | users and those compiling a custom package, may also be helpful.
47 |
48 |
49 | History
50 | =======
51 | PyLNP started as a port of LucasUP and tolyK's Lazy Newb Pack Launcher to
52 | Python, making a launcher available on all the same platforms as Dwarf
53 | Fortress.
54 |
55 | The new edition includes many new and improved features; some of the
56 | non-obvious ones including:
57 |
58 | - Dwarf Fortress can be placed in an arbitrarily-named folder.
59 | - If multiple valid DF folders are detected, you will be asked to select the
60 | desired instance. This allows you to manage multiple installs separately with
61 | the same launcher, though this is not recommended.
62 | - A new menu item, File > Output log has been added. This opens a window
63 | containing various messages captured while executing the launcher. If errors
64 | occur, they will show up here, and are also written to a file.
65 | - In addition to excluding specific file names from utilities, you can also
66 | *include* specific file names, if they're found. Simply create a file
67 | include.txt in the Utilities folder and fill it in with the same syntax as
68 | exclude.txt.
69 | - Multiple utilities can be selected and launched simultaneously.
70 | - Utilities may be automatically started at the same time as Dwarf Fortress.
71 | - Color scheme installation and preview.
72 | - Installing graphics sets by patching instead of replacing init.txt and
73 | d_init.txt. This preserves all options not strictly related to graphics sets.
74 |
75 |
76 | When something goes wrong
77 | =========================
78 | You may experience error messages or similar issues while running the
79 | program. As long as it has not crashed, you can retrieve these error messages
80 | by opening File > Output log. The contents shown in here can be very useful
81 | for fixing the problem, so include them if you report an error.
82 |
83 | If the program *does* crash, you can look at stdout.txt and stderr.txt which
84 | are automatically created in the application directory and show the same
85 | contents as the output log inside the program. Note that these files get
86 | overwritten every time the program launches.
87 |
88 | Please be as specific as possible when reporting an error - tell exactly what
89 | you were doing. If you were installing a graphics pack, mention which one
90 | (provide a link to where you got it). If the problem is with a utility, make
91 | sure the utility works if you launch it manually - if it doesn't, then it's a
92 | problem with the utility, not with PyLNP.
93 |
--------------------------------------------------------------------------------
/core/hacks.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """DFHack management."""
4 |
5 | import collections
6 | import filecmp
7 | import os
8 | import shutil
9 | import sys
10 |
11 | from . import log, paths
12 | from .lnp import lnp
13 |
14 |
15 | def open_dfhack_readme():
16 | """Open the DFHack Readme in the default browser."""
17 | from . import launcher
18 | index = paths.get('df', 'hack', 'docs', 'index.html')
19 | if os.path.isfile(index):
20 | launcher.open_file(index)
21 | else:
22 | launcher.open_url('https://dfhack.readthedocs.org')
23 |
24 |
25 | def read_hacks():
26 | """Reads which hacks are enabled."""
27 | hacklines = []
28 | for init_file in ('dfhack', 'onLoad', 'onMapLoad'):
29 | try:
30 | with open(paths.get('dfhack_config', init_file + '_PyLNP.init'),
31 | encoding='latin1') as f:
32 | hacklines.extend(line.strip() for line in f.readlines())
33 | except IOError:
34 | log.debug(init_file + '_PyLNP.init not found.')
35 | return {name: hack for name, hack in get_hacks().items()
36 | if hack['command'] in hacklines}
37 |
38 |
39 | def is_dfhack_enabled():
40 | """Returns YES if DFHack should be used."""
41 | if sys.platform == 'win32':
42 | if 'dfhack' not in lnp.df_info.variations:
43 | return False
44 | sdl = paths.get('df', 'SDL.dll')
45 | sdlreal = paths.get('df', 'SDLreal.dll')
46 | if not os.path.isfile(sdlreal):
47 | return False
48 | return not filecmp.cmp(sdl, sdlreal, 0)
49 | return lnp.userconfig.get_value('use_dfhack', True)
50 |
51 |
52 | def toggle_dfhack():
53 | """Toggles the use of DFHack."""
54 | if sys.platform == 'win32':
55 | if 'dfhack' not in lnp.df_info.variations:
56 | return
57 | sdl = paths.get('df', 'SDL.dll')
58 | sdlhack = paths.get('df', 'SDLhack.dll')
59 | sdlreal = paths.get('df', 'SDLreal.dll')
60 | if is_dfhack_enabled():
61 | shutil.copyfile(sdl, sdlhack)
62 | shutil.copyfile(sdlreal, sdl)
63 | else:
64 | shutil.copyfile(sdl, sdlreal)
65 | shutil.copyfile(sdlhack, sdl)
66 | else:
67 | lnp.userconfig['use_dfhack'] = not lnp.userconfig.get_value(
68 | 'use_dfhack', True)
69 | lnp.save_config()
70 |
71 |
72 | def get_hacks():
73 | """Returns dict of available hacks."""
74 | return collections.OrderedDict(sorted(
75 | lnp.config.get_dict('dfhack').items(), key=lambda t: t[0]))
76 |
77 |
78 | def get_hack(title):
79 | """
80 | Returns the hack titled , or None if this does not exist.
81 |
82 | Args:
83 | title: the title of the hack.
84 | """
85 | try:
86 | return get_hacks()[title]
87 | except KeyError:
88 | log.d('No hack configured with name ' + title)
89 | return None
90 |
91 |
92 | def toggle_hack(name):
93 | """
94 | Toggles the hack .
95 |
96 | Args:
97 | name: the name of the hack to toggle.
98 |
99 | Returns:
100 | True if the hack is now enabled,
101 | False if the hack is now disabled,
102 | None on error (no change in status)
103 | """
104 | # Setup - get the hack, which file, and validate
105 | hack = get_hack(name)
106 | init_file = hack.get('file', 'dfhack')
107 | if init_file not in ('dfhack', 'onLoad', 'onMapLoad'):
108 | log.e('Illegal file configured for hack %s; must be one of '
109 | '"dfhack", "onLoad", "onMapLoad"', name)
110 | return None
111 | # Get the enabled hacks for this file, and toggle our state
112 | hacks = {name: h for name, h in read_hacks().items()
113 | if h.get('file', 'dfhack') == init_file}
114 | is_enabled = False
115 | if not hacks.pop(name, False):
116 | is_enabled = True
117 | hacks[name] = hack
118 | # Write back to the file
119 | fname = paths.get('dfhack_config', init_file + '_PyLNP.init')
120 | log.i('Rebuilding {} with the enabled hacks'.format(fname))
121 | lines = ['# {}\n# {}\n{}\n\n'.format(
122 | k, h['tooltip'].replace('\n', '\n#'), h['command'])
123 | for k, h in hacks.items()]
124 | if lines:
125 | with open(fname, 'w', encoding='latin1') as f:
126 | f.write('# Generated by PyLNP\n\n')
127 | f.writelines(lines)
128 | elif os.path.isfile(fname):
129 | os.remove(fname)
130 | return is_enabled
131 |
--------------------------------------------------------------------------------
/core/colors.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Color scheme management."""
4 |
5 | import os
6 | import shutil
7 |
8 | from . import helpers, log, paths
9 | from .dfraw import DFRaw
10 | from .lnp import lnp
11 |
12 | _df_colors = (
13 | 'BLACK', 'BLUE', 'GREEN', 'CYAN',
14 | 'RED', 'MAGENTA', 'BROWN', 'LGRAY',
15 | 'DGRAY', 'LBLUE', 'LGREEN', 'LCYAN',
16 | 'LRED', 'LMAGENTA', 'YELLOW', 'WHITE'
17 | )
18 |
19 |
20 | def read_colors():
21 | """Returns a sorted tuple of color scheme basenames, in LNP/Colors."""
22 | return tuple(sorted(
23 | [os.path.splitext(os.path.basename(p))[0] for p in
24 | helpers.get_text_files(paths.get('colors'))],
25 | key=helpers.key_from_underscore_prefixed_string))
26 |
27 |
28 | def get_colors(colorscheme=None):
29 | """
30 | Returns RGB tuples for all 16 colors in .txt, or
31 | data/init/colors.txt if no scheme is provided. On errors, returns an empty
32 | list."""
33 | try:
34 | if colorscheme is not None:
35 | f = colorscheme
36 | if not f.endswith('.txt'):
37 | f = f + '.txt'
38 | if os.path.dirname(f) == '':
39 | f = paths.get('colors', f)
40 | else:
41 | if lnp.df_info.version <= '0.31.03':
42 | f = paths.get('init', 'init.txt')
43 | else:
44 | f = paths.get('init', 'colors.txt')
45 | color_fields = [(c + '_R', c + '_G', c + '_B') for c in _df_colors]
46 | result = DFRaw(f).get_values(*color_fields)
47 | return [tuple(int(x) for x in t) for t in result]
48 | except Exception:
49 | if colorscheme:
50 | log.e('Unable to read colorscheme %s', colorscheme, stack=True)
51 | else:
52 | log.e('Unable to read current colors', stack=True)
53 | return []
54 |
55 |
56 | def load_colors(filename):
57 | """
58 | Replaces the current DF color scheme.
59 |
60 | Args:
61 | filename: The name of the new colorscheme to apply (extension optional).
62 | If no path is specified, file is assumed to be in LNP/Colors.
63 | """
64 | log.i('Loading colorscheme ' + filename)
65 | if not filename.endswith('.txt'):
66 | filename = filename + '.txt'
67 | if os.path.dirname(filename) == '':
68 | filename = paths.get('colors', filename)
69 | if lnp.df_info.version <= '0.31.03':
70 | colors = ([c + '_R' for c in _df_colors] + [c + '_G' for c in _df_colors]
71 | + [c + '_B' for c in _df_colors])
72 | lnp.settings.read_file(filename, colors, False)
73 | lnp.settings.write_settings()
74 | else:
75 | shutil.copyfile(filename, paths.get('init', 'colors.txt'))
76 |
77 |
78 | def save_colors(filename):
79 | """
80 | Save current keybindings to a file.
81 |
82 | Args:
83 | filename: the name of the new color scheme file.
84 | """
85 | log.i('Saving colorscheme ' + filename)
86 | if not filename.endswith('.txt'):
87 | filename = filename + '.txt'
88 | filename = paths.get('colors', filename)
89 | if lnp.df_info.version <= '0.31.03':
90 | colors = ([c + '_R' for c in _df_colors] + [c + '_G' for c in _df_colors]
91 | + [c + '_B' for c in _df_colors])
92 | lnp.settings.create_file(filename, colors)
93 | else:
94 | shutil.copyfile(paths.get('init', 'colors.txt'), filename)
95 |
96 |
97 | def color_exists(filename):
98 | """
99 | Returns whether a color scheme already exists.
100 |
101 | Args:
102 | filename: the filename to check.
103 | """
104 | if not filename.endswith('.txt'):
105 | filename = filename + '.txt'
106 | return os.access(paths.get('colors', filename), os.F_OK)
107 |
108 |
109 | def delete_colors(filename):
110 | """
111 | Deletes a color scheme file.
112 |
113 | Args:
114 | filename: the filename to delete.
115 | """
116 | log.i('Deleting colorscheme ' + filename)
117 | if not filename.endswith('.txt'):
118 | filename = filename + '.txt'
119 | os.remove(paths.get('colors', filename))
120 |
121 |
122 | def get_installed_file():
123 | """Returns the name of the currently installed color scheme, or None."""
124 | files = helpers.get_text_files(paths.get('colors'))
125 | current_scheme = get_colors()
126 | for scheme in files:
127 | if get_colors(scheme) == current_scheme:
128 | return os.path.splitext(os.path.basename(scheme))[0]
129 | return None
130 |
--------------------------------------------------------------------------------
/tkgui/dfhack.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init
4 | """DFHack tab for the TKinter GUI."""
5 |
6 | import sys
7 | from tkinter import * # noqa: F403
8 | from tkinter.ttk import * # noqa: F403
9 |
10 | from core import hacks
11 |
12 | from . import binding, controls
13 | from .layout import GridLayouter
14 | from .tab import Tab
15 |
16 |
17 | class DFHackTab(Tab):
18 | """DFHack tab for the TKinter GUI."""
19 | def read_data(self):
20 | self.update_hack_list()
21 | # Fix focus bug
22 | if self.hacklist.get_children():
23 | self.hacklist.focus(self.hacklist.get_children()[0])
24 |
25 | def create_controls(self):
26 | button_group = controls.create_control_group(self, None, True)
27 | button_group.pack(side=TOP, fill=BOTH, expand=N)
28 | grid = GridLayouter(2)
29 | grid.add(controls.create_trigger_option_button(
30 | button_group, 'Enable DFHack',
31 | 'Controls whether DFHack should be enabled. Turning DFHack off '
32 | 'also disables addons like TwbT.',
33 | self.toggle_dfhack, 'use_dfhack', lambda v: ('NO', 'YES')[
34 | hacks.is_dfhack_enabled()]))
35 |
36 | grid.add(controls.create_trigger_button(
37 | button_group, 'Open DFHack Readme',
38 | 'Open the DFHack documentation in your browser.',
39 | hacks.open_dfhack_readme))
40 |
41 | hacks_frame = controls.create_control_group(self, 'Available hacks')
42 | hacks_frame.pack(side=TOP, expand=Y, fill=BOTH)
43 | Grid.columnconfigure(hacks_frame, 0, weight=1)
44 | Grid.rowconfigure(hacks_frame, 1, weight=1)
45 |
46 | Label(
47 | hacks_frame, text='Click on a hack to toggle it.').grid(
48 | column=0, row=0)
49 |
50 | self.hacklist = controls.create_toggle_list(hacks_frame, ('tooltip'), {
51 | 'column': 0, 'row': 1, 'sticky': "nsew"})
52 | self.hacklist.grid(column=0, row=0, sticky="nsew")
53 | self.configure_hacklist()
54 |
55 | def configure_hacklist(self):
56 | """Configures the treeview."""
57 | hacklist = self.hacklist
58 |
59 | # Do not show headings
60 | hacklist.configure(show=['tree'], displaycolumns=(), selectmode="none")
61 |
62 | for seq in ("", "", "<1>",
63 | "<2>" if sys.platform == 'darwin' else "<3>"):
64 | hacklist.bind(seq, self.toggle_hack)
65 |
66 | self.hack_tooltip = controls.create_tooltip(hacklist, '')
67 | hacklist.bind('', self.update_hack_tooltip)
68 |
69 | # Make it easy to differentiate between enabled
70 | hacklist.tag_configure('enabled', background='pale green')
71 |
72 | def update_hack_tooltip(self, event):
73 | """
74 | Event handler for mouse motion over items in the hack list.
75 |
76 | If the mouse has moved out of the last list element, hides the tooltip.
77 | Then, if the mouse is over a list item, wait controls._TOOLTIP_DELAY
78 | milliseconds (without mouse movement) before showing the tooltip"""
79 | tooltip = self.hack_tooltip
80 | hacklist = self.hacklist
81 | item = hacklist.identify_row(event.y)
82 |
83 | def show():
84 | """Sets and shows a tooltip"""
85 | tooltip.settext(hacklist.set(item, 'tooltip'))
86 | tooltip.showtip()
87 |
88 | if tooltip.event:
89 | hacklist.after_cancel(tooltip.event)
90 | tooltip.event = None
91 | if hacklist.set(item, 'tooltip') != tooltip.text:
92 | tooltip.hidetip()
93 | if item:
94 | # pylint: disable=protected-access
95 | tooltip.event = hacklist.after(controls._TOOLTIP_DELAY, show)
96 |
97 | def update_hack_list(self):
98 | """Updates the hack list."""
99 | for hack in self.hacklist.get_children():
100 | self.hacklist.delete(hack)
101 |
102 | enabled = set(hacks.read_hacks())
103 | for title, hack in hacks.get_hacks().items():
104 | tags = ('enabled') if title in enabled else ()
105 | self.hacklist.insert('', 'end', text=title, tags=tags,
106 | values=(hack['tooltip'],))
107 |
108 | def toggle_hack(self, event):
109 | """Toggles the selected hack."""
110 | if event.keysym == '??':
111 | item = self.hacklist.identify_row(event.y)
112 | else:
113 | item = self.hacklist.focus()
114 |
115 | if item:
116 | title = self.hacklist.item(item, 'text')
117 | is_enabled = hacks.toggle_hack(title)
118 | # pylint: disable=not-callable
119 | self.hacklist.tag_set('enabled', item, is_enabled)
120 | # pylint: enable=not-callable
121 |
122 | @staticmethod
123 | def toggle_dfhack():
124 | """Toggles the use of DFHack."""
125 | hacks.toggle_dfhack()
126 | binding.update()
127 |
--------------------------------------------------------------------------------
/core/rawlint.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Linter for raw files. Ported from Lethosor's Lua script:
4 | https://github.com/lethosor/dfhack-scripts/blob/master/raw-lint.lua"""
5 |
6 | import os
7 |
8 | from . import log
9 | from .dfraw import DFRaw
10 |
11 | # TODO: Handle older versions correctly
12 | # For example, 40d and earlier use object names MATGLOSS and DESCRIPTOR
13 | valid_objnames = [
14 | 'BODY_DETAIL_PLAN',
15 | 'BODY',
16 | 'BUILDING',
17 | 'CREATURE_VARIATION',
18 | 'CREATURE',
19 | 'DESCRIPTOR_COLOR',
20 | 'DESCRIPTOR_PATTERN',
21 | 'DESCRIPTOR_SHAPE',
22 | 'ENTITY',
23 | 'INORGANIC',
24 | 'INTERACTION',
25 | 'ITEM',
26 | 'LANGUAGE',
27 | 'MATERIAL_TEMPLATE',
28 | 'PLANT',
29 | 'REACTION',
30 | 'TISSUE_TEMPLATE',
31 | ]
32 |
33 | objname_overrides = {
34 | 'b_detail_plan': 'BODY_DETAIL_PLAN',
35 | 'c_variation': 'CREATURE_VARIATION',
36 | }
37 |
38 |
39 | def check_file(path):
40 | """Validates the raw file located at . Error details are printed to
41 | the log with level WARNING. Returns True/False."""
42 | # pylint:disable=too-many-branches
43 | file_ok = True
44 | if not path.endswith('.txt'):
45 | log.w('Unrecognized filename')
46 | return False
47 | contents = DFRaw.read(path)
48 | filename = os.path.basename(path)[:-4]
49 | try:
50 | realname = contents.splitlines()[0]
51 | except IndexError:
52 | realname = ''
53 | try:
54 | rawname = realname.split()[0]
55 | except IndexError:
56 | rawname = realname
57 | # Everything before first whitespace must match filename
58 | if not (realname == realname.lstrip() and rawname == filename):
59 | log.w('Name mismatch: expected %s, found %s' % (filename, rawname))
60 | file_ok = False
61 | objname = filename
62 | check_objnames = []
63 | for k, v in objname_overrides.items():
64 | if filename.startswith(k) and v in valid_objnames:
65 | check_objnames.append(v)
66 | for o in valid_objnames:
67 | if filename.upper().startswith(o):
68 | check_objnames.append(o)
69 | if check_objnames:
70 | found = False
71 | for i, objname in enumerate(check_objnames):
72 | objname = '[OBJECT:' + objname.upper() + ']'
73 | if objname in contents:
74 | found = True
75 | check_objnames[i] = objname
76 | if not found:
77 | log.w('None of %s found' % ', '.join(check_objnames))
78 | file_ok = False
79 | else:
80 | log.w('No valid object names')
81 | file_ok = False
82 | return file_ok
83 |
84 |
85 | def check_folder(path):
86 | """Validates all raw files in and its subfolders. Problems with
87 | individual files are printed to the log with level WARNING. General problems
88 | are printed to the log with level ERROR.
89 |
90 | Returns:
91 | (passed, failed): two lists of paths of files that passed or failed,
92 | respectively.
93 | """
94 | log.push_prefix('RawLint')
95 | files = []
96 | for d in os.walk(path):
97 | files += [os.path.join(d[0], f) for f in d[2]]
98 | passed = []
99 | failed = []
100 | if not files:
101 | log.e('Could not find any files in ' + path)
102 | for f in files:
103 | f_parts = f.split(os.sep)
104 | if (f.endswith('.txt') and 'notes' not in f_parts
105 | and 'examples and notes' not in f_parts and 'text' not in f_parts):
106 | log.push_prefix(f)
107 | has_passed = check_file(f)
108 | log.pop_prefix()
109 | if has_passed:
110 | passed.append(f)
111 | else:
112 | failed.append(f)
113 | log.pop_prefix()
114 | return (passed, failed)
115 |
116 |
117 | def check_df(path):
118 | """Validates the raw/objects folder in the Dwarf Fortress folder located at
119 | . Problem with individual files are printed to the log with level
120 | WARNING. General problems are printed to the log with level ERROR.
121 |
122 | Returns:
123 | (passed, failed): two lists of paths of files that passed or failed,
124 | respectively.
125 | """
126 | return check_folder(os.path.join(path, 'raw', 'objects'))
127 |
128 |
129 | def check_folder_bool(path):
130 | """Returns True if all raw files in pass validation. Problems with
131 | individual files are printed to the log with level WARNING. General
132 | problems are printed to the log with level ERROR."""
133 | p, f = check_folder(path)
134 | return len(f) == 0 and len(p) != 0
135 |
136 |
137 | def check_df_bool(path):
138 | """Validates the raw/objects folder in the Dwarf Fortress folder located at
139 | and returns True if all files pass validation. Problems with
140 | individual files are printed to the log with level WARNING. General
141 | problems are printed to the log with level ERROR."""
142 | p, f = check_df(path)
143 | return len(f) == 0 and len(p) != 0
144 |
--------------------------------------------------------------------------------
/core/json_config.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Proxy to abstract access to JSON configuration and gracefully handle missing
4 | keys."""
5 |
6 | import json
7 | import os
8 |
9 | from . import log
10 |
11 |
12 | class JSONConfiguration(object):
13 | """Proxy for JSON-based configuration files."""
14 |
15 | def __init__(self, filename, default=None, warn=True):
16 | """
17 | Constructor for JSONConfiguration.
18 |
19 | Args:
20 | filename: JSON filename to load data from.
21 | Use None to only use object from default.
22 | default: default value to use in case loading fails.
23 | warn (bool): Log a warning if the file is missing, default True.
24 | """
25 | self.filename = filename
26 | self.data = default if default else {}
27 | if filename is None:
28 | return
29 | if not os.path.isfile(filename):
30 | if warn:
31 | log.w("JSONConfiguration: File " + filename + " does not exist")
32 | return
33 | try:
34 | with open(filename, encoding="utf-8") as file:
35 | self.data = json.load(file)
36 | except Exception:
37 | log.e('Note: Failed to read JSON from ' + filename
38 | + ', ignoring data - details follow', stack=True)
39 |
40 | @staticmethod
41 | def from_text(text):
42 | """Create a JSONConfiguration object from a string."""
43 | return JSONConfiguration(None, json.loads(text))
44 |
45 | def save_data(self):
46 | """Saves the data to the original JSON file. Has no effect if no
47 | filename was given during construction."""
48 | if self.filename:
49 | with open(self.filename, 'w', encoding="utf-8") as file:
50 | json.dump(self.data, file, indent=2)
51 |
52 | def get(self, path, default=None):
53 | """
54 | Retrieves a value from the configuration.
55 | Returns default if the path does not exist.
56 |
57 | Args:
58 | path: ``/``-delimited path to the string.
59 | default: value returned if path does not exist.
60 | """
61 | try:
62 | path = path.split('/')
63 | result = self.data
64 | for p in path:
65 | result = result[p]
66 | return result
67 | except KeyError:
68 | return default
69 |
70 | def has_value(self, path):
71 | """Returns True if the path exists in the configuration."""
72 | return self.get_value(path) is not None
73 |
74 | def get_value(self, path, default=None):
75 | """
76 | Retrieves a value from the configuration.
77 | Returns default if the path does not exist.
78 |
79 | Args:
80 | path: ``/``-delimited path to the string.
81 | default: value returned if path does not exist.
82 | """
83 | return self.get(path, default)
84 |
85 | def get_string(self, path):
86 | """
87 | Retrieves a value from the configuration.
88 | Returns an empty string if the path does not exist.
89 |
90 | Args:
91 | path: ``/``-delimited path to the string.
92 | """
93 | return self.get_value(path, "")
94 |
95 | def get_number(self, path):
96 | """
97 | Retrieves a value from the configuration.
98 | Returns 0 if the path does not exist.
99 |
100 | Args:
101 | path: ``/``-delimited path to the string.
102 | """
103 | return self.get_value(path, 0)
104 |
105 | def get_bool(self, path):
106 | """
107 | Retrieves a value from the configuration.
108 | Returns False if the path does not exist.
109 |
110 | Args:
111 | path: ``/``-delimited path to the string.
112 | """
113 | return self.get_value(path, False)
114 |
115 | def get_list(self, path):
116 | """
117 | Retrieves a value from the configuration.
118 | Returns an empty list if the path does not exist.
119 |
120 | Args:
121 | path: ``/``-delimited path to the string.
122 | """
123 | return self.get_value(path, [])
124 |
125 | def get_dict(self, path):
126 | """
127 | Retrieves a value from the configuration.
128 | Returns an empty dictionary if the path does not exist.
129 |
130 | Args:
131 | path: ``/``-delimited path to the string.
132 | """
133 | return self.get_value(path, {})
134 |
135 | def set_value(self, key, value):
136 | """
137 | Writes a value to a key.
138 | Note: Arbitrary paths not supported - you must refresh entire key.
139 |
140 | Args:
141 | key: the key to save the value under.
142 | value: the value to save.
143 | """
144 | self.__setitem__(key, value) # pylint: disable=unnecessary-dunder-call
145 |
146 | def __getitem__(self, key):
147 | """Accessor for indexing directly into the configuration."""
148 | return self.get_value(key)
149 |
150 | def __setitem__(self, key, value):
151 | """Accessor for writing into the configuration with indexing."""
152 | self.data[key] = value
153 |
--------------------------------------------------------------------------------
/core/log.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Logging module."""
4 |
5 | import sys
6 | import traceback
7 |
8 | _log = None
9 |
10 | # Logging levels
11 | VERBOSE = 0
12 | DEBUG = 1
13 | INFO = 2
14 | WARNING = 3
15 | ERROR = 4
16 |
17 |
18 | class Log(object):
19 | """Logging class."""
20 | def __init__(self):
21 | """Constructor for Log. Sets the maximum logging level to INFO."""
22 | self.max_level = INFO
23 | self.output_err = False
24 | self.output_out = False
25 | self.level_stack = []
26 | self.lines = []
27 | self.prefixes = []
28 |
29 | def push_level(self, level):
30 | """Temporarily changes the logging level to . Call pop_level to
31 | restore the previous level."""
32 | self.level_stack.append(level)
33 | self.set_level(level)
34 |
35 | def pop_level(self):
36 | """Restores the previous logging level, if the level stack is not
37 | empty."""
38 | try:
39 | self.max_level = self.level_stack.pop()
40 | except IndexError:
41 | self.e('Tried to pop logging level, but stack empty', stack=True)
42 |
43 | def set_level(self, level):
44 | """Sets the maximum logging level to ."""
45 | self.max_level = level
46 |
47 | def push_prefix(self, prefix):
48 | """Adds a prefix to future log messages. Old prefixes will appear before
49 | new prefixes."""
50 | self.prefixes.append(prefix)
51 |
52 | def pop_prefix(self):
53 | """Removes the most recently added prefix from future log messages."""
54 | try:
55 | self.prefixes.pop()
56 | except IndexError:
57 | self.e('Tried to pop logging prefix, but stack empty', stack=True)
58 |
59 | def __get_prefixes(self):
60 | """Returns a string containing the prefixes for this log message."""
61 | if not self.prefixes:
62 | return ''
63 | return ': '.join(self.prefixes + [''])
64 |
65 | def log(self, log_level, message, *args, **kwargs):
66 | """Logs a message if the current logging level includes messages at
67 | level .
68 |
69 | Args:
70 | log_level: the level to log the message at. If less than the current
71 | logging level, nothing will happen.
72 | message: the message to log.
73 | *args: Used to format the message with the ``%`` operator.
74 | stack: if True, logs a stack trace. If sys.excinfo contains an
75 | exception, this will be formatted and logged instead.
76 | """
77 | if log_level < self.max_level:
78 | return
79 | p = self.__get_level_string(log_level) + self.__get_prefixes()
80 | self.__write(p + str(message) % args + "\n")
81 | if kwargs.get('stack', False):
82 | ex = sys.exc_info()
83 | if ex[2]:
84 | for line in traceback.format_exception(*ex):
85 | self.__write(line)
86 | else:
87 | for line in traceback.format_stack():
88 | self.__write(line)
89 |
90 | @staticmethod
91 | def __get_level_string(level):
92 | """Returns a prefix corresponding to the given logging level."""
93 | return ["VERBOSE", "DEBUG", "INFO", "WARNING", "ERROR"][level] + ": "
94 |
95 | def d(self, message, *args, **kwargs):
96 | """Writes a DEBUG message to the log. See `Log.log` for details."""
97 | return self.log(DEBUG, message, *args, **kwargs)
98 |
99 | def e(self, message, *args, **kwargs):
100 | """Writes an ERROR message to the log. See `Log.log` for details."""
101 | return self.log(ERROR, message, *args, **kwargs)
102 |
103 | def i(self, message, *args, **kwargs):
104 | """Writes a INFO message to the log. See `Log.log` for details."""
105 | return self.log(INFO, message, *args, **kwargs)
106 |
107 | def v(self, message, *args, **kwargs):
108 | """Writes a VERBOSE message to the log. See `Log.log` for details."""
109 | return self.log(VERBOSE, message, *args, **kwargs)
110 |
111 | def w(self, message, *args, **kwargs):
112 | """Writes a WARNING message to the log. See `Log.log` for details."""
113 | return self.log(WARNING, message, *args, **kwargs)
114 |
115 | def get_lines(self):
116 | """Returns all logged lines."""
117 | return self.lines
118 |
119 | def __write(self, text):
120 | """Writes a line of text to the log."""
121 | if self.output_err:
122 | sys.stderr.write(text)
123 | if self.output_out:
124 | sys.stdout.write(text)
125 | self.lines.append(text)
126 |
127 |
128 | def get():
129 | """Returns the default Log instance."""
130 | return _log
131 |
132 |
133 | # prepare the default instance
134 | _log = Log()
135 | # Output to error log on the default instance
136 | _log.output_err = True
137 |
138 | # expose the methods targeting the default instance on the module itself
139 | push_level = _log.push_level
140 | pop_level = _log.pop_level
141 | set_level = _log.set_level
142 | log = _log.log
143 | debug = d = _log.d
144 | error = e = _log.e
145 | info = i = _log.i
146 | verbose = v = _log.v
147 | warning = w = _log.w
148 | get_lines = _log.get_lines
149 | push_prefix = _log.push_prefix
150 | pop_prefix = _log.pop_prefix
151 |
--------------------------------------------------------------------------------
/core/keybinds.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Keybinding management."""
4 |
5 | import collections
6 | import os
7 | import shutil
8 |
9 | from . import baselines, helpers, log, paths
10 | from .lnp import lnp
11 |
12 |
13 | def _keybind_fname(filename):
14 | """Turn a string into a valid filename for storing keybindings."""
15 | filename = os.path.basename(filename)
16 | if not filename.endswith('.txt'):
17 | filename = filename + '.txt'
18 | return paths.get('keybinds', filename)
19 |
20 |
21 | def read_keybinds():
22 | """Returns a list of keybinding files."""
23 | files = []
24 | for fname in helpers.get_text_files(paths.get('keybinds')):
25 | with open(fname, encoding='cp437') as f:
26 | if ('[DISPLAY_STRING:' in f.read()) == \
27 | ('legacy' in lnp.df_info.variations):
28 | files.append(fname)
29 | return tuple(sorted(os.path.basename(o) for o in files))
30 |
31 |
32 | def _sdl_get_binds(filename, compressed=True):
33 | """Return serialised keybindings for the given file.
34 | Returns a compressed version, without vanilla entries, unless disabled.
35 |
36 | Allows keybindings to be stored as files with only the non-vanilla
37 | bindings, improving readability and compatibility across DF versions.
38 | Only compatible with SDL versions, however.
39 | """
40 | with open(filename, encoding='cp437') as f:
41 | lines = f.readlines()
42 | od, lastkey = collections.OrderedDict(), None
43 | for line in (line.strip() for line in lines if line.strip()):
44 | if line.startswith('[BIND:'):
45 | od[line], lastkey = [], line
46 | elif lastkey is not None:
47 | od[lastkey].append(line)
48 | if not compressed:
49 | return od
50 | van = _get_vanilla_binds()
51 | if van is not None:
52 | return collections.OrderedDict(
53 | (k, v) for k, v in od.items()
54 | # only keep items with a vanilla counterpart, which is different
55 | if van.get(k) and set(van.get(k)) != set(v))
56 | return None
57 |
58 |
59 | def _sdl_write_binds(filename, binds_od, expanded=False):
60 | """Write keybindings to the given file, optionally expanding them."""
61 | if expanded:
62 | van = _get_vanilla_binds()
63 | if van is not None:
64 | binds_od = collections.OrderedDict(
65 | (k, binds_od.get(k) or v) for k, v in van.items())
66 | lines = ['']
67 | for bind, vals in binds_od.items():
68 | lines.append(bind)
69 | # no indent allowed in interface.txt; otherwise makes reading easier
70 | lines.extend(vals if expanded else [' ' + v for v in vals])
71 | text = '\n'.join(lines) + '\n'
72 | if filename is None:
73 | return text
74 | with open(filename, 'w', encoding='cp437') as f:
75 | f.write(text)
76 | return None
77 |
78 |
79 | def _get_vanilla_binds():
80 | """Return the vanilla keybindings for use in compression or expansion."""
81 | try:
82 | vanfile = os.path.join(
83 | baselines.find_vanilla(False), 'data', 'init', 'interface.txt')
84 | return _sdl_get_binds(vanfile, compressed=False)
85 | except TypeError:
86 | log.w("Can't load or change keybinds with missing baseline!")
87 | return None
88 |
89 |
90 | def load_keybinds(filename):
91 | """
92 | Overwrites Dwarf Fortress keybindings from a file.
93 |
94 | Params:
95 | filename
96 | The keybindings file to use.
97 | """
98 | target = paths.get('init', 'interface.txt')
99 | filename = _keybind_fname(filename)
100 | log.i('Loading keybinds: ' + filename)
101 | if 'legacy' in lnp.df_info.variations:
102 | shutil.copyfile(filename, target)
103 | else:
104 | _sdl_write_binds(target, _sdl_get_binds(filename), expanded=True)
105 |
106 |
107 | def keybind_exists(filename):
108 | """
109 | Returns whether a keybindings file already exists.
110 |
111 | Args:
112 | filename: the filename to check.
113 | """
114 | return os.access(_keybind_fname(filename), os.F_OK)
115 |
116 |
117 | def save_keybinds(filename):
118 | """
119 | Save current keybindings to a file.
120 |
121 | Args:
122 | filename: the name of the new keybindings file.
123 | """
124 | installed = paths.get('init', 'interface.txt')
125 | filename = _keybind_fname(filename)
126 | log.i('Saving current keybinds as ' + filename)
127 | if 'legacy' in lnp.df_info.variations:
128 | shutil.copyfile(installed, filename)
129 | else:
130 | _sdl_write_binds(filename, _sdl_get_binds(installed))
131 |
132 |
133 | def delete_keybinds(filename):
134 | """
135 | Deletes a keybindings file.
136 |
137 | Args:
138 | filename: the filename to delete.
139 | """
140 | log.i('Deleting ' + filename + 'keybinds')
141 | os.remove(_keybind_fname(filename))
142 |
143 |
144 | def get_installed_file():
145 | """Returns the name of the currently installed keybindings."""
146 | def unordered(fname):
147 | """An order-independent representation of keybindings from a file."""
148 | return {k: set(v) for k, v in _sdl_get_binds(fname).items()}
149 |
150 | try:
151 | installed = unordered(paths.get('df', 'data', 'init', 'interface.txt'))
152 | for fname in helpers.get_text_files(paths.get('keybinds')):
153 | if installed == unordered(fname):
154 | return os.path.basename(fname)
155 | except Exception:
156 | # Baseline missing, or interface.txt is missing from baseline - use
157 | # plain file comparison
158 | pass
159 |
160 | files = helpers.get_text_files(paths.get('keybinds'))
161 | current = paths.get('init', 'interface.txt')
162 | result = helpers.detect_installed_file(current, files)
163 | return os.path.basename(result)
164 |
--------------------------------------------------------------------------------
/lnp.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python -*-
2 | # If PIL or similar is available on this system, it will be available for the
3 | # generated executable. Since this is the only factor in whether or not we
4 | # will be able to use non-GIF images, we only include the appropriate version.
5 | import sys
6 |
7 | if sys.platform == 'win32':
8 | try:
9 | from PyInstaller.utils.winmanifest import Manifest
10 | except ImportError:
11 | # Newer PyInstaller versions
12 | from PyInstaller.utils.win32.winmanifest import Manifest
13 | Manifest.old_toprettyxml = Manifest.toprettyxml
14 |
15 | def new_toprettyxml(self, indent=" ", newl=os.linesep, encoding="UTF-8"): # noqa: F821
16 | s = self.old_toprettyxml(indent, newl, encoding)
17 | # Make sure we only modify our own manifest
18 | if 'name="lnp"' in s:
19 | d = (indent
20 | + ''
21 | ''
22 | 'false'
23 | + newl)
24 | s = s.replace('', d + '')
25 | return s
26 | Manifest.toprettyxml = new_toprettyxml
27 |
28 | try:
29 | from PIL import Image, ImageTk
30 | has_PIL = True
31 | except ImportError: # Some PIL installations live outside of the PIL package
32 | try:
33 | import Image # noqa: F401
34 | import ImageTk # noqa: F401
35 | has_PIL = True
36 | except ImportError: # No PIL compatible library
37 | has_PIL = False
38 |
39 | from tkinter import * # noqa: F403
40 |
41 | if has_PIL or TkVersion >= 8.6:
42 | logo = 'LNPSMALL.png'
43 | icon = 'LNP.png'
44 | else:
45 | logo = 'LNPSMALL.gif'
46 | icon = 'LNP.gif'
47 |
48 | extension = ''
49 | script = 'launch.py'
50 | if sys.platform == 'win32':
51 | icon = 'LNP.ico'
52 | extension = '.exe'
53 |
54 | hiddenimports = []
55 | if sys.platform.startswith('linux'):
56 | hiddenimports = ['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder']
57 |
58 | needs_tcl_copy = False
59 |
60 | if sys.platform == 'darwin' and sys.hexversion >= 0x3070000:
61 | needs_tcl_copy = True
62 | try:
63 | # HACK: PyInstaller is not handling the bundled Tcl and Tk in Python 3.7 from python.org
64 | # properly.
65 | #
66 | # This patch intercepts the value that causes PyInstaller to attempt to use the wrong Tcl/Tk
67 | # version and triggers a fallback to treat Tcl/Tk as a Unix-style build.
68 | #
69 | # See https://github.com/pyinstaller/pyinstaller/issues/3753 for the relevant bug report for
70 | # PyInstaller
71 | from PyInstaller.depend import bindepend
72 | old_selectImports = bindepend.selectImports
73 |
74 | def patched_selectImports(pth, xtrapath=None):
75 | rv = old_selectImports(pth, xtrapath)
76 | if '_tkinter' in pth:
77 | import inspect
78 | caller = inspect.stack()[1]
79 | if ('hook-_tkinter.py' in caller.filename and 'Library/Frameworks' in rv[0][1]
80 | and 'Python' in rv[0][1]):
81 | return [('libtcl8.6.dylib', ''), ('libtk8.6.dylib', '')]
82 | return rv
83 | bindepend.selectImports = patched_selectImports
84 | except ImportError:
85 | pass
86 |
87 | a = Analysis(
88 | [script], pathex=['.'], hiddenimports=hiddenimports, hookspath=None, runtime_hooks=None)
89 | a.datas += [(logo, logo, 'DATA'), (icon, icon, 'DATA')]
90 | if sys.platform == 'win32':
91 | # Importing pkg_resources fails with Pillow on Windows due to
92 | # un-normalized case; this works around the problem
93 | a.datas = list({tuple(map(str.upper, t)) for t in a.datas})
94 | pyz = PYZ(a.pure)
95 | if sys.platform != 'darwin':
96 | exe = EXE(
97 | pyz, a.scripts, a.binaries, a.zipfiles, a.datas, name='PyLNP' + extension,
98 | debug=False, strip=None, upx=False, console=False, icon='LNP.ico')
99 | else:
100 | info = {'NSHighResolutionCapable': 'True'}
101 | exe = EXE(
102 | pyz, a.scripts, exclude_binaries=True, name='PyLNP' + extension,
103 | debug=False, strip=None, upx=True, console=False)
104 | coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=None, upx=True, name='PyLNP')
105 | app = BUNDLE(coll, name='PyLNP.app', icon='LNP.icns', info_plist=info)
106 | if needs_tcl_copy:
107 | import shutil
108 | import os
109 |
110 | def copytree(src, dst, symlinks=False, ignore=None):
111 | if not os.path.exists(dst):
112 | os.makedirs(dst)
113 | for item in os.listdir(src):
114 | s = os.path.join(src, item)
115 | d = os.path.join(dst, item)
116 | if os.path.isdir(s):
117 | copytree(s, d, symlinks, ignore)
118 | else:
119 | if not os.path.exists(d) or os.stat(s).st_mtime - os.stat(d).st_mtime > 1:
120 | shutil.copy2(s, d)
121 |
122 | # Manually copy tcl/tk files into .app - based on copy commands mentioned here:
123 | # https://github.com/pyinstaller/pyinstaller/issues/3753#issuecomment-432464838
124 | # https://stackoverflow.com/questions/56092383/how-to-fix-msgcatmc-error-after-running-app-from-pyinstaller-on-macos-mojave
125 | basepath = os.path.normpath(os.path.join(os.path.dirname(sys.executable), '..', 'lib'))
126 | for e in os.listdir(basepath):
127 | p = os.path.join(basepath, e)
128 | if not os.path.isdir(p):
129 | continue
130 | if e == 'tcl8':
131 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tcl8'))
132 | elif e.startswith('tcl'):
133 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tcl'))
134 | elif e.startswith('tk') or e.startswith('Tk'):
135 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tk'))
136 | else:
137 | continue
138 | copytree(p, dst)
139 |
140 | # vim:expandtab
141 |
--------------------------------------------------------------------------------
/core/legends_processor.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """A module to compress and sort legends exports from DF 0.40.09 and later.
4 |
5 | - .bmp converted to .png where possible.
6 | - Create an archive for Legends Viewer if possible, or just compress
7 | the (huge) xml.
8 | - Sort files into region folder, with maps subfolders, and move to user
9 | content folder if this folder exists.
10 | """
11 |
12 | import glob
13 | import os
14 | import re
15 | import subprocess
16 | import zipfile
17 |
18 | from . import log, paths
19 | from .lnp import lnp
20 |
21 |
22 | def get_region_info():
23 | """Returns a tuple of strings for an available region and date.
24 | Eg: ('region1', '00250-01-01')
25 | """
26 | files = [f for f in glob.glob(paths.get('df', 'region*-*-??-??-*')) if
27 | os.path.isfile(f)]
28 | if files:
29 | fname = os.path.basename(files[0])
30 | region = re.search(
31 | r'^.*(?=(-\d{5,}-\d{2}-\d{2}))', fname).group()
32 | date = re.search(r'\d{5,}-\d{2}-\d{2}', fname).group()
33 | return region, date
34 | return None
35 |
36 |
37 | def compress_bitmaps():
38 | """Compresses all bitmap maps."""
39 | # pylint: disable=import-error
40 | try:
41 | from PIL import Image
42 | except ImportError:
43 | try:
44 | import Image
45 | except ImportError:
46 | call_optipng()
47 | else:
48 | log.i('Compressing bitmaps with PIL/Pillow')
49 | for fname in glob.glob(paths.get(
50 | 'df', '-'.join(get_region_info()) + '-*.bmp')):
51 | f = Image.open(fname)
52 | f.save(fname[:-3] + 'png', format='PNG', optimize=True)
53 | os.remove(fname)
54 |
55 |
56 | def call_optipng():
57 | """Calling optipng can work well, but isn't very portable."""
58 | if os.name == 'nt' and os.path.isfile(paths.get('df', 'optipng.exe')):
59 | log.w('Falling back to optipng for image compression. '
60 | 'It is recommended to install PIL.')
61 | for fname in glob.glob(paths.get(
62 | 'df', '-'.join(get_region_info()) + '-*.bmp')):
63 | ret = subprocess.call([paths.get('df', 'optipng'), '-zc9', '-zm9',
64 | '-zs0', '-f0', fname],
65 | creationflags=0x00000008)
66 | if ret == 0:
67 | os.remove(fname)
68 | else:
69 | log.e('A PIL-compatible library is required to compress bitmaps.')
70 |
71 |
72 | def choose_region_map():
73 | """Returns the most-preferred region map available, or fallback."""
74 | pattern = paths.get('df', '-'.join(get_region_info()) + '-')
75 | for name in ('detailed', 'world_map'):
76 | for ext in ('.png', '.bmp'):
77 | if os.path.isfile(pattern + name + ext):
78 | return pattern + name + ext
79 | return pattern + 'world_map.bmp'
80 |
81 |
82 | def create_archive():
83 | """Creates a legends archive, or zips the xml if files are missing."""
84 | pattern = paths.get('df', '-'.join(get_region_info()) + '-')
85 | worldgen = paths.get('df', get_region_info()[0] + '-world_gen_param.txt')
86 | filepaths = [pattern + 'legends.xml', pattern + 'world_history.txt', worldgen,
87 | choose_region_map(), pattern + 'world_sites_and_pops.txt']
88 | if os.path.isfile(pattern + 'legends_plus.xml'):
89 | filepaths.append(pattern + 'legends_plus.xml')
90 | if all(os.path.isfile(f) for f in filepaths):
91 | with zipfile.ZipFile(pattern + 'legends_archive.zip',
92 | 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipped:
93 | for f in filepaths:
94 | zipped.write(f, os.path.basename(f))
95 | os.remove(f)
96 | elif os.path.isfile(pattern + 'legends.xml'):
97 | with zipfile.ZipFile(pattern + 'legends_xml.zip',
98 | 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipped:
99 | zipped.write(pattern + 'legends.xml',
100 | os.path.basename(pattern + 'legends.xml'))
101 | os.remove(pattern + 'legends.xml')
102 |
103 |
104 | def move_files():
105 | """Moves files to a subdir, and subdir to ../User Generated Content if
106 | that dir exists."""
107 | pattern = paths.get('df', '-'.join(get_region_info()))
108 | region = get_region_info()[0]
109 | dirname = get_region_info()[0] + '_legends_exports'
110 | if os.path.isdir(paths.get('root', 'User Generated Content')):
111 | dirname = paths.get(
112 | 'root', 'User Generated Content', 'Legends', dirname)
113 | if not os.path.isdir(dirname):
114 | os.makedirs(dirname)
115 | else:
116 | dirname = paths.get('df', dirname)
117 | for site_map in glob.glob(pattern + '-site_map-*'):
118 | target = os.path.join(dirname, 'site_maps', os.path.basename(site_map))
119 | if os.path.isfile(target):
120 | os.remove(site_map)
121 | continue
122 | os.renames(site_map, target)
123 | maps = ('world_map', 'bm', 'detailed', 'dip', 'drn', 'el', 'elw',
124 | 'evil', 'hyd', 'nob', 'rain', 'sal', 'sav', 'str', 'tmp',
125 | 'trd', 'veg', 'vol')
126 | for m in maps:
127 | m = glob.glob(pattern + '-' + m + '.???')
128 | if m:
129 | log.d('Found the following region map: ' + str(m[0]))
130 | t = os.path.join(dirname, 'region_maps', os.path.basename(m[0]))
131 | if os.path.isfile(t):
132 | os.remove(m[0])
133 | continue
134 | os.renames(m[0], t)
135 | for f in glob.glob(paths.get('df', region + '-*')):
136 | log.d('Found the following misc files: ' + str(f))
137 | if os.path.isfile(f):
138 | target = os.path.join(dirname, os.path.basename(f))
139 | if os.path.isfile(target):
140 | os.remove(f)
141 | continue
142 | os.renames(f, target)
143 | for f in glob.glob(paths.get('df', '*_color_key.txt')):
144 | os.remove(f)
145 |
146 |
147 | def process_legends():
148 | """Process all legends exports in sets."""
149 | if lnp.df_info.version >= '0.40.09':
150 | i = 0
151 | while get_region_info():
152 | log.i('Processing legends from ' + get_region_info()[0])
153 | compress_bitmaps()
154 | create_archive()
155 | move_files()
156 | i += 1
157 | return i
158 | return None
159 |
--------------------------------------------------------------------------------
/core/baselines.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Advanced raw and data folder management, for mods or graphics packs."""
4 |
5 | import fnmatch
6 | import glob
7 | import os
8 | import shutil
9 | import tarfile
10 | import zipfile
11 |
12 | from . import log, paths, update
13 | from .lnp import lnp
14 |
15 |
16 | def find_vanilla(download_missing=True):
17 | """Finds the vanilla baseline for the current version.
18 |
19 | Starts by unzipping any DF releases in baselines and preprocessing them.
20 | If download_missing is set to True, missing baselines will be downloaded.
21 |
22 | Returns:
23 | Path to the vanilla folder, eg ``'LNP/Baselines/df_40_15'``,
24 | ``False`` if baseline not available (and start download),
25 | ``None`` if version detection is not accurate
26 | """
27 | if lnp.df_info.source == "init detection":
28 | log.w('Baseline DF version from init detection; highly unreliable!')
29 | return None
30 | prepare_baselines()
31 | version = 'df_' + str(lnp.df_info.version)[2:].replace('.', '_')
32 | if os.path.isdir(paths.get('baselines', version)):
33 | return paths.get('baselines', version)
34 | if download_missing:
35 | update.download_df_baseline()
36 | return False
37 |
38 |
39 | def find_vanilla_raws(download_missing=True):
40 | """Finds vanilla raws for the current version."""
41 | retval = find_vanilla(download_missing)
42 | if retval:
43 | return os.path.join(retval, 'raw')
44 | return retval
45 |
46 |
47 | def prepare_baselines():
48 | """Unzip any DF releases found, and discard non-universal files."""
49 | archives = glob.glob(os.path.join(paths.get('baselines'), 'df_??_?*.???'))
50 | if archives:
51 | log.i('Extracting archives in baselines: ' + str(archives))
52 | for item in archives:
53 | version = os.path.basename(item)
54 | for s in ('_win32', '_osx32', '_linux32', '_legacy32',
55 | '_win', '_osx', '_linux', '_legacy', '_s',
56 | '.zip', '.tar.bz2'):
57 | version = version.replace(s, '')
58 | f = paths.get('baselines', version)
59 | if not os.path.isdir(f):
60 | if item.endswith('.zip'):
61 | with zipfile.ZipFile(item) as zipped:
62 | zipped.extractall(f)
63 | elif item.endswith('.tar.bz2'):
64 | with tarfile.TarFile(item) as tarred:
65 | tarred.extractall(f)
66 | for k in glob.glob(os.path.join(f, 'df_*x', '*')):
67 | shutil.move(k, f)
68 | simplify_pack(version, 'baselines')
69 | os.remove(item)
70 |
71 |
72 | def set_auto_download(value):
73 | """Sets the option for auto-download of baselines."""
74 | lnp.userconfig['downloadBaselines'] = value
75 | lnp.userconfig.save_data()
76 |
77 |
78 | def simplify_pack(pack, folder):
79 | """Removes unnecessary files from LNP//.
80 |
81 | Args:
82 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings
83 |
84 | Returns:
85 | The number of files removed if successful,
86 | ``False`` if an exception occurred,
87 | ``None`` if folder is empty
88 | """
89 | if folder not in ('graphics', 'mods', 'baselines'):
90 | return False
91 | log.i('Simplifying {}: {}'.format(folder, pack))
92 | packdir = paths.get(folder, pack)
93 | files_before = sum(len(f) for _, _, f in os.walk(packdir))
94 | if files_before == 0:
95 | return None
96 | keep = [('raw',), ('data', 'speech')]
97 | if folder == 'graphics':
98 | keep = [('raw', 'objects'), ('raw', 'graphics')]
99 | if folder != 'mods':
100 | keep += [('data', 'art')] + [
101 | ('data', 'init', f + '.txt') for f in
102 | ('colors', 'd_init', 'init', 'overrides')]
103 | if folder == 'baselines':
104 | keep.append(('data', 'init', 'interface.txt'))
105 | keep = [os.path.join(*k) for k in keep]
106 | for root, _, files in os.walk(packdir):
107 | for k in files:
108 | if k == 'manifest.json' or 'readme' in k.lower():
109 | continue
110 | f = os.path.join(root, k)
111 | if not any(fnmatch.fnmatch(f, os.path.join(packdir, pattern, '*'))
112 | for pattern in keep):
113 | os.remove(f)
114 | files_after = sum(len(f) for _, _, f in os.walk(packdir))
115 | log.v('Removed {} files'.format(files_before - files_after))
116 | return files_before - files_after
117 |
118 |
119 | def remove_vanilla_raws_from_pack(pack, folder):
120 | """Remove files identical to vanilla raws, return files removed
121 |
122 | Args:
123 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings
124 |
125 | Returns:
126 | int: the number of files removed
127 | """
128 | if not find_vanilla():
129 | return 0
130 | i = 0
131 | for _folder, van_folder in (
132 | [paths.get(folder, pack, 'raw'), find_vanilla_raws()],
133 | [paths.get(folder, pack, 'data', 'speech'),
134 | os.path.join(find_vanilla(), 'data', 'speech')]):
135 | for root, _, files in os.walk(_folder):
136 | for k in files:
137 | f = os.path.join(root, k)
138 | silently_kill = ('Thumbs.db', 'installed_raws.txt')
139 | if any(f.endswith(x) for x in silently_kill):
140 | os.remove(f)
141 | continue
142 | van_f = os.path.join(van_folder, os.path.relpath(f, _folder))
143 | if os.path.isfile(van_f):
144 | with open(van_f, encoding='cp437', errors='replace') as v:
145 | vtext = v.read()
146 | with open(f, encoding='cp437', errors='replace') as m:
147 | mtext = m.read()
148 | if vtext == mtext:
149 | os.remove(f)
150 | i += 1
151 | return i
152 |
153 |
154 | def remove_empty_dirs(pack, folder):
155 | """Removes empty subdirs in a mods or graphics pack.
156 |
157 | Args:
158 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings
159 |
160 | Returns:
161 | int: the number of dirs removed
162 | """
163 | i = 0
164 | for _ in range(3):
165 | # only catches the lowest level each iteration
166 | for root, dirs, files in os.walk(paths.get(folder, pack)):
167 | if not dirs and not files:
168 | os.rmdir(root)
169 | i += 1
170 | return i
171 |
--------------------------------------------------------------------------------
/tkgui/utilities.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init
4 | """Utilities tab for the TKinter GUI."""
5 |
6 | import sys
7 | from tkinter import * # noqa: F403
8 | from tkinter import messagebox
9 | from tkinter.ttk import * # noqa: F403
10 |
11 | from core import launcher, paths, utilities
12 | from core.lnp import lnp
13 |
14 | from . import controls
15 | from .tab import Tab
16 |
17 |
18 | class UtilitiesTab(Tab):
19 | """Utilities tab for the TKinter GUI."""
20 | def read_data(self):
21 | self.read_utilities()
22 |
23 | # Fix focus bug
24 | if self.proglist.get_children():
25 | self.proglist.focus(self.proglist.get_children()[0])
26 |
27 | def create_controls(self):
28 | progs = controls.create_control_group(
29 | self, 'Programs/Utilities', True)
30 | progs.pack(side=TOP, expand=Y, fill=BOTH)
31 | Grid.rowconfigure(progs, 3, weight=1)
32 |
33 | controls.create_trigger_button(
34 | progs, 'Run Program', 'Runs the selected program(s).',
35 | self.run_selected_utilities).grid(column=0, row=0, sticky="nsew")
36 | controls.create_trigger_button(
37 | progs, 'Open Utilities Folder', 'Open the utilities folder',
38 | utilities.open_utils).grid(column=1, row=0, sticky="nsew")
39 | Label(
40 | progs, text='Double-click on a program to launch it.').grid(
41 | column=0, row=1, columnspan=2)
42 | Label(
43 | progs, text='Right-click on a program to toggle auto-launch.').grid(
44 | column=0, row=2, columnspan=2)
45 |
46 | self.proglist = controls.create_toggle_list(
47 | progs, ('path', 'tooltip'), {
48 | 'column': 0, 'row': 3, 'columnspan': 2, 'sticky': "nsew"})
49 | self.configure_proglist()
50 |
51 | open_readme = controls.create_trigger_button(
52 | progs, 'Open Readme', 'Open the readme file associated with the '
53 | 'selected utilities.', self.open_readmes)
54 | open_readme.grid(column=0, row=4, columnspan=2, sticky="nsew")
55 |
56 | refresh = controls.create_trigger_button(
57 | progs, 'Refresh List', 'Refresh the list of utilities',
58 | self.read_utilities)
59 | refresh.grid(column=0, row=5, columnspan=2, sticky="nsew")
60 |
61 | def configure_proglist(self):
62 | """Configures the treeview."""
63 | proglist = self.proglist
64 |
65 | # Do not show headings
66 | proglist.configure(show=['tree'], displaycolumns=())
67 |
68 | for seq in ("", ""):
69 | proglist.bind(seq, lambda e: self.run_selected_utilities())
70 |
71 | for seq in ("", "<2>" if sys.platform == 'darwin' else "<3>",):
72 | proglist.bind(seq, self.toggle_autorun)
73 |
74 | self.list_tooltip = controls.create_tooltip(proglist, '')
75 | proglist.bind('', self.update_tooltip)
76 |
77 | # Make it easy to differentiate between autorun
78 | proglist.tag_configure('autorun', background='pale green')
79 |
80 | # Deselect everything if blank area is clicked
81 | proglist.bind("<1>", self.proglist_click)
82 |
83 | def proglist_click(self, event):
84 | """Deselect everything if event occurred in blank area"""
85 | region = self.proglist.identify_region(event.x, event.y)
86 | if region == 'nothing':
87 | self.proglist.selection_set('')
88 |
89 | def update_tooltip(self, event):
90 | """
91 | Event handler for mouse motion over items in the utility list.
92 |
93 | If the mouse has moved out of the last list element, hides the tooltip.
94 | Then, if the mouse is over a list item, wait controls._TOOLTIP_DELAY
95 | milliseconds (without mouse movement) before showing the tooltip"""
96 | tooltip = self.list_tooltip
97 | proglist = self.proglist
98 | item = proglist.identify_row(event.y)
99 |
100 | def show():
101 | """Sets and shows a tooltip"""
102 | tooltip.settext(proglist.set(item, 'tooltip'))
103 | tooltip.showtip()
104 |
105 | if tooltip.event:
106 | proglist.after_cancel(tooltip.event)
107 | tooltip.event = None
108 | if proglist.set(item, 'tooltip') != tooltip.text:
109 | tooltip.hidetip()
110 | if item:
111 | # pylint: disable=protected-access
112 | tooltip.event = proglist.after(controls._TOOLTIP_DELAY, show)
113 |
114 | def read_utilities(self):
115 | """Reads list of utilities."""
116 | for prog in self.proglist.get_children():
117 | self.proglist.delete(prog)
118 |
119 | for prog in utilities.read_utilities():
120 | self.proglist.insert('', 'end', prog,
121 | text=utilities.get_title(prog),
122 | values=(prog, utilities.get_tooltip(prog)))
123 | self.update_autorun_list()
124 |
125 | def toggle_autorun(self, event):
126 | """
127 | Toggles autorun for a utility.
128 |
129 | Args:
130 | event: Data for the click event that triggered this.
131 | """
132 | if event.keysym == '??':
133 | item = self.proglist.identify_row(event.y)
134 | else:
135 | item = self.proglist.focus()
136 |
137 | if item:
138 | utilities.toggle_autorun(item)
139 | # pylint: disable=not-callable
140 | self.proglist.tag_set('autorun', item, item in lnp.autorun)
141 | # pylint: enable=not-callable
142 |
143 | def update_autorun_list(self):
144 | """Updates the autorun list."""
145 | for item in self.proglist.get_children():
146 | # pylint: disable=not-callable
147 | self.proglist.tag_set('autorun', item, item in lnp.autorun)
148 | # pylint: enable=not-callable
149 |
150 | def run_selected_utilities(self):
151 | """Runs selected utilities."""
152 | for item in self.proglist.selection():
153 | # utility_path = self.proglist.item(item, 'text')
154 | launcher.run_program(paths.get('utilities', item))
155 |
156 | def open_readmes(self):
157 | """Attempts to open the readme for the selected utilities."""
158 | launched_any = False
159 | if len(self.proglist.selection()) == 0:
160 | return
161 | for item in self.proglist.selection():
162 | launched_any = utilities.open_readme(item) or launched_any
163 | if not launched_any:
164 | messagebox.showinfo(
165 | message='Readme not found for any of the selected utilities.',
166 | title='No readmes found')
167 |
--------------------------------------------------------------------------------
/core/launcher.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Launching of programs, folders, URLs, etc.."""
4 |
5 | import copy
6 | import os
7 | import re
8 | import subprocess
9 | import sys
10 |
11 | from . import hacks, log, paths, terminal
12 | from .lnp import lnp
13 |
14 |
15 | def toggle_autoclose():
16 | """Toggle automatic closing of the UI when launching DF."""
17 | lnp.userconfig['autoClose'] = not lnp.userconfig.get_bool('autoClose')
18 | lnp.userconfig.save_data()
19 |
20 |
21 | def get_df_executable():
22 | """Returns the path of the executable needed to launch Dwarf Fortress."""
23 | spawn_terminal = False
24 | if sys.platform == 'win32':
25 | if ('legacy' in lnp.df_info.variations
26 | and lnp.df_info.version <= '0.31.14'):
27 | df_filename = 'dwarfort.exe'
28 | else:
29 | df_filename = 'Dwarf Fortress.exe'
30 | elif sys.platform == 'darwin' and lnp.df_info.version <= '0.28.181.40d':
31 | df_filename = 'Dwarf Fortress.app'
32 | else:
33 | # Linux/OSX: Run DFHack if available and enabled
34 | if (os.path.isfile(paths.get('df', 'dfhack'))
35 | and hacks.is_dfhack_enabled()):
36 | df_filename = 'dfhack'
37 | spawn_terminal = True
38 | else:
39 | df_filename = 'df'
40 | if lnp.args.df_executable:
41 | df_filename = lnp.args.df_executable
42 | return df_filename, spawn_terminal
43 |
44 |
45 | def run_df(force=False):
46 | """Launches Dwarf Fortress."""
47 | validation_result = lnp.settings.validate_config()
48 | if validation_result:
49 | if not lnp.ui.on_invalid_config(validation_result):
50 | return None
51 | df_filename, spawn_terminal = get_df_executable()
52 |
53 | executable = paths.get('df', df_filename)
54 | result = run_program(executable, force, True, spawn_terminal)
55 | if (force and not result) or result is False:
56 | log.e('Could not launch ' + executable)
57 | raise Exception('Failed to run Dwarf Fortress.')
58 |
59 | for prog in lnp.autorun:
60 | utility = paths.get('utilities', prog)
61 | if os.access(utility, os.F_OK):
62 | run_program(utility)
63 |
64 | if lnp.userconfig.get_bool('autoClose'):
65 | sys.exit()
66 | return result
67 |
68 |
69 | def run_program(path, force=False, is_df=False, spawn_terminal=False):
70 | """
71 | Launches an external program.
72 |
73 | Args:
74 | path: the path of the program to launch.
75 | spawn_terminal: whether to spawn a new terminal for this app.
76 | Used only for DFHack.
77 | """
78 | path = os.path.abspath(path)
79 | check_nonchild = ((spawn_terminal and sys.platform.startswith('linux'))
80 | or (sys.platform == 'darwin' and (
81 | path.endswith('.app') or spawn_terminal)))
82 |
83 | is_running = program_is_running(path, check_nonchild)
84 | if not force and is_running:
85 | log.i(path + ' is already running')
86 | lnp.ui.on_program_running(path, is_df)
87 | return None
88 |
89 | try:
90 | workdir = os.path.dirname(path)
91 | run_args = path
92 | if spawn_terminal and not sys.platform.startswith('win'):
93 | run_args = terminal.get_terminal_command([path,])
94 | elif path.endswith('.jar'): # Explicitly launch JAR files with Java
95 | run_args = ['java', '-jar', os.path.basename(path)]
96 | elif path.endswith('.app'): # OS X application bundle
97 | run_args = ['open', path]
98 | workdir = path
99 |
100 | environ = os.environ
101 | if lnp.bundle:
102 | # pylint: disable=protected-access
103 | environ = copy.deepcopy(os.environ)
104 | if ('TCL_LIBRARY' in environ
105 | and sys._MEIPASS in environ['TCL_LIBRARY']):
106 | del environ['TCL_LIBRARY']
107 | if ('TK_LIBRARY' in environ
108 | and sys._MEIPASS in environ['TK_LIBRARY']):
109 | del environ['TK_LIBRARY']
110 | if 'LD_LIBRARY_PATH' in environ:
111 | del environ['LD_LIBRARY_PATH']
112 | if 'PYTHONPATH' in environ:
113 | del environ['PYTHONPATH']
114 |
115 | with subprocess.Popen(run_args, cwd=workdir, env=environ) as p:
116 | lnp.running[path] = p
117 | return True
118 | except OSError:
119 | sys.excepthook(*sys.exc_info())
120 | return False
121 |
122 |
123 | def program_is_running(path, nonchild=False):
124 | """
125 | Returns True if a program is currently running.
126 |
127 | Args:
128 | path: the path of the program.
129 | nonchild: if set to True, attempts to check for the process among all
130 | running processes, not just known child processes. Used for
131 | DFHack on Linux and OS X; currently unsupported for Windows.
132 | """
133 | if nonchild:
134 | with subprocess.Popen(['ps', 'axww'], stdout=subprocess.PIPE) as ps:
135 | s = ps.stdout.read()
136 | encoding = sys.getfilesystemencoding()
137 | if encoding is None:
138 | # Encoding was not detected, assume UTF-8
139 | encoding = 'UTF-8'
140 | s = s.decode(encoding, 'replace')
141 | return re.search('\\B%s( |$)' % re.escape(path), s, re.M) is not None
142 | if path not in lnp.running:
143 | return False
144 | lnp.running[path].poll()
145 | return lnp.running[path].returncode is None
146 |
147 |
148 | def open_folder_idx(i):
149 | """Opens the folder specified by index i, as listed in PyLNP.json."""
150 | open_file(os.path.join(
151 | paths.get('root'), lnp.config['folders'][i][1].replace(
152 | '', paths.get('df'))))
153 |
154 |
155 | def open_savegames():
156 | """Opens the save game folder."""
157 | open_file(paths.get('save'))
158 |
159 |
160 | def open_link_idx(i):
161 | """Opens the link specified by index i, as listed in PyLNP.json."""
162 | open_url(lnp.config['links'][i][1])
163 |
164 |
165 | def open_url(url):
166 | """Launches a web browser to the Dwarf Fortress webpage."""
167 | import webbrowser
168 | webbrowser.open(url)
169 |
170 |
171 | def open_file(path):
172 | """
173 | Opens a file with the system default viewer for the respective file type.
174 |
175 | Args:
176 | path: the file path to open.
177 | """
178 | path = os.path.normpath(path)
179 | try:
180 | if sys.platform == 'darwin':
181 | subprocess.check_call(['open', '--', path])
182 | elif sys.platform.startswith('linux'):
183 | subprocess.check_call(['xdg-open', path])
184 | elif sys.platform in ['windows', 'win32']:
185 | os.startfile(path)
186 | else:
187 | log.e('Unknown platform, cannot open file')
188 | except Exception:
189 | log.e('Could not open file ' + path)
190 |
--------------------------------------------------------------------------------
/core/importer.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Import user content from an old DF or Starter Pack install.
4 |
5 | The content to import is defined in PyLNP.json
6 |
7 | Two import strategies are currently supported:
8 |
9 | :copy_add:
10 | copy a file or directory contents, non-recursive, no overwriting
11 | :text_prepend:
12 | prepend imported file content (for logfiles)
13 |
14 |
15 | These strategies support the 'low-hanging fruit' of imports. Other content
16 | or more advanced strategies have been identified, but are difficult to
17 | implement without risking a 'bad import' scenario:
18 |
19 | :init files:
20 | Not simply copyable. Sophisticated merging (similar to graphics
21 | upgrades) may lead to bad config when using settings from an older
22 | version of DF. Will not be supported.
23 | :keybinds:
24 | Could be imported by minimising interface.txt (and ``LNP/Keybinds/*``)
25 | (see core/keybinds.py), and copying if a duplicate set is not yet
26 | available. Planned for future update.
27 | :world_gen, embark_profiles:
28 | Importing world gen and embark profiles may be supported eventually.
29 | No obvious downsides beyond tricky implementation.
30 | :other:
31 | Custom settings importer - e.g. which graphics pack, are aquifers
32 | disabled, other PyLNP settings... May be added later but no plans.
33 |
34 | """
35 |
36 | import os
37 | import shutil
38 |
39 | from . import log, paths
40 | from .lnp import lnp
41 |
42 |
43 | def strat_fallback(strat):
44 | """Log error if an unknown strategy is attempted."""
45 | def __fallback(src, dest):
46 | # pylint:disable=unused-argument
47 | log.w('Attempted to use unknown strategy ' + strat)
48 | return False
49 | return __fallback
50 |
51 |
52 | def strat_copy_add(src, dest):
53 | """Copy a file or directory contents from src to dest, without overwriting.
54 | If a single file, an existing file may be overwritten if it only contains
55 | whitespace. For directory contents, only the top level is 'filled in'.
56 | """
57 | # handle the simple case, one file
58 | if os.path.isfile(src):
59 | if os.path.isfile(dest):
60 | with open(dest, encoding="utf-8") as f:
61 | if f.read().strip():
62 | log.i('Skipping import of {} to {}; dest is non-empty file'
63 | .format(src, dest))
64 | return False
65 | log.i('importing {} to {} by copying'.format(src, dest))
66 | shutil.copy2(src, dest)
67 | return True
68 | # adding dir contents
69 | ret = False
70 | for it in os.listdir(src):
71 | if os.path.exists(os.path.join(dest, it)):
72 | log.i('Skipping import of {}/{}, exists in dest'.format(src, it))
73 | continue
74 | ret = True # *something* was imported
75 | log.i('importing {} from {} to {}'.format(it, src, dest))
76 | if not os.path.isdir(dest):
77 | os.makedirs(dest)
78 | item = os.path.join(src, it)
79 | if os.path.isfile(item):
80 | shutil.copy2(item, dest)
81 | else:
82 | shutil.copytree(item, os.path.join(dest, it))
83 | return ret
84 |
85 |
86 | def strat_text_prepend(src, dest):
87 | """Prepend the src textfile to the dest textfile, creating it if needed."""
88 | if not os.path.isfile(src):
89 | log.i('Cannot import {} - not a file'.format(src))
90 | return False
91 | if not os.path.isfile(dest):
92 | log.i('importing {} to {} by copying'.format(src, dest))
93 | shutil.copy2(src, dest)
94 | return True
95 | with open(src, encoding='latin1') as f:
96 | srctext = f.read()
97 | with open(dest, encoding='latin1') as f:
98 | desttext = f.read()
99 | with open(src, 'w', encoding='latin1') as f:
100 | log.i('importing {} to {} by prepending'.format(src, dest))
101 | f.writelines([srctext, '\n', desttext])
102 | return True
103 |
104 |
105 | def do_imports(from_df_dir):
106 | """Import content (defined in PyLNP.json) from the given previous df_dir,
107 | and associated LNP install if any.
108 | """
109 | # pylint:disable=too-many-branches
110 | # validate that from_df_dir is, in fact, a DF dir
111 | if not all(os.path.exists(os.path.join(from_df_dir, *p)) for p in
112 | [('data', 'init', 'init.txt'), ('raw', 'objects')]):
113 | return (False, 'Does not seem to be a DF install directory.')
114 | # Get list of paths, and add dest where implicit (ie same as src)
115 | if not lnp.config.get('to_import'):
116 | return (False, 'Nothing is configured for import in PyLNP.json')
117 | raw_config = [(c + [c[1]])[:3] for c in lnp.config['to_import']]
118 |
119 | path_pairs = []
120 | # Turn "paths" in PyLNP.json into real paths
121 | for st, src, dest in raw_config:
122 | if '' in src:
123 | newsrc = src.replace('', from_df_dir)
124 | elif '' in src:
125 | if os.path.exists(os.path.join(from_df_dir, 'hack', 'init')):
126 | newsrc = src.replace('', os.path.join(
127 | from_df_dir, 'dfhack-config', 'init'))
128 | else:
129 | newsrc = src.replace('', from_df_dir)
130 | else:
131 | newsrc = os.path.join(from_df_dir, '../', src)
132 | newsrc = os.path.abspath(os.path.normpath(newsrc))
133 | if '' in dest:
134 | newdest = dest.replace('', paths.get('df'))
135 | elif '' in src:
136 | newdest = dest.replace(
137 | '', paths.get('dfhack_config'))
138 | else:
139 | newdest = paths.get('root', dest)
140 | newdest = os.path.abspath(os.path.normpath(newdest))
141 | path_pairs.append((st, newsrc, newdest))
142 |
143 | # Sanity-check the provided paths...
144 | src_prefix = os.path.commonprefix([src for _, src, _ in path_pairs])
145 | dest_prefix = os.path.commonprefix([dest for _, _, dest in path_pairs])
146 | log.i('Importing from {} to {}'.format(src_prefix, dest_prefix))
147 | if not (os.path.isdir(src_prefix) or os.path.dirname(src_prefix)):
148 | # parent dir is a real path, even when os.path.commonprefix isn't
149 | msg = 'Can only import content from single basedir'
150 | log.w(msg)
151 | return (False, msg)
152 | if not dest_prefix:
153 | # checking .startswith avoids the os.path.commonprefix issue
154 | msg = 'Can only import content to destinations below current basedir'
155 | log.w(msg)
156 | return (False, msg)
157 |
158 | strat_funcs = {
159 | 'copy_add': strat_copy_add,
160 | 'text_prepend': strat_text_prepend,
161 | }
162 | imported = []
163 | for strat, src, dest in path_pairs:
164 | if not os.path.exists(src):
165 | log.w('Cannot import {} - does not exist'.format(src))
166 | continue
167 | if strat_funcs.get(strat, strat_fallback(strat))(src, dest):
168 | imported.append(src)
169 | if not imported:
170 | return (False, 'Nothing was found to import!')
171 | return (True, '\n'.join(imported))
172 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " applehelp to make an Apple Help Book"
34 | @echo " devhelp to make HTML files and a Devhelp project"
35 | @echo " epub to make an epub"
36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
37 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
39 | @echo " text to make text files"
40 | @echo " man to make manual pages"
41 | @echo " texinfo to make Texinfo files"
42 | @echo " info to make Texinfo files and run them through makeinfo"
43 | @echo " gettext to make PO message catalogs"
44 | @echo " changes to make an overview of all changed/added/deprecated items"
45 | @echo " xml to make Docutils-native XML files"
46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
47 | @echo " linkcheck to check all external links for integrity"
48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
49 | @echo " coverage to run coverage check of the documentation (if enabled)"
50 |
51 | clean:
52 | rm -rf $(BUILDDIR)/*
53 |
54 | html:
55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
56 | @echo
57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
58 |
59 | dirhtml:
60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
61 | @echo
62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
63 |
64 | singlehtml:
65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
66 | @echo
67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
68 |
69 | pickle:
70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
71 | @echo
72 | @echo "Build finished; now you can process the pickle files."
73 |
74 | json:
75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
76 | @echo
77 | @echo "Build finished; now you can process the JSON files."
78 |
79 | htmlhelp:
80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
81 | @echo
82 | @echo "Build finished; now you can run HTML Help Workshop with the" \
83 | ".hhp project file in $(BUILDDIR)/htmlhelp."
84 |
85 | qthelp:
86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
87 | @echo
88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyLNP.qhcp"
91 | @echo "To view the help file:"
92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyLNP.qhc"
93 |
94 | applehelp:
95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
96 | @echo
97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
98 | @echo "N.B. You won't be able to view it unless you put it in" \
99 | "~/Library/Documentation/Help or install it in your application" \
100 | "bundle."
101 |
102 | devhelp:
103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
104 | @echo
105 | @echo "Build finished."
106 | @echo "To view the help file:"
107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyLNP"
108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyLNP"
109 | @echo "# devhelp"
110 |
111 | epub:
112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
113 | @echo
114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
115 |
116 | latex:
117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
118 | @echo
119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
121 | "(use \`make latexpdf' here to do that automatically)."
122 |
123 | latexpdf:
124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
125 | @echo "Running LaTeX files through pdflatex..."
126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
128 |
129 | latexpdfja:
130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
131 | @echo "Running LaTeX files through platex and dvipdfmx..."
132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
134 |
135 | text:
136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
137 | @echo
138 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
139 |
140 | man:
141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
142 | @echo
143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
144 |
145 | texinfo:
146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
147 | @echo
148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
149 | @echo "Run \`make' in that directory to run these through makeinfo" \
150 | "(use \`make info' here to do that automatically)."
151 |
152 | info:
153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
154 | @echo "Running Texinfo files through makeinfo..."
155 | make -C $(BUILDDIR)/texinfo info
156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
157 |
158 | gettext:
159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
160 | @echo
161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
162 |
163 | changes:
164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
165 | @echo
166 | @echo "The overview file is in $(BUILDDIR)/changes."
167 |
168 | linkcheck:
169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
170 | @echo
171 | @echo "Link check complete; look for any errors in the above output " \
172 | "or in $(BUILDDIR)/linkcheck/output.txt."
173 |
174 | doctest:
175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
176 | @echo "Testing of doctests in the sources finished, look at the " \
177 | "results in $(BUILDDIR)/doctest/output.txt."
178 |
179 | coverage:
180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
181 | @echo "Testing of coverage in the sources finished, look at the " \
182 | "results in $(BUILDDIR)/coverage/python.txt."
183 |
184 | xml:
185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
186 | @echo
187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
188 |
189 | pseudoxml:
190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
191 | @echo
192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
193 |
--------------------------------------------------------------------------------
/docs/building.rst:
--------------------------------------------------------------------------------
1 | Running or Building PyLNP
2 | #########################
3 |
4 | This document describes how to run PyLNP. For most users, we suggest
5 | just using `the latest stand-alone executable version
6 | `_,
7 | which are available for Windows, OSX, and Linux.
8 | You may wish to download PyLNP as part of a complete package for beginners,
9 | `which can be found here `_.
10 |
11 | If you have configuration problems or other errors, want to run the source
12 | code directly, or want to build your own stand-alone executable, the
13 | remainder of this page is for you.
14 |
15 |
16 | .. contents::
17 |
18 |
19 | Platform-specific notes
20 | =======================
21 | Windows
22 | -------
23 | If the program refuses to start, or gives an error message like:
24 |
25 | *The application has failed to start because the side-by-side configuration
26 | is incorrect. Please see the application event log for more details.*
27 |
28 | you most likely need to install the `Microsoft Visual C++ 2015 redistributable
29 | package `_.
30 |
31 | The user interface library used by PyLNP has issues with high-DPI displays.
32 | For builds made after February 28, 2016 (ie PyLNP v0.11 and later),
33 | Windows should automatically scale the PyLNP window to match your
34 | DPI settings, thereby avoiding these problems.
35 |
36 | Linux
37 | -----
38 | On Linux and OS X, it is necessary to spawn a new terminal when using DFHack.
39 | This is handled automatically on OS X, but unfortunately Linux provides no
40 | standard way of doing this; it varies depending on your setup.
41 |
42 | PyLNP will attempt to detect which terminals are available on your system. On
43 | first launch, you will be asked to select which terminal to use; only terminals
44 | available on your system will appear in the list.
45 |
46 | PyLNP should be able to detect the GNOME, KDE, i3, LXDE, Mate, Xfce desktop
47 | environments and window managers. It can also handle the [u]rxvt
48 | (urxvt is used if available, else rxvt) and xterm stand-alone terminals.
49 |
50 | For other setups, you must configure a custom command.
51 | For example, if your terminal can be spawned using::
52 |
53 | term -e
54 |
55 | then you should write this as ``term -e`` - the command will be automatically
56 | appended. If you need the command to be placed elsewhere, use ``$`` as a
57 | placeholder for the command.
58 |
59 | Depending on your choice of terminal, desktop environment, etc., it may also be
60 | necessary to use ``nohup`` with the command, e.g. ``nohup term -e``.
61 |
62 | The terminal configuration UI includes a button to test if your custom command
63 | is able to launch terminals correctly. The test consists of two processes - a
64 | parent and a child - which will communicate with each other in various ways to
65 | ensure that they are running independently of the other.
66 |
67 | If the test fails, you will get an error message describing the issue briefly.
68 | You will have to adjust your command accordingly.
69 |
70 |
71 | Running from source
72 | ===================
73 | If you think the download is too large, I suggest running from source
74 | instead. There really isn't much to it, especially if you can live with a
75 | slightly less pretty logo.
76 |
77 | You will need to match the directory structure of the normal LNP. A download
78 | without utilities is available in the Bay12 Forums thread for PyLNP.
79 |
80 | You need Python 3.3 or later installed to run the source code, optionally with
81 | Pillow for better icons. Linux users may also need to install ``tk``; see
82 | below.
83 |
84 | If Pillow is not available and you are using an old version of tk, the log
85 | (:menuselection:`File --> Output`) will contain a line that starts with::
86 |
87 | Note: PIL not found and Tk version too old for PNG support...
88 |
89 | PyLNP will still work, it will just look a little less pretty.
90 |
91 | Windows
92 | -------
93 | Download a Windows installer for Python from https://python.org, which will
94 | contain everything required to run PyLNP. To get a better looking logo,
95 | run the command ``pip install pillow`` in a terminal.
96 |
97 | To run the code, double-click ``launch.py`` in the LNP folder. If you want
98 | to get rid of the console window that pops up, rename it to ``launch.pyw``.
99 |
100 | Linux
101 | -----
102 | Virtually all Linux distributions these days include Python, although
103 | especially older installations may not have an appropriate version, and
104 | some may not have Tk support installed by default.
105 |
106 | If you can't get it to work, you'll need to install those things.
107 | For Debian-based distributions, including Ubuntu and Linux Mint, the
108 | ``python-tk`` package is required, while ``python-imaging-tk`` is optional
109 | (used to show nicer version of icon and logo). For other distributions,
110 | look for similar packages in your package manager.
111 |
112 | To run the code, make sure launch.py is executable. Next, double-click and run it, or start
113 | a terminal and execute it from there with ``python launch.py`` or
114 | ``./launch.py``.
115 |
116 | OS X
117 | ----
118 | If you're running OS X 10.7 or later, you should have everything that's
119 | required. For 10.6 or earlier, upgrade Python to the latest 3.x release; an
120 | installer is available on https://python.org .
121 |
122 | To make the logo look better, you will need to install Pillow, a python
123 | library for images. If you have MacPorts installed, use it to install the
124 | package py-Pillow. If not, keep reading.
125 |
126 | .. _osx_compilers:
127 |
128 | First, you need to install command-line compilers. The easiest way I've
129 | found is to install Xcode, then open it and go to :menuselection:`Preferences --> Downloads`
130 | and install them from there. It should also be possible to download these
131 | compilers directly from `Apple `_,
132 | but you're on your own for that.
133 |
134 | Once the compilers are in place, open a Terminal and type ``sudo
135 | easy_install pillow``. OS X should come with the libraries needed to build
136 | Pillow to load the logo.
137 |
138 | OS X does not provide a way to launch a Python script from Finder, so
139 | to run the code you will need to start a terminal, navigate to the directory,
140 | and execute ``python launch.py`` or ``./launch.py``.
141 |
142 |
143 | Building your own executable
144 | ============================
145 | If you want to make your own executable, you can do that. This is
146 | particularly useful on OS X, which doesn't have any good way of launching a
147 | Python script directly from Finder.
148 |
149 | The executables are built using `PyInstaller `_
150 | (v4.2 or later), which can be usually be installed with
151 | ``pip install pyinstaller``. See below for specific instructions.
152 |
153 | Open the PyLNP directory in a terminal and type ``pyinstaller lnp.spec``.
154 | Wait for the build to finish, and you will find a new folder named dist.
155 | Inside that folder is the stand-alone executable, named ``lnp.exe`` on Windows,
156 | ``lnp`` on Linux, and ``PyLNP`` (an application bundle) on OS X.
157 |
158 | .. note::
159 | The resulting executable must be placed somewhere such that the program can
160 | find the folder containing Dwarf Fortress by navigating up the folder tree.
161 | For example, if Dwarf Fortress is located in ``/Games/Dwarf Fortress``, the
162 | PyLNP executable may be located in ``/Games``, ``/Games/PyLNP``,
163 | ``/Games/Utilities/Launcher``, etc.
164 |
165 | If ``pip`` is not available on your system, you may need to install it, either from a package manager or by running ``python -m ensurepip`` from the command-line. If you can't use the regular pip command, ``python -m pip `` works too.
166 |
167 | Windows
168 | -------
169 | PyInstaller 4.8 introduces a hook script which will break DFHack. A `bug report `_ already exists for Pyinstaller for this issue, but at time of writing, it's still an issue. For now, use an older version; anything from 4.2 to 4.7 should definitely work; 4.6 is being used for the official builds. Use ``pip install PyInstaller==4.6`` to install that one.
170 |
171 | Note that your resulting build will have the same Windows requirements as the Python version used to build. To support Windows Vista and 7, you need to use Python 3.8 or earlier.
172 |
173 | Linux
174 | -----
175 | If your package manager provides PyInstaller, install it from there. Otherwise, use pip.
176 |
177 | OS X
178 | ----
179 | You may need to :ref:`install command-line compilers `.
180 |
181 |
--------------------------------------------------------------------------------
/core/utilities.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Utility management.
4 |
5 | There are now two separate metadata systems:
6 |
7 | - the manifest system, which applies to each dir with a manifest (and subdirs)
8 | - the global system, which applies to everything else
9 |
10 | Utilities are uniformly and uniquely identified by the relative path
11 | from ``LNP/Utilities/`` to the executable file.
12 |
13 | Metadata for each is found by looking back up the path for a manifest, and
14 | in the global metadata if one is not found.
15 |
16 | Utilities are found by walking down from the base dir.
17 |
18 | For each dir, if a manifest is found it and all it's subdirs are only analysed
19 | by the manifest system. See the README for how this works, and note that it
20 | is more structured as well as more powerful, slightly decreasing flexibility -
21 | for example mandating only one executable per platform, but specifying
22 | requirements for DFHack or a terminal.
23 |
24 | Otherwise, each file (and on OSX, dir) is matched against standard patterns
25 | and user include patterns. Any matches that do not also match a user exclude
26 | pattern are added to the list of identified utilities. This global config is
27 | found in some combination of include.txt, exclude.txt, and utilities.txt.
28 | """
29 |
30 | import os
31 | import re
32 | from fnmatch import fnmatch
33 |
34 | from . import log, manifest, paths
35 | from .launcher import open_file
36 | from .lnp import lnp
37 |
38 |
39 | def open_utils():
40 | """Opens the utilities folder."""
41 | open_file(paths.get('utilities'))
42 |
43 |
44 | def read_metadata():
45 | """Read metadata from the utilities directory."""
46 | metadata = {}
47 | for e in read_utility_lists(paths.get('utilities', 'utilities.txt')):
48 | fname, title, tooltip = (e.split(':', 2) + ['', ''])[:3]
49 | metadata[fname] = {'title': title, 'tooltip': tooltip}
50 | return metadata
51 |
52 |
53 | def manifest_for(path):
54 | """Returns the JsonConfiguration from manifest for the given utility,
55 | or None if no manifest exists."""
56 | while path:
57 | path = os.path.dirname(path)
58 | if os.path.isfile(os.path.join(
59 | paths.get('utilities'), path, 'manifest.json')):
60 | return manifest.get_cfg('utilities', path)
61 | return None
62 |
63 |
64 | def get_title(path):
65 | """
66 | Returns a title for the given utility. If a non-blank override exists, it
67 | will be used; otherwise, the filename will be manipulated according to
68 | PyLNP.json settings."""
69 | config = manifest_for(path)
70 | if config is not None:
71 | if config.get_string('title'):
72 | return config.get_string('title')
73 | else:
74 | metadata = read_metadata()
75 | if os.path.basename(path) in metadata:
76 | if metadata[os.path.basename(path)]['title']:
77 | return metadata[os.path.basename(path)]['title']
78 | head, result = os.path.split(path)
79 | if not lnp.config.get_bool('hideUtilityPath'):
80 | result = os.path.join(os.path.basename(head), result)
81 | if lnp.config.get_bool('hideUtilityExt'):
82 | result = os.path.splitext(result)[0]
83 | return result
84 |
85 |
86 | def get_tooltip(path):
87 | """Returns the tooltip for the given utility, or an empty string."""
88 | config = manifest_for(path)
89 | if config is not None:
90 | return config.get_string('tooltip')
91 | return read_metadata().get(os.path.basename(path), {}).get('tooltip', '')
92 |
93 |
94 | def read_utility_lists(path):
95 | """
96 | Reads a list of filenames/tags from a utility list (e.g. include.txt).
97 |
98 | Args:
99 | path: The file to read.
100 | """
101 | result = []
102 | try:
103 | with open(path, encoding='utf-8') as util_file:
104 | for line in util_file:
105 | for match in re.findall(r'\[(.+?)]', line):
106 | result.append(match)
107 | except IOError:
108 | pass
109 | return result
110 |
111 |
112 | def scan_manifest_dir(root):
113 | """Yields the configured utility (or utilities) from root and subdirs."""
114 | m_path = os.path.relpath(root, paths.get('utilities'))
115 | util = manifest.get_cfg('utilities', m_path).get_string(lnp.os + '_exe')
116 | if manifest.is_compatible('utilities', m_path):
117 | if os.path.isfile(os.path.join(root, util)):
118 | return os.path.join(m_path, util)
119 | log.w('Utility not found: {}'.format(os.path.join(m_path, util)))
120 | return None
121 |
122 |
123 | def any_match(filename, include, exclude):
124 | """Return True if at least one pattern matches the filename, or False."""
125 | return any(fnmatch(filename, p) for p in include) and \
126 | not any(fnmatch(filename, p) for p in exclude)
127 |
128 |
129 | def scan_normal_dir(root, dirnames, filenames):
130 | """Yields candidate utilities in the given root directory.
131 |
132 | Allow for an include list of filenames that will be treated as valid
133 | utilities. Useful for e.g. Linux, where executables rarely have
134 | extensions. Also accepts glob patterns for filename (not path).
135 | """
136 | metadata = read_metadata()
137 | patterns = ['*.jar', '*.sh']
138 | if lnp.os == 'win':
139 | patterns = ['*.jar', '*.exe', '*.bat']
140 | exclude = read_utility_lists(paths.get('utilities', 'exclude.txt'))
141 | # pylint: disable=consider-using-dict-items
142 | exclude += [u for u in metadata if metadata[u]['title'] == 'EXCLUDE']
143 | include = read_utility_lists(paths.get('utilities', 'include.txt'))
144 | include += [u for u in metadata if metadata[u]['title'] != 'EXCLUDE']
145 | # pylint: enable=consider-using-dict-items
146 | if lnp.os == 'osx':
147 | # OS X application bundles are really directories, and always end .app
148 | for dirname in dirnames:
149 | if any_match(dirname, ['*.app'], exclude):
150 | yield os.path.relpath(os.path.join(root, dirname),
151 | paths.get('utilities'))
152 | for filename in filenames:
153 | if any_match(filename, patterns + include, exclude):
154 | yield os.path.relpath(os.path.join(root, filename),
155 | paths.get('utilities'))
156 |
157 |
158 | def read_utilities():
159 | """Returns a sorted list of utility programs."""
160 | utilities = []
161 | for root, dirs, files in os.walk(paths.get('utilities')):
162 | if 'manifest.json' in files:
163 | util = scan_manifest_dir(root)
164 | if util is not None:
165 | utilities.append(util)
166 | dirs[:] = [] # Don't run normal scan in subdirs
167 | else:
168 | utilities.extend(scan_normal_dir(root, dirs, files))
169 | return sorted(utilities, key=get_title)
170 |
171 |
172 | def toggle_autorun(item):
173 | """
174 | Toggles autorun for the specified item.
175 |
176 | Args:
177 | item: the item to toggle autorun for.
178 | """
179 | if item in lnp.autorun:
180 | lnp.autorun.remove(item)
181 | else:
182 | lnp.autorun.append(item)
183 | save_autorun()
184 |
185 |
186 | def load_autorun():
187 | """Loads autorun settings."""
188 | lnp.autorun = []
189 | try:
190 | with open(paths.get('utilities', 'autorun.txt'),
191 | encoding='utf-8') as file:
192 | for line in file:
193 | lnp.autorun.append(line.rstrip('\n'))
194 | except IOError:
195 | pass
196 |
197 |
198 | def save_autorun():
199 | """Saves autorun settings."""
200 | filepath = paths.get('utilities', 'autorun.txt')
201 | with open(filepath, 'w', encoding="utf-8") as autofile:
202 | autofile.write("\n".join(lnp.autorun))
203 |
204 |
205 | def open_readme(path):
206 | """
207 | Opens the readme associated with the utility , if one exists.
208 | Returns False if no readme was found; otherwise True.
209 | """
210 | readme = None
211 | log.d('Finding readme for ' + path)
212 | m = manifest_for(path)
213 | path = paths.get('utilities', os.path.dirname(path))
214 | if m:
215 | readme = m.get('readme', None)
216 | if not readme:
217 | dir_contents = os.listdir(path)
218 | for s in sorted(dir_contents):
219 | if re.match('read[ _]?me', s, re.IGNORECASE):
220 | readme = s
221 | break
222 | else:
223 | log.d('No readme found')
224 | return False
225 | readme = os.path.join(path, readme)
226 | log.d('Found readme at ' + readme)
227 | open_file(readme)
228 | return True
229 |
--------------------------------------------------------------------------------
/core/download.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Background download management."""
4 |
5 | import os
6 | import shutil
7 | import tempfile
8 | from threading import Lock, Thread
9 | from urllib.error import URLError
10 | from urllib.request import Request, urlopen
11 |
12 | from . import log
13 | from .lnp import VERSION
14 |
15 | __download_queues = {}
16 |
17 |
18 | def download_str(url, **kwargs):
19 | """Instantly download a file from and return its contents. Failed
20 | downloads return None. NOTE: This is a blocking method. Use a download queue
21 | for non-blocking downloads.
22 |
23 | Args:
24 | url: the URL to download
25 | encoding: used to decode the data to text. Defaults to UTF-8.
26 | timeout: timeout used for the URL request, in seconds. Defaults to 3.
27 |
28 | Returns:
29 | The contents of the downloaded file as text, or None.
30 | """
31 | try:
32 | req = Request(url, headers={'User-Agent': 'PyLNP/' + VERSION})
33 | return urlopen(
34 | req, timeout=kwargs.get('timeout', 3)).read().decode(
35 | kwargs.get('encoding', 'utf-8'))
36 | except URLError as ex:
37 | log.e('Error downloading ' + url + ': ' + ex.reason)
38 | except Exception:
39 | log.e('Error downloading ' + url)
40 | return None
41 |
42 |
43 | def download(queue, url, destination, end_callback=None, **kwargs):
44 | """Adds a download to the specified queue."""
45 | return get_queue(queue).add(url, destination, end_callback, **kwargs)
46 |
47 |
48 | def queue_empty(queue):
49 | """Returns True if the specified queue does not exist, or is empty;
50 | otherwise False."""
51 | return queue not in __download_queues or __download_queues[queue].empty()
52 |
53 |
54 | def get_queue(queue):
55 | """Returns the specified queue object, creating a new queue if necessary."""
56 | __download_queues.setdefault(queue, DownloadQueue(queue))
57 | return __download_queues[queue]
58 |
59 |
60 | class DownloadQueue(object):
61 | """Queue used for downloading files."""
62 | # pylint: disable=too-many-instance-attributes
63 | def __init__(self, name):
64 | self.name = name
65 | self.queue = []
66 | self.on_start_queue = []
67 | self.on_begin_download = []
68 | self.on_progress = []
69 | self.on_end_download = []
70 | self.on_end_queue = []
71 | self.thread = None
72 | self.lock = Lock()
73 | if name == 'immediate':
74 | def _immediate_progress(_, url, progress, total):
75 | if total != -1:
76 | msg = "Downloading %s... (%s/%s)" % (
77 | os.path.basename(url), progress, total)
78 | else:
79 | msg = ("Downloading %s... (%s bytes downloaded)" % (
80 | os.path.basename(url), progress))
81 | print("\r%s" % msg, end='')
82 | self.register_progress(_immediate_progress)
83 |
84 | def add(self, url, target, end_callback):
85 | """Adds a download to the queue.
86 |
87 | Args:
88 | url: the URL to download.
89 | target: the target path for the download.
90 | end_callback: a function(url, filename, success) which is called
91 | when the download finishes.
92 | """
93 | with self.lock:
94 | if url not in [q[0] for q in self.queue]:
95 | self.queue.append((url, target, end_callback))
96 | log.d(self.name + ': queueing ' + url + ' for download to ' + target)
97 | else:
98 | log.d(self.name + ': skipping add of ' + url + ', already in queue')
99 | if not self.thread and self.name != 'immediate':
100 | log.d('Download queue ' + self.name + ' not running, starting it')
101 | self.thread = t = Thread(target=self.__process_queue)
102 | t.daemon = True
103 | t.start()
104 | if self.name == 'immediate':
105 | log.i('Downloading immediately...')
106 | self.__process_queue()
107 |
108 | def empty(self):
109 | """Returns True if the queue is empty, otherwise False."""
110 | return len(self.queue) == 0
111 |
112 | def register_start_queue(self, func):
113 | """Registers a function func(queue_name) to be called when the queue is
114 | started. If False is returned by any function, the queue is cleared."""
115 | self.on_start_queue.append(func)
116 |
117 | def unregister_start_queue(self, func):
118 | """Unregisters a function func from being called when the queue is
119 | started."""
120 | self.on_start_queue.remove(func)
121 |
122 | def register_begin_download(self, func):
123 | """Registers a function func(queue_name, url) to be called when a
124 | download is started."""
125 | self.on_begin_download.append(func)
126 |
127 | def unregister_begin_download(self, func):
128 | """Unregisters a function func from being called when a download is
129 | started."""
130 | self.on_begin_download.remove(func)
131 |
132 | def register_progress(self, func):
133 | """Registers a function func(queue_name, url, downloaded, total_size)
134 | to be called for download progress reports.
135 | If total size is unknown, None will be sent."""
136 | self.on_progress.append(func)
137 |
138 | def unregister_progress(self, func):
139 | """Unregisters a function from being called for download progress
140 | reports."""
141 | self.on_progress.remove(func)
142 |
143 | def register_end_download(self, func):
144 | """Registers a function func(queue_name, url, filename, success) to be
145 | called when a download is finished."""
146 | self.on_end_download.append(func)
147 |
148 | def unregister_end_download(self, func):
149 | """Unregisters a function func from being called when a download is
150 | finished."""
151 | self.on_end_download.remove(func)
152 |
153 | def register_end_queue(self, func):
154 | """Registers a function func(queue_name) to be called when the
155 | queue is emptied."""
156 | self.on_end_queue.append(func)
157 |
158 | def unregister_end_queue(self, func):
159 | """Unregisters a function func from being called when the queue is
160 | emptied."""
161 | self.on_end_queue.remove(func)
162 |
163 | def __process_callbacks(self, callbacks, *args):
164 | """Calls the provided set of callback functions with ."""
165 | results = []
166 | for c in callbacks:
167 | try:
168 | results.append(c(self.name, *args))
169 | except Exception:
170 | results.append(None)
171 | return results
172 |
173 | def __process_queue(self):
174 | """Processes the download queue."""
175 | if False in self.__process_callbacks(self.on_start_queue):
176 | with self.lock:
177 | self.queue = []
178 | self.thread = None
179 | return
180 |
181 | while True:
182 | with self.lock:
183 | if self.empty():
184 | self.thread = None
185 | break
186 | url, target, end_callback = self.queue[0]
187 | log.d(self.name + ': About to download ' + url + ' to ' + target)
188 | self.__process_callbacks(self.on_begin_download, url, target)
189 | dirname = os.path.dirname(target)
190 | if not os.path.isdir(dirname):
191 | os.makedirs(dirname)
192 | outhandle, outpath = tempfile.mkstemp(dir=dirname)
193 | outfile = os.fdopen(outhandle, 'wb')
194 | try:
195 | req = Request(url, headers={'User-Agent': 'PyLNP/' + VERSION})
196 | with urlopen(req, timeout=5) as response:
197 | data = 0
198 | while True:
199 | chunk = response.read(8192)
200 | if not chunk:
201 | break
202 | total = response.info().get('Content-Length')
203 | data += len(chunk)
204 | outfile.write(chunk)
205 | self.__process_callbacks(
206 | self.on_progress, url, data, total)
207 | except Exception:
208 | outfile.close()
209 | os.remove(outpath)
210 | log.e(self.name + ': Error downloading ' + url, stack=True)
211 | self.__process_callbacks(
212 | self.on_end_download, url, target, False)
213 | if end_callback:
214 | end_callback(url, target, False)
215 | else:
216 | outfile.close()
217 | shutil.move(outpath, target)
218 | log.d(self.name + ': Finished downloading ' + url)
219 | self.__process_callbacks(
220 | self.on_end_download, url, target, True)
221 | if end_callback:
222 | end_callback(url, target, True)
223 | with self.lock:
224 | self.queue.pop(0)
225 |
226 | self.__process_callbacks(self.on_end_queue)
227 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Sphinx docs configuration for PyLNP"""
4 | #
5 | # PyLNP documentation build configuration file, created by
6 | # sphinx-quickstart on Tue Oct 11 18:59:22 2016.
7 | #
8 | # This file is execfile()d with the current directory set to its
9 | # containing dir.
10 | #
11 | # Note that not all possible configuration values are present in this
12 | # autogenerated file.
13 | #
14 | # All configuration values have a default; values that are commented out
15 | # serve to show the default.
16 |
17 | import datetime
18 | import glob
19 | import os
20 | import re
21 | import shutil
22 | import subprocess
23 | import sys
24 |
25 | # Before we get started, let's generate fresh API documentation from the code
26 | # pylint: disable=missing-function-docstring
27 | def ages(dname):
28 | return [os.stat(f).st_mtime for f in glob.glob(os.path.join(dname, '*'))]
29 |
30 |
31 | for mod in ('core', 'tkgui'):
32 | code_dir = os.path.join('..', mod)
33 | if not os.path.isdir(mod) or min(ages(mod)) <= max(ages(code_dir)):
34 | print('Regenerating {} API docs'.format(mod))
35 | shutil.rmtree(mod, ignore_errors=True)
36 | subprocess.check_output(['sphinx-apidoc', '--separate', '--force',
37 | '--no-toc', '-o', mod, code_dir])
38 | # pylint: enable=missing-function-docstring
39 |
40 |
41 | # If extensions (or modules to document with autodoc) are in another directory,
42 | # add these directories to sys.path here. If the directory is relative to the
43 | # documentation root, use os.path.abspath to make it absolute, like shown here.
44 | sys.path.insert(0, os.path.abspath('..'))
45 |
46 | from core import lnp
47 |
48 | # -- General configuration ------------------------------------------------
49 |
50 | # If your documentation needs a minimal Sphinx version, state it here.
51 | needs_sphinx = '1.0'
52 |
53 | # Add any Sphinx extension module names here, as strings. They can be
54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
55 | # ones.
56 | extensions = [
57 | 'sphinx.ext.autodoc',
58 | 'sphinx.ext.napoleon',
59 | 'sphinx.ext.viewcode',
60 | ]
61 |
62 | # Napoleon settings
63 | # napoleon_google_docstring = True
64 | # napoleon_numpy_docstring = True
65 | # napoleon_include_private_with_doc = False
66 | # napoleon_include_special_with_doc = True
67 | # napoleon_use_admonition_for_examples = False
68 | # napoleon_use_admonition_for_notes = False
69 | # napoleon_use_admonition_for_references = False
70 | # napoleon_use_ivar = False
71 | # napoleon_use_param = True
72 | napoleon_use_rtype = False
73 |
74 | # Add any paths that contain templates here, relative to this directory.
75 | templates_path = ['.']
76 |
77 | # The suffix(es) of source filenames.
78 | source_suffix = '.rst'
79 |
80 | # The master toctree document.
81 | master_doc = 'index'
82 |
83 | # General information about the project.
84 | project = 'PyLNP'
85 | author = 'Michael Madsen (Pidgeot) and collaborators'
86 | # pylint: disable=redefined-builtin
87 | copyright = '{}, {}'.format(datetime.datetime.now().year, author)
88 | # pylint: enable=redefined-builtin
89 |
90 | # The version info for the project you're documenting, acts as replacement for
91 | # |version| and |release|, also used in various other places throughout the
92 | # built documents.
93 | #
94 | # The full version, including alpha/beta/rc tags.
95 | release = lnp.VERSION
96 | # The short X.Y version.
97 | try:
98 | version = re.match(r'\d*\.\d*', release).group()
99 | except AttributeError:
100 | version = release
101 |
102 | # today_fmt is used as the format for a strftime call replacing |today|.
103 | today_fmt = '%B %d, %Y-%m-%d'
104 |
105 | # List of patterns, relative to source directory, that match files and
106 | # directories to ignore when looking for source files.
107 | exclude_patterns = ['_build']
108 |
109 | # The reST default role (used for this markup: `text`) to use for all
110 | # documents.
111 | default_role = 'any'
112 |
113 | # If true, '()' will be appended to :func: etc. cross-reference text.
114 | # add_function_parentheses = True
115 |
116 | # If true, the current module name will be prepended to all description
117 | # unit titles (such as .. function::).
118 | # add_module_names = True
119 |
120 | # If true, sectionauthor and moduleauthor directives will be shown in the
121 | # output. They are ignored by default.
122 | # show_authors = False
123 |
124 | # The name of the Pygments (syntax highlighting) style to use.
125 | pygments_style = 'sphinx'
126 |
127 |
128 | # -- Options for HTML output ----------------------------------------------
129 |
130 | # The theme to use for HTML and HTML Help pages. See the documentation for
131 | # a list of builtin themes.
132 | html_theme = 'classic'
133 |
134 | # Theme options are theme-specific and customize the look and feel of a theme
135 | # further. For a list of options available for each theme, see the
136 | # documentation.
137 | # html_theme_options = {}
138 |
139 | # Add any paths that contain custom themes here, relative to this directory.
140 | # html_theme_path = []
141 |
142 | # The name for this set of Sphinx documents. If None, it defaults to
143 | # " v documentation".
144 | # html_title = None
145 |
146 | # A shorter title for the navigation bar. Default is the same as html_title.
147 | # html_short_title = None
148 |
149 | # The name of an image file (relative to this directory) to place at the top
150 | # of the sidebar.
151 | # html_logo = None
152 |
153 | # The name of an image file (within the static path) to use as favicon of the
154 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
155 | # pixels large.
156 | # html_favicon = None
157 |
158 | # Add any paths that contain custom static files (such as style sheets) here,
159 | # relative to this directory. They are copied after the builtin static files,
160 | # so a file named "default.css" will overwrite the builtin "default.css".
161 | html_static_path = ['static']
162 |
163 | # Add any extra paths that contain custom files (such as robots.txt or
164 | # .htaccess) here, relative to this directory. These files are copied
165 | # directly to the root of the documentation.
166 | # html_extra_path = []
167 |
168 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
169 | # using the given strftime format.
170 | html_last_updated_fmt = today_fmt
171 |
172 | # If false, no index is generated.
173 | # html_use_index = True
174 |
175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
176 | html_show_sphinx = False
177 |
178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
179 | html_show_copyright = False
180 |
181 | # Output file base name for HTML help builder.
182 | htmlhelp_basename = 'PyLNPdoc'
183 |
184 | # -- Options for LaTeX output ---------------------------------------------
185 |
186 | latex_elements = {
187 | # The paper size ('letterpaper' or 'a4paper').
188 | # 'papersize': 'letterpaper',
189 |
190 | # The font size ('10pt', '11pt' or '12pt').
191 | # 'pointsize': '10pt',
192 |
193 | # Additional stuff for the LaTeX preamble.
194 | # 'preamble': '',
195 |
196 | # Latex figure (float) alignment
197 | # 'figure_align': 'htbp',
198 | }
199 |
200 | # Grouping the document tree into LaTeX files. List of tuples
201 | # (source start file, target name, title,
202 | # author, documentclass [howto, manual, or own class]).
203 | latex_documents = [
204 | (master_doc, 'PyLNP.tex', 'PyLNP Documentation',
205 | 'Michael Madsen (Pidgeot) and collaborators', 'manual'),
206 | ]
207 |
208 | # The name of an image file (relative to this directory) to place at the top of
209 | # the title page.
210 | # latex_logo = None
211 |
212 | # For "manual" documents, if this is true, then toplevel headings are parts,
213 | # not chapters.
214 | # latex_use_parts = False
215 |
216 | # If true, show page references after internal links.
217 | # latex_show_pagerefs = False
218 |
219 | # If true, show URL addresses after external links.
220 | # latex_show_urls = False
221 |
222 | # Documents to append as an appendix to all manuals.
223 | # latex_appendices = []
224 |
225 | # If false, no module index is generated.
226 | # latex_domain_indices = True
227 |
228 |
229 | # -- Options for manual page output ---------------------------------------
230 |
231 | # One entry per manual page. List of tuples
232 | # (source start file, name, description, authors, manual section).
233 | man_pages = [
234 | (master_doc, 'pylnp', 'PyLNP Documentation',
235 | [author], 1)
236 | ]
237 |
238 | # If true, show URL addresses after external links.
239 | # man_show_urls = False
240 |
241 |
242 | # -- Options for Texinfo output -------------------------------------------
243 |
244 | # Grouping the document tree into Texinfo files. List of tuples
245 | # (source start file, target name, title, author,
246 | # dir menu entry, description, category)
247 | texinfo_documents = [
248 | (master_doc, 'PyLNP', 'PyLNP Documentation',
249 | author, 'PyLNP', 'One line description of project.',
250 | 'Miscellaneous'),
251 | ]
252 |
253 | # Documents to append as an appendix to all manuals.
254 | # texinfo_appendices = []
255 |
256 | # If false, no module index is generated.
257 | # texinfo_domain_indices = True
258 |
259 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
260 | # texinfo_show_urls = 'footnote'
261 |
262 | # If true, do not generate a @detailmenu in the "Top" node's menu.
263 | # texinfo_no_detailmenu = False
264 |
--------------------------------------------------------------------------------
/tkgui/advanced.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init
4 | """Advanced tab for the TKinter GUI."""
5 |
6 | from tkinter import * # noqa: F403
7 | from tkinter import messagebox
8 | from tkinter.ttk import * # noqa: F403
9 |
10 | from core import launcher, legends_processor
11 | from core.lnp import lnp
12 |
13 | from . import controls
14 | from .layout import GridLayouter
15 | from .tab import Tab
16 |
17 |
18 | class AdvancedTab(Tab):
19 | """Advanced tab for the TKinter GUI."""
20 | def create_variables(self):
21 | self.volume_var = StringVar()
22 | self.fps_var = StringVar()
23 | self.gps_var = StringVar()
24 | self.winX_var = StringVar()
25 | self.winY_var = StringVar()
26 | self.fullX_var = StringVar()
27 | self.fullY_var = StringVar()
28 |
29 | def create_controls(self):
30 | # pylint: disable=too-many-statements
31 | Grid.columnconfigure(self, 0, weight=1)
32 | Grid.columnconfigure(self, 1, weight=1)
33 |
34 | main_grid = GridLayouter(2, pad=(4, 0))
35 |
36 | if lnp.settings.version_has_option('sound'):
37 | sound = controls.create_control_group(self, 'Sound')
38 | main_grid.add(sound)
39 |
40 | controls.create_option_button(
41 | sound, 'Sound', 'Turn game music on/off', 'sound').pack(
42 | side=LEFT, fill=X, expand=Y)
43 | if lnp.settings.version_has_option('volume'):
44 | controls.create_numeric_entry(
45 | sound, self.volume_var, 'volume',
46 | 'Music volume (0 to 255)').pack(side=LEFT, padx=(6, 0))
47 | Label(sound, text='/255').pack(side=LEFT)
48 | if lnp.settings.version_has_option('fpsCounter'):
49 | fps = controls.create_control_group(self, 'FPS')
50 | main_grid.add(fps, rowspan=2)
51 |
52 | controls.create_option_button(
53 | fps, 'FPS Counter', 'Whether or not to display your FPS',
54 | 'fpsCounter').pack(fill=BOTH)
55 |
56 | caps = controls.create_control_group(fps, 'FPS Caps')
57 | caps.rowconfigure((1, 2), weight=1)
58 | caps.columnconfigure((1, 3), weight=1)
59 | if lnp.settings.version_has_option('fpsCap'):
60 | Label(caps, text='Calculation ').grid(
61 | row=1, column=1, sticky='e')
62 | controls.create_numeric_entry(
63 | caps, self.fps_var, 'fpsCap',
64 | 'How fast the game runs').grid(
65 | row=1, column=2)
66 | Label(caps, text='FPS').grid(row=1, column=3, sticky='w')
67 | if lnp.settings.version_has_option('gpsCap'):
68 | Label(caps, text='Graphical ').grid(row=2, column=1, sticky='e')
69 | controls.create_numeric_entry(
70 | caps, self.gps_var, 'gpsCap', 'How fast the game visually '
71 | 'updates.\nLower value may give small boost to FPS but '
72 | 'will be less responsive.').grid(
73 | row=2, column=2, pady=(3, 0))
74 | Label(caps, text='FPS').grid(row=2, column=3, sticky='w')
75 | if caps.children:
76 | caps.pack(fill=BOTH, expand=Y)
77 |
78 | if lnp.settings.version_has_option('introMovie'):
79 | startup = controls.create_control_group(self, 'Startup')
80 | main_grid.add(startup)
81 | Grid.columnconfigure(startup, 0, weight=1)
82 |
83 | controls.create_option_button(
84 | startup, 'Intro Movie',
85 | 'Do you want to see the beautiful ASCII intro movie?',
86 | 'introMovie').grid(column=0, row=0, sticky="nsew")
87 | controls.create_option_button(
88 | startup, 'Windowed', 'Start windowed or fullscreen',
89 | 'startWindowed').grid(column=0, row=1, sticky="nsew")
90 |
91 | resolution = controls.create_control_group(self, 'Resolution')
92 | main_grid.add(resolution, 2)
93 | resolution['pad'] = (4, 0, 4, 8)
94 | resolution.columnconfigure((0, 5), weight=1)
95 | resolution.rowconfigure((2, 4), minsize=3)
96 |
97 | Label(resolution, text='Windowed').grid(row=1, column=1, sticky='e')
98 | Label(resolution, text='Fullscreen').grid(row=3, column=1, sticky='e')
99 | Label(resolution, text='Width').grid(row=0, column=2)
100 | Label(resolution, text='Height').grid(row=0, column=4)
101 | Label(resolution, text='x').grid(row=1, column=3)
102 | Label(resolution, text='x').grid(row=3, column=3)
103 | if lnp.df_info.version < '50.01':
104 | Label(resolution, justify=CENTER,
105 | text='Values less than 255 represent # tiles,\n'
106 | 'values greater than 255 represent # pixels.\n'
107 | 'Fullscreen "0" to autodetect.').grid(
108 | row=5, column=0, columnspan=6)
109 | controls.create_numeric_entry(
110 | resolution, self.winX_var, ('WINDOWEDX', 'GRAPHICS_WINDOWEDX'),
111 | '').grid(row=1, column=2)
112 | controls.create_numeric_entry(
113 | resolution, self.winY_var, ('WINDOWEDY', 'GRAPHICS_WINDOWEDY'),
114 | '').grid(row=1, column=4)
115 | controls.create_numeric_entry(
116 | resolution, self.fullX_var, ('FULLSCREENX', 'GRAPHICS_FULLSCREENX'),
117 | '').grid(row=3, column=2)
118 | controls.create_numeric_entry(
119 | resolution, self.fullY_var, ('FULLSCREENY', 'GRAPHICS_FULLSCREENY'),
120 | '').grid(row=3, column=4)
121 |
122 | saverelated = controls.create_control_group(
123 | self, 'Save-related', True)
124 | main_grid.add(saverelated, 2)
125 |
126 | grid = GridLayouter(2)
127 | grid.add(controls.create_option_button(
128 | saverelated, 'Autosave',
129 | 'How often the game will automatically save', 'autoSave'))
130 | grid.add(controls.create_option_button(
131 | saverelated, 'Initial Save', 'Saves as soon as you embark',
132 | 'initialSave'))
133 | grid.add(controls.create_option_button(
134 | saverelated, 'Pause on Save', 'Pauses the game after auto-saving',
135 | 'autoSavePause'))
136 | grid.add(controls.create_option_button(
137 | saverelated, 'Pause on Load', 'Pauses the game as soon as it loads',
138 | 'pauseOnLoad'))
139 | grid.add(controls.create_option_button(
140 | saverelated, 'Backup Saves', 'Makes a backup of every autosave',
141 | 'autoBackup'))
142 | if lnp.df_info.version >= '0.31.01':
143 | grid.add(controls.create_option_button(
144 | saverelated, 'Compress Saves', 'Whether to compress the '
145 | 'savegames (keep this on unless you experience problems with '
146 | 'your saves', 'compressSaves'))
147 | grid.add(controls.create_trigger_button(
148 | saverelated, 'Open Savegame Folder', 'Open the savegame folder',
149 | launcher.open_savegames))
150 |
151 | if lnp.df_info.version >= '0.31.01':
152 | announcements_group = controls.create_control_group(
153 | self, 'Announcements')
154 | main_grid.add(announcements_group, 2)
155 | controls.create_option_button(
156 | announcements_group, 'Damp Stone',
157 | 'Pause and center view when damp stone is found',
158 | 'focusDamp').pack(fill=X)
159 | controls.create_option_button(
160 | announcements_group, 'Warm Stone',
161 | 'Pause and center view when warm stone is found',
162 | 'focusWarm').pack(fill=X)
163 |
164 | misc_group = controls.create_control_group(self, 'Miscellaneous')
165 | main_grid.add(misc_group, 2)
166 | controls.create_option_button(
167 | misc_group, 'Processor Priority',
168 | 'Adjusts the priority given to Dwarf Fortress by your OS',
169 | 'procPriority').pack(fill=X)
170 |
171 | if lnp.df_info.version >= '0.40.09':
172 | controls.create_trigger_button(
173 | misc_group, 'Process Legends Exports',
174 | 'Compress and sort files exported from legends mode',
175 | self.process_legends).pack(fill=X)
176 |
177 | @staticmethod
178 | def process_legends():
179 | """Process legends exports."""
180 | if not legends_processor.get_region_info():
181 | messagebox.showinfo('No legends exports',
182 | 'There were no legends exports to process.')
183 | else:
184 | messagebox.showinfo('Exports will be compressed',
185 | 'Maps exported from legends mode will be '
186 | 'converted to .png format, a compressed archive'
187 | ' will be made, and files will be sorted and '
188 | 'moved to a subfolder. Please wait...')
189 | i = legends_processor.process_legends()
190 | string = str(i) + ' region'
191 | if i > 1:
192 | string += 's'
193 | messagebox.showinfo(string + ' processed',
194 | 'Legends exported from ' + string
195 | + ' were found and processed')
196 |
--------------------------------------------------------------------------------
/tkgui/mods.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init
4 | """Mods tab for the TKinter GUI."""
5 |
6 | from tkinter import * # noqa: F403
7 | from tkinter import messagebox, simpledialog
8 | from tkinter.ttk import * # noqa: F403
9 |
10 | from core import mods
11 |
12 | from . import controls, tkhelpers
13 | from .layout import GridLayouter
14 | from .tab import Tab
15 |
16 |
17 | class ModsTab(Tab):
18 | """Mods tab for the TKinter GUI."""
19 | def create_variables(self):
20 | self.installed = []
21 | self.available = []
22 | self.installed_var = Variable()
23 | self.available_var = Variable()
24 | self.status = 3
25 |
26 | def read_data(self):
27 | mods.clear_temp()
28 | self.available = mods.read_mods()
29 | self.installed = mods.get_installed_mods_from_log()
30 | self.available = [m for m in self.available if m not in self.installed]
31 | self.update_lists()
32 |
33 | def create_controls(self):
34 | Grid.columnconfigure(self, 0, weight=1, uniform="mods")
35 | Grid.columnconfigure(self, 1, weight=1, uniform="mods")
36 | Grid.rowconfigure(self, 0, weight=1)
37 | Grid.rowconfigure(self, 2, weight=1)
38 | main_grid = GridLayouter(2)
39 |
40 | f = controls.create_control_group(self, 'Merged')
41 | install_frame, self.installed_list = controls.create_file_list(
42 | f, None, self.installed_var, selectmode='multiple')
43 | controls.listbox_dyn_tooltip(
44 | self.installed_list, lambda i: self.installed[i], mods.get_tooltip)
45 | self.installed_list.bind(
46 | "", lambda e: self.remove_from_installed())
47 | reorder_frame = controls.create_control_group(install_frame, None)
48 | controls.create_trigger_button(
49 | reorder_frame, '↑', 'Move up', self.move_up).pack()
50 | controls.create_trigger_button(
51 | reorder_frame, '↓', 'Move down', self.move_down).pack()
52 | reorder_frame.grid(row=0, column=2, sticky="nse")
53 |
54 | main_grid.add(f, 2)
55 |
56 | main_grid.add(controls.create_trigger_button(
57 | self, '⇑', 'Add', self.add_to_installed))
58 | main_grid.add(controls.create_trigger_button(
59 | self, '⇓', 'Remove', self.remove_from_installed))
60 |
61 | f = controls.create_control_group(self, 'Available')
62 | _, self.available_list = controls.create_file_list(
63 | f, None, self.available_var, selectmode='multiple')
64 | controls.listbox_dyn_tooltip(
65 | self.available_list, lambda i: self.available[i], mods.get_tooltip)
66 | self.available_list.bind(
67 | "", lambda e: self.add_to_installed())
68 | main_grid.add(f, 2)
69 |
70 | main_grid.add(controls.create_trigger_button(
71 | self, 'Install Mods', 'Copy merged mods to DF folder.',
72 | self.install_mods))
73 | main_grid.add(controls.create_trigger_option_button(
74 | self, 'Pre-merge Graphics',
75 | 'Whether to start with the current graphics pack, or '
76 | 'vanilla (ASCII) raws', self.toggle_preload, 'premerge_graphics',
77 | lambda v: ('NO', 'YES')[mods.will_premerge_gfx()]))
78 | main_grid.add(controls.create_trigger_button(
79 | self, 'Simplify Mods', 'Removes unnecessary files.',
80 | self.simplify_mods))
81 | main_grid.add(controls.create_trigger_button(
82 | self, 'Extract Installed', 'Creates a mod from unique changes '
83 | 'to your installed raws. Use to preserve custom tweaks.',
84 | self.create_from_installed))
85 |
86 | def update_lists(self):
87 | """Updates the lists."""
88 | self.available.sort(key=mods.get_title)
89 | self.available_var.set(tuple(mods.get_title(m) for m in self.available))
90 | self.installed_var.set(tuple(mods.get_title(m) for m in self.installed))
91 |
92 | @staticmethod
93 | def toggle_preload():
94 | """Toggles whether to preload graphics before merging mods."""
95 | mods.toggle_premerge_gfx()
96 |
97 | def move_up(self):
98 | """Moves the selected item/s up in the merge order and re-merges."""
99 | if len(self.installed_list.curselection()) == 0:
100 | return
101 | selection = [int(i) for i in self.installed_list.curselection()]
102 | lst = self.installed
103 | for i in range(1, len(lst)):
104 | j = i
105 | while j in selection and i - 1 not in selection and j < len(lst):
106 | lst[j - 1], lst[j] = lst[j], lst[j - 1]
107 | j += 1
108 | self.update_lists()
109 | first_missed = False
110 | self.installed_list.selection_clear(0, END)
111 | for i in range(0, len(lst)):
112 | if i not in selection:
113 | first_missed = True
114 | else:
115 | self.installed_list.select_set(i - int(first_missed))
116 | self.perform_merge()
117 |
118 | def move_down(self):
119 | """Moves the selected item/s down in the merge order and re-merges."""
120 | if len(self.installed_list.curselection()) == 0:
121 | return
122 | selection = [int(i) for i in self.installed_list.curselection()]
123 | lst = self.installed
124 | for i in range(len(lst) - 1, 0, -1):
125 | j = i
126 | while i not in selection and j - 1 in selection and j > 0:
127 | lst[j - 1], lst[j] = lst[j], lst[j - 1]
128 | j -= 1
129 | self.update_lists()
130 | first_missed = False
131 | self.installed_list.selection_clear(0, END)
132 | for i in range(len(lst), 0, -1):
133 | if i - 1 not in selection:
134 | first_missed = True
135 | else:
136 | self.installed_list.select_set(i - 1 + int(first_missed))
137 | self.perform_merge()
138 |
139 | def create_from_installed(self):
140 | """Extracts a mod from the currently installed raws."""
141 | if mods.make_mod_from_installed_raws('') is not None:
142 | name = simpledialog.askstring("Create Mod", "New mod name:")
143 | if name:
144 | if mods.make_mod_from_installed_raws(name):
145 | messagebox.showinfo('Mod extracted',
146 | 'Your mod was extracted as ' + name)
147 | else:
148 | messagebox.showinfo(
149 | 'Error', 'There is already a mod with that name.')
150 | self.read_data()
151 | else:
152 | messagebox.showinfo('Error', 'No unique mods were found.')
153 |
154 | def add_to_installed(self):
155 | """Move selected mod/s from available to merged list and re-merge."""
156 | if len(self.available_list.curselection()) == 0:
157 | return
158 | for i in self.available_list.curselection():
159 | self.installed.append(self.available[int(i)])
160 | for i in self.available_list.curselection()[::-1]:
161 | self.available.remove(self.available[int(i)])
162 | self.available_list.selection_clear(0, END)
163 | self.update_lists()
164 | self.perform_merge()
165 |
166 | def remove_from_installed(self):
167 | """Move selected mod/s from merged to available list and re-merge."""
168 | if len(self.installed_list.curselection()) == 0:
169 | return
170 | for i in self.installed_list.curselection()[::-1]:
171 | self.available.append(self.installed[int(i)])
172 | self.installed.remove(self.installed[int(i)])
173 | self.installed_list.selection_clear(0, END)
174 | self.update_lists()
175 | self.perform_merge()
176 |
177 | def perform_merge(self):
178 | """Merge the selected mods, with background color for user feedback."""
179 | if not tkhelpers.check_vanilla_raws():
180 | return
181 | colors = ['pale green', 'yellow', 'orange', 'red', 'white']
182 | result = mods.merge_all_mods(self.installed)
183 | for i, status in enumerate(result):
184 | self.installed_list.itemconfig(i, bg=colors[status])
185 | self.status = max(result + [0])
186 |
187 | def install_mods(self):
188 | """Replaces /raw with the contents LNP/Baselines/temp/raw"""
189 | if messagebox.askokcancel(
190 | message=('Your raws will be changed.\n\n'
191 | 'The mod merging function is still in beta. This '
192 | 'could break new worlds, or even cause crashes.\n\n'
193 | 'Changing mods or graphics later might break a save, '
194 | 'so keep backups of everything you care about!'),
195 | title='Are you sure?'):
196 | if self.status < 2:
197 | if mods.install_mods():
198 | messagebox.showinfo(
199 | 'Mods installed',
200 | 'The selected mods were installed.\nGenerate a new '
201 | 'world to start playing with them!')
202 | else:
203 | messagebox.showinfo(
204 | 'Mods not installed',
205 | 'No mods were merged to install.')
206 | else:
207 | messagebox.showinfo(
208 | 'Mods not ready',
209 | 'The selected mods have merge conflicts and should not be '
210 | 'installed.\n\nResolve merge issues and try again.')
211 |
212 | @staticmethod
213 | def simplify_mods():
214 | """Simplify mods; runs on startup if called directly by button."""
215 | if not tkhelpers.check_vanilla_raws():
216 | return
217 | m, f = mods.simplify_mods()
218 | messagebox.showinfo(
219 | str(m) + ' mods simplified',
220 | str(f) + ' files were removed from ' + str(m) + ' mods.')
221 |
--------------------------------------------------------------------------------
/core/update.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """Update handling."""
4 |
5 | import os
6 | import re
7 | import tarfile
8 | import threading
9 | import time
10 | import zipfile
11 | from urllib.parse import quote, unquote, urlparse
12 |
13 | from . import download, launcher, log, paths
14 | from .json_config import JSONConfiguration
15 | from .lnp import lnp
16 |
17 |
18 | def updates_configured():
19 | """Returns True if update checking have been configured."""
20 | return prepare_updater() is not None
21 |
22 |
23 | def check_update():
24 | """Checks for updates using the URL specified in PyLNP.json."""
25 | if not updates_configured():
26 | return
27 | if not lnp.userconfig.has_value('updateDays'):
28 | interval = lnp.config.get_value('updates/defaultInterval', -1)
29 | if interval != -1 and lnp.ui.on_request_update_permission(interval):
30 | next_update(interval)
31 | else:
32 | next_update(-1)
33 | if lnp.userconfig.get_value('updateDays', -1) == -1:
34 | return
35 | if lnp.userconfig.get_number('nextUpdate') < time.time():
36 | t = threading.Thread(target=perform_update_check)
37 | t.daemon = True
38 | t.start()
39 |
40 |
41 | def perform_update_check():
42 | """Performs the actual update check. Runs in a thread."""
43 | prepare_updater()
44 | if lnp.updater.update_needed():
45 | lnp.new_version = lnp.updater.get_version()
46 | lnp.ui.on_update_available()
47 |
48 |
49 | def prepare_updater():
50 | """Returns an Updater object for the configured updater."""
51 | if lnp.updater:
52 | return lnp.updater
53 | updaters = {'regex': RegexUpdater, 'json': JSONUpdater, 'dffd': DFFDUpdater}
54 | updater_id = lnp.config.get('updates/updateMethod', None)
55 | if updater_id is None:
56 | # TODO: Remove this after packs have had time to migrate
57 | log.w(
58 | 'Update method not configured in PyLNP.json! Will attempt to '
59 | 'auto-detect. Please set this value correctly, auto-detection will '
60 | 'go away eventually!')
61 | if lnp.config.get_string('updates/dffdID'):
62 | updater_id = 'dffd'
63 | log.w('Updater detected: dffd')
64 | elif lnp.config.get_string('updates/versionRegex'):
65 | updater_id = 'regex'
66 | log.w('Updater detected: regex')
67 | elif lnp.config.get_string('updates/versionJsonPath'):
68 | updater_id = 'json'
69 | log.w('Updater detected: json')
70 | else:
71 | log.w('Could not detect update method, updates will not work')
72 | return None
73 | elif updater_id == '' or not lnp.config.get('updates'):
74 | return None
75 | if updater_id not in updaters:
76 | log.e('Unknown update method: ' + updater_id)
77 | return None
78 | lnp.updater = updaters[updater_id]()
79 | return lnp.updater
80 |
81 |
82 | def next_update(days):
83 | """Sets the next update check to occur in days."""
84 | lnp.userconfig['nextUpdate'] = (time.time() + days * 24 * 60 * 60)
85 | lnp.userconfig['updateDays'] = days
86 | lnp.save_config()
87 |
88 |
89 | def start_update():
90 | """Launches a web browser to the specified update URL."""
91 | launcher.open_url(lnp.updater.get_download_url())
92 |
93 |
94 | def download_df_baseline(immediate=False):
95 | """Download the current version of DF from Bay12 Games to serve as a
96 | baseline, in LNP/Baselines/"""
97 | filename = lnp.df_info.get_archive_name()
98 | url = 'https://www.bay12games.com/dwarves/' + filename
99 | target = os.path.join(paths.get('baselines'), filename)
100 | queue_name = 'immediate' if immediate else 'baselines'
101 | download.download(queue_name, url, target)
102 |
103 |
104 | def direct_download_pack():
105 | """Directly download a new version of the pack to the current BASEDIR"""
106 | url = lnp.updater.get_direct_url()
107 | fname = lnp.updater.get_direct_filename()
108 | target = os.path.join(lnp.BASEDIR, fname)
109 | download.download('updates', url, target,
110 | end_callback=extract_new_pack)
111 |
112 |
113 | def extract_new_pack(_, fname, bool_val):
114 | """Extract a downloaded new pack to a sibling dir of the current pack."""
115 | exts = ('.zip', '.bz2', '.gz', '.7z', '.xz')
116 | if not bool_val or not any(fname.endswith(ext) for ext in exts):
117 | return None
118 | archive = os.path.join(lnp.BASEDIR, os.path.basename(fname))
119 | return extract_archive(archive, os.path.join(lnp.BASEDIR, '..'))
120 |
121 |
122 | def extract_archive(fname, target):
123 | """Extract the archive fname to dir target, avoiding explosions."""
124 | if zipfile.is_zipfile(fname):
125 | with zipfile.ZipFile(fname) as zf:
126 | namelist = zf.namelist()
127 | topdir = namelist[0].split(os.path.sep)[0]
128 | if not all(f.startswith(topdir) for f in namelist):
129 | target = os.path.join(target, os.path.basename(fname).split('.')[0])
130 | zf.extractall(target)
131 | os.remove(fname)
132 | return True
133 | if tarfile.is_tarfile(fname):
134 | with tarfile.open(fname) as tf:
135 | namelist = tf.getmembers()
136 | topdir = namelist[0].split(os.path.sep)[0]
137 | if not all(f.startswith(topdir) for f in namelist):
138 | target = os.path.join(target, fname.split('.')[0])
139 | tf.extractall(target)
140 | os.remove(fname)
141 | return True
142 | # TODO: support '*.xz' and '*.7z' files.
143 | return False
144 |
145 |
146 | # pylint: disable=attribute-defined-outside-init
147 |
148 | class Updater(object):
149 | """General class for checking for updates."""
150 | def update_needed(self):
151 | """Checks if an update is necessary."""
152 | self.text = download.download_str(self.get_check_url())
153 | if self.text is None:
154 | log.e("Error checking for updates, could not download text")
155 | curr_version = lnp.config.get_string('updates/packVersion')
156 | if not curr_version:
157 | log.e("Current pack version is not set, cannot check for updates")
158 | return False
159 | return self.get_version() != curr_version
160 |
161 | def get_check_url(self):
162 | """Returns the URL used to check for updates."""
163 | return lnp.config.get_string('updates/checkURL')
164 |
165 | def get_version(self):
166 | """Returns the version listed at the update URL. Must be overridden by
167 | subclasses."""
168 |
169 | def get_download_url(self):
170 | """Returns a URL from which the user can download the update."""
171 | return lnp.config.get_string('updates/downloadURL')
172 |
173 | def get_direct_url(self):
174 | """Returns a URL pointing directly to the update, for download by the
175 | program."""
176 | return lnp.config.get_string('updates/directURL')
177 |
178 | def get_direct_filename(self):
179 | """Returns the filename that should be used for direct downloads."""
180 | directFilename = lnp.config.get_string('updates/directFilename')
181 | if directFilename:
182 | return directFilename
183 | url_fragments = urlparse(self.get_direct_url())
184 | return os.path.basename(unquote(url_fragments.path))
185 |
186 |
187 | class RegexUpdater(Updater):
188 | """Updater class which uses regular expressions to locate the version (and
189 | optionally also the download URLs)."""
190 | def get_version(self):
191 | versionRegex = lnp.config.get_string('updates/versionRegex')
192 | if not versionRegex:
193 | log.e('Version regex not configured!')
194 | return re.search(versionRegex, self.text).group(1)
195 |
196 | def get_download_url(self):
197 | urlRegex = lnp.config.get_string('updates/downloadURLRegex')
198 | result = ''
199 | if urlRegex:
200 | result = re.search(urlRegex, self.text).group(1)
201 | if result:
202 | return result
203 | return super().get_download_url()
204 |
205 | def get_direct_url(self):
206 | urlRegex = lnp.config.get_string('updates/directURLRegex')
207 | result = ''
208 | if urlRegex:
209 | result = re.search(urlRegex, self.text).group(1)
210 | if result:
211 | return result
212 | return super().get_direct_url()
213 |
214 |
215 | class JSONUpdater(Updater):
216 | """Updater class which uses a JSON object to locate the version (and
217 | optionally also the download URLs)."""
218 | def get_version(self):
219 | self.json = JSONConfiguration.from_text(self.text)
220 | jsonPath = lnp.config.get_string('updates/versionJsonPath')
221 | if not jsonPath:
222 | log.e('JSON path to version not configured!')
223 | return self.json.get_string(jsonPath)
224 |
225 | def get_download_url(self):
226 | jsonPath = lnp.config.get_string('updates/downloadURLJsonPath')
227 | result = ''
228 | if jsonPath:
229 | result = self.json.get_string(jsonPath)
230 | if result:
231 | return result
232 | return super().get_download_url()
233 |
234 | def get_direct_url(self):
235 | jsonPath = lnp.config.get_string('updates/directURLJsonPath')
236 | result = ''
237 | if jsonPath:
238 | result = self.json.get_string(jsonPath)
239 | if result:
240 | return result
241 | return super().get_direct_url()
242 |
243 | def get_direct_filename(self):
244 | jsonPath = lnp.config.get_string('updates/directFilenameJsonPath')
245 | result = ''
246 | if jsonPath:
247 | result = self.json.get_string(jsonPath)
248 | if result:
249 | return result
250 | return super().get_direct_filename()
251 |
252 |
253 | class DFFDUpdater(Updater):
254 | """Updater class for DFFD-hosted downloads."""
255 | def get_check_url(self):
256 | self.dffd_id = lnp.config.get_string('updates/dffdID')
257 | if not self.dffd_id:
258 | log.e('Field "updates/dffdID" must be set in PyLNP.json')
259 | return 'https://dffd.bay12games.com/file_data/{}.json'.format(
260 | self.dffd_id)
261 |
262 | def get_version(self):
263 | self.json = JSONConfiguration.from_text(self.text)
264 | return self.json.get_string('version')
265 |
266 | def get_download_url(self):
267 | return 'https://dffd.bay12games.com/file.php?id=' + self.dffd_id
268 |
269 | def get_direct_url(self):
270 | result = 'https://dffd.bay12games.com/download.php?id={0}&f={1}'
271 | return result.format(
272 | self.dffd_id, quote(self.json.get_string('filename')))
273 |
274 | def get_direct_filename(self):
275 | return self.json.get_string('filename')
276 |
--------------------------------------------------------------------------------
/docs/pylnp-json.rst:
--------------------------------------------------------------------------------
1 | :orphan:
2 |
3 | .. _PyLNP.json:
4 |
5 | PyLNP.json
6 | ##########
7 |
8 | For basic pack customization, a JSON file named PyLNP.json is used. This file
9 | must be stored in either the base folder (the folder containing the Dwarf
10 | Fortress folder itself), or in `the LNP folder `. If both exist, the
11 | one in the LNP folder will be used.
12 |
13 | This file configures several aspects of the launcher. All parts are optional
14 | in the sense that the launcher will work even if nothing is there.
15 |
16 | Each key in the file is documented below.
17 |
18 |
19 | .. contents::
20 |
21 |
22 | ``folders``, ``links``
23 | ----------------------
24 | ``folders`` and ``links`` are both lists containing other lists. These are
25 | used to populate the Folders and Links menu in the program.
26 |
27 | Each entry is a list containing 2 values: the caption for the menu item, and
28 | the destination to be opened when the menu item is activated. To insert a
29 | separator, use a dash as a caption (``-``).
30 |
31 | Folder paths are relative to the base directory, meaning the directory
32 | containing the Dwarf Fortress directory. Use ```` as a placeholder for the
33 | actual Dwarf Fortress directory.
34 |
35 | Example::
36 |
37 | "folders": [
38 | ["Savegame folder","/data/save"],
39 | ["Utilities folder","LNP/Utilities"],
40 | ["Graphics folder","LNP/Graphics"],
41 | ["-","-"],
42 | ["Main folder",""],
43 | ["LNP folder","LNP"],
44 | ["Dwarf Fortress folder",""],
45 | ["Init folder","/data/init"]
46 | ],
47 | links: [
48 | ["DF Homepage","https://www.bay12games.com/dwarves/"],
49 | ["DF Wiki","https://dwarffortresswiki.org/"],
50 | ["DF Forums","http://www.bay12forums.com/smf/"]
51 | ]
52 |
53 | ``hideUtilityPath``, ``hideUtilityExt``
54 | ---------------------------------------
55 | These options control whether to hide the path and extension of utilities in
56 | the utility list.
57 |
58 | Using "DwarfTool/DwarfTool.exe" as an example:
59 |
60 | ``hideUtilityPath`` is false, ``hideUtilityExt`` is false:
61 | DwarfTool/DwarfTool.exe
62 |
63 | ``hideUtilityPath`` is false, ``hideUtilityExt`` is true:
64 | DwarfTool/DwarfTool
65 |
66 | ``hideUtilityPath`` is true, ``hideUtilityExt`` is false:
67 | DwarfTool.exe
68 |
69 | ``hideUtilityPath`` is true, ``hideUtilityExt`` is true:
70 | DwarfTool
71 |
72 | Only the *last* folder name is ever displayed: if the full path is
73 | "Utilities/Foo/DwarfTool", only "DwarfTool" will be shown for the path name.
74 |
75 | For customization of displayed utility titles, see `relabeling-utilities`.
76 |
77 | ``updates``
78 | -----------
79 | This object contains information used to check for pack updates.
80 |
81 | The most important field in this object is ``updateMethod``, which controls how
82 | PyLNP checks for updates.
83 |
84 | There are three methods available, ``dffd``, ``regex`` and ``json``, each of
85 | which require additional fields to be specified. These will be described below.
86 |
87 | If ``updateMethod`` is missing, a warning will be printed when checking for
88 | updates, and the program will attempt to auto-detect the correct method. *Please
89 | set this field correctly*, since auto-detection is a temporary measure to
90 | handle backwards compatibility.
91 |
92 | When checking for updates, the version retrieved online will be compared with
93 | the ``packVersion`` field. If they are different, PyLNP will show a notice that
94 | updates are available. *All update methods require this field to be specified.*
95 |
96 | If you do not want update checking, remove the ``updates`` object, or set
97 | ``updateMethod`` to a blank string.
98 |
99 | By default, the user must explicitly enable automatic checking for updates.
100 | However, pack authors may add an additional field to the ``updates`` object,
101 | ``defaultInterval`` which specifies the suggested number of days between each
102 | check. If this field is present in PyLNP.json, and the user has not previously
103 | chosen an update frequency, the user will be prompted to enable updates when
104 | they first launch the program, using the specified frequency as the default.
105 |
106 | It is strongly recommended that you use one of the options already visible in
107 | the program (0, 1, 3, 7, 14, 30).
108 |
109 | Note that the time for the next update check is determined when the option is
110 | set, i.e. when the user makes a choice. If you default to 0 days (every
111 | launch), the first check will happen immediately after the user has been
112 | prompted.
113 |
114 | ``dffd``
115 | ~~~~~~~~
116 | For files hosted on https://dffd.bay12games.com/, simply add a field ``dffdID``
117 | which contains the ID of your hosted file. No other configuration is necessary.
118 | Example::
119 |
120 | "updates": {
121 | "updateMethod": "dffd",
122 | "packVersion": "x.yy.zz r2",
123 | "dffdID": "1234"
124 | }
125 |
126 |
127 | ``regex``
128 | ~~~~~~~~~
129 | This method extracts version information using a regular expression. All regular
130 | expressions must capture a single group containing the appropriate value.
131 |
132 | This method uses five extra values:
133 |
134 | * ``checkURL``: A URL to a page containing the latest version string of
135 | your pack.
136 | * ``versionRegex``: A regular expression that extracts the latest version
137 | from the page contents of the aforementioned URL. If you do not understand
138 | regular expressions, ask on the forums or use DFFD for hosting.
139 | * ``downloadURL``: the URL of the pack's download webpage, to be opened in a
140 | browser **or**
141 | * ``downloadURLRegex``: A regular expression that extracts the pack's download
142 | webpage from the same URL that contained the version string.
143 | * ``directURL`` is the URL of the (future) package for direct download **or**
144 | * ``directURLRegex``: A regular expression that extracts the pack's direct
145 | download webpage from the same URL that contained the version string.
146 | * ``directFilename``: Filename to use when downloading directly (optional)
147 | **or**
148 | * ``directFilenameRegex``: A regular expression that extracts the file name to
149 | use when downloading directly.
150 |
151 | ``downloadURL`` and ``directURL`` are both optional, but at least one should be
152 | provided (or their regular expression counterparts).
153 |
154 | When doing direct downloads, the URL's file name will be used as the target file
155 | name (e.g. ``https://example.com/downloads/my_pack.zip`` gets downloaded as
156 | ``my_pack.zip``) if neither ``directFilename`` or ``directFilenameRegex`` is
157 | set.
158 |
159 | Example::
160 |
161 | "updates": {
162 | "updateMethod": "regex",
163 | "packVersion": "x.yy.zz r2",
164 | "checkURL": "https://example.com/my_df_pack.html",
165 | "downloadURL": "https://example.com/my_df_pack.html",
166 | "versionRegex": "Version: (.+)"
167 | }
168 |
169 | ``json``
170 | ~~~~~~~~~
171 | This method extracts version information from a JSON document.
172 |
173 | This method uses *JSON paths*, which are strings which provide a path into the
174 | JSON object. The path is specified by a slash-separated string of object names.
175 | Example::
176 |
177 | {
178 | "foo": "" //path is "foo"
179 | "bar": { //path is "bar"
180 | "baz": "" //path is "bar/baz"
181 | "quux": { //path is "bar/quux"
182 | "xyzzy": "" //path is "bar/quux/xyzzy"
183 | }
184 | }
185 | }
186 |
187 | This method requires four extra values:
188 |
189 | * ``checkURL``: A URL to a JSON document containing the necessary information.
190 | * ``versionJsonPath``: A JSON path that points to the latest version of your
191 | pack.
192 | * ``downloadURL``: the URL of the pack's download webpage, to be opened in a
193 | browser **or**
194 | * ``downloadURLJsonPath``: A JSON path that points to the pack's download
195 | webpage.
196 | * ``directURL`` is the URL of the (future) package for direct download **or**
197 | * ``directURLJsonPath``: A JSON path that points to the pack's direct download
198 | webpage from the same URL that contained the version string.
199 | * ``directFilename``: Filename to use when downloading directly (optional)
200 | **or**
201 | * ``directFilenameJsonPath``: A JSON path that points to the file name to use
202 | when downloading directly
203 |
204 | ``downloadURL`` and ``directURL`` are both optional, but at least one should be
205 | provided (or their JSON path counterparts).
206 |
207 | When doing direct downloads, the URL's file name will be used as the target file
208 | name (e.g. ``https://example.com/downloads/my_pack.zip`` gets downloaded as
209 | ``my_pack.zip``) if neither ``directFilename`` or ``directFilenameJsonPath`` is
210 | set.
211 |
212 | Example::
213 |
214 | "updates": {
215 | "updateMethod": "json",
216 | "packVersion": "x.yy.zz r2",
217 | "checkURL": "https://example.com/my_df_pack_version.json",
218 | "downloadURL": "https://example.com/my_df_pack.html",
219 | "versionJsonPath": "version"
220 | }
221 |
222 |
223 | .. _pylnp-json-dfhack:
224 |
225 | ``dfhack``
226 | ----------
227 | This is an object containing hacks that can be toggled on or off on the
228 | DFHack tab.
229 |
230 | Each individual hack consists of three elements: a title, a command to be
231 | executed by DFHack, and a tooltip. The ``dfhack`` object should contain
232 | subobjects where the title is used as the name of the key for a subobject,
233 | and the subobject itself contains two keys: ``command`` and ``tooltip``.
234 |
235 | The ``enabled`` and ``file`` keys are optional; ``file`` may be any of
236 | "dfhack" (default), "onLoad", or "onMapLoad" and if "enabled" is ``true``
237 | the command will be saved to ``_PyLNP.init`` and executed by DFHack
238 | at the appropriate time. See the `DFHack docs on init files`__.
239 |
240 | .. __: https://dfhack.readthedocs.org/en/stable/docs/Core.html#init-files
241 |
242 | Example::
243 |
244 | "dfhack": {
245 | "Partial Mouse Control": {
246 | "command": "mousequery edge enable",
247 | "tooltip": "allows scrolling by hovering near edge of map"
248 | },
249 | "Performance Tweaks": {
250 | "command": "repeat -time 3 months -command cleanowned x",
251 | "tooltip": "regularly confiscates worn clothes and old items"
252 | "enabled": true,
253 | "file": "onMapLoad"
254 | }
255 | }
256 |
257 | ``to_import``
258 | -------------
259 | This configuration lists paths and strategies used to import user content
260 | from an older install or package (triggered from the ``file>Import...``
261 | menu). Each item in the list is of the form [strategy, source, dest];
262 | if the destination is not different to the source it may be omitted.
263 |
264 | Available strategies are:
265 |
266 | :copy_add: Copies the given file or directory contents. A source file
267 | which exists at the destination will be skipped.
268 | A destination directory will be created if it does not exist;
269 | files and subdirectories are copied without overwriting.
270 | This is safe for e.g. save files.
271 | :text_prepend: Prepends the text of source to dest (for logfiles).
272 |
273 | Example::
274 |
275 | "to_import": [
276 | ["text_prepend", "/gamelog.txt"],
277 | ["copy_add", "/data/save"],
278 | ["copy_add", "/soundsense", "LNP/Utilities/Soundsense/packs"]
279 | ]
280 |
--------------------------------------------------------------------------------
/core/lnp.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """PyLNP main library."""
4 |
5 | import os
6 | import sys
7 |
8 | from . import log
9 | from .json_config import JSONConfiguration
10 |
11 | VERSION = '0.14e-pre1'
12 |
13 |
14 | class UI(object):
15 | """
16 | Specifies the interface required by the core PyLNP library for communicating
17 | with the user. Provided for reference; UIs do not need to inherit from this.
18 | """
19 | def start(self):
20 | """Notifies the UI to start. On return, PyLNP will terminate."""
21 |
22 | def on_update_available(self):
23 | """
24 | Called when an update is available. Use this to show a notification
25 | and prompt the user for further action.
26 | """
27 |
28 | def on_program_running(self, path, is_df):
29 | """
30 | Called when attempting to launch a program that is already running.
31 | provides the path to the program that is being launched, so you
32 | can request a forced launch.
33 | specifies if the program is DF (True) or a utility (False).
34 | """
35 |
36 | def on_invalid_config(self, errors):
37 | """
38 | Called before running DF if an invalid configuration is detected.
39 | contains a list of discovered problems, which should be shown
40 | to the user.
41 | A true return value will launch DF anyway; a false return value cancels.
42 | """
43 |
44 | def on_request_update_permission(self, interval):
45 | """
46 | Called when PyLNP.json specifies a desired update interval but the
47 | user configuration does not hold a value for this.
48 | contains the number of days between update checks.
49 | A true return value will change the configuration to use the specified
50 | interval. A false return value will turn off automatic update checks.
51 | """
52 |
53 | def on_query_migration(self):
54 | """
55 | When no saves are detected, this function will be called.
56 | This should provide the user with an option to import a previous
57 | DF install or starter pack into the newly selected DF version.
58 | """
59 |
60 |
61 | lnp = None
62 |
63 |
64 | class PyLNP(object):
65 | """
66 | PyLNP library class.
67 |
68 | Acts as an abstraction layer between the UI and the Dwarf Fortress
69 | instance.
70 | """
71 | # pylint: disable=too-many-instance-attributes
72 | def __init__(self):
73 | """Constructor for the PyLNP library."""
74 | # pylint:disable=global-statement
75 | global lnp
76 | lnp = self
77 | self.args = self.parse_commandline()
78 |
79 | self.BASEDIR = '.'
80 | if sys.platform == 'win32':
81 | self.os = 'win'
82 | elif sys.platform.startswith('linux'):
83 | self.os = 'linux'
84 | elif sys.platform == 'darwin':
85 | self.os = 'osx'
86 |
87 | self.bundle = ''
88 | if hasattr(sys, 'frozen'):
89 | self.bundle = self.os
90 | os.chdir(os.path.dirname(sys.executable))
91 | if self.bundle == 'osx':
92 | # OS X bundles start in different directory
93 | os.chdir('../../..')
94 | else:
95 | os.chdir(os.path.join(os.path.dirname(__file__), '..'))
96 |
97 | from . import update
98 |
99 | self.folders = []
100 | self.df_info = None
101 | self.settings = None
102 | self.running = {}
103 | self.autorun = []
104 | self.updater = None
105 | self.config = None
106 | self.userconfig = None
107 | self.ui = None
108 |
109 | self.initialize_program()
110 |
111 | self.initialize_df()
112 |
113 | self.new_version = None
114 |
115 | self.initialize_ui()
116 | update.check_update()
117 | from . import paths
118 | save_dir = paths.get('save')
119 | saves_exist = os.path.isdir(save_dir) and os.listdir(save_dir)
120 | if paths.get('df') and not saves_exist:
121 | self.ui.on_query_migration()
122 | self.ui.start()
123 |
124 | def initialize_program(self):
125 | """Initializes the main program (errorlog, path registration, etc.)."""
126 | from . import errorlog, paths, utilities
127 | self.BASEDIR = '.'
128 | self.detect_basedir()
129 | paths.clear()
130 | paths.register('root', self.BASEDIR)
131 | errorlog.start()
132 |
133 | paths.register('lnp', self.BASEDIR, 'LNP')
134 | if not os.path.isdir(paths.get('lnp')):
135 | log.w('LNP folder is missing!')
136 | paths.register('keybinds', paths.get('lnp'), 'Keybinds')
137 | paths.register('graphics', paths.get('lnp'), 'Graphics')
138 | paths.register('utilities', paths.get('lnp'), 'Utilities')
139 | paths.register('colors', paths.get('lnp'), 'Colors')
140 | paths.register('embarks', paths.get('lnp'), 'Embarks')
141 | paths.register('tilesets', paths.get('lnp'), 'Tilesets')
142 | paths.register('baselines', paths.get('lnp'), 'Baselines')
143 | paths.register('mods', paths.get('lnp'), 'Mods')
144 |
145 | config_file = 'PyLNP.json'
146 | if os.access(paths.get('lnp', 'PyLNP.json'), os.F_OK):
147 | config_file = paths.get('lnp', 'PyLNP.json')
148 |
149 | default_config = {
150 | "folders": [
151 | ["Savegame folder", "/data/save"],
152 | ["Utilities folder", "LNP/Utilities"],
153 | ["Graphics folder", "LNP/Graphics"],
154 | ["-", "-"],
155 | ["Main folder", ""],
156 | ["LNP folder", "LNP"],
157 | ["Dwarf Fortress folder", ""],
158 | ["Init folder", "/data/init"]
159 | ],
160 | "links": [
161 | ["DF Homepage", "https://www.bay12games.com/dwarves/"],
162 | ["DF Wiki", "https://dwarffortresswiki.org/"],
163 | ["DF Forums", "http://www.bay12forums.com/smf/"]
164 | ],
165 | "to_import": [
166 | ['text_prepend', '/gamelog.txt'],
167 | ['text_prepend', '/ss_fix.log'],
168 | ['text_prepend', '/dfhack.history'],
169 | ['copy_add', '/data/save'],
170 | ['copy_add', '/soundsense',
171 | 'LNP/Utilities/Soundsense/packs'],
172 | ['copy_add', 'LNP/Utilities/Soundsense/packs'],
173 | ['copy_add', 'User Generated Content']
174 | ],
175 | "hideUtilityPath": False,
176 | "hideUtilityExt": False,
177 | "updates": {
178 | "updateMethod": ""
179 | }
180 | }
181 | self.config = JSONConfiguration(config_file, default_config)
182 | self.userconfig = JSONConfiguration('PyLNP.user')
183 | self.autorun = []
184 | utilities.load_autorun()
185 |
186 | if self.args.terminal_test_parent:
187 | from . import terminal
188 | errorlog.stop()
189 | sys.exit(terminal.terminal_test_parent(
190 | self.args.terminal_test_parent[0]))
191 | if self.args.terminal_test_child:
192 | from . import terminal
193 | errorlog.stop()
194 | sys.exit(terminal.terminal_test_child(
195 | self.args.terminal_test_child[0]))
196 |
197 | def initialize_df(self):
198 | """Initializes the DF folder and related variables."""
199 | from . import df
200 | self.df_info = None
201 | self.folders = []
202 | self.settings = None
203 | df.find_df_folder()
204 |
205 | def initialize_ui(self):
206 | """Instantiates the UI object."""
207 | from tkgui.tkgui import TkGui
208 | self.ui = TkGui()
209 |
210 | def reload_program(self):
211 | """Reloads the program to allow the user to change DF folders."""
212 | self.args.df_folder = None
213 | self.initialize_program()
214 | self.initialize_df()
215 | self.initialize_ui()
216 | self.ui.start()
217 |
218 | def parse_commandline(self):
219 | """Parses and acts on command line options."""
220 | args = self.get_commandline_args()
221 | if args.debug == 1:
222 | log.set_level(log.DEBUG)
223 | elif args.debug is not None and args.debug > 1:
224 | log.set_level(log.VERBOSE)
225 | if args.release_prep:
226 | args.raw_lint = True
227 | log.d(args)
228 | return args
229 |
230 | @staticmethod
231 | def get_commandline_args():
232 | """Responsible for the actual parsing of command line options."""
233 | import argparse
234 | parser = argparse.ArgumentParser(
235 | description="PyLNP " + VERSION)
236 | parser.add_argument(
237 | '-d', '--debug', action='count',
238 | help='Turn on debugging output (use twice for extra verbosity)')
239 | parser.add_argument(
240 | '--raw-lint', action='store_true',
241 | help='Verify contents of raw files and exit')
242 | parser.add_argument(
243 | 'df_folder', nargs='?',
244 | help='Dwarf Fortress folder to use (if it exists)')
245 | parser.add_argument(
246 | '--version', action='version', version="PyLNP " + VERSION)
247 | parser.add_argument(
248 | '--df-executable', action='store',
249 | help='Override DF/DFHack executable name')
250 | parser.add_argument(
251 | '--release-prep', action='store_true',
252 | help=argparse.SUPPRESS)
253 | parser.add_argument(
254 | '--terminal-test-parent', nargs=1,
255 | help=argparse.SUPPRESS)
256 | parser.add_argument(
257 | '--terminal-test-child', nargs=1,
258 | help=argparse.SUPPRESS)
259 | return parser.parse_known_args()[0]
260 |
261 | def save_config(self):
262 | """Saves LNP configuration."""
263 | self.userconfig.save_data()
264 |
265 | def macos_check_translocated(self):
266 | """Verify that macOS isn't isolating our application."""
267 | assert self.os == 'osx'
268 | if '/AppTranslocation/' in sys.executable:
269 | try:
270 | from tkinter import messagebox
271 | except ImportError:
272 | import tkMessageBox as messagebox
273 | messagebox.showinfo(
274 | 'Cannot launch PyLNP',
275 | 'PyLNP cannot be run from a disk image or from the Downloads '
276 | 'folder. Please copy the PyLNP app and its other Dwarf '
277 | 'Fortress files elsewhere, such as to the Applications folder.')
278 |
279 | def detect_basedir(self):
280 | """Detects the location of Dwarf Fortress by walking up the directory
281 | tree."""
282 | prev_path = '.'
283 |
284 | from . import df
285 | try:
286 | while os.path.abspath(self.BASEDIR) != prev_path:
287 | df.find_df_folders()
288 | if len(self.folders) != 0:
289 | return
290 | prev_path = os.path.abspath(self.BASEDIR)
291 | self.BASEDIR = os.path.join(self.BASEDIR, '..')
292 | except UnicodeDecodeError:
293 | # This seems to no longer be an issue, but leaving in the check
294 | # just in case
295 | log.e(
296 | "PyLNP is being stored in a path containing non-ASCII "
297 | "characters, and cannot continue. Folder names may only use "
298 | "the characters A-Z, 0-9, and basic punctuation.\n"
299 | "Alternatively, you may run PyLNP from source using Python 3.")
300 | sys.exit(1)
301 | log.e("Could not find any Dwarf Fortress installations.")
302 | if self.os == 'osx':
303 | self.macos_check_translocated()
304 | sys.exit(2)
305 |
306 |
307 | # vim:expandtab
308 |
--------------------------------------------------------------------------------