Plugin API

The plugin API gives you immense power to customize and extend fman. For a quick introduction to writing your first plugin, please see here. Once you've gotten started, you may also want to take a look at the Core plugin. It gives lots of examples of how the API is meant to be used.

If you have questions about developing plugins for fman, or want to suggest improvements/extensions to the API, please get in touch. We want to make fman's plugin support truly outstanding. Your feedback can guide us there!

Everything is a URL

In fman's API, files are identified by URLs instead of traditional file paths. For example: file://C:/Windows instead of C:\Windows. This lets fman handle many different file systems. For instance, when working with a Zip archive, the URL zip://C:/archive.zip/directory/file.txt identifies one of its members.

The module fman.url exposes several functions that make working with URLs easier:

If your plugin only supports "normal" files, then you should check that the URLs you are working with are actually file:// URLs. The idiomatic way of doing this is as follows:

from fman import DirectoryPaneCommand
from fman.url import splitscheme, as_human_readable

class MyCommand(DirectoryPaneCommand):
	def __call__(self):
		url = self.pane.get_path()           # Eg. 'file://C:/Windows'
		scheme, path = splitscheme(url)      # Eg. 'file://', 'C:/Windows'
		if scheme != 'file://':
			show_alert('Not supported.')
			return
		local_path = as_human_readable(url)  # Eg. 'C:\Windows'
		...

In all cases, it is recommended that you use the fman.fs module to work with files. Eg. use fman.fs.mkdir(...) instead of Python's os.mkdir(...) to create directories. The reason is that fman caches files. When you use the recommended functions, you not only make your plugin potentially usable on more file systems. You also let fman update its caches more quickly.

Finally, it must be noted that fman URLs are not URLs in the strict technical sense. For example, file://C:/My file.txt is not valid according to the official URL standard (it would have to be encoded as file:///C:/My%20file.txt). We ignore these requirements. For our purposes, a URL is any string that starts with a scheme:// prefix and is optionally followed by a path whose components are separated by forward slashes /.

Module fman

This module contains the main and most basic functions for interacting with fman. When writing a plugin, you usually create a subclass of one of the following:

  • DirectoryPaneCommand
    Use this if you want to perform an action in the current folder. For example, fman's own commands for copying and moving files are based on this class.
  • ApplicationCommand
    This class is meant for "global" functionality that does not depend on the current folder. For instance, the built-in About command, which displays version and licensing information, is based on this class.
  • DirectoryPaneListener
    Lets you react to events in the current folder such as when the user doubleclicks a file. You can also be notified when the user navigates to another folder. The built-in GoTo feature uses this to count how often you visit each directory, and then recommends the most-visited locations first.

show_alert(text, buttons=OK, default_button=OK)

Shows an alert to the user:

An alert dialog in fman

The optional arguments buttons and default_button let you specify the buttons in the dialog. The available buttons are OK, CANCEL, YES, NO, YES_TO_ALL, NO_TO_ALL and ABORT. You can combine them with |. For instance, YES | NO shows two buttons, Yes and No.

The function returns the button clicked by the user. It is guaranteed that one of the supplied buttons is always returned: If there is only one button and the user cancels the dialog, that button is returned. If there are two buttons or more and none of them is NO, CANCEL or ABORT, then the dialog is not cancellable. Otherwise, the dialog is cancellable and an appropriate button is returned when the user does cancel.

Example:

from fman import ApplicationCommand, show_alert, YES, NO, YES_TO_ALL, \
	NO_TO_ALL, ABORT

class AlertExample(ApplicationCommand):
	def __call__(self):
		choice = show_alert(
			'Do you want to continue?',
			buttons=YES | NO | YES_TO_ALL | NO_TO_ALL | ABORT,
			default_button=YES
		)
		if choice == YES:
			...
		elif choice == NO:
			...
		...

show_prompt(text, default='', selection_start=0, selection_end=None)

Prompts the user to enter a value:

A prompt in fman

If given, the default parameter lets you prefill the text in the dialog. When you do this, selection_start and selection_end let you specify which part(s) of the text should be pre-selected. When selection_end is not given, it defaults to the end of the text. If you only want to place the cursor without selecting any text, use the same value for both selection_start and selection_end.

The function returns a tuple text, ok. When the user accepts the dialog, ok is True and text contains the text he entered (possibly the empty string). If the dialog was cancelled, '', False is returned.

The usual way of handling the result is as follows:

from fman import ApplicationCommand, show_prompt

class ShowPrompt(ApplicationCommand):
	def __call__(self):
		text, ok = show_prompt('Please enter a value')
		if text and ok:
			show_alert('You entered ' + text)
		else:
			show_alert("You cancelled or didn't enter any text")

show_status_message(text, timeout_secs=None)

Shows the given text in fman's status bar:

If optional parameter timeout_secs is given, the message disappears after the specified number of seconds.

clear_status_message()

Clears the status bar by writing a default "idle text" (usually "Ready") into the status bar.

show_file_open_dialog(caption, dir_path, filter_text='')

Shows a native file open dialog in the given directory. The dialog's caption is set to the given caption. If dir_path points to a file, that file is selected. You can use filter_text to only show some files. Valid examples are *.jpg, Images (*.png *.jpg). To use multiple filters, separate them with ;;. For instance: Images (*.png);;Text files (*.txt).

The function returns the path of the file chosen by the user. If the dialog was cancelled, the empty string '' is returned.

show_quicksearch(get_items, get_tab_completion=None)

Shows a Quicksearch dialog. The classic example of this is GoTo:

  • ~/.config/fman
  • ~/dev/fman
  • ~/Dropbox/fman

The parameter get_items must be a function taking a single parameter, query. It is supposed to return an iterable of QuicksearchItems that represent the items to be displayed when the user types in the given query.

The optional parameter get_tab_completion lets you customize the Tab completion behaviour. It should be a function taking two parameters: query and curr_item. The first parameter indicates the text which the user typed in. The second gives the currently selected QuicksearchItem. If no item is currently shown, it is None. The function should return the string the query should be completed to.

If the user cancels the dialog, None is returned. Otherwise, the result is a tuple query, value. query is the (possibly unfinished) query which the user typed into the dialog. In the example above, it would be 'fman'. The second element of the tuple is the value of the selected QuicksearchItem. In the example above, this would be the row ~/dev/fman. If no item was selected (because no suggestions were displayed), the second tuple element is None.

The following example shows a Quicksearch dialog with a filterable list of elements:

from fman import ApplicationCommand, show_quicksearch, QuicksearchItem

class Quicksearch(ApplicationCommand):
	def __call__(self):
		result = show_quicksearch(self._get_items)
		if result:
			query, value = result
			show_alert('You typed %r and selected %r.' % (query, value))
		else:
			show_alert('You cancelled the dialog.')
	def _get_items(self, query):
		for item in ['Some item', 'Another item', 'And another']:
			try:
				index = item.lower().index(query)
			except ValueError as not_found:
				continue
			else:
				# The characters that should be highlighted:
				highlight = range(index, index + len(query))
				yield QuicksearchItem(item, highlight=highlight)

QuicksearchItem(value, title=value, highlight=None, hint='', description='')

A suggestion in a Quicksearch dialog (see show_quicksearch(...)). The title, hint and description fields are best summarized by an image:

A suggestion in an fman Quicksearch dialog

Thus in the image:

  • title = 'ZipSelected'
  • hint = '5★'
  • description = 'Zip the files selected in the current panel.'

In addition to these attributes, every QuicksearchItem has a value and (optionally) a highlight.

The motivation for value becomes clear when you think about fman's built-in GoTo dialog. A suggested path might be ~ (the user's home directory). But the tilde ~ is just a nice representation for the user. What it stands for is the full path to the home directory (eg. C:\Users\Michael). In this case, the title would be '~' but the value is 'C:\Users\Michael'.

If title is not given, the value is used (/displayed) instead.

Finally, highlight specifies which characters in the title should be highlighted. In the above example, you see that the first three characters Zip of the title are highlighted in white. This is achieved by setting highlight to the indices of these characters: [0, 1, 2]. If we wanted to highlight just the Z and the S instead (as in ZipSelected), we would have to set this to [0, 3].

load_json(name, default=None, save_on_quit=False)

Loads the JSON file with the given name from the currently loaded plugins. If the file does not exist, default is returned. If you pass save_on_quit=True, then any changes you make to the returned object are persisted across fman restarts. Either way, within one fman session, it is guaranteed that multiple calls to load_json(...) with the same JSON name always return the same object.

As an example, here is how you can prompt the user for a setting that is persisted across fman restarts:

from fman import ApplicationCommand, show_prompt, load_json

class Prompt(ApplicationCommand):
	def __call__(self):
		settings = load_json('My Settings.json', default={}, save_on_quit=True)
		value = settings.get('value', '')
		new_value, do_save = show_prompt('Please enter a value:', value)
		if do_save:
			settings['value'] = new_value

(Note: It would be more elegant to use save_json(...) instead of save_on_quit here. But the above lets us demonstrate all parameters.)

Automatic merging of JSON files

When multiple JSON files with the same name exist, load_json(...) merges them automatically. This naturally occurs when a file appears in multiple plugins. On top of that, load_json(...) also supports platform-specific JSON files. For example, suppose you are on Windows and create two files:

  • Settings.json with contents {"a": 1, "b": 2}
  • Settings (Windows).json with contents {"b": 99}

When you call load_json('Settings.json'), you get {"a": 1, "b": 99}. (On other platforms, use My Settings (Mac).json for Mac and My Settings (Linux).json for Linux.)

In the above example, we defined a dictionary {...}. Values in a dictionary override previous values. JSON files can also contain lists [...]. In this case, lists loaded later are prepended to previous ones. The order in which the files are loaded (and thus merged) is given by the Plugin load order.

save_json(name, value=None)

Saves the JSON file with the given name in the User's Settings plugin. If value is not given, then the file must have been loaded with load_json(...) first.

get_application_commands()

Returns a set of names of available ApplicationCommands. You can pass these names to run_application_command(...) to actually execute the respective command. The canonical use case for this function is the Command Palette, which displays a list of commands and lets you execute them.

Note that there are also other kinds of commands, namely DirectoryPaneCommands. If you want to get a list of all commands available in fman, you have to combine the results of this function with those of DirectoryPane.get_commands(...).

run_application_command(name, args=None)

Runs the ApplicationCommand with the given name. The optional parameter args, if given, must be a dictionary of arguments to pass to the command's __call__ method. For instance:

run_application_command('install_plugin', args={'github_repo': 'mherrmann/ArrowNavigation'})

Note that there are not just ApplicationCommands but also DirectoryPaneCommands. To execute a DirectoryPaneCommand, use DirectoryPane.run_command(...) instead.

get_application_command_aliases(command_name)

Returns a list of the aliases associated with the given ApplicationCommand. If the ApplicationCommand does not define .aliases, then a single alias is auto-generated from the name of the command (eg. MyCommand gets alias My command). The aliases are what's displayed by the Command Palette.

Note that this function is not meant for DirectoryPaneCommands. To get the aliases of a DirectoryPaneCommand, use DirectoryPane.get_command_aliases(...) instead.

load_plugin(plugin_path)

Loads the plugin in the given directory. This is currently used by fman's command for installing plugins.

unload_plugin(plugin_path)

Unloads the plugin with the given directory path. If the plugin was not loaded, a ValueError is raised. This function is currently used by fman's RemovePlugin and ReloadPlugins commands.

DirectoryPaneCommand

Lets you execute arbitrary actions in the context of the current directory pane. For example, the following command shows an alert with the URL of the current folder:

from fman import DirectoryPaneCommand, show_alert

class ShowLocation(DirectoryPaneCommand):
	def __call__(self):
		show_alert(self.pane.get_path())

DirectoryPaneCommands can be bound to keyboard shortcuts. Additionally, every DirectoryPaneCommand you define automatically appears in (and can be executed from) the Command Palette.

An alternative to DirectoryPaneCommands are ApplicationCommands. Use the latter if your command is global to the entire application and does not operate in a specific pane or directory.

DirectoryPaneCommand.__call__(**kwargs)

When implementing a DirectoryPaneCommand, you must define __call__ as in the previous example. You are free to define additional parameters. For instance, here is the definition of fman's command for moving the cursor down to the next file:

class MoveCursorDown(DirectoryPaneCommand):
	def __call__(self, toggle_selection=False):
		...

Its entries in Key Bindings.json look as follows:

{ "keys": ["Down"], "command": "move_cursor_down" },
{ "keys": ["Shift+Down"], "command": "move_cursor_down", "args": {"toggle_selection": true} }

You can also fill in the parameters when you call DirectoryPane.run_command(...).

DirectoryPaneCommand.get_chosen_files()

Returns a list of the currently selected files (=the files marked "red"). If no files are selected, returns a one-element list consisting of the file under the cursor. If there is no file under the cursor (eg. when the current directory is empty), then an empty list is returned.

Because the "empty directory" case is common, your plugin should handle it. A common way of doing this is:

class MyCommand(DirectoryPaneCommand):
	def __call__(self):
		chosen_files = self.get_chosen_files()
		if not chosen_files:
			show_alert('No file is selected!')
			return
		# Perform your action here...

DirectoryPaneCommand.is_visible()

You can override this method to prevent your command from appearing in the Command Palette and in context menus. For example, to only show your command when the user is viewing the contents of a Zip archive:

from fman.url import splitscheme

class MyCommand(DirectoryPaneCommand):
	def is_visible(self):
		return splitscheme(self.pane.get_path())[0] == 'zip://'
	...

The default implementation simply returns True (meaning that commands are always visible by default).

DirectoryPaneCommand.aliases

You can set this property to a list of names under which your command should appear in the Command Palette. For example, the built-in Reload command specifies the following aliases:

class Reload(DirectoryPaneCommand):
	aliases = ('Reload', 'Refresh')
	...

If you don't set this property, a single alias is automatically generated for your command (MyCommand -> My command). For more information, see DirectoryPane.get_command_aliases(...)

DirectoryPaneCommand.pane

The DirectoryPane in which this command is executed. You usually access it via self.pane.

DirectoryPane

Represents a directory pane. You are not meant to use this class directly. Instead, you usually use it through the .pane attribute of DirectoryPaneCommand.

DirectoryPane.get_path()

Returns the URL of the directory currently displayed by this directory pane. For instance, if you are viewing the contents of C:\Windows, then file://C:/Windows is returned.

DirectoryPane.set_path(dir_url, callback=None, onerror=go to existing parent directory)

Navigate to the given directory. The optional callback parameter gets called when the new directory has been fully loaded. For example, to navigate to C:\Windows and place the cursor at C:\Windows\System32:

from fman import DirectoryPaneCommand
from fman.url import as_url

class MyCommand(DirectoryPaneCommand):
	def __call__(self):
		def callback():
			self.pane.place_cursor_at(as_url(r'C:\Windows\System32')))
		self.pane.set_path(as_url(r'C:\Windows'), callback=callback)

The optional parameter onerror lets you specify what happens when an error occurs while trying to navigate to the given directory. The default implementation looks as follows:

fman fman.url import dirname

def onerror(e, url):
	if isinstance(e, FileNotFoundError):
		return dirname(url)
	raise

In words: When a FileNotFoundError occurs, fman attempts to open the parent directory instead. This for instance happens when the original directory was deleted. All other errors are re-raised by the raise statement in the above code.

If onerror is given, fman keeps calling it until it raises an error or until it returns the same URL twice. For example: Suppose we call set_path(C:/X/Y) when C:/X does not exist. Then:

  1. onerror is called with a FileNotFoundError and path C:/X/Y. It returns C:/X.
  2. fman gets another FileNotFoundError while navigating to C:/X. So it calls onerror with C:/X. This returns C:.
  3. fman finally succeeds and the path is changed to C:.

To disable the default implementation above, pass onerror=None. This re-raises all errors and does not go to the first existing parent directory.

DirectoryPane.get_selected_files()

Returns a list of the currently selected (="red") files. If no files are selected in this way, the empty list [] is returned.

DirectoryPane.get_file_under_cursor()

Returns the file under the cursor (the "cursor" is what you move when you press Arrow Up/Down). If there is no file currently under the cursor, None is returned. This happens when the pane is currently displaying an empty directory.

DirectoryPane.place_cursor_at(file_url)

Place the cursor at the given file in the current directory. If the file does not exist (or hast not yet been loaded!), a ValueError is raised. See set_path(...) for an example.

DirectoryPane.move_cursor_down(toggle_selection=False)

Move the cursor down. If toggle_selection is given and true, then the current file is also (de-)selected.

DirectoryPane.move_cursor_up(toggle_selection=False)

Move the cursor up. If toggle_selection is given and true, then the current file is also (de-)selected.

DirectoryPane.move_cursor_home(toggle_selection=False)

Move the cursor to the top. If toggle_selection is given and true, then the files above the cursor are also (de-)selected.

DirectoryPane.move_cursor_end(toggle_selection=False)

Move the cursor to the bottom. If toggle_selection is given and true, then the files below the cursor are also (de-)selected.

DirectoryPane.move_cursor_page_down(toggle_selection=False)

Move the cursor one page down. If toggle_selection is given and true, then the files between the current and the new cursor position are also (de-)selected.

DirectoryPane.move_cursor_page_up(toggle_selection=False)

Move the cursor one page up. If toggle_selection is given and true, then the files between the current and the new cursor position are also (de-)selected.

DirectoryPane.select_all()

Select all files in the current directory.

DirectoryPane.clear_selection()

Deselect all files in the current directory.

DirectoryPane.toggle_selection(file_url)

If the given file in the current directory is selected, deselect it. Otherwise, select it.

DirectoryPane.select(file_urls)

Select the given files. Note that file_urls must be a list, even if you want to select a single file. That is, you need to write self.pane.select([file]), not ...select(file).

If any of the given files do not exist in the current pane, that is ignored.

DirectoryPane.deselect(file_urls)

Deselect the given files. This works analogously to DirectoryPane.select(...). Please see its documentation.

DirectoryPane.reload()

Reload the current directory. Note: If you find that you need to call this function because fman is not noticing that your files or directories have changed, that's a bug. Please get in touch.

DirectoryPane.get_commands()

Return the names of all DirectoryPaneCommands available for this pane, as a set of strings. You can then use run_command to run them. This function is used by the Command Palette to find all available commands.

Note that, in addition to DirectoryPaneCommands, there are also ApplicationCommands. To get a list of all commands available in fman, you will likely have to combine the results of this function with those of get_application_commands(...).

DirectoryPane.run_command(name, args=None)

Run the DirectoryPaneCommand with the given name in the context of this pane. The optional parameter args, if given, must be a dictionary of arguments to pass to the command's __call__ method. For instance:

self.pane.run_command('move_cursor_down', args={'toggle_selection': True})

Note that this function does not work for ApplicationCommands. To run an ApplicationCommand, use run_application_command(...) instead.

DirectoryPane.get_command_aliases(command_name)

Returns a list of the aliases associated with the given DirectoryPaneCommand. If the DirectoryPaneCommand does not define .aliases, then a single alias is auto-generated from the name of the command (eg. MyCommand gets alias My command). The aliases are what's displayed by the Command Palette.

Note that this method only works for DirectoryPaneCommands. To get the aliases of an ApplicationCommand, use get_application_command_aliases(...) instead.

DirectoryPane.is_command_visible(command_name)

Returns whether the given command's is_visible(...) returns True for this pane. This is used by the Command Palette to filter out commands that may not be applicable in the current context.

DirectoryPane.focus()

Give the keyboard focus to this pane.

DirectoryPane.edit_name(file_url, selection_start=0, selection_end=None)

Start editing the name of the given file in the current directory. To be notified when the editing process is complete, implement DirectoryPaneListener.on_name_edited(...). For an example, please see the implementation of the Rename command (Shift+F6).

The optional parameters selection_start and selection_end let you specify which part(s) of the file name should be pre-selected. The default implementation uses this to only select the file's base name without the extension. If you only want to place the cursor without selecting any text, use the same value for both selection_start and selection_end.

DirectoryPane.get_columns()

Returns a list of the names of the columns in the current pane. For instance: ['core.Name', 'core.Size', 'core.Modified'].

DirectoryPane.set_sort_column(column, ascending=True)

Sets the sort order of files and directories in this pane. The column parameter identifies the column by which the files are to be sorted. For instance, if get_columns() returns ['core.Name', 'core.Size', 'core.Modified'] and you set column to 'core.Size', then the files are sorted by their Size. The ascending parameter lets you specify whether you want the files sorted in ascending or descending order.

DirectoryPane.get_sort_column()

Returns a tuple column, ascending that indicates the current sort order. For the meaning of the two values, see set_sort_column(...).

DirectoryPane.window

The fman Window this directory pane belongs to. A common use case for this attribute is to find out what the "opposite" pane is in copy/move operations:

from fman import DirectoryPaneCommand

class MirrorInOtherPane(DirectoryPaneCommand):
	def __call__(self):
		panes = self.pane.window.get_panes()
		this_pane = panes.index(self.pane)
		other_pane = panes[(this_pane + 1) % len(panes)]
		other_pane.set_path(self.pane.get_path())

ApplicationCommand

You can extend this class to create "global" commands that do not operate in a specific pane / directory. For example:

from fman import ApplicationCommand, show_alert, FMAN_VERSION

class ShowVersion(ApplicationCommand):
	def __call__(self):
		show_alert('fman version is ' + FMAN_VERSION)

ApplicationCommands are automatically displayed in the Command Palette. You can also bind them to keyboard shortcuts.

To implement a command that executes in the context of a directory pane, use DirectoryPaneCommand instead.

ApplicationCommand.__call__(**kwargs)

Implement this method to execute the command. Similarly to DirectoryPaneCommand.__call__, you are free to define extra parameters.

ApplicationCommand.aliases

You can set this property to a list of names under which your command should appear in the Command Palette. For example, the built-in Quit command specifies the following aliases:

class Quit(ApplicationCommand):
	aliases = ('Quit', 'Exit')
	...

If you don't set this property, a single alias is automatically generated for your command (MyCommand -> My command). For more information, see get_application_command_aliases(...)

ApplicationCommand.window

The fman Window in which this command lives.

Window

This class is exposed as the .window property on the DirectoryPane and ApplicationCommand classes. It is only meant to be accessed through that property.

Window.get_panes()

Returns a list of the directory panes in this window. Please See DirectoryPane.window for an example where it is useful.

Window.minimize()

Minimize the window.

DirectoryPaneListener

Extend this class to be notified of various events in a directory pane. For example, the following listener shows an alert when a file is double-clicked:

from fman import DirectoryPaneListener, show_alert

class DoubleclickListener(DirectoryPaneListener):
	def on_doubleclicked(self, file_url):
		show_alert('You doubleclicked ' + file_url)

The most interesting capability of DirectoryPaneListener right now is probably to rewrite commands via on_command(...).

DirectoryPaneListener.on_command(command_name, args)

Implement this function to be notified when a command is executed, or to execute another command instead. The function receives the name of the command about to be executed and its args as a (possibly empty) dictionary.

To rewrite the command that is executed, you can return a new tuple command_name, args that is then executed instead. The Core plugin for instance uses this to display the contents of Zip files in fman: The built-in Zip file system handles zip:// URLs. But when you press Enter on a local file, its URL is file://, not zip://. To make the zip:// file system handle the file, the Core plugin rewrites the URL via (roughly) the following logic:

class ArchiveOpenListener(DirectoryPaneListener):
	def on_command(self, command_name, args):
		if command_name in ('open_file', 'open_directory'):
			url = args['url']
			scheme, path = splitscheme(url)
			if scheme == 'file://' and path.endswith('.zip'):
				new_args = dict(args)
				new_args['url'] = 'zip://' + path
				return 'open_directory', new_args

It is currently possible to redefine existing commands by simply creating a command with the same name in your own plugin. This is discouraged. Instead, define a command with a new name and use on_command(...) to rewrite the old command to your new one.

DirectoryPaneListener.before_location_change(url, sort_column='', ascending=True)

This method is called before a pane's location changes. You can override it to redirect the pane to a different location, or change the sort order. To do this, return a three-tuple url, sort_column, ascending. For instance, here is how the Core plugin uses this mechanism to remember the sort settings for your various folders:

from fman import DirectoryPaneListener, load_json

class RememberSortSettings(DirectoryPaneListener):
	def before_location_change(self, url, sort_column='', ascending=True):
		settings = load_json('Sort Settings.json', default={})
		try:
			data = settings[url]
		except KeyError:
			return
		remembered_col, remembered_asc = data['column'], data['is_ascending']
		return url, remembered_col, remembered_asc

To leave the URL and sort order unchanged, simply return nothing. In the example above, this is done when a KeyError signifies that there is no saved sort order for the given URL.

DirectoryPaneListener.on_path_changed()

This method is called whenever the user navigates to a different directory. The GoTo command uses this to keep track of the directories you visit most often, so it can suggest them to you first. Use self.pane.get_path() to find out what the new path is.

DirectoryPaneListener.on_doubleclicked(file_url)

Implement this method to be notified when a file was doubleclicked.

DirectoryPaneListener.on_name_edited(file_url, new_name)

This method is called when the user changes the name of a file. The process of editing the name is normally started via DirectoryPane.edit_name(...).

DirectoryPaneListener.on_files_dropped(file_urls, dest_dir, is_copy_not_move)

This method is called at the end of a drag and drop operation, when the user drops the dragged files in fman. The files are given by the list file_urls. dest_dir is the URL of the directory in fman on which the files were dropped. is_copy_not_move is a boolean flag indicating whether the files should be copied or moved: The user indicates this by pressing Ctrl while performing the drag (Alt on Mac).

DirectoryPaneListener.on_location_bar_clicked()

Called when the user clicks the location bar (= the address bar) in this directory pane.

Task(title, size=0, fn=None, args=(), kwargs=None)

Tasks let you display progress feedback for long running operations. A common example is copying files:

Here is a "dummy" Task that emulates this:

from fman import Task
from time import sleep

class CopyFile(Task):
	def __init__(self):
		super().__init__('Copying some file...')
	def __call__(self):
		self.set_text('Calculating size...')
		# Let's say calculating the file size takes 3 seconds:
		sleep(3)
		# Our fake file's size is 10 MB:
		self.set_size(10)

		self.set_text('Copying...')
		num_mb_written = 0
		while num_mb_written < self.get_size():
			self.check_canceled()
			# Pretend writing 1 MB takes 1 second:
			sleep(1)
			num_mb_written += 1
			self.set_progress(num_mb_written)

When you execute this Task, you get the following dialog:

Then, after three seconds, the dialog changes to display the running progress:

After another 10 seconds, the dialog closes. Or you can press the Cancel button to close it earlier.

The above example extends Task and overrides __call__(...) to implement the operation. Alternatively, you can also use the fn, args and kwargs parameters. For example:

from fman import Task
from fman.fs import FileSystem

class MyFileSystem(FileSystem):
	...
	def delete(self, path):
		...
	def prepare_delete(self, path):
		return [Task(
			'Deleting ' + path, fn=self.delete, args=(path,)
		)]

Task.__call__()

Override this method to perform the steps required for executing this Task. For an example, see above. If you find yourself wanting to use fman.show_alert(...), please use Task.show_alert(...) instead.

Task.set_size(size)

Set the size of this Task to the given integer. It can be understood as the total number of steps required to complete this task. When fman computes the completion percentage and draws the progress bar, the size serves as the upper bound for the progress. The default value 0 means that this Task does not have a definite size. In this case, the progress bar shows a general "loading" animation but no specific percentage:

Task.get_size()

Return this Task's size; See Task.set_size(...).

Task.set_progress(progress)

Set the progress made so far in the execution of this Task to the given integer. Unlike the Task's size, which gives the total number of steps, the progress gives the number of completed steps.

Task.get_progress()

Return the progress that has been made towards completing this Task; See Task.set_progress(...).

Task.set_text(text)

Set the text displayed above the progress bar to the given string. Note that this is different from the title, which is displayed in the progress dialog's window title bar.

Task.check_canceled()

Call this function in your implementation of __call__(...) to give fman a chance to abort your Task when the user presses the Cancel button in the progress dialog. You should especially do this in potentially time intensive for and while loops. For example:

from fman import Task

class MyTask(Task):
	def __call__(self):
		for i in range(999999999):
			self.check_canceled()
			# do some processing...
		while True:
			self.check_canceled()
			# do some more processing...

Technically, check_canceled() raises Task.Canceled if the Task was aborted. Leveraging Python's exception mechanism in this way has the advantage of cleanly suspending the Task's execution. Further, it leads to easy propagation of Task cancellation across methods and subtasks.

Task.run(subtask)

Run the given Task as a subtask of this Task. Changes to the subtask's text and progress are reflected in this Task: Changing the subtask's text sets this Task's text as well. And any increase in the subtask's progress is also applied to this Task. (Note how this assumes that the subtask's size is already included in this Task's.)

Task.show_alert(*args, **kwargs)

Show an alert pertaining to the execution of this Task. The parameters and return value are the same as for fman.show_alert(...).

The main motivation for having a separate Task.show_alert(...) is to let fman coordinate progress dialogs and alert windows. But there is also a more semantic reason: Future versions of fman might display tasks in a separate GUI. This GUI may have special rules for handling alerts; In particular, it may want to display them in the background. Using a dedicated method for showing alerts for Tasks leaves this road open.

Task.get_title()

Return the title of this Task, as passed to the constructor and displayed in the progress dialog's window title bar.

Task.Canceled

This exception is raised by check_canceled() when the user cancels the Task in the progress dialog. You may catch it in your implementation of __call__() to perform necessary cleanup steps. Typically however, you will not need to handle it.

The exception extends Python's KeyboardInterrupt, because it is similar in spirit. Note that this implies that you can't handle it with except Exception:. For details why, see this post.

submit_task(task)

Use this function to submit a Task for execution by fman. If the task takes more than one second to complete, this function opens a progress dialog for it. The function also handles the cancellation of tasks by catching Task.Canceled. Upon completion of the task (successful or otherwise), this function closes the progress dialog again.

For example: To run the CopyFile Task from above, add the following code and save it as a plugin. Then, use the Command Palette to "Reload plugins", then "Run example".

from fman import ApplicationCommand, submit_task

class RunExample(ApplicationCommand):
	def __call__(self):
		submit_task(CopyFile())

Module fman.url

As explained in the Introduction, many of the functions in fman's API use URLs. This module makes it easier to work with them.

The functions in this module simply perform string manipulations. They do not actually query the file system. For this, you should use module fman.fs.

Except where noted otherwise, the functions in this module are platform-independent. That is, given the same inputs, they return the same output independently of the operating system they are executed on.

splitscheme(url)

Splits the given URL into a pair scheme, path such that scheme ends with '://' and url == scheme + path. For example: splitscheme('file://C:/test.txt') gives 'file://', 'C:/test.txt'. A common use case of this is checking whether the URL of a file is supported by your command / plugin. Please see the section Everything is a URL for an example.

If the URL is not valid because it doesn't contain ://, then a ValueError is raised.

as_url(local_file_path, scheme='file://')

Converts the given file path into a URL with the given scheme. For instance, on Windows, as_url(r'C:\Directory') returns 'file://C:/Directory'.

If you want the URL to have a scheme other than file://, supply the optional scheme parameter.

This function may return platform-dependent results.

as_human_readable(url)

If given a file:// URL, this function serves as the inverse of as_url(...). That is, it turns file:// URLs into their corresponding local file paths. Eg. on Windows, file://C:/test.txt becomes C:\test.txt.

If the given URL is not a file:// URL, it is returned unchanged.

The function gets its name from its use for presenting URLs and paths to the user. For example: Say you want to prompt the user before overwriting a file (which is given to you as a URL by fman's API). If it is a file:// URL, then you want to show it to the user as a local file path (ie. you want to ask "Do you want to override C:\test.txt", not "... file://C:/test.txt"). Otherwise, you want to show the URL unchanged.

The return value of this function is platform-dependent.

dirname(url)

Returns the parent directory of the given URL. For example: dirname('file://C:/test.txt') returns file://C:. Like all functions in this module, this is a purely lexicographic operation. It does not resolve ... For instance: dirname('file:///Users/..') gives file:///Users. If the URL consists of a scheme only (eg. 'file://'), it is returned unchanged. Perhaps also notable is that dirname('file:///') returns file://.

basename(url)

Returns the last component of the given URL's path. For instance: basename('file://C:/test.txt') returns test.txt. If the last component is empty (as eg. in file:/// and file://), then the empty string '' is returned.

join(url, *paths)

Constructs a new URL relative to the given url. A very common use case of this function is to construct the URL of a file in the current directory. For example, here is the (rough) implementation of fman's built-in command for creating a new directory:

from fman import DirectoryPaneCommand, show_prompt
from fman.fs import mkdir
from fman.url import join

class CreateDirectory(DirectoryPaneCommand):
	def __call__(self):
		name, ok = show_prompt("New folder (directory)")
		if ok and name:
			dir_url = join(self.pane.get_path(), name)
			mkdir(dir_url)

relpath(dst, src)

Computes the relative path from the given src URL to the given dst URL. For example:

relpath('file:///a/b', 'file:///a')       # gives 'b'
relpath('file:///dst', 'file:///dst/sub') # gives '..'

This only works if dst and src have the same scheme. Otherwise, a ValueError is raised.

normalize(url)

Resolve the given URL syntactically. For example, scheme://a/./b becomes scheme://a/b, scheme://a//b becomes scheme://a/b and scheme://a/b/.. becomes scheme://a.

Module fman.fs

This module lets you interact with the file system. As explained in section Everything is a URL, it does not use paths C:\test.txt to identify files, but URLs file://C:/test.txt. This makes it possible for fman to uniformly support many different file systems, such as zip://, ftp://, dropbox:// etc.

Caching

To improve performance, fman's file system backend caches files and their attributes. For example, when you call iterdir(...) to list the contents of a directory, then repeat calls to this function with the same parameter don't query the file system, but return a cached result instead.

The modifying functions in this module, such as mkdir(...) for creating a directory, update the cache. For this reason, it is recommended that you use the functions in this module whenever possible. So in the example just given, the recommendation would be not to use Python's os.mkdir(...), even if it is available.

exists(url)

Returns True or False depending on whether the file with the given URL exists.

is_dir(existing_url)

Returns True or False depending on whether the given URL denotes a directory. If the URL does not exist, FileNotFoundError is raised. Note that this is different from Python's isdir(...), which returns False.

iterdir(url)

Returns the names of the files in the given directory. This is similar to Python's os.listdir(...), but returns an iterable. The difference is that listdir(...) loads and returns all contents at once. This means that you need to "wait" until all contents have been loaded before you can access even the first one. iterdir(...) on the other hand returns the files one by one, and may thus let you access the first ones sooner. You can iterate over the results just like you would iterate over a list:

from fman import DirectoryPaneCommand, show_alert
from fman.fs import iterdir

class ListContents(DirectoryPaneCommand):
	def __call__(self):
		for file_name in iterdir(self.pane.get_path()):
			show_alert(file_name)

If you really do need a list, use list(iterdir(url)) to load all files at once.

touch(url)

Create the file with the given URL. If the file already exists, update its modification time to the current time. May raise NotImplementedError if the target file system does not support this operation.

mkdir(url)

Create the given directory. If it already exists, a FileExistsError is raised. If the parent directory of url does not yet exist, FileNotFoundError is raised. If the target file system does not support this operation, a NotImplementedError is raised.

If you want to create a directory and all its parent directories, use makedirs(...) instead.

makedirs(url, exist_ok=False)

Create the given directory and all its parent directories. Unless exist_ok is True, a FileExistsError is raised if the directory already exists. If the target file system does not support this operation, a NotImplementedError is raised.

move(src_url, dst_url)

Move the file or directory given by src_url to dst_url. It is important that dst_url must be the final destination URL, not just the parent directory. That is, you can't call move('file://C:/test.txt', 'file://C:/dir'). Instead, you should call move('file://C:/test.txt', 'file://C:/dir/test.txt'). The destination's parent directory (file://C:/dir) must exist. If neither the source nor the destination file system supports the operation, io.UnsupportedOperation or NotImplementedError is raised.

prepare_move(src_url, dst_url)

This function is analogous to prepare_copy(...). Please consult its documentation instead.

copy(src_url, dst_url)

Copy the file or directory given by src_url to dst_url. It is important that dst_url must be the final destination URL, not just the parent directory. That is, you can't call copy('file://C:/test.txt', 'file://C:/dir'). Instead, you should call copy('file://C:/test.txt', 'file://C:/dir/test.txt'). The destination's parent directory (file://C:/dir) must exist. If neither the source nor the destination file system supports the operation, io.UnsupportedOperation or NotImplementedError is raised.

prepare_copy(src_url, dst_url)

copy(...) immediately copies the given file or directory without a progress dialog. It completes when the copy has been performed. If you do want to show progress feedback as the file is being copied, use prepare_copy(...) instead. Like FileSystem.prepare_copy(...), it returns an iterable of Task objects. Here is how you would use it:

from fman import DirectoryPaneCommand, Task, submit_task
from fman.fs import prepare_copy
from fman.url import basename, join

class MyCopy(DirectoryPaneCommand):
	def __call__(self):
		src_url = self.pane.get_file_under_cursor()
		dst_url = join(self._get_opposite_pane().get_path(), basename(src_url))
		submit_task(_Copy(src_url, dst_url))
	def _get_opposite_pane(self):
		panes = self.pane.window.get_panes()
		this_pane = panes.index(self.pane)
		return panes[(this_pane + 1) % len(panes)]

class _Copy(Task):
	def __init__(self, src_url, dst_url):
		super().__init__('Copying ' + basename(src_url))
		self._src_url = src_url
		self._dst_url = dst_url
	def __call__(self):
		tasks = list(prepare_copy(self._src_url, self._dst_url))
		self.set_size(sum(task.get_size() for task in tasks))
		for task in tasks:
			self.run(task)

When you run the above command MyCopy via the Command Palette, fman will copy the file under the cursor to the opposite pane, showing a progress dialog as it proceeds:

(Note that the file has to be pretty large, or else the operation completes so quickly that the progress dialog is never shown.)

move_to_trash(url)

Move the file or directory with the given URL to the respective file system's trash. For example: move_to_trash('file://...') moves the file to your OS's trash but move_to_trash('dropbox://...') may move the file to Dropbox's recycle bin. If the operation is not supported, NotImplementedError is raised.

To permanently delete a file or directory, use delete(...) instead.

prepare_trash(url)

This function is the analogue of prepare_copy(...) for move_to_trash(...). Please consult its documentation instead.

delete(url)

Permanently delete the given file or directory. If the file system identified by url does not support this, NotImplementedError is raised.

prepare_delete(url)

This function is the analogue of prepare_copy(...) for delete(...). Please consult its documentation instead.

samefile(url1, url2)

Return True if both URLs refer to the same file or directory.

query(url, fs_method_name)

The Name, Size and Modified columns are well-known. But what if a file system wants to display another value? For example, the zip:// file system may want to display the packed size of a file in an archive.

query(...) lets you call arbitrary methods on FileSystem classes. In the example above, the zip:// file system could define a method packed_size(path):

class ZipFileSystem(FileSystem):
...
	def packed_size(self, path):
		return 1234 # The packed size of the given file
...

Then, to display this value, you could create a new Column that uses query(...) to obtain it:

class PackedSize(Column):
	def get_str(self, url):
		return str(query(url, 'packed_size'))

query(...) internally uses Cache.query(...). That is, query('myfs://directory/file.txt', 'myprop') is equivalent to:

compute_value = lambda: file_system.myprop('directory/file.txt')
file_system.cache.query('directory/file.txt', 'myprop', compute_value)

where file_system is the FileSystem for the myfs:// scheme.

This has several consequences: First, you can use query(...) to obtain values which your own FileSystem placed in the cache via Cache.put(...). Second, it means query(...) also puts items in the cache and may return cached values for consecutive calls.

resolve(url)

Return the "true" location of the given URL. If it does not exist, raise a FileNotFoundError

For example: The drives:// file system on windows shows your local disks. To achieve this, its iterdir(...) method returns 'C:', 'D:' etc. But this means that their URLs are drives://C:, drives://D: etc. When you open one of them, what we want is to navigate to file://C: instead. That's what resolve(...) is for: In the above example, it turns drives://C: into file://C:.

FileSystem

Extend this class to add support for new file systems. fman does this itself to implement support for local files (via the file:// FS), Zip files (via zip://) or the drives:// file system on Windows that shows your drives. You can find the source code of these implementations in the Core plugin. For a more introductory example, check out this blog post.

FileSystem.scheme

As mentioned in the introduction, every file in fman is identified by a URL. For example, a file on your local hard drive may have the URL file://C:/test.txt, whereas a member of a Zip archive may be identified as zip://C:/archive.zip/member.txt.

The scheme property lets fman determine which FileSystem is responsible for handling a given URL. You must set it to a string ending with ://. For instance:

class Example(FileSystem):

	scheme = 'example://'

	...

This tells fman that URLs starting with example:// are to be handled by your FileSystem.

Every file system's scheme must be unique. In other words, "overriding" other another file system by reusing its scheme is not supported.

FileSystem.iterdir(path)

Implement this method to tell fman which files (and directories) are in the folder with the given path. It should return an iterable of file names. If the folder with the given path does not exist, raise FileNotFoundError.

Here is a complete example implementation:

from fman.fs import FileSystem

class Example(FileSystem):

	scheme = 'example://'

	def iterdir(self, path):
		return ['File.txt', 'Image.jpg']

When you navigate to example://, you see:

Note how the two files from the code snippet are visible and the location is example://.

Instead of returning a list [...], a common idiom is to use Python's yield keyword:

def iterdir(self, path):
	yield 'File.txt'
	yield 'Image.jpg'

This has the following advantage: When you use return, fman only receives the file names once the entire iterdir(...) computation has completed. Say for example that you use a custom file system to implement file search. Then fman can display the search results only after the entire disk has been searched. If on the other hand you use yield, then the files are displayed one by one as they are found.

FileSystem.is_dir(path)

Implement this method to tell fman whether the given path is a file or a directory. If the given path does not exist, you must raise a FileNotFoundError. This is unlike Python's standard isdir(...) function, which returns False when the path does not exist. For an example of this method, see the introductory blog post.

FileSystem.get_default_columns(path)

Return the qualified names of the columns that should be displayed by default for the given path. The standard implementation returns the one-element tuple ('core.Name',). Override this method to display other columns. A typical implementation looks as follows:

class Example(FileSystem):
	...
	def get_default_columns(self, path):
		return 'core.Name', 'core.Size', 'core.Modified'
	...

Only the Name column works "out of the box". To get the Size or Modified columns to work for your file system, please see here. If you want to define an entirely new column (say a Permissions column), please see the documentation of the Column class.

The qualified names you need to return from this method are of the form 'package_name.class_name'. For example, if the code of your plugin lies in my_plugin/__init__.py and your column class is called MyColumn, then you need to return 'my_plugin.MyColumn'.

FileSystem.resolve(path)

Given the path of a file on this file system, return the canonical URL of the file, possibly on a different file system. If the given path does not exist, raise a FileNotFoundError.

Consider the following example: You're inside a Zip file at zip://C:/test.zip and go up a directory. fman removes the last part of the URL and navigates to zip://C:. This is not a Zip archive. What we want is to open file://C: instead. Overriding resolve(...) lets us do this: Before fman opens zip://C:, it calls ZipFileSystem.resolve('C:'). This returns file://C: and so fman goes through the usual procedure of displaying local files.

Another example where resolve(...) is useful is for the built-in drives:// file system, which displays your drives on Windows. When DrivesFileSystem.iterdir(...) returns ['C:', 'D:', ...], their URLs are drives://C: etc. But when the user opens them, we want to go to file://C: instead. DrivesFileSystem overrides resolve(...) to achieve this.

The default implementation of this method first calls .exists(path). If this returns False, a FileNotFoundError is raised. Otherwise, normalize(...) is used to normalize the given path.

FileSystem.exists(path)

Returns whether the given path exists. The default implementation relies on the fact that is_dir(...) raises FileNotFoundError if the given path does not exist to return True or False. You can override this method to use a more specialized implementation.

FileSystem.samefile(path1, path2)

Return True or False depending on whether the two paths refer to the same file. The default implementation simply checks if the two paths resolve(...) to the same URL. You can override this method if you want to use a more specialized implementation.

FileSystem.touch(path)

Implement this method to support touch(...) for your file system. This is for instance used by the command CreateAndEditFile (Shift+F4) from the Core plugin. If the file was successfully created, you should call notify_file_added(path). This updates fman's caches and makes the file appear immediately (instead of after the next reload).

FileSystem.mkdir(path)

Implement this method to support mkdir(...) and makedirs(...) for your file system. If the given path already exists, raise a FileExistsError. If the parent directory of the given path does not exist, raise a FileNotFoundError. If the directory was successfully created, you should call notify_file_added(path). This updates fman's caches and makes the directory appear immediately (instead of after the next reload).

This method is for instance used when you execute the CreateDirectory command (F7) from the Core plugin.

FileSystem.makedirs(path, exist_ok=True)

Standard implementation of makedirs(...) using FileSystem.mkdir(...). You can override this method if you want to use a more specialized implementation.

FileSystem.move_to_trash(path)

Implement this method to support move_to_trash(...) for your file system. To permanently delete files, implement FileSystem.delete(...) instead.

If the operation fails, raise OSError. You can set a description what went wrong via the second parameter to OSError(...). For example:

raise OSError(errno.EACCES, "File is in use")

This is then displayed to the user:

If the operation was successful, call notify_file_removed(path). This updates fman's caches and makes the file disappear immediately (instead of after the next reload).

Some of fman's commands show a progress dialog for file operations. If you want to give progress feedback as the file is being deleted, implement FileSystem.prepare_trash(...).

FileSystem.delete(path)

Implement this method to support delete(...) for your file system.

If the operation fails, raise OSError. You can set a description what went wrong via the second parameter to OSError(...). For example:

raise OSError(errno.EACCES, "File is in use")

This is then displayed to the user:

If the operation was successful, call notify_file_removed(path). This updates fman's caches and makes the file disappear immediately (instead of after the next reload).

Some of fman's commands show a progress dialog for file operations. If you want to give progress feedback as the file is being deleted, implement FileSystem.prepare_delete(...).

FileSystem.copy(src_url, dst_url)

Implement this method to support copy(...) for this file system. As for that function, it's important that dst_url is the final destination URL, not that of the parent directory. Note that unlike most other functions in this class, this method works with URLs instead of paths (that is, scheme://a/b instead of a/b). This lets you support copying across file systems. For example, a "copy" from zip://C:/archive.zip/member.txt to file://C:/Temp/member.txt extracts the file. At least one of src_url and dst_url is guaranteed to be from the current file system. If your file system does not support the given source or destination, raise io.UnsupportedOperation.

A typical implementation of this method looks as follows:

from fman.url import splitscheme

class ZipFileSystem(FileSystem):

	scheme = 'zip://'

	def copy(self, src_url, dst_url):
		src_scheme, src_path = splitscheme(src_url)
		dst_scheme, dst_path = splitscheme(dst_url)
		if src_scheme == 'zip://' and dst_scheme == 'file://':
			# extract the file...
		elif src_scheme == 'file://' and dst_scheme == 'zip://':
			# pack the file to the archive...
		elif src_scheme == dst_scheme:
			# copy from one archive to another...
		else:
			raise UnsupportedOperation()

	...

When you invoke copy(...), fman first forwards this request to the source file system. If this raises io.UnsupportedOperation, fman then tries the destination file system. For example: When you call copy(file:// -> zip://), fman invokes LocalFileSystem.copy(...), where LocalFileSystem handles the file:// scheme. This cannot copy to zip://, so raises UnsupportedOperation. fman then calls ZipFileSystem.copy(...). This succeeds and the file is extracted.

fman only expects your implementation of copy(...) to support the following cases:

  • src_url is a file. dst_url may exist and be a file. In that case, it is overwritten.
  • src_url is a directory. dst_url does not exist.

In particular, fman does not require you to handle the case where both the source and the destination are (existing) directories. In this case, the built-in Copy command for example "merges" the two folders by asking the user "do you want to overwrite X?" as it goes along.

Your implementation should call notify_file_added(...) for every file/directory it created and notify_file_changed(path) for every file it changed (/overwrote). This updates fman's caches and ensures that your changes are visible immediately (instead of only after the next reload).

Some of fman's commands show a progress dialog for file operations. If you want to give detailed progress feedback for your FileSystem's copy(...), you need to implement FileSystem.prepare_copy(...).

FileSystem.move(src_url, dst_url)

Implement this method to support move(...) for this file system. This is analogous to FileSystem.copy(...). Please refer to its documentation for further details. The only difference is that you should also call notify_file_removed(...) for any files or directories removed during the move.

FileSystem.prepare_delete(path),

FileSystem.prepare_trash(path),

FileSystem.prepare_copy(src_url, dst_url),

FileSystem.prepare_move(src_url, dst_url)

Consider what's required to display progress feedback when copying a directory:

To display meaningful percentages, we need to calculate the size of all files in the directory. Then, we need to update the progress as each file is being copied.

This is achieved via the prepare_* functions listed above. They return iterables of Task objects, which encode the steps required for executing the respective operation. In the copy example, prepare_copy(...) returns one Task object per file in the directory. Each task has its size set to the size of the file. Further, it contains the logic for copying the respective file, and for updating the progress indicator as it goes along.

The default implementations check if your FileSystem implements the operation to be prepared. (So eg. the default prepare_copy checks if you implemented copy.) If not, they raise NotImplementedError. Otherwise, they return a single Task with size=0 that calls the respective operation. In the case of copy:

def prepare_copy(self, src_url, dst_url):
	if ...: # Check if this FS doesn't implement copy(...)
		raise NotImplementedError()
	else:
		return [Task(
			'Copying ' + basename(src_url), size=0,
			target=self.copy, args=(src_url, dst_url)
		)]

Because the Task's size is 0, this gives a progress dialog without a percentage indicator:

The main reason why you would want to implement prepare_* is to give more detailed progress information than the above.

FileSystem.notify_file_added(path)

Notify fman that the file or directory with the given path now exists in this file system. This lets fman update its caches (and display the new file) immediately instead of having to wait for the next reload. You for instance call this method from your implementation of mkdir(...). To notify fman that a file was added to a different file system, use fman.fs.notify_file_added(...) instead.

FileSystem.notify_file_changed(path)

Notify fman that the file or directory with the given path in this file system was changed. In the case of a directory, this includes that its list of files changed. This method lets fman update its caches immediately instead of having to wait for the next reload. You may for instance call this from your implementation of touch(...) when the file's modification date was changed. To notify fman that a file from a different file system was changed, use fman.fs.notify_file_changed(...) instead.

FileSystem.notify_file_removed(path)

Notify fman that the file with the given path no longer exists in this file system. This lets fman update its caches (and remove the file) immediately instead of having to wait for the next reload. You for instance call this method from your implementation of delete(...). To notify fman that a file from a different file system was removed, use fman.fs.notify_file_removed(...).

FileSystem.cache

The Cache in which you can store file attributes for faster retrieval. For an introduction, please see the discussion below.

notify_file_added(url)

Notify fman that the file or directory with the given URL now exists. Unlike FileSystem.notify_file_added(...), this function takes a URL instead of a path as parameter. Otherwise, the semantics are the same.

notify_file_changed(url)

Notify fman that the file or directory with the given URL was changed. Unlike FileSystem.notify_file_changed(...), this function takes a URL instead of a path as parameter. Otherwise, the semantics are the same.

notify_file_removed(url)

Notify fman that the file or directory with the given URL was removed. Unlike FileSystem.notify_file_removed(...), this function takes a URL instead of a path as parameter. Otherwise, the semantics are the same.

Caching (continued)

Most file systems would be unusably slow without caching. Consider for example FTP: If fman had to make a round trip to the server to query the attributes of each file in a directory, then listing the folder's contents would take ages. The solution is to ask the server "send me all files and their attributes", then store and retrieve them later when needed.

fman does its best to cache the values returned by custom FileSystem implementations. But because the flows of information differ so much across file systems, you will most likely have to think a little about caching to make your FileSystem usable. Fortunately, the API makes this pretty easy.

The simplest case is when file attributes can only be queried individually. A good example of this is the local file system. To determine the size of C:\a.txt and C:\b.txt, your computer needs to first look at a.txt and then at b.txt. It can't do both at the same time.

Here's what an implementation could look like:

from fman.fs import FileSystem, cached
from os.path import getsize

class LocalFileSystem(FileSystem):

	scheme = 'file://'

	...

	@cached
	def size_bytes(self, path):
		return getsize(path)

	...

It uses @cached (described below) to cache the result of Python's getsize(...). This avoids excessive reads from local disk.

A more complex (but common) situation is that you receive file information alongside the list of directory contents. This would be the case in the FTP example above. Under these circumstances, you want to put the information into the cache as you receive it. Here is an example of what this could look like:

class FTP(FileSystem):

	...

	def iterdir(self, path):
		for file_info in server.list_contents(path):
			file_path = path + '/' file_info.name
			self.cache.put(file_path, 'is_dir', file_info.is_dir)
			yield file_info.name

	def is_dir(self, path):
		return self.cache.query(path, 'is_dir', server.is_dir)

	...

The server is assumed to be given in this example. It returns file_info objects, which have a name and an is_dir property. In other words, the server gives us several pieces of information at once when we list the contents of a directory. We store them in FileSystem.cache before yielding the respective file names to the caller. For the workings of .cache, please consult the documentation of the Cache class.

Subtleties of caching in fman

There are a few subtle points when it comes to caching in fman. First, fman will often empty "your" FileSystem's caches, for instance when a directory pane is reloaded. Don't use the cache to store information that you need across pane reloads. A server connection for example is something you most likely don't want to store in the cache.

The second issue pertains to iterdir(...): fman doesn't just cache the results of this function. It also updates them when you call modifying functions such as delete(...). Consider this example:

from fman.fs import FileSystem

class Example(FileSystem):

	scheme = 'example://'

	def iterdir(self, path):
		yield 'File.txt'
		yield 'Image.jpg'

	def delete(self, path):
		self.notify_file_removed(path)

As in previous examples, this gives you:

However, things get interesting when you delete one of the files, say File.txt:

File.txt has disappeared from the list of files. But why? iterdir(...) above hasn't changed and should still return it.

The reason is that delete(...) above calls notify_file_removed(...), then fman's caches are updated and the file is removed. Only when the pane is reloaded does File.txt appear again.

The learning from this is that iterdir(...) is special when it comes to caching. Unlike other functions such as is_dir(...), where you almost certainly want to perform some caching, you most likely won't see much benefit from caching the results of iterdir(...).

Cache

This class lets you cache file attributes for improved performance. You are not meant to use it directly. Instead, you access it through the .cache attribute of the FileSystem class.

Cache.put(path, attr, value)

Place a file attribute in the cache. For example: cache.put('C:/test.txt', 'is_dir', False). The attribute can later be retrieved via Cache.get(...) or Cache.query(...).

Cache.get(path, attr)

Retrieve a value from the cache. Eg: cache.get('C:/test.txt', 'is_dir'). If the value is not stored in the cache, a KeyError is raised.

Cache.query(path, attr, compute_value)

Retrieve a value from the cache. If it doesn't exist, execute compute_value(), place its result in the cache and then return it.

The important property of this method is that it is a canonical operation. Consider the following example: fman starts, and both the left and the right pane are at C:\. It would be unnecessary for both panes to load the contents of C:\. Instead, only the first pane loads the contents. The caching mechanism ensures that the results are shared with the second pane.

In technical terms, consecutive calls to Cache.query(...) with the same path and attr block until the initial call has completed.

Cache.clear(path)

Deletes the cached attributes for the given path and all paths below it.

cached

You can use this decorator to annotate any FileSystem method which takes a path as its single parameter. The two methods in the following code snippet are equivalent:

from fman.fs import FileSystem, cached
from os.path import getsize

class LocalFileSystem(FileSystem):

	@cached
	def size_bytes(self, path):
		return getsize(path)

	def size_bytes_2(self, path):
		compute_size = lambda: getsize(path)
		return self.cache.query(
			path, 'size_bytes_2', compute_size
		)

	...

Column

Extend this class to define a new column. (If you only want to enable one of the built-in columns Size, Modified or Name for your custom FileSystem, please see here.)

Say you have a pizza:// file system and want to define a new column Yumminess for it:

To do this, you can create a new subclass of Column as follows:

from fman.fs import Column
from fman.url import splitscheme

class Yumminess(Column):
	def get_str(self, url):
		if url == 'pizza://Margherita':
			return '2'
		if url == 'pizza://Quattro Stagioni':
			return '9'

At the moment, the only way to enable a column for a file system is to override FileSystem.get_default_columns(...). A consequence of this is that you can (currently) only define new columns for a FileSystem you created yourself. Here is the implementation of the pizza:// file system. Note how it returns 'pizza.Yumminess' from its get_default_columns(...):

from fman.fs import FileSystem

class PizzaFS(FileSystem):

	scheme = 'pizza://'

	def get_default_columns(self, path):
		return 'core.Name', 'pizza.Yumminess'
	def iterdir(self, path):
		return ['Margherita', 'Quattro Stagioni']

The pizza. prefix assumes that the code for the Yuminess column lies in <your plugin>/pizza/__init__.py.

Columns often have to obtain values from the underlying file system (or from its cache). This can be done with query(...).

Column.display_name

By default, columns are displayed in fman under their class name: In the example above, our Yumminess(Column) was displayed in the screenshot as "Yumminess". If we wanted it to appear as "Taste" instead, we could set its display_name as follows:

class Yumminess(Column):

	display_name = 'Taste'

	...

Column.get_str(url)

Return the text to be displayed in the column for the file with the given URL.

Column.get_sort_value(url, is_ascending)

Return the value that should be used to compare files when sorting by this column. You must always return a value, and it must always be of the same type. That is, you can't return 'foo' (a string) in one case and 3 (an integer) in another.

The is_ascending parameter indicates the order in which the column is being sorted. Its purpose is best illustrated by an example.

Say you want to define your own Name column, Name2. A first version may look as follows:

from fman.fs import Column

class Name2(Column):
	def get_str(self, url):
		# Return everything after the last slash:
		return url[url.rindex('/')+1 : ]
	def get_sort_value(self, url, is_ascending):
		# Sort by the displayed name, case insensitively:
		return self.get_str(url).lower()

This works well, except that files are interspersed with directories:

To fix this, you decide to take is_dir(...) into account as well:

from fman.fs import Column, is_dir

class Name2(Column):

	... as before

	def get_sort_value(self, url, is_ascending):
		return not is_dir(url), self.get_str(url).lower()

This looks good at first:

However, when you reverse the sort order, the directories appear at the bottom:

The solution to this is to take is_ascending into account as well:

def get_sort_value(self, url, is_ascending):
	return is_ascending ^ is_dir(url), self.get_str(url).lower()

This finally produces the expected result. Note how the files are sorted in descending order, but the folders still appear at the top:

(In case you want to look it up, ^ is Python's exclusive or operator.)

Built-in columns

The Core plugin comes with several built-in columns. When you implement a new FileSystem, only the Name column is shown by default. This section explains the steps required for also enabling the Size and Modified columns. It does not cover implementing entirely new columns. If this is your goal, please see the documentation of the Column class instead.

Here is an example of a file system that implements all of the built-in columns:

from datetime import datetime
from fman.fs import FileSystem

class Example(FileSystem):

	scheme = 'example://'

	def iterdir(self, path):
		return ['File.txt', 'Image.jpg']
	def get_default_columns(self, path):
		return 'core.Name', 'core.Size', 'core.Modified'
	def size_bytes(self, path):
		if path == 'File.txt':
			return 4 * 1024
		elif path == 'Image.jpg':
			return 1300 * 1024
	def modified_datetime(self, path):
		if path == 'File.txt':
			return datetime(2018, 2, 1, 12, 7)
		elif path == 'Image.jpg':
			return datetime(2017, 4, 15, 9, 36)

When you navigate to this file system, you get:

You see in the code that the file system overrides get_default_columns(...). This tells fman which columns should be shown.

The size_bytes(...) method computes the value for the Size column. In the example, we for instance see that the "size" of File.txt is 4 * 1024, which is displayed in the screenshot as 4 KB.

Finally, the modified_datetime(...) method gives the value for the Modified column. It returns a datetime object. In our example, this is datetime(2017, 4, 15, 9, 36) which is displayed as 15.04.2017. (The actual display on your system depends on your locale. If you are in the US, the month will likely be displayed first.)

In short, the steps required for supporting a built-in column in your file system are:

  1. Return the column's qualified name from get_default_columns(...).
  2. Implement size_bytes(...) and / or modified_datetime(...).

core.Size

This is the standard Size column. It displays the size of the given file (eg. 388 MB). To support it, you need to implement the size_bytes(...) method in your FileSystem subclass:

class MyFileSystem(FileSystem):
	...
	def size_bytes(self, path):
		# 388 MB:
		return 388 * 1024 * 1024
	...

If the given path does not have a size (eg. when it's a directory), your size_bytes(...) can simply not return a value (or equivalently return None). If the given path does not exist, you should raise a FileNotFoundError. Here is an example:

def size_bytes(self, path):
	if path == 'File.txt':
		return 1234
	elif path == 'Directory':
		return None
	else:
		raise FileNotFoundError(path)

As a final note, you need to return 'core.Size' from get_default_columns(...) to actually enable the Size column.

core.Modified

This is the standard Modified column. It displays the modified time of the given file (eg. 07.01.18 12:41). To support it, you need to implement the modified_datetime(...) method in your FileSystem subclass:

from datetime import datetime

class MyFileSystem(FileSystem):
	...
	def modified_datetime(self, path):
		# January 7, 2018 12:41
		return datetime(2018, 1, 7, 12, 41)
	...

If the given path does not have a modified date, your modified_datetime(...) can simply not return a value (or equivalently return None). If the given path does not exist, you should raise a FileNotFoundError. Here is an example:

def modified_datetime(self, path):
	if path == 'File.txt':
		return datetime(2018, 2, 1, 12, 49)
	elif path == 'Directory':
		return None
	else:
		raise FileNotFoundError(path)

As a final note, you need to return 'core.Modified' from get_default_columns(...) to actually enable the Modified column.

core.Name

This is the standard Name column. By default, it displays the base name of the given file (eg. test.txt for file://C:/test.txt). If you want it to display a different value, you can override the name(...) method in your FileSystem subclass:

class MyFileSystem(FileSystem):
	...
	def name(self, path):
		# Return the name you want displayed:
		return 'Some file.txt'
	...

As an implementation detail that might be of educational interest, all three column classes above use query(...) to call your FileSystem.

Module fman.clipboard

This module contains functions that let you interact with your Operating System's clipboard. A modern OS's clipboard doesn't just contain a single value. Instead, it can contain values for multiple formats. For example, when you copy an image to the clipboard, then the "image" format may contain the image's pixels, the "plain text" format may contain the image's file name and the "path" format may contain the image file's path. Depending on where you paste, the appropriate format is used.

This module lets you manipulate the "plain text" and the "path/URL" formats of the clipboard.

set_text(text)

Copies the given text into the clipboard.

get_text()

Returns the plain text in the clipboard.

copy_files(file_urls)

Copies the given files to the clipboard, so you can paste them into fman or your OS's native file manager.

cut_files(file_urls)

Cuts the given files to the clipboard, so that when you paste them into fman or your OS's native file manager, the files are moved to the new destination. On macOS, this function raises NotImplementedError. The reason for this is that it is not technically possible on macOS to place files on the clipboard in such a way that apps like Finder understand that the files are meant to be moved and not copied. Instead the choice of whether to copy or move is made when you paste, by pressing either Cmd+V or Cmd+Alt+V.

get_files()

Returns a list of URLs of the files currently on the clipboard. To determine whether the files were cut or copied, use files_were_cut().

files_were_cut()

On Windows and Linux, returns whether the files on the clipboard are meant to be moved or copied when pasted. On Mac, cutting files is not supported (see cut_files(...)), so False is always returned.

clear()

Clears all contents of the clipboard.