diff options
Diffstat (limited to 'pnote')
| -rw-r--r-- | pnote/__init__.py | 3 | ||||
| -rw-r--r-- | pnote/__main__.py | 44 | ||||
| -rw-r--r-- | pnote/layout.py | 68 | ||||
| -rw-r--r-- | pnote/metadata.py | 188 | ||||
| -rw-r--r-- | pnote/project.py | 322 | ||||
| -rw-r--r-- | pnote/tools/__init__.py | 3 | ||||
| -rw-r--r-- | pnote/tools/admin.py | 48 | ||||
| -rw-r--r-- | pnote/tools/search.py | 79 | ||||
| -rw-r--r-- | pnote/tools/tag.py | 33 | ||||
| -rw-r--r-- | pnote/tools/tool.py | 8 |
10 files changed, 796 insertions, 0 deletions
diff --git a/pnote/__init__.py b/pnote/__init__.py new file mode 100644 index 0000000..feb7ceb --- /dev/null +++ b/pnote/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.0.25" + +from pnote.__main__ import main diff --git a/pnote/__main__.py b/pnote/__main__.py new file mode 100644 index 0000000..e539c36 --- /dev/null +++ b/pnote/__main__.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +import os, argparse +from pnote.project import * +from pnote.tools import * +from pnote import __version__ + +def main(): + ## Parse arguments + parser = argparse.ArgumentParser( + prog='PNote', + description='Note management tool', + epilog='pnote v'+__version__) + parser.add_argument('path', help="Path to a pnote project") + parser.add_argument('-t', '--today', help="Open today's note file", action="store_true") + parser.add_argument('-o', '--open', help="Open specific note file") + subparsers = parser.add_subparsers(dest="tool", help='Tool to use') + + # Tools + searcht=ToolSearch() + searcht.add_parser(subparsers) + tagt=ToolTag() + tagt.add_parser(subparsers) + admint=ToolAdmin() + admint.add_parser(subparsers) + + # Parse arguments + args = parser.parse_args() + + ## Load project + project=Project(args.path) + + ## Run tool + if args.tool == "search": + searcht.run(project,args) + elif args.tool == "tag": + tagt.run(project,args) + elif args.tool == "admin": + admint.run(project,args) + else: + if args.today: + project.opentoday() + elif args.open: + project.open(args.open) diff --git a/pnote/layout.py b/pnote/layout.py new file mode 100644 index 0000000..8d3fe1d --- /dev/null +++ b/pnote/layout.py @@ -0,0 +1,68 @@ +from pathlib import Path +from datetime import datetime +import os + +class Layout: + + def __init__(self, conf, paths): + self.conf=conf + self.paths=paths + self.today=datetime.today() + self.today_backup=self.today + + def settoday(self,timestamp): + self.today=datetime.fromtimestamp(timestamp) + + def restoretoday(self): + self.today=self.today_backup + + def gettoday(self): + return self.today + + def flatten(self): + """ + List all subpath present on disk. + """ + paths=list(Path(self.paths["files"]).rglob("*")) + result=list() + for p in paths: + if os.path.isfile(p): + result.append(p.relative_to(self.paths["files"])) + return result + + def create(self): + """ + Create today's note file. + """ + file=self.todaypath() + if not os.path.exists(file): + open(file, 'a').close() + return self.todaysubpath() + + def todayname(self): + """ + Get today's note file name. + """ + return self.today.strftime(self.conf["filename"]) + + def todaysubdir(self): + """ + Must be overriden by child classes + """ + subdir=self.today.strftime(self.conf["layout"]) + if not os.path.exists(subdir): + Path(os.path.join(self.paths["files"],subdir)).mkdir(parents=True, exist_ok=True) + return subdir + + def todaysubpath(self): + """ + Get the subpath of today's note file. + """ + subdir=self.todaysubdir() + return os.path.join(self.todaysubdir(), self.todayname()) + + def todaypath(self): + """ + Get the path of today's note file. + """ + return os.path.join(self.paths["files"],self.todaysubpath()) diff --git a/pnote/metadata.py b/pnote/metadata.py new file mode 100644 index 0000000..f84ebb0 --- /dev/null +++ b/pnote/metadata.py @@ -0,0 +1,188 @@ +import os, json, platform, socket, sqlite3 +from datetime import datetime +from pathlib import Path + +class Metadata: + + def __init__(self, paths): + self.paths=paths + self.today=datetime.today() + + ## Create folders + self.paths["metadata"]=os.path.join(self.paths["root"], "metadata.db") + + ## Init database + self.con=sqlite3.connect(self.paths["metadata"]) + cur=self.con.cursor() + tables=cur.execute("""SELECT name FROM sqlite_master WHERE type='table' AND name='files'; """).fetchall() + + if len(tables) == 0: + cur.execute("CREATE TABLE files(id INTEGER PRIMARY KEY AUTOINCREMENT, subpath TEXT UNIQUE, created REAL, added REAL, hostname TEXT, platform TEXT);") + self.con.commit() + cur.execute("CREATE TABLE tags(id INTEGER, name TEXT, FOREIGN KEY(id) REFERENCES files(id));") + self.con.commit() + cur.execute("CREATE TABLE cache(name TEXT PRIMARY KEY, value TEXT);") + self.con.commit() + + + def create(self, subpath, created): + """ + Create a new note file entry. + """ + cur=self.con.cursor() + cur.execute("""INSERT INTO files(subpath,created,added,hostname,platform) values('{}','{}','{}','{}','{}')""".format( + subpath, + created.timestamp(), + datetime.today().timestamp(), + socket.gethostname(), + platform.platform() + )) + self.con.commit() + + def getfileinfo(self, subpath, name): + """ + Get associated info (name argument e.g hostname) with a subpath. + """ + subpath_id=self.subpathid(subpath, True) + cur=self.con.cursor() + cur.execute('SELECT {} FROM files WHERE id="{}"'.format(name,subpath_id)) + return list(cur.fetchone())[0] + + def setcache(self, name, value): + """ + Set the value of a cache entry. + """ + cur=self.con.cursor() + cur.execute('SELECT value FROM cache WHERE name="{}"'.format(name)) + if cur.fetchone() is None: + cur.execute('INSERT INTO cache values("{}","{}")'.format(name,value)) + else: + cur.execute('UPDATE cache SET value="{}" WHERE name="{}"'.format(value,name)) + self.con.commit() + + def getcache(self,name): + """ + Get the value of a cache entry. + """ + cur=self.con.cursor() + cur.execute('SELECT value FROM cache WHERE name="{}"'.format(name)) + result=cur.fetchone() + if result is None: + return None + return result[0] + + def subpathid(self, subpath, required=False): + """ + Get the id (sqlite id) of a subpath. If required=True then abort if subpath not found. + """ + cur=self.con.cursor() + cur.execute('SELECT id FROM files WHERE subpath="{}"'.format(subpath)) + result=cur.fetchone() + if result is not None: + return list(result)[0] + if required: + print("Subpath not found: "+subpath) + exit(1) + return None + + def delete(self,subpath): + """ + Delete subpath and its associated tags from the metadata. + """ + cur=self.con.cursor() + subpath_id=self.subpathid(subpath, True) + cur.execute('DELETE FROM tags WHERE id={}'.format(subpath_id)) + cur.execute('DELETE FROM files WHERE id={}'.format(subpath_id)) + self.con.commit() + + def addtag(self, subpath, tag): + """ + Attach a tag to a specific subpath. + """ + taglist=self.listtags(subpath) + if tag not in taglist: + cur=self.con.cursor() + subpath_id=self.subpathid(subpath, True) + cur.execute('INSERT INTO tags(id, name) VALUES({},"{}")'.format(subpath_id,tag)) + self.con.commit() + else: + print("{} as already be tagged with {}".format(subpath,tag)) + + def deletetag(self, subpath, tag): + """ + Delete a tag attached to a specific subpath. + """ + cur=self.con.cursor() + subpath_id=self.subpathid(subpath, True) + cur.execute('DELETE FROM tags WHERE id={} AND name="{}"'.format(subpath_id,tag)) + self.con.commit() + + def obliteratetag(self, tag): + """ + Remove all occurences of a tag from the database. + """ + cur=self.con.cursor() + cur.execute('DELETE FROM tags WHERE name="{}"'.format(tag)) + self.con.commit() + + def searchtag(self,tag): + """ + Get all subpaths associated with a specific tag. + """ + cur=self.con.cursor() + ids=[i[0] for i in cur.execute('SELECT id FROM tags WHERE name="{}"'.format(tag)) ] + subpaths=[cur.execute('SELECT subpath FROM files WHERE id={}'.format(i)).fetchone()[0] for i in ids] + return subpaths + + def listtags(self, subpath=None): + """ + List either all tags (subpath is None), or the ones associated with a subpath. + """ + cur=self.con.cursor() + if subpath is not None: + subpath_id=self.subpathid(subpath, True) + tags=[i[0] for i in cur.execute('SELECT DISTINCT name FROM tags WHERE id={}'.format(subpath_id)) ] + else: + tags=[i[0] for i in cur.execute('SELECT DISTINCT name FROM tags') ] + return tags + + def fix_deleted(self, dry=True): + """ + Search for files deleted by the user and update database accordingly. + """ + cur=self.con.cursor() + for result in cur.execute("SELECT subpath FROM files"): + subpath=result[0] + path=os.path.join(self.paths["files"], subpath) + if not os.path.exists(path): + if dry: + print("Deletion detected => " + subpath) + else: + print("Fixing file deletion => " + subpath) + self.delete(subpath) + + def fix_new(self, layout, dry=True): + """ + Search for new files added by the user and update the database accordingly. + """ + cur=self.con.cursor() + for subpath in layout.flatten(): + result=cur.execute('SELECT * from files where subpath="{}"'.format(subpath)) + if len(result.fetchall()) <= 0 : + if dry: + print("New file detected => "+str(subpath)) + else: + print("Fixing new file => "+str(subpath)) + self.create(subpath,layout.gettoday()) + + def flatten_ordered(self, desc=False, ordby="created"): + """ + List all subpaths present in the database. Results are sorted (DESC and ASC) by creation date. + """ + cur=self.con.cursor() + if desc: + result=cur.execute("SELECT subpath FROM files ORDER BY {} DESC".format(ordby)) + else: + result=cur.execute("SELECT subpath FROM files ORDER BY {} ASC".format(ordby)) + result=[subpath[0] for subpath in result.fetchall()] + return result diff --git a/pnote/project.py b/pnote/project.py new file mode 100644 index 0000000..432b688 --- /dev/null +++ b/pnote/project.py @@ -0,0 +1,322 @@ +import os, json, socket, re, subprocess, shutil +from datetime import datetime +from jsonschema import validate +from pathlib import Path +from pnote.layout import * +from pnote.metadata import * + +class ProjectConfig: + FILE="config.json" + DEFAULT_CONFIG = { + "layout": "%Y/%m", + "filename": "%Y-%m-%d.md", + "editor": ["vim"], + "template": "" + } + SCHEMA_CONFIG = { + "type": "object", + "properties": { + "layout": {"type": "string"}, + "filename": {"type": "string"}, + "editor": {"type": "array"}, + "template": {"type": "string"} + }, + "required":[ + "layout", + "filename", + "editor", + "template" + ] + } + + def __init__(self, root): + self.pfile=os.path.join(root,self.FILE) + if "EDITOR" in os.environ: + self.DEFAULT_CONFIG["editor"]=[os.environ["EDITOR"]] + self.config=self.DEFAULT_CONFIG + self.load() + + def load(self): + if os.path.exists(self.pfile): + with open(self.pfile) as f: + self.config=json.load(f) + try: + validate(instance=self.config, schema=self.SCHEMA_CONFIG) + except: + print("Invalid configuration file") + exit(1) + else: + self.save() + + def save(self): + with open(self.pfile, "w") as f: + f.write(json.dumps(self.config,indent=4, sort_keys=True)) + + def __getitem__(self, key): + return self.config[key] + + def __setitem__(self, key, value): + self.config[key]=value + + +class Project: + + def __init__(self, path): + self.paths={ + "root": path, + "files": os.path.join(path,"files"), + "lockfile": os.path.join(path,"lockfile"), + } + + if not os.path.exists(self.paths["root"]): + print("Creating project...") + Path(self.paths["root"]).mkdir(parents=True, exist_ok=True) + Path(self.paths["files"]).mkdir(parents=True, exist_ok=True) + + self.conf=ProjectConfig(self.paths["root"]) + self.metadata=Metadata(self.paths) + self.layout=Layout(self.conf,self.paths) + + if os.path.exists(self.paths["lockfile"]): + print("Your project contains a lock file! Your project might be corrupted :(") + exit(1) + + def lock(self): + open(self.paths["lockfile"], 'a').close() + + def unlock(self): + os.remove(self.paths["lockfile"]) + + def create(self,subpath=None): + """ + Create a today's note file (subpath=None) or create the metadata associated with the subpath passed in argument. + """ + self.lock() + if subpath is None: + subpath=self.layout.create() + try: + self.metadata.create(subpath, self.layout.gettoday()) + except sqlite3.IntegrityError: + print("The file you are trying to edit was deleted!") + answer=input("Do you want to use its old metadata [Y/n]? ") + if answer.lower() not in ["yes", "y", ""]: + self.metadata.delete(self.layout.todaysubpath()) + self.metadata.create(subpath, self.layout.gettoday()) + self.unlock() + + def find(self, string, ignore_case=False): + """ + Find all subpath that contains a specific string. + """ + files=list() + for file in self.layout.flatten(): + if string is None: + files.append(str(file)) + elif ignore_case: + if string.lower() in file.name.lower(): + files.append(str(file)) + elif string in file.name: + files.append(str(file)) + return files + + def listlastcreated(self): + return self.metadata.flatten_ordered() + + def listlastadded(self): + return self.metadata.flatten_ordered(ordby="added") + + def getfileinfo(self,subpath, name): + """ + Get a specific info (name argument) associated with a subpath. + """ + return self.metadata.getfileinfo(subpath,name) + + def grep(self, exp, ignore_case=False): + """ + Search for a specific regex on every note file. + """ + if ignore_case: + r=re.compile(exp,flags=re.IGNORECASE) + else: + r=re.compile(exp) + results=list() + for subpath in self.layout.flatten(): + path=os.path.join(self.paths["files"],subpath) + lines=list() + with open(path, "r") as f: + ln=1 + for line in f: + if r.search(line): + lines.append((ln,line.rstrip())) + ln+=1 + if len(lines) > 0: + results.append((str(subpath),lines)) + return results + + def searchtag(self,tag): + """ + Get all subpaths that have a specific tag. + """ + return self.metadata.searchtag(tag) + + def addtags(self, subpaths, tags): + """ + Add tags to specific note files. + """ + for subpath in subpaths: + for tag in tags: + self.metadata.addtag(subpath, tag) + + def addtagslastedited(self, tags): + """ + Add tags to the last edited note file. + """ + subpath=self.metadata.getcache("last_edited") + if subpath is not None: + for tag in tags: + self.metadata.addtag(subpath, tag) + else: + print("You did not edit any files yet!") + exit(1) + + def addfile(self,filepath,timestamp=None): + """ + Add a custom file to the note files. + Timestamp can be specified or not! + """ + if timestamp is not None: + self.layout.settoday(timestamp) + path=self.layout.todaypath() + ignore=False + if not os.path.exists(path): + self.create() + else: + print("The following subpath is already taken: "+self.layout.todaysubpath()) + answer="" + while answer.lower() not in ["ignore","replace","append", "i", "r", "a"]: + answer=input("What do you want to do [Ignore/Replace/Append]? ") + if answer.lower() in ["ignore", "i"]: + ignore=True + elif answer.lower() in ["append", "a"]: + ignore=True + with open(filepath, "r") as src: + with open(path, "a") as dst: + for line in src: + dst.write(line) + if timestamp is not None: + self.layout.restoretoday() + if not ignore: + shutil.copyfile(filepath, path) + + def addtagstoday(self,tags): + """ + Add tags to today's note file + """ + path=self.layout.todaypath() + subpath=self.layout.todaysubpath() + if not os.path.exists(path): + print("Today's file not created yet!") + exit(1) + else: + for tag in tags: + self.metadata.addtag(subpath, tag) + + def listtags(self,subpath=None): + """ + List all tags (subpath=None) or tags from a specific subpath + """ + return self.metadata.listtags(subpath) + + def deletetags(self, subpaths, tags): + """ + Remove some tags linked to some subpaths + """ + for subpath in subpaths: + for tag in tags: + self.metadata.deletetag(subpath, tag) + + def obliteratetags(self, tags): + """ + Remove all references of some tags from the metadata + """ + for tag in tags: + self.metadata.obliteratetag(tag) + + def getpath(self,subpath): + """ + Get file path from the subpath + """ + return os.path.join(self.paths["files"],subpath) + + def fix(self, dry): + """ + Fixing user's new and deleted not files + """ + for f in self.layout.flatten(): + path=self.getpath(str(f)) + if os.path.isfile(path): + if os.stat(path).st_size == 0: + if dry: + print("Empty note file detected => "+f.name) + else: + print("Fixing empty note file => "+f.name) + self.metadata.delete(str(f)) + os.remove(path) + + self.metadata.fix_deleted(dry) + self.metadata.fix_new(self.layout,dry) + + def apply_template(self): + """ + Apply template to today's note file + """ + template_path=os.path.join(self.paths["root"],self.conf["template"]) + if os.path.isfile(template_path): + result = subprocess.run([template_path, self.layout.todaysubpath()], stdout=subprocess.PIPE) + with open(self.layout.todaypath(), "w") as f: + f.write(result.stdout.decode('utf-8')) + + def opentoday(self): + """ + Open today's note file + """ + path=self.layout.todaypath() + if not os.path.exists(path): + self.create() + self.apply_template() + self.exec_editor(self.layout.todaysubpath()) + + def open(self,string): + """ + Open a note file that contains a string + """ + files=list() + for path in self.layout.flatten(): + if string in path.name: + files.append(path) + if len(files) == 0: + path=self.getpath(string) + if not os.path.exists(path): + self.create(string) + self.apply_template() + self.exec_editor(string) + elif len(files) == 1: + self.exec_editor(files[0]) + else: + print("Multiple file match:") + for path in files: + print(path.name) + + def exec_editor(self, subpath): + """ + Open note editor supplied by the user + """ + self.metadata.setcache("last_edited",subpath) + path=self.getpath(subpath) + command=self.conf["editor"]+[path] + try: + os.execvp(command[0],command) + except: + print("Cannot open editor \"{}\"".format(self.conf["editor"])) + exit(1) + diff --git a/pnote/tools/__init__.py b/pnote/tools/__init__.py new file mode 100644 index 0000000..20a6981 --- /dev/null +++ b/pnote/tools/__init__.py @@ -0,0 +1,3 @@ +from pnote.tools.search import * +from pnote.tools.tag import * +from pnote.tools.admin import * diff --git a/pnote/tools/admin.py b/pnote/tools/admin.py new file mode 100644 index 0000000..f3e03d5 --- /dev/null +++ b/pnote/tools/admin.py @@ -0,0 +1,48 @@ +from pnote.tools.tool import Tool +import argparse +from datetime import datetime + +class ToolAdmin(Tool): + + def add_parser(self,subparsers): + self.p = subparsers.add_parser("admin", description="Manage your notes tags") + self.p.add_argument("--fix-dry", help="fix new and deleted note files (DRY RUN)", action='store_true') + self.p.add_argument("--fix", help="fix new and delete note files", action='store_true') + self.p.add_argument("--import", help="Import file(s) to notes", nargs="+", dest="imports") + self.p.add_argument("--timestamp", help="Timestamp to use for file(s) import") + self.p.add_argument("--file-infos", help="Get note file(s) infos", action='store_true') + self.p.add_argument("--subpath", help="") + self.p.add_argument("-s", "--subpaths", help="Subpath to use for file(s) infos", nargs="+") + + def run(self, project, args): + if args.fix_dry: + project.fix(True) + elif args.fix: + project.fix(False) + elif args.imports: + if args.timestamp: + for f in args.imports: + project.addfile(f,int(args.timestamp)) + else: + for f in args.imports: + project.addfile(f) + elif args.file_infos: + if args.subpaths: + subpaths=args.subpaths + else: + subpaths=project.find(None) + first=True + for subpath in subpaths: + if not first: + print() + print("=> "+subpath) + ts_created=project.getfileinfo(subpath,"created") + ts_added=project.getfileinfo(subpath,"added") + print("Created on: "+str(datetime.fromtimestamp(int(ts_created)))) + print("Added on: "+str(datetime.fromtimestamp(int(ts_added)))) + print("Added with host: "+str(project.getfileinfo(subpath,"hostname"))) + print("Added host infos: "+str(project.getfileinfo(subpath,"platform"))) + print("Tags: "+str(project.listtags(subpath))) + first=False + else: + self.p.print_help() diff --git a/pnote/tools/search.py b/pnote/tools/search.py new file mode 100644 index 0000000..298010b --- /dev/null +++ b/pnote/tools/search.py @@ -0,0 +1,79 @@ +from pnote.tools.tool import Tool +import argparse + +class ToolSearch(Tool): + + def add_parser(self,subparsers): + p = subparsers.add_parser("search", description="Perform search operation on your notes") + p.add_argument("-g", "--grep", help="Grep an expression") + p.add_argument("-n", "--name", help="Search for a note path") + p.add_argument("-i", "--ignore-case", help="Ignore case during search", action='store_true') + p.add_argument("-t", "--tag", help="Search for a note with a tag") + p.add_argument("-c", "--content-only", help="Show content only", action='store_true') + p.add_argument("-s", "--subpath-only", help="Show file subpath only", action='store_true') + p.add_argument("--last-created", help="Get last n created note files") + p.add_argument("--last-added", help="Get last n added note files") + + def catsubpath(self,project,subpath): + with open(project.getpath(subpath),"r") as fp: + for line in fp: + print(line,end="") + + def catsubpaths(self, project, subpaths, content_only=False, subpath_only=False): + first=True + for subpath in subpaths: + if subpath_only: + print(subpath) + continue + if not content_only: + if not first: + print() + print("=> "+subpath) + self.catsubpath(project,subpath) + first=False + + def run(self, project, args): + ignore_case=True if args.ignore_case else False + content_only=True if args.content_only else False + subpath_only=True if args.subpath_only else False + + if content_only and subpath_only: + print("content and file-path options cannot be used at the same time") + exit(1) + if args.grep: + first=True + for entry in project.grep(args.grep, ignore_case): + subpath=entry[0] + if subpath_only: + print(subpath) + continue + if not content_only: + if not first: + print() + print("=> "+subpath) + for line in entry[1]: + ln=line[0] + content=line[1] + if content_only: + print(content) + else: + print("L{}: {}".format(ln,content)) + first=False + + elif args.tag: + self.catsubpaths(project, project.searchtag(args.tag),content_only,subpath_only) + + elif args.last_created: + subpaths=project.listlastcreated() + self.catsubpaths(project, subpaths[-abs(int(args.last_created)):],content_only,subpath_only) + + elif args.last_added: + subpaths=project.listlastadded() + self.catsubpaths(project, subpaths[-abs(int(args.last_added)):],content_only,subpath_only) + + else: + if args.name: + self.catsubpaths(project, project.find(args.name,ignore_case),content_only,subpath_only) + else: + self.catsubpaths(project, project.find(None),content_only,subpath_only) + diff --git a/pnote/tools/tag.py b/pnote/tools/tag.py new file mode 100644 index 0000000..e45b8c8 --- /dev/null +++ b/pnote/tools/tag.py @@ -0,0 +1,33 @@ +from pnote.tools.tool import Tool +import argparse + +class ToolTag(Tool): + + def add_parser(self,subparsers): + p = subparsers.add_parser("tag", description="Manage your notes tags") + p.add_argument("-s", "--subpaths", help="Subpaths to edit", nargs="+") + p.add_argument("-a", "--add", help="Add tags to notes", nargs="+") + p.add_argument("-d", "--delete", help="Delete tags from notes", nargs="+") + p.add_argument('-l', '--last-edited', help="Tag last edited file", action="store_true") + + def run(self, project, args): + if args.subpaths: + if args.add: + project.addtags(args.subpaths,args.add) + elif args.delete: + project.deletetags(args.subpaths,args.delete) + else: + for subpath in args.subpaths: + for tag in project.listtags(subpath): + print(tag) + else: + if args.delete: + project.obliteratetags(args.delete) + elif args.add: + if args.last_edited: + project.addtagslastedited(args.add) + else: + project.addtagstoday(args.add) + else: + for tag in project.listtags(): + print(tag) diff --git a/pnote/tools/tool.py b/pnote/tools/tool.py new file mode 100644 index 0000000..5aa7901 --- /dev/null +++ b/pnote/tools/tool.py @@ -0,0 +1,8 @@ + +class Tool: + + def add_parser(self,subparsers): + pass + + def run(self, project, args): + pass |
