fix indexing issues

- add --show-headers general option.
This commit is contained in:
Johann Dreo 2023-07-28 11:36:15 +02:00
commit ff2d313fea
2 changed files with 103 additions and 49 deletions

View file

@ -1,6 +1,9 @@
"STATUS","ID","TITLE","DETAILS","TAGS","DEADLINE" "ID","STATUS","TITLE","DETAILS","TAGS","DEADLINE"
"DOING",1.0,"print card","pretty print fixed-width cards given a content","klyban","" 1,"DOING","print card","pretty print fixed-width cards given a content","klyban",""
"TODO",2.0,"print table","pretty print set of cards on each column","klyban","" 2,"TODO","print table","pretty print set of cards on each column","klyban",""
"TODO",3.0,"nested prints","print cards within cards","klyban","" 3,"TODO","nested prints","print cards within cards","klyban",""
"TODO",4.0,"and yet another test","","","" 4,"TODO","a test","","",""
"TODO",5.0,"what","the fuck","","" 5,"TODO","another test","","",""
6,"TODO","another test","","",""
7,"TODO","anothering test","","",""
8,"TODO","anothering test","","",""

1 ID STATUS TITLE DETAILS TAGS DEADLINE
2 1.0 1 DOING print card pretty print fixed-width cards given a content klyban
3 2.0 2 TODO print table pretty print set of cards on each column klyban
4 3.0 3 TODO nested prints print cards within cards klyban
5 4.0 4 TODO and yet another test a test
6 5.0 5 TODO what another test the fuck
7 6 TODO another test
8 7 TODO anothering test
9 8 TODO anothering test

137
klyban.py
View file

@ -15,9 +15,9 @@ error_codes = {
} }
def error(id,msg): def error(name,msg):
print("ERROR:",msg) print("ERROR:",msg)
sys.exit(error_codes[id]) sys.exit(error_codes[name])
def load_data(context): def load_data(context):
@ -28,6 +28,7 @@ def load_data(context):
# FIXME data sanity checks: unique index, aligned status, valid dates # FIXME data sanity checks: unique index, aligned status, valid dates
except FileNotFoundError: except FileNotFoundError:
# Create an empty file.
df = pd.DataFrame(columns=[ df = pd.DataFrame(columns=[
context.obj['id_key'], context.obj['id_key'],
context.obj['status_key'], context.obj['status_key'],
@ -39,12 +40,23 @@ def load_data(context):
]) ])
save_data(context, df) save_data(context, df)
df.set_index(context.obj['id_key']) # set index on TID.
df = df.astype({context.obj['id_key']:int}).set_index(context.obj['id_key'])
if context.obj['debug']:
print("Loaded:")
print(df)
return df return df
def save_data(context, df): def save_data(context, df):
if context.obj['debug']:
print("Save:")
print(df)
# FIXME double check that there are actually data. # FIXME double check that there are actually data.
# Bring back TID as a regular column.
df = df.reset_index()
# Automagically manages standard input if input=="-", thanks to allow_dash=True. # Automagically manages standard input if input=="-", thanks to allow_dash=True.
with click.open_file(context.obj['input'], mode='w') as fd: with click.open_file(context.obj['input'], mode='w') as fd:
df.to_csv(fd, index=False, quoting=csv.QUOTE_NONNUMERIC) df.to_csv(fd, index=False, quoting=csv.QUOTE_NONNUMERIC)
@ -67,6 +79,7 @@ def configure(context, param, filename):
# Global group holding global options. # Global group holding global options.
@click.group() @click.group()
# Core options.
@click.option( @click.option(
'-c', '--config', '-c', '--config',
type = click.Path(dir_okay=False), type = click.Path(dir_okay=False),
@ -78,6 +91,9 @@ def configure(context, param, filename):
show_default = True, show_default = True,
) )
@click.option('-i', '--input' , help="CSV data file.", default='.klyban.csv', type=click.Path(writable=True, readable=True, allow_dash=True), show_default=True) @click.option('-i', '--input' , help="CSV data file.", default='.klyban.csv', type=click.Path(writable=True, readable=True, allow_dash=True), show_default=True)
# Display options.
@click.option('-H','--show-headers', is_flag=True, help="Show the headers.")
# Low-level configuration options.
@click.option('--status-key' , default='STATUS' , type=str, show_default=True, help="Header key defining the status of tasks.") @click.option('--status-key' , default='STATUS' , type=str, show_default=True, help="Header key defining the status of tasks.")
@click.option('--status-list' , default='TODO,DOING,HOLD,DONE', type=str, show_default=True, help="Comma-separated, ordered list of possible values for the status of tasks.") @click.option('--status-list' , default='TODO,DOING,HOLD,DONE', type=str, show_default=True, help="Comma-separated, ordered list of possible values for the status of tasks.")
@click.option('--id-key' , default='ID' , type=str, show_default=True, help="Header key defining the unique ID of tasks.") @click.option('--id-key' , default='ID' , type=str, show_default=True, help="Header key defining the unique ID of tasks.")
@ -87,6 +103,7 @@ def configure(context, param, filename):
@click.option('--deadline-key', default='DEADLINE', type=str, show_default=True, help="Header key defining the deadlines tasks.") @click.option('--deadline-key', default='DEADLINE', type=str, show_default=True, help="Header key defining the deadlines tasks.")
@click.option('--touched-key', default='TOUCHED', type=str, show_default=True, help="Header key defining the deadlines tasks.") @click.option('--touched-key', default='TOUCHED', type=str, show_default=True, help="Header key defining the deadlines tasks.")
@click.option('--show-keys' , default='ID,TITLE,DETAILS,DEADLINE,TAGS', type=str , show_default=True, help="Comma-separated, ordered list of fields that should be shown") @click.option('--show-keys' , default='ID,TITLE,DETAILS,DEADLINE,TAGS', type=str , show_default=True, help="Comma-separated, ordered list of fields that should be shown")
@click.option('--debug', is_flag=True, help="Print debugging information.")
@click.pass_context @click.pass_context
def cli(context, **kwargs): def cli(context, **kwargs):
@ -97,6 +114,8 @@ def cli(context, **kwargs):
context.obj['input'] = kwargs['input'] context.obj['input'] = kwargs['input']
context.obj['show_headers'] = kwargs['show_headers']
context.obj['id_key'] = kwargs['id_key'] context.obj['id_key'] = kwargs['id_key']
context.obj['status_key'] = kwargs['status_key'] context.obj['status_key'] = kwargs['status_key']
context.obj['title_key'] = kwargs['title_key'] context.obj['title_key'] = kwargs['title_key']
@ -108,6 +127,8 @@ def cli(context, **kwargs):
context.obj['status_list'] = kwargs['status_list'].split(',') context.obj['status_list'] = kwargs['status_list'].split(',')
context.obj['show_keys'] = kwargs['show_keys'].split(',') context.obj['show_keys'] = kwargs['show_keys'].split(',')
context.obj['debug'] = kwargs['debug']
@cli.command() @cli.command()
@click.pass_context @click.pass_context
@ -119,13 +140,18 @@ def show(context):
print("No task.") print("No task.")
return return
# Group by status.
tables = df.groupby(context.obj['status_key']) tables = df.groupby(context.obj['status_key'])
# Loop over the asked ordered status groups.
for k in context.obj['status_list']: # Ordered. for k in context.obj['status_list']: # Ordered.
if k in tables.groups: if k in tables.groups:
df = tables.get_group(k) df = tables.get_group(k)
# STATUS # Bring back TID as a regular column.
df = df.reset_index()
# Print status as header.
print(k) print(k)
try: try:
# Print asked columns.
t = df[context.obj['show_keys']] t = df[context.obj['show_keys']]
except KeyError as e: except KeyError as e:
msg = "" msg = ""
@ -134,80 +160,105 @@ def show(context):
msg += "cannot show field `{}`, not found in `{}` ".format(k, context.obj['input']) msg += "cannot show field `{}`, not found in `{}` ".format(k, context.obj['input'])
error("INVALID_KEY", msg) error("INVALID_KEY", msg)
else: else:
print(tabulate.tabulate(t.fillna(""), headers=context.obj['show_keys'], tablefmt="fancy_grid", showindex=False)) if context.obj['show_headers']:
print(tabulate.tabulate(t.fillna(""), headers=context.obj['show_keys'], tablefmt="fancy_grid", showindex=False))
else:
print(tabulate.tabulate(t.fillna(""), tablefmt="fancy_grid", showindex=False))
@cli.command() @cli.command()
@click.argument('TITLE', required=True, nargs=-1) @click.argument('TITLE', required=True, nargs=-1)
@click.option('-d', '--details' , type=str, default="", prompt=True) @click.option('-d', '--details' , type=str, default=None, prompt=True)
@click.option('-t', '--tags' , type=str, default="", prompt=True) @click.option('-t', '--tags' , type=str, default=None, prompt=True)
@click.option('-a', '--deadline', type=str, default="", prompt=True) @click.option('-a', '--deadline', type=str, default=None, prompt=True)
@click.option('-s', '--status' , type=str, default='TODO') @click.option('-s', '--status' , type=str, default='TODO')
@click.pass_context @click.pass_context
def add(context, title, status, details, tags, deadline): def add(context, title, status, details, tags, deadline):
"""Add a new task.""" """Add a new task."""
df = load_data(context) df = load_data(context)
next_id = df[context.obj['id_key']].max() + 1 next_id = df.index.max() + 1
df.loc[next_id] = { df.loc[next_id] = pd.Series({
context.obj['id_key']:next_id,
context.obj['status_key']: status, context.obj['status_key']: status,
context.obj['title_key']: " ".join(title), context.obj['title_key']: " ".join(title),
context.obj['details_key']: details, context.obj['details_key']: details,
context.obj['tags_key']: tags, context.obj['tags_key']: tags,
context.obj['deadline_key']: deadline, context.obj['deadline_key']: deadline,
context.obj['touched_key']: datetime.datetime.now().isoformat(), context.obj['touched_key']: datetime.datetime.now().isoformat(),
} })
# Remove any values consisting of empty spaces or quotes.
df = df.replace(r'^[\s"\']*$', float("nan"), regex=True)
save_data(context,df) save_data(context,df)
context.invoke(show) context.invoke(show)
def check_id(context, param, value): def check_id(context, param, value):
"""Eager callback, checking ID before edit's options prompting.""" """Callback checking if task exists."""
assert(type(value) == int)
df = load_data(context) df = load_data(context)
if id not in df.index: if value not in df.index:
error("ID_NOT_FOUND", "{} `{}` was not found in data `{}`".format(context.obj['id_key'], value, context.obj['input'])) error("ID_NOT_FOUND", "{} `{}` was not found in data `{}`".format(context.obj['id_key'], value, context.obj['input']))
return value
@cli.command() @cli.command()
@click.argument('ID', required=True, type=int, is_eager=True, callback=check_id) @click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.option('-t', '--title' , type=str, prompt=True) @click.option('-t', '--title' , type=str, prompt=True)
@click.option('-s', '--status' , type=str, prompt=True) @click.option('-s', '--status' , type=str, prompt=True)
@click.option('-d', '--details' , type=str, prompt=True, default="") @click.option('-d', '--details' , type=str, prompt=True, default="")
@click.option('-t', '--tags' , type=str, prompt=True, default="") @click.option('-t', '--tags' , type=str, prompt=True, default="")
@click.option('-a', '--deadline', type=str, prompt=True, default="") @click.option('-a', '--deadline', type=str, prompt=True, default="")
# FIXME populate the defaults with actual existing data. # FIXME populate the defaults with the existing data.
@click.pass_context @click.pass_context
def edit(context, id, title, status, details, tags, deadline): def edit(context, tid, title, status, details, tags, deadline):
"""Add a new task.""" """Add a new task."""
df = load_data(context) df = load_data(context)
assert(tid in df.index)
df.loc[id] = { df.loc[tid] = pd.Series({
context.obj['id_key']: id, context.obj['status_key']: status,
context.obj['status_key']: status, context.obj['title_key']: title,
context.obj['title_key']: " ".join(title), context.obj['details_key']: details,
context.obj['details_key']: details, context.obj['tags_key']: tags,
context.obj['tags_key']: tags, context.obj['deadline_key']: deadline,
context.obj['deadline_key']: deadline, context.obj['touched_key']: datetime.datetime.now().isoformat(),
context.obj['touched_key']: datetime.datetime.now().isoformat(), })
}
save_data(context,df) save_data(context,df)
context.invoke(show) context.invoke(show)
def check_yes(context, param, value):
"""Callback cheking for explicit user consent."""
if not value:
context.abort()
return value
@cli.command() @cli.command()
@click.argument('ID', required=True) @click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.option('-y', '--yes', is_flag=True, expose_value=False, callback=check_yes, prompt="Permanently delete task from records?")
@click.pass_context @click.pass_context
def promote(context, id): def delete(context, tid):
"""Upgrade the status of task `ID` to the next one. """Delete a task."""
df = load_data(context)
df = df.drop(index=tid)
save_data(context, df)
context.invoke(show)
@cli.command()
@click.argument('TID', required=True, type=int)
@click.pass_context
def promote(context, tid):
"""Upgrade the status of task `TID` to the next one.
Use status names configured with --status-list.""" Use status names configured with --status-list."""
df = load_data(context) df = load_data(context)
row = df.loc[ df[context.obj['id_key']] == int(id) ] row = df.loc[ df[context.obj['id_key']] == tid ]
if row.empty: if row.empty:
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], id, context.obj['input'])) error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], tid, context.obj['input']))
i=0 i=0
for i in range(len(context.obj['status_list'])): for i in range(len(context.obj['status_list'])):
@ -216,10 +267,10 @@ def promote(context, id):
else: else:
i += 1 i += 1
if i >= len(context.obj['status_list'])-1: if i >= len(context.obj['status_list'])-1:
error("UNKNOWN_STATUS", "Cannot promote task {}, already at the last status.".format(id)) error("UNKNOWN_STATUS", "Cannot promote task {}, already at the last status.".format(tid))
else: else:
df.loc[df[context.obj['id_key']] == int(id), context.obj['status_key']] = context.obj['status_list'][i+1] df.loc[df[context.obj['id_key']] == tid, context.obj['status_key']] = context.obj['status_list'][i+1]
df.loc[df[context.obj['id_key']] == int(id), context.obj['touched_key']] = datetime.datetime.now().isoformat() df.loc[df[context.obj['id_key']] == tid, context.obj['touched_key']] = datetime.datetime.now().isoformat()
save_data(context, df) save_data(context, df)
@ -227,18 +278,18 @@ def promote(context, id):
@cli.command() @cli.command()
@click.argument('ID', required=True) @click.argument('TID', required=True, type=int)
@click.pass_context @click.pass_context
def demote(context, id): def demote(context, tid):
"""Downgrade the status of task `ID` to the previous one. """Downgrade the status of task `TID` to the previous one.
Use status names configured with --status-list.""" Use status names configured with --status-list."""
df = load_data(context) df = load_data(context)
row = df.loc[ df[context.obj['id_key']] == int(id) ] row = df.loc[ df[context.obj['id_key']] == tid ]
if row.empty: if row.empty:
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], id, context.obj['input'])) error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], tid, context.obj['input']))
i=0 i=0
for i in range(len(context.obj['status_list'])): for i in range(len(context.obj['status_list'])):
@ -247,10 +298,10 @@ def demote(context, id):
else: else:
i += 1 i += 1
if i == 0: if i == 0:
error("UNKNOWN_STATUS", "Cannot demote task {}, already at the first status.".format(id)) error("UNKNOWN_STATUS", "Cannot demote task {}, already at the first status.".format(tid))
else: else:
df.loc[df[context.obj['id_key']] == int(id), context.obj['status_key']] = context.obj['status_list'][i-1] df.loc[df[context.obj['id_key']] == tid, context.obj['status_key']] = context.obj['status_list'][i-1]
df.loc[df[context.obj['id_key']] == int(id), context.obj['touched_key']] = datetime.datetime.now().isoformat() df.loc[df[context.obj['id_key']] == tid, context.obj['touched_key']] = datetime.datetime.now().isoformat()
save_data(context, df) save_data(context, df)