Changeset View
Changeset View
Standalone View
Standalone View
plugins/python/plugin_importer/plugin_importer.py
- This file was added.
1 | # Copyright (c) 2019 Rebecca Breu <rebecca@rbreu.de> | ||||
---|---|---|---|---|---|
2 | | ||||
3 | # This file is part of Krita. | ||||
4 | | ||||
5 | # Krita is free software: you can redistribute it and/or modify | ||||
6 | # it under the terms of the GNU General Public License as published by | ||||
7 | # the Free Software Foundation, either version 3 of the License, or | ||||
8 | # (at your option) any later version. | ||||
9 | | ||||
10 | # Krita is distributed in the hope that it will be useful, | ||||
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
13 | # GNU General Public License for more details. | ||||
14 | | ||||
15 | # You should have received a copy of the GNU General Public License | ||||
16 | # along with Krita. If not, see <https://www.gnu.org/licenses/>. | ||||
17 | | ||||
18 | """This module provides the actual importing logic. See | ||||
19 | `:class:PluginImporter` for more info. | ||||
20 | | ||||
21 | For easy command line testing, call this module like this: | ||||
22 | > python plugin_importer.py foo.zip /output/path | ||||
23 | """ | ||||
24 | | ||||
25 | from configparser import ConfigParser, Error as ConfigParserError | ||||
26 | import os | ||||
27 | import shutil | ||||
28 | import sys | ||||
29 | from tempfile import TemporaryDirectory | ||||
30 | import zipfile | ||||
31 | from xml.etree import ElementTree | ||||
32 | | ||||
33 | | ||||
34 | class PluginImportError(Exception): | ||||
35 | """Base class for all exceptions of this module.""" | ||||
36 | pass | ||||
37 | | ||||
38 | | ||||
39 | class NoPluginsFoundException(PluginImportError): | ||||
40 | """No valid plugins can be found in the zip file.""" | ||||
41 | pass | ||||
42 | | ||||
43 | | ||||
44 | class PluginReadError(PluginImportError): | ||||
45 | """Zip file can't be read or its content can't be parsed.""" | ||||
46 | pass | ||||
47 | | ||||
48 | | ||||
49 | class PluginImporter: | ||||
50 | """Import a Krita Python Plugin from a zip file into the given | ||||
51 | directory. | ||||
52 | | ||||
53 | The Importer makes barely any assumptions about the file structure | ||||
54 | in the zip file. It will find one or more plugins with the | ||||
55 | following strategy: | ||||
56 | | ||||
57 | 1. Find files with the ending `.desktop` and read the Python | ||||
58 | module name from them | ||||
59 | 2. Find directories that correspond to the Python module names | ||||
60 | and that contain an `__init__.py` file | ||||
61 | 3. Find files with ending `.action` that have matching | ||||
62 | `<Action name=...>` tags (these files are optional) | ||||
63 | 4. Extract the desktop- and action-files and the Python module | ||||
64 | directories into the corresponding pykrita and actions folders | ||||
65 | | ||||
66 | Usage: | ||||
67 | | ||||
68 | >>> importer = PluginImporter( | ||||
69 | '/path/to/plugin.zip', | ||||
70 | '/path/to/krita/resources/', | ||||
71 | confirm_overwrite_callback) | ||||
72 | >>> imported = importer.import_all() | ||||
73 | | ||||
74 | """ | ||||
75 | | ||||
76 | def __init__(self, zip_filename, resources_dir, | ||||
77 | confirm_overwrite_callback): | ||||
78 | | ||||
79 | """Initialise the importer. | ||||
80 | | ||||
81 | :param zip_filename: Filename of the zip archive containing the | ||||
82 | plugin(s) | ||||
83 | :param resources_dir: The Krita resources directory into which | ||||
84 | to extract the plugin(s) | ||||
85 | :param confirm_overwrite_callback: A function that gets called | ||||
86 | if a plugin already exists in the resources directory. It gets | ||||
87 | called with a dictionary of information about the plugin and | ||||
88 | should return whether the user wants to overwrite the plugin | ||||
89 | (True) or not (False). | ||||
90 | """ | ||||
91 | | ||||
92 | self.resources_dir = resources_dir | ||||
93 | self.confirm_overwrite_callback = confirm_overwrite_callback | ||||
94 | try: | ||||
95 | self.archive = zipfile.ZipFile(zip_filename) | ||||
96 | except(zipfile.BadZipFile, zipfile.LargeZipFile, OSError) as e: | ||||
97 | raise PluginReadError(str(e)) | ||||
98 | | ||||
99 | self.desktop_filenames = [] | ||||
100 | self.action_filenames = [] | ||||
101 | for filename in self.archive.namelist(): | ||||
102 | if filename.endswith('.desktop'): | ||||
103 | self.desktop_filenames.append(filename) | ||||
104 | if filename.endswith('.action'): | ||||
105 | self.action_filenames.append(filename) | ||||
106 | | ||||
107 | @property | ||||
108 | def destination_pykrita(self): | ||||
109 | dest = os.path.join(self.resources_dir, 'pykrita') | ||||
110 | if not os.path.exists(dest): | ||||
111 | os.mkdir(dest) | ||||
112 | return dest | ||||
113 | | ||||
114 | @property | ||||
115 | def destination_actions(self): | ||||
116 | dest = os.path.join(self.resources_dir, 'actions') | ||||
117 | if not os.path.exists(dest): | ||||
118 | os.mkdir(dest) | ||||
119 | return dest | ||||
120 | | ||||
121 | def get_destination_module(self, plugin): | ||||
122 | return os.path.join(self.destination_pykrita, plugin['name']) | ||||
123 | | ||||
124 | def get_destination_desktop(self, plugin): | ||||
125 | return os.path.join( | ||||
126 | self.destination_pykrita, '%s.desktop' % plugin['name']) | ||||
127 | | ||||
128 | def get_destination_actionfile(self, plugin): | ||||
129 | return os.path.join( | ||||
130 | self.destination_actions, '%s.action' % plugin['name']) | ||||
131 | | ||||
132 | def get_source_module(self, name): | ||||
133 | namelist = self.archive.namelist() | ||||
134 | for filename in namelist: | ||||
135 | if filename.endswith('/%s/' % name): | ||||
136 | # Sanity check: There should be an __init__.py inside | ||||
137 | if ('%s__init__.py' % filename) in namelist: | ||||
138 | return filename | ||||
139 | | ||||
140 | def get_source_actionfile(self, name): | ||||
141 | for filename in self.action_filenames: | ||||
142 | try: | ||||
143 | root = ElementTree.fromstring( | ||||
144 | self.archive.read(filename).decode('utf-8')) | ||||
145 | except ElementTree.ParseError as e: | ||||
146 | raise PluginReadError( | ||||
147 | '%s: %s' % (i18n('Action file'), str(e))) | ||||
148 | | ||||
149 | for action in root.findall('./Actions/Action'): | ||||
150 | if action.get('name') == name: | ||||
151 | return filename | ||||
152 | | ||||
153 | def read_desktop_config(self, desktop_filename): | ||||
154 | config = ConfigParser() | ||||
155 | try: | ||||
156 | config.read_string( | ||||
157 | self.archive.read(desktop_filename).decode('utf-8')) | ||||
158 | except ConfigParserError as e: | ||||
159 | raise PluginReadError( | ||||
160 | '%s: %s' % (i18n('Desktop file'), str(e))) | ||||
161 | return config | ||||
162 | | ||||
163 | def get_plugin_info(self): | ||||
164 | names = [] | ||||
165 | for filename in self.desktop_filenames: | ||||
166 | config = self.read_desktop_config(filename) | ||||
167 | try: | ||||
168 | name = config['Desktop Entry']['X-KDE-Library'] | ||||
169 | ui_name = config['Desktop Entry']['Name'] | ||||
170 | except KeyError as e: | ||||
171 | raise PluginReadError( | ||||
172 | 'Desktop file: Key %s not found' % str(e)) | ||||
173 | module = self.get_source_module(name) | ||||
174 | if module: | ||||
175 | names.append({ | ||||
176 | 'name': name, | ||||
177 | 'ui_name': ui_name, | ||||
178 | 'desktop': filename, | ||||
179 | 'module': module, | ||||
180 | 'action': self.get_source_actionfile(name) | ||||
181 | }) | ||||
182 | return names | ||||
183 | | ||||
184 | def extract_desktop(self, plugin): | ||||
185 | with open(self.get_destination_desktop(plugin), 'wb') as f: | ||||
186 | f.write(self.archive.read(plugin['desktop'])) | ||||
187 | | ||||
188 | def extract_module(self, plugin): | ||||
189 | with TemporaryDirectory() as tmp_dir: | ||||
190 | for name in self.archive.namelist(): | ||||
191 | if name.startswith(plugin['module']): | ||||
192 | self.archive.extract(name, tmp_dir) | ||||
193 | module_dirname = os.path.join( | ||||
194 | tmp_dir, *plugin['module'].split('/')) | ||||
195 | try: | ||||
196 | shutil.rmtree(self.get_destination_module(plugin)) | ||||
197 | except FileNotFoundError: | ||||
198 | pass | ||||
199 | shutil.copytree(module_dirname, | ||||
200 | self.get_destination_module(plugin)) | ||||
201 | | ||||
202 | def extract_actionfile(self, plugin): | ||||
203 | with open(self.get_destination_actionfile(plugin), 'wb') as f: | ||||
204 | f.write(self.archive.read(plugin['action'])) | ||||
205 | | ||||
206 | def extract_plugin(self, plugin): | ||||
207 | # Check if the plugin already exists in the source directory: | ||||
208 | if (os.path.exists(self.get_destination_desktop(plugin)) | ||||
209 | or os.path.exists(self.get_destination_module(plugin))): | ||||
210 | confirmed = self.confirm_overwrite_callback(plugin) | ||||
211 | if not confirmed: | ||||
212 | return False | ||||
213 | | ||||
214 | self.extract_desktop(plugin) | ||||
215 | self.extract_module(plugin) | ||||
216 | if plugin['action']: | ||||
217 | self.extract_actionfile(plugin) | ||||
218 | return True | ||||
219 | | ||||
220 | def import_all(self): | ||||
221 | """Imports all plugins from the zip archive. | ||||
222 | | ||||
223 | Returns a list of imported plugins. | ||||
224 | """ | ||||
225 | | ||||
226 | plugins = self.get_plugin_info() | ||||
227 | if not plugins: | ||||
228 | raise NoPluginsFoundException(i18n('No plugins found in archive')) | ||||
229 | | ||||
230 | imported = [] | ||||
231 | for plugin in plugins: | ||||
232 | success = self.extract_plugin(plugin) | ||||
233 | if success: | ||||
234 | imported.append(plugin) | ||||
235 | | ||||
236 | return imported | ||||
237 | | ||||
238 | | ||||
239 | if __name__ == '__main__': | ||||
240 | def callback(plugin): | ||||
241 | print('Overwriting plugin:', plugin['ui_name']) | ||||
242 | return True | ||||
243 | | ||||
244 | imported = PluginImporter( | ||||
245 | sys.argv[1], sys.argv[2], callback).import_all() | ||||
246 | for plugin in imported: | ||||
247 | print('Imported plugin:', plugin['ui_name']) |