diff --git a/file_archive/main.py b/file_archive/main.py index 89bc3a0..0fcfbfe 100644 --- a/file_archive/main.py +++ b/file_archive/main.py @@ -275,12 +275,21 @@ def cmd_verify(store, args): store.verify() +def cmd_view(store, args): + if args: + sys.stderr.write("view command accepts no argument\n") + sys.exit(1) + from .viewer import run_viewer + run_viewer(store) + + commands = { 'add': cmd_add, 'query': cmd_query, 'print': cmd_print, 'remove': cmd_remove, 'verify': cmd_verify, + 'view': cmd_view, } @@ -295,7 +304,8 @@ def main(args): " or: {bin} print [-m] [-t] [key1=value1] [...]\n" " or: {bin} remove [-f] \n" " or: {bin} remove [-f] [...]\n" - " or: {bin} verify\n".format( + " or: {bin} verify\n" + " or: {bin} view\n".format( bin='file_archive')) if len(args) < 2: diff --git a/file_archive/parser.py b/file_archive/parser.py new file mode 100644 index 0000000..c6eccbf --- /dev/null +++ b/file_archive/parser.py @@ -0,0 +1,99 @@ +from tdparser import Lexer, Parser, Token, ParserError +from tdparser.topdown import EndToken + +lexer = Lexer() + + +class Key(Token): + regexp = r'[A-Za-z_][A-Za-z0-9_]*' + + def nud(self, context): + return self + + +class Number(Token): + regexp = r'-?\d+' + type = 'int' + + def __init__(self, text): + Token.__init__(self, text) + self.value = int(text) + + def nud(self, context): + return self + + +class String(Token): + regexp = r'"(?:[^\\"]|\\\\|\\")*"' + type = 'str' + + def __init__(self, text): + Token.__init__(self, text) + self.value = text[1:-1].replace('\\"', '"').replace('\\\\', '\\') + + def nud(self, context): + return self + + +class Operator(Token): + regexp = r'[=<>]' + lbp = 10 + + def led(self, left, context): + right = context.expression(self.lbp) + inverted = False + if not isinstance(left, Key) and isinstance(right, Key): + left, right = right, left + inverted = True + elif not isinstance(left, Key): + raise ParserError("Condition does not involve a key") + elif isinstance(right, Key): + raise ParserError("Condition involves two keys") + if self.text == '=': + cond = 'equal' + elif self.text == '<': + cond = 'gt' if inverted else 'lt' + elif self.text == '>': # pragma: no branch + cond = 'lt' if inverted else 'gt' + return left.text, {'type': right.type, cond: right.value} + + +lexer.register_tokens(Key, Number, String, Operator) + + +def _update_conditions(conditions, expr): + key, cond = expr + prec = conditions.get(key) + if prec is not None: + if prec['type'] != cond['type']: + raise ParserError("Differing types for conditions on key %s: " + "%s, %s" % (key, prec['type'], cond['type'])) + for k in cond.keys(): + if k != 'type' and k in prec: + raise ParserError("Multiple conditions %s on key %s" % ( + k, key)) + prec.update(cond) + else: + conditions[key] = cond + + +def parse_expression(expression): + tokens = lexer.lex(expression) + parser = Parser(tokens) + conditions = {} + while not isinstance(parser.current_token, EndToken): + expr = parser.expression() + if isinstance(expr, Token): + raise ParserError("Found unexpected token %s in query" % expr) + _update_conditions(conditions, expr) + return conditions + + +def parse_expressions(expression_list): + conditions = {} + for expression in expression_list: + expr = lexer.parse(expression) + if isinstance(expr, Token): + raise ParserError("Found unexpected token %s in query" % expr) + _update_conditions(conditions, expr) + return conditions diff --git a/file_archive/viewer.py b/file_archive/viewer.py new file mode 100644 index 0000000..2b230fe --- /dev/null +++ b/file_archive/viewer.py @@ -0,0 +1,222 @@ +import platform +import subprocess +import sys + +try: + import sip + + api2_classes = [ + 'QData', 'QDateTime', 'QString', 'QTextStream', + 'QTime', 'QUrl', 'QVariant', + ] + for cl in api2_classes: + sip.setapi(cl, 2) + + from PyQt4 import QtCore, QtGui +except ImportError: + sys.stderr.write("PyQt4 is required by 'file_archive view'\n") + sys.exit(3) + + +try: + import tdparser +except ImportError: + sys.stderr.write("tdparser is required by 'file_archive view'\n") + sys.exit(3) + + +from .compat import int_types +from .parser import parse_expression + + +system = platform.system().lower() +if system == 'windows': + def openfile(filename): + subprocess.call(['cmd', '/c', 'start', filename]) +elif system == 'darwin': + def openfile(filename): + subprocess.call(['open', filename]) +elif system.startswith('linux'): + def openfile(filename): + subprocess.call(['xdg-open', filename]) +else: + openfile = None + + +def _(s, disambiguation=None, **kwargs): + if kwargs: + s = s.format(**kwargs) + return QtCore.QCoreApplication.translate( + 'file_archive.viewer', + s, + disambiguation, + QtCore.QCoreApplication.UnicodeUTF8) + + +class SearchError(Exception): + """Error while querying the file store. + """ + + +class FileItem(QtGui.QTreeWidgetItem): + def __init__(self, filehash): + QtGui.QTreeWidgetItem.__init__(self, [filehash]) + self.hash = filehash + + +class MetadataItem(FileItem): + def __init__(self, filehash, key, value): + if isinstance(value, int_types): + t = 'int' + value = '%d' % value + else: # isinstance(v, string_types): + t = 'str' + QtGui.QTreeWidgetItem.__init__(self, [key, value, t]) + self.hash = filehash + + +class StoreViewerWindow(QtGui.QMainWindow): + def __init__(self, store): + QtGui.QMainWindow.__init__(self) + + self.store = store + + searchbar = QtGui.QHBoxLayout() + + # Input line for the query + self._input = QtGui.QLineEdit() + self._input.setPlaceholderText(_(u"Enter query here")) + self._input.returnPressed.connect(self._search) + searchbar.addWidget(self._input) + + # Search button + searchbutton = QtGui.QPushButton(_(u"Search")) + searchbutton.clicked.connect(self._search) + searchbar.addWidget(searchbutton) + + results = QtGui.QHBoxLayout() + + # Result view, as a tree with metadata + self._result_tree = QtGui.QTreeWidget() + self._result_tree.setColumnCount(3) + self._result_tree.setHeaderLabels([_(u"Key"), _(u"Value"), _(u"Type")]) + self._result_tree.itemSelectionChanged.connect(self._selection_changed) + results.addWidget(self._result_tree) + + # Buttons, enabled/disabled when the selection changes + buttons = QtGui.QVBoxLayout() + self._buttons = [] + + # Open button; uses the system to choose the program to open with + # (on Windows, might ask you what to use every time because of filename + # scheme) + open_button = QtGui.QPushButton(_(u"Open")) + if openfile is not None: + open_button.clicked.connect(self._openfile) + self._buttons.append(('single', open_button)) + else: + open_button.setEnabled(False) + buttons.addWidget(open_button) + + # Delete button, removes what's selected (with confirmation) + remove_button = QtGui.QPushButton(_(u"Delete")) + remove_button.clicked.connect(self._delete) + self._buttons.append(('multi', remove_button)) + buttons.addWidget(remove_button) + + self._selection_changed() + results.addLayout(buttons) + + layout = QtGui.QVBoxLayout() + layout.addLayout(searchbar) + layout.addLayout(results) + + widget = QtGui.QWidget() + widget.setLayout(layout) + self.setCentralWidget(widget) + + def _search(self): + error = None + + query = self._input.text() + + if len(query.split()) == 1 and all(o not in query.strip() + for o in '=<>'): + h = query.strip() + try: + entries = [self.store.get(h)] + except KeyError: + error = _(u"{h} not found", h=h) + + else: + try: + conditions = parse_expression(query) + except ValueError as e: + error = e.args[0] + else: + entries = self.store.query(conditions) + + self._result_tree.clear() + + if error is not None: + w = QtGui.QTreeWidgetItem([error]) + w.setForeground(0, QtGui.QColor(255, 0, 0)) + self._result_tree.addTopLevelItem(w) + else: + for entry in entries: + file_item = FileItem(entry['hash']) + self._result_tree.addTopLevelItem(file_item) + self._result_tree.setFirstItemColumnSpanned(file_item, True) + for k, v in entry.metadata.items(): + if k == 'hash': + continue + file_item.addChild(MetadataItem(entry['hash'], k, v)) + self._result_tree.expandAll() + + def _selection_changed(self): + items = self._result_tree.selectedItems() + for t, button in self._buttons: + if t == 'single': + button.setEnabled( + len(items) == 1 and + isinstance(items[0], FileItem)) + else: + button.setEnabled(bool(items) and + all(isinstance(i, FileItem) for i in items)) + + def _openfile(self): + item = self._result_tree.currentItem() + if item is not None: + openfile(self.store.get_filename(item.hash)) + + def _delete(self): + items = self._result_tree.selectedItems() + if not items: + return + confirm = QtGui.QMessageBox.question( + self, + _(u"Are you sure?"), + _(u"You are about to delete {num} entries from the store. " + "Please confirm.", num=len(items)), + QtGui.QMessageBox.Ok | QtGui.QMessageBox.Cancel, + QtGui.QMessageBox.Cancel) + if confirm == QtGui.QMessageBox.Ok: + hashes = set([i.hash for i in items]) + i = 0 + while i < self._result_tree.topLevelItemCount(): + h = self._result_tree.topLevelItem(i).hash + if h in hashes: + self.store.remove(h) + self._result_tree.takeTopLevelItem(i) + else: + i += 1 + + +def run_viewer(store): + application = QtGui.QApplication([]) + + window = StoreViewerWindow(store) + window.show() + + application.exec_() + sys.exit(0) diff --git a/setup.py b/setup.py index 77c8fdb..d64db8b 100644 --- a/setup.py +++ b/setup.py @@ -36,4 +36,5 @@ 'Operating System :: OS Independent', 'Intended Audience :: Developers', 'Intended Audience :: Science/Research', - 'Topic :: System :: Archiving']) + 'Topic :: System :: Archiving'], + requires=['tdparser (>=1.1.4)']) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..d923b88 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,69 @@ +import tdparser +try: + import unittest2 as unittest +except ImportError: + import unittest + +from file_archive.parser import parse_expression, parse_expressions + + +class TestParser(unittest.TestCase): + def test_single(self): + self.assertEqual(parse_expression('key1=-12'), + {'key1': {'type': 'int', 'equal': -12}}) + self.assertEqual(parse_expression('key2 = "41"'), + {'key2': {'type': 'str', 'equal': '41'}}) + self.assertEqual(parse_expression('41 < key3'), + {'key3': {'type': 'int', 'gt': 41}}) + with self.assertRaises(tdparser.MissingTokensError): + parse_expression('key4=') + self.assertEqual(parse_expression('key5 >0'), + {'key5': {'type': 'int', 'gt': 0}}) + with self.assertRaises(tdparser.LexerError): + parse_expression('key6!"somethg') + with self.assertRaises(tdparser.ParserError): + parse_expression('"foo" = "bar"') + with self.assertRaises(tdparser.ParserError): + parse_expression('key7 = key8') + with self.assertRaises(tdparser.ParserError): + parse_expression('"value"') + + def test_strings(self): + self.assertEqual(parse_expression(r'key1="some string"'), + {'key1': {'type': 'str', 'equal': "some string"}}) + self.assertEqual(parse_expression(r'key1="some \"string\""'), + {'key1': {'type': 'str', 'equal': 'some "string"'}}) + with self.assertRaises(tdparser.LexerError): + parse_expression(r'key1="some \"string') + with self.assertRaises(tdparser.LexerError): + parse_expression(r'key1="some \"string\"') + with self.assertRaises(tdparser.LexerError): + self.assertEqual(parse_expression(r'key1="'), None) + + def test_multi(self): + self.assertEqual(parse_expression('key1=-12 key2 = "4 2" 41< key3'), + {'key1': {'type': 'int', 'equal': -12}, + 'key2': {'type': 'str', 'equal': '4 2'}, + 'key3': {'type': 'int', 'gt': 41}}) + self.assertEqual(parse_expression('key1>2 key1<4 '), + {'key1': {'type': 'int', 'gt': 2, 'lt': 4}}) + with self.assertRaises(tdparser.ParserError): + parse_expression('key1="str" key1<2') + with self.assertRaises(tdparser.ParserError): + parse_expression('key1="str" key1="otherstr"') + + def test_multi_splitted(self): + self.assertEqual(parse_expressions(['key1=-12', 'key2 = "4 2"', '41< key3']), + {'key1': {'type': 'int', 'equal': -12}, + 'key2': {'type': 'str', 'equal': '4 2'}, + 'key3': {'type': 'int', 'gt': 41}}) + self.assertEqual(parse_expressions(['key1>2 ', ' key1<4 ']), + {'key1': {'type': 'int', 'gt': 2, 'lt': 4}}) + with self.assertRaises(tdparser.ParserError): + parse_expressions(['key1="str"', 'key1<2']) + with self.assertRaises(tdparser.ParserError): + parse_expressions(['key1="str"', 'key1="otherstr"']) + with self.assertRaises(tdparser.ParserError): + parse_expressions(['key1="str" key1<2']) + with self.assertRaises(tdparser.ParserError): + parse_expressions(['"value"']) diff --git a/tox.ini b/tox.ini index 7264b5b..34cc405 100644 --- a/tox.ini +++ b/tox.ini @@ -2,9 +2,12 @@ envlist=py26,py27,py33 [testenv] commands=nosetests tests -deps=nose +deps= + nose + tdparser [testenv:py26] deps= nose unittest2 + tdparser