class TestProjectManager(common.TestCase): def setUp(self): super().setUp() self.setup_app() def setup_app(self, app=None): if not app: app = mock.MagicMock() self.manager = ProjectManager(app) self.listener = ProjectManagerListener(self.manager) self.signals = self.listener.signals def test_loading_missing_project_file(self): """Checks new-project-failed is emitted for unsuitable formatters.""" project_uri = Gst.filename_to_uri(tempfile.NamedTemporaryFile().name) self.manager.load_project(project_uri) # loading name, args = self.signals[0] self.assertEqual(project_uri, args[0].get_uri(), self.signals) # failed name, args = self.signals[1] self.assertEqual("new-project-failed", name) signal_uri, unused_message = args self.assertEqual(project_uri, signal_uri, self.signals) def test_new_blank_project_signals(self): self.manager.new_blank_project() name, _args = self.signals[0] self.assertEqual("new-project-loading", name, self.signals) name, _args = self.signals[1] self.assertEqual("new-project-created", name, self.signals) name, _args = self.signals[2] self.assertEqual("new-project-loaded", name, self.signals) def test_missing_uri_forwarded(self): self.setup_app(app=common.create_pitivi_mock()) mainloop = common.create_main_loop() def missing_uri_cb(self, project, error, clip_asset, result): result[0] = True mainloop.quit() result = [False] self.manager.connect("missing-uri", missing_uri_cb, result) with common.cloned_sample(): asset_uri = common.get_sample_uri("missing.png") with common.created_project_file(asset_uri) as uri: self.assertIsNotNone(self.manager.load_project(uri)) mainloop.run() self.assertTrue(result[0], "missing-uri has not been emitted") def test_loaded(self): mainloop = common.create_main_loop() def new_project_loaded_cb(project_manager, project): mainloop.quit() self.manager.connect("new-project-loaded", new_project_loaded_cb) with common.cloned_sample("flat_colour1_640x480.png"): asset_uri = common.get_sample_uri("flat_colour1_640x480.png") with common.created_project_file(asset_uri=asset_uri) as uri: project = self.manager.load_project(uri) self.assertIsNotNone(project) mainloop.run() self.assertFalse(project.at_least_one_asset_missing) self.assertTrue(project.loaded) self.assertFalse(project.has_unsaved_modifications()) def test_close_running_project_no_project(self): self.assertTrue(self.manager.close_running_project()) self.assertFalse(self.signals) def test_close_running_project_refuse_from_signal(self): def closing_cb(manager, project): return False self.manager.current_project = mock.Mock() self.manager.current_project.uri = "file:///ciao" self.manager.connect("closing-project", closing_cb) self.assertFalse(self.manager.close_running_project()) self.assertEqual(1, len(self.signals)) name, args = self.signals[0] self.assertEqual("closing-project", name) project = args[0] self.assertTrue(project is self.manager.current_project) def test_close_running_project(self): project = self.manager.new_blank_project() self.assertTrue(self.manager.close_running_project()) self.assertEqual(5, len(self.signals), self.signals) name, args = self.signals[-2] self.assertEqual("closing-project", name) self.assertEqual(args[0], project) name, args = self.signals[-1] self.assertEqual("project-closed", name) self.assertEqual(args[0], project) self.assertTrue(self.manager.current_project is None) def test_new_blank_project(self): self.assertIsNotNone(self.manager.new_blank_project()) self.assertEqual(3, len(self.signals)) name, args = self.signals[0] self.assertEqual("new-project-loading", name) project = args[0] self.assertTrue(project.get_uri() is None) name, args = self.signals[1] self.assertEqual("new-project-created", name) project = args[0] self.assertEqual(project.get_uri(), project.uri) name, args = self.signals[2] self.assertEqual("new-project-loaded", name) project = args[0] self.assertTrue(project is self.manager.current_project) def test_marker_container(self): project = self.manager.new_blank_project() self.assertIsNotNone(project) self.assertIsNotNone(project.ges_timeline) self.assertIsNotNone(project.ges_timeline.get_marker_list("markers")) def test_save_project(self): self.manager.new_blank_project() unused, path = tempfile.mkstemp(suffix=".xges") unused, path2 = tempfile.mkstemp(suffix=".xges") try: uri = "file://" + os.path.abspath(path) uri2 = "file://" + os.path.abspath(path2) # Save the project. self.assertTrue(self.manager.save_project(uri=uri, backup=False)) self.assertTrue(os.path.isfile(path)) # Wait a bit. time.sleep(0.1) # Save the project at a new location. self.assertTrue(self.manager.save_project(uri2, backup=False)) self.assertTrue(os.path.isfile(path2)) # Make sure the old path and the new path have different mtimes. mtime = os.path.getmtime(path) mtime2 = os.path.getmtime(path2) self.assertLess(mtime, mtime2) # Wait a bit more. time.sleep(0.1) # Save project again under the new path (by omitting uri arg) self.assertTrue(self.manager.save_project(backup=False)) # regression test for bug 594396 # make sure we didn't save to the old URI self.assertEqual(mtime, os.path.getmtime(path)) # make sure we did save to the new URI self.assertLess(mtime2, os.path.getmtime(path2)) finally: os.remove(path) os.remove(path2) def test_make_backup_uri(self): uri = "file:///tmp/x.xges" self.assertEqual(uri + "~", self.manager._make_backup_uri(uri)) def test_backup_project(self): self.manager.new_blank_project() # Assign an uri to the project where it's saved by default. unused, xges_path = tempfile.mkstemp(suffix=".xges") uri = "file://" + os.path.abspath(xges_path) self.manager.current_project.uri = uri # This is where the automatic backup file is saved. backup_uri = self.manager._make_backup_uri(uri) # Save the backup self.assertTrue(self.manager.save_project( self.manager.current_project, backup=True)) self.assertTrue(os.path.isfile(path_from_uri(backup_uri))) self.manager.close_running_project() self.assertFalse(os.path.isfile(path_from_uri(backup_uri)), "Backup file not deleted when project closed")
class Pitivi(Gtk.Application, Loggable): """Hello world. Attributes: action_log (UndoableActionLog): The undo/redo log for the current project. effects (EffectsManager): The effects which can be applied to a clip. gui (MainWindow): The main window of the app. recent_manager (Gtk.RecentManager): Manages recently used projects. project_manager (ProjectManager): The holder of the current project. settings (GlobalSettings): The application-wide settings. system (pitivi.utils.system.System): The system running the app. """ __gsignals__ = { "version-info-received": (GObject.SignalFlags.RUN_LAST, None, (object, )) } def __init__(self): Gtk.Application.__init__(self, application_id="org.pitivi.Pitivi", flags=Gio.ApplicationFlags.NON_UNIQUE | Gio.ApplicationFlags.HANDLES_OPEN) Loggable.__init__(self) self.settings = None self.threads = None self.effects = None self.system = None self.project_manager = ProjectManager(self) self.action_log = None self.project_observer = None self._last_action_time = Gst.util_get_timestamp() self.gui = None self.recent_manager = Gtk.RecentManager.get_default() self.__inhibit_cookies = {} self._version_information = {} self._scenario_file = None self._first_action = True Zoomable.app = self self.shortcuts = ShortcutsManager(self) def write_action(self, action, **kwargs): if self._scenario_file is None: return if self._first_action: self._scenario_file.write( "description, seek=true, handles-states=true\n") self._first_action = False now = Gst.util_get_timestamp() if now - self._last_action_time > 0.05 * Gst.SECOND: # We need to make sure that the waiting time was more than 50 ms. st = Gst.Structure.new_empty("wait") st["duration"] = float((now - self._last_action_time) / Gst.SECOND) self._scenario_file.write(st.to_string()) self._scenario_file.write("\n") self._last_action_time = now if not isinstance(action, Gst.Structure): structure = Gst.Structure.new_empty(action) for key, value in kwargs.items(): key = key.replace("_", "-") structure[key] = value action = structure self._scenario_file.write(action.to_string()) self._scenario_file.write("\n") self._scenario_file.flush() def do_startup(self): Gtk.Application.do_startup(self) # Init logging as early as possible so we can log startup code enable_color = os.environ.get('PITIVI_DEBUG_NO_COLOR', '0') not in ('', '1') # Let's show a human-readable Pitivi debug output by default, and only # show a crazy unreadable mess when surrounded by gst debug statements. enable_crack_output = "GST_DEBUG" in os.environ loggable.init('PITIVI_DEBUG', enable_color, enable_crack_output) self.info('starting up') self._setup() self._check_version() def _setup(self): # pylint: disable=attribute-defined-outside-init self.settings = GlobalSettings() self.threads = ThreadMaster() self.effects = EffectsManager() self.proxy_manager = ProxyManager(self) self.system = get_system() self.plugin_manager = PluginManager(self) self.project_manager.connect("new-project-loaded", self._new_project_loaded_cb) self.project_manager.connect_after("project-closed", self._project_closed_cb) self.project_manager.connect("project-saved", self.__project_saved_cb) self._create_actions() self._sync_do_undo() def _create_actions(self): self.shortcuts.register_group("app", _("General"), position=10) # pylint: disable=attribute-defined-outside-init self.undo_action = Gio.SimpleAction.new("undo", None) self.undo_action.connect("activate", self._undo_cb) self.add_action(self.undo_action) self.shortcuts.add("app.undo", ["<Primary>z"], self.undo_action, _("Undo the most recent action")) self.redo_action = Gio.SimpleAction.new("redo", None) self.redo_action.connect("activate", self._redo_cb) self.add_action(self.redo_action) self.shortcuts.add("app.redo", ["<Primary><Shift>z"], self.redo_action, _("Redo the most recent action")) self.quit_action = Gio.SimpleAction.new("quit", None) self.quit_action.connect("activate", self._quit_cb) self.add_action(self.quit_action) self.shortcuts.add("app.quit", ["<Primary>q"], self.quit_action, _("Quit")) self.show_shortcuts_action = Gio.SimpleAction.new( "shortcuts_window", None) self.show_shortcuts_action.connect("activate", self._show_shortcuts_cb) self.add_action(self.show_shortcuts_action) self.shortcuts.add("app.shortcuts_window", ["<Primary>F1", "<Primary>question"], self.show_shortcuts_action, _("Show the Shortcuts Window")) def do_activate(self): if self.gui: # The app is already started and the window already created. # Present the already existing window. if self.system.has_x11(): # TODO: Use present() instead of present_with_time() when # https://bugzilla.gnome.org/show_bug.cgi?id=688830 is fixed. from gi.repository import GdkX11 x11_server_time = GdkX11.x11_get_server_time( self.gui.get_window()) self.gui.present_with_time(x11_server_time) else: # On Wayland or Quartz (Mac OS X) backend there is no GdkX11, # so just use present() directly here. self.gui.present() # No need to show the welcome wizard. return self.create_main_window() self.gui.show_perspective(self.gui.greeter) def create_main_window(self): if self.gui: return self.gui = MainWindow(self) self.gui.setup_ui() self.add_window(self.gui) def do_open(self, giofiles, unused_count, unused_hint): assert giofiles self.create_main_window() if len(giofiles) > 1: self.warning( "Opening only one project at a time. Ignoring the rest!") project_file = giofiles[0] self.project_manager.load_project(quote_uri(project_file.get_uri())) return True def shutdown(self): """Closes the app. Returns: bool: True if successful, False otherwise. """ self.debug("shutting down") # Refuse to close if we are not done with the current project. if not self.project_manager.close_running_project(): self.warning( "Not closing since running project doesn't want to close") return False if self.gui: self.gui.destroy() self.threads.wait_all_threads() self.settings.store_settings() self.quit() return True def _set_scenario_file(self, uri): if uri: project_path = path_from_uri(uri) else: # New project. project_path = None if 'PITIVI_SCENARIO_FILE' in os.environ: scenario_path = os.environ['PITIVI_SCENARIO_FILE'] else: cache_dir = xdg_cache_home("scenarios") scenario_name = str(time.strftime("%Y%m%d-%H%M%S")) if project_path: scenario_name += os.path.splitext( project_path.replace(os.sep, "_"))[0] scenario_path = os.path.join(cache_dir, scenario_name + ".scenario") scenario_path = path_from_uri(quote_uri(scenario_path)) self._scenario_file = open(scenario_path, "w") if project_path and not project_path.endswith(".scenario"): # It's an xges file probably. with open(project_path) as project: content = project.read().replace("\n", "") self.write_action("load-project", serialized_content=content) def _new_project_loaded_cb(self, unused_project_manager, project): uri = project.get_uri() if uri: # We remove the project from recent projects list # and then re-add it to this list to make sure it # gets positioned at the top of the recent projects list. try: self.recent_manager.remove_item(uri) except GLib.Error as e: if e.domain != "gtk-recent-manager-error-quark": raise e self.recent_manager.add_item(uri) self.action_log = UndoableActionLog() self.action_log.connect("pre-push", self._action_log_pre_push_cb) self.action_log.connect("commit", self._action_log_commit) self.action_log.connect("move", self._action_log_move_cb) self.project_observer = ProjectObserver(project, self.action_log) self._set_scenario_file(project.get_uri()) def __project_saved_cb(self, unused_project_manager, unused_project, uri): if uri: self.recent_manager.add_item(uri) def _project_closed_cb(self, unused_project_manager, project): if project.loaded: self.action_log = None self._sync_do_undo() if self._scenario_file: self.write_action("stop") self._scenario_file.close() self._scenario_file = None def _check_version(self): """Checks online for new versions of the app.""" self.info("Requesting version information async") giofile = Gio.File.new_for_uri(RELEASES_URL) giofile.load_contents_async(None, self._version_info_received_cb, None) def _version_info_received_cb(self, giofile, result, user_data): # pylint: disable=broad-except try: raw = giofile.load_contents_finish(result)[1] if not isinstance(raw, str): raw = raw.decode() raw = raw.split("\n") # Split line at '=' if the line is not empty or a comment line data = [ element.split("=") for element in raw if element and not element.startswith("#") ] # search newest version and status status = "UNSUPPORTED" current_version = None for version, version_status in data: if VERSION == version: status = version_status if version_status.upper() == "CURRENT": # This is the latest. current_version = version self.info("Latest software version is %s", current_version) version_split = [int(i) for i in VERSION.split(".")] current_version_split = [ int(i) for i in current_version.split(".") ] if version_split > current_version_split: status = "CURRENT" self.info( "Running version %s, which is newer than the latest known version. Considering it as the latest current version.", VERSION) elif status == "UNSUPPORTED": self.warning("Using an outdated version of Pitivi (%s)", VERSION) self._version_information["current"] = current_version self._version_information["status"] = status self.emit("version-info-received", self._version_information) except Exception as e: self.warning("Version info could not be read: %s", e) def is_latest(self): """Whether the app's version is the latest as far as we know.""" status = self._version_information.get("status") return status is None or status.upper() == "CURRENT" def get_latest(self): """Get the latest version of the app or None.""" return self._version_information.get("current") def _quit_cb(self, unused_action, unused_param): self.shutdown() def _undo_cb(self, unused_action, unused_param): self.action_log.undo() def _redo_cb(self, unused_action, unused_param): self.action_log.redo() def _show_shortcuts_cb(self, unused_action, unused_param): show_shortcuts(self) def _action_log_pre_push_cb(self, unused_action_log, action): scenario_action = action.as_scenario_action() if scenario_action: self.write_action(scenario_action) def _action_log_commit(self, action_log, unused_stack): if action_log.is_in_transaction(): return self._sync_do_undo() def _action_log_move_cb(self, action_log, unused_stack): self._sync_do_undo() def _sync_do_undo(self): can_undo = self.action_log and bool(self.action_log.undo_stacks) self.undo_action.set_enabled(bool(can_undo)) can_redo = self.action_log and bool(self.action_log.redo_stacks) self.redo_action.set_enabled(bool(can_redo)) if not self.project_manager.current_project: return dirty = self.action_log and self.action_log.dirty() self.project_manager.current_project.set_modification_state(dirty) # In the tests we do not want to create any gui if self.gui is not None: self.gui.editor.show_project_status() def simple_inhibit(self, reason, flags): """Informs the session manager about actions to be inhibited. Keeps track of the reasons received. A specific reason should always be accompanied by the same flags. Calling the method a second time with the same reason has no effect unless `simple_uninhibit` has been called in the meanwhile. Args: reason (str): The reason for which to perform the inhibition. flags (Gtk.ApplicationInhibitFlags): What should be inhibited. """ if reason in self.__inhibit_cookies: self.debug("Inhibit reason already active: %s", reason) return self.debug("Inhibiting %s for %s", flags, reason) cookie = self.inhibit(self.gui, flags, reason) self.__inhibit_cookies[reason] = cookie def simple_uninhibit(self, reason): """Informs the session manager that an inhibition is not needed anymore. Args: reason (str): The reason which is not valid anymore. """ try: cookie = self.__inhibit_cookies.pop(reason) except KeyError: self.debug("Inhibit reason not active: %s", reason) return self.debug("Uninhibiting %s", reason) self.uninhibit(cookie)