def read_fraction(s, loc): if M.search(r'^[0-9.]+$', s): return float(s) if M.search(r'^([0-9]+)%$', s): return int(M.group(1)) / 100 error(loc, f"unexpected fraction syntax: {s}") raise ValueError("silly Pylint fails to understand NoReturn")
def export(net, dump=False): """Generate PERT chart in Graphviz from declarative plan.""" p = PR.SourcePrinter(io.StringIO()) p.writeln('digraph G {') with p: p.write('''\ #graph [rankdir = LR, splines = ortho] #graph [rankdir = LR, concentrate = true] graph [rankdir = LR] ''') net.visit_goals(callback=lambda g: _print_node(g, p)) p.writeln('') net.visit_goals(callback=lambda g: _print_node_edges(g, p)) p.writeln('}') if dump: print(p.out.getvalue()) else: gv_file = 'pert.gv' pdf_file = 'pert.pdf' with open(gv_file, 'wt') as out: out.write(p.out.getvalue()) try: with open(pdf_file, 'wb') as f: if subprocess.call(['dot', '-Tpdf', gv_file], stdout=f) != 0: error("failed to run dot(1); do you have Graphviz installed?") except FileNotFoundError: error("dot program not found; do you have Graphviz installed?") platform.open_file(pdf_file)
def add_attrs(self, attrs, loc): for a in attrs: if a[0].isdigit(): self.efficiency = P.read_fraction(a, loc) elif M.search(r'vacations?\s+(.*)', a): duration = P.read_date2(M.group(1), loc) self.vacations.append(duration) else: error(loc, f"unexpected resource attribute: {a}")
def expect(self, type): """Return current lexeme and advance if type matches.""" l = self.next() if isinstance(type, list): if l.type not in type: type_str = ', '.join(map(lambda t: f"'{type}'")) error(l.loc, f"expecting '{type_str}', got '{l.type}'") elif l.type != type: error(l.loc, f"expecting '{type}', got '{l.type}'") return l
def next_internal(self): if self.line == '': # File exhausted type = LexemeType.EOF data = text = '' elif self.mode == LexerMode.ATTR: nest = 0 self.line = self.line.lstrip() if self.line[0] == ',': type = ',' i = 1 else: type = LexemeType.LIST_ELT for i, c in enumerate(self.line): if c == ',' and not nest: break if c == '(': nest += 1 elif c == ')': nest -= 1 else: i += 1 self.mode = LexerMode.NORMAL text = self.line[:i] data = text.rstrip() self.line = self.line[i:] else: data = None if M.match(r'( *)\|([<>])-', self.line): type = LexemeType.LARROW if M.group( 2) == '<' else LexemeType.RARROW data = len(M.group(1)) elif M.match(r'( *)\|\[([^\]]*)\]\s*(.*?)(?=(//|$))', self.line): type = LexemeType.CHECK data = len(M.group(1)), M.group(2), M.group(3).strip() elif M.match(r'^(\s*)(--|\|\|)\s*', self.line): type = LexemeType.SCHED data = len(M.group(1)), M.group(2) elif M.match(r'( *)\|(.*?)(?=//|$)', self.line): type = LexemeType.GOAL data = len(M.group(1)), M.group(2).strip() elif M.match(r'\s*//', self.line): type = LexemeType.ATTR_START self.mode = LexerMode.ATTR elif M.match(r'([A-Za-z][A-Za-z0-9_]*)(?=\s*=)', self.line): type = LexemeType.PRJ_ATTR data = M.group(1) elif M.match(r'\s*=\s*', self.line): type = LexemeType.ASSIGN self.mode = LexerMode.ATTR else: error(self._loc(), f"unexpected syntax: {self.line}") self.line = self.line[len(M.group(0)):] text = M.group(0) self.lexemes.append(PA.Lexeme(type, data, text, self._loc()))
def read_alloc(a, loc): """Parse allocation directive e.g. "@dev1/dev2 (dev3)".""" aa = a.split('(') if len(aa) > 2 or not M.search(r'^@\s*(.*)', aa[0]): error(loc, f"unexpected allocation syntax: {a}") alloc = M.group(1).strip().split('/') if len(aa) <= 1: real_alloc = [] else: if not M.search(r'^([^)]*)\)', a): error(loc, f"unexpected allocation syntax: {a}") real_alloc = M.group(1).strip().split('/') return alloc, real_alloc
def _recompute(self): self.members_map = {m.name : m for m in self.members} self.teams_map = {t.name : t for t in self.teams} if 'all' in self.teams_map: error(self.teams_map['all'].loc, "predefined goal 'all' overriden") self.teams_map['all'] = Team('all', self.members, self.loc) # Resolve resources for team in self.teams: error_if(team.name in self.members_map, team.loc, f"team '{team.name}' clashes with developer '{team.name}'") for i, name in enumerate(team.members): if not isinstance(name, str): continue m = self.members_map.get(name) error_if(m is None, team.loc, f"no member with name '{name}'") team.members[i] = m
def add_attrs(self, attrs, loc): attrs = add_common_attrs(loc, self, attrs) for a in attrs: if re.search(r'^[0-9.]+[hdwmy]', a): # Parse estimate self.effort = PA.read_eta(a, loc) continue if a.startswith('@'): self.alloc, self.real_alloc = PA.read_alloc(a, loc) continue # TODO: specify in effort attribute? if M.search(r'^[0-9]{4}-', a): self.duration = PA.read_date2(a, loc) continue if M.match(r'^id\s+(.*)', a): self.id = M.group(1) if M.match(r'over\s+(\S+)\s+(.*)', a): other_id = M.group(1) overlap, a = PA.read_float(M.group(2), loc) if a == '%': overlap /= 100 self.overlaps[other_id] = overlap if a.startswith('||'): self.parallel = PA.read_par(a) continue if not M.search(r'^([a-z_0-9]+)\s*(.*)', a): error(loc, f"failed to parse attribute: {a}") k = M.group(1).strip() #v = M.group(2).strip() if k == 'global': self.globl = True continue error(loc, f"unknown activity attribute: '{k}'")
def parse(self, W): net_loc = project_loc = sched_loc = Location() root_goals = [] root_blocks = [] while True: l = self.lex.peek() if l is None: break logger.debug(f"parse: next lexeme: {l}") if l.type == LexemeType.GOAL: error_if(l.data[0] != 0, l.loc, f"root goal '{l.data[1]}' must be left-adjusted") goal = self.parse_goal(l.data[0], None, False) if not net_loc: net_loc = goal.loc root_goals.append(goal) elif l.type == LexemeType.PRJ_ATTR: if not project_loc: project_loc = l.loc self.parse_project_attr() elif l.type == LexemeType.SCHED: error_if(l.data[0] != 0, l.loc, "root block must be left-adjusted") block = self.parse_sched_block(l.data[0]) if not sched_loc: sched_loc = block.loc root_blocks.append(block) elif l.type == LexemeType.EOF: break else: # TODO: anonymous goals error(l.loc, f"unexpected lexeme: '{l.text}'") net = G.Net(root_goals, W, net_loc) prj = project.Project(project_loc) prj.add_attrs(self.project_attrs) sched = schedule.SchedPlan(root_blocks, sched_loc) return net, prj, sched
def read_eta(s, loc): """Parse effort estimate e.g. "1h", "1h-3d" or "1h-3d (1d)".""" min, rest = read_effort(s, loc) max = min if rest and rest[0] == '-': max, rest = read_effort(rest[1:], loc) real = None completion = 0 if M.search(r'^\s*\((.*)\)\s*$', rest): for a in re.split(r'\s*,\s*', M.group(1)): if re.search(r'^[0-9.]+[hdwmy]', a): real, _ = read_effort(a, loc) elif M.search(r'^([0-9]+)%', a): completion = float(M.group(1)) / 100 else: error(loc, f"unknown ETA attribute: {a}") return ETA(min, max, real, completion)
def check(self, W): """Verify invariants.""" if W and not self.defined and not self.dummy: warn(self.loc, f"goal '{self.name}' is undefined") pending_conds = [c.name for c in self.checks if not c.done()] if W and self.completion_date is not None \ and self.completion_date <= datetime.date.today() \ and pending_conds: warn(self.loc, "goal '%s' marked as completed but some checks are still pending:\n %s" % (self.name, '\n '.join(pending_conds))) if W and self.is_completed() and not self.completion_date: warn(self.loc, f"goal '{self.name}' marked as completed but is missing tracking data") for act in self.global_preds: if not act.is_instant(): error(act.loc, "global dependencies must be instant") if W and not act.head: warn(act.loc, f"goal '{self.name}' has empty global dependency") for act in self.preds: act.check(W) if act.head and self.iter is not None and act.head.iter is None: warn(self.loc, f"goal has been scheduled but one of it's dependents is not: " f"'{act.head.name}'") if self.is_completed() and (not act.duration or not act.effort.real): warn(self.loc, f"goal '{self.name}' is achieved but " f"one of it's actions is missing tracking data")
def add_attrs(self, attrs, loc): for a in attrs: if a.startswith('@'): self.alloc, _ = PA.read_alloc(a, loc) continue if M.search(r'^[0-9]{4}-', a): self.duration = PA.read_date2(a, loc) continue if a.startswith('||'): self.parallel = PA.read_par(a) continue if not M.search(r'^([a-z_0-9]+)\s*(.*)', a): error(loc, f"failed to parse block attribute: {a}") k = M.group(1).strip() v = M.group(2).strip() if k == 'deadline': self.deadline, _ = PA.read_date(v, loc) continue error(loc, f"unknown block attribute '{k}'")
def parse_project_attr(self): l = self.lex.next() name = l.data attr_loc = l.loc self.lex.expect('=') rhs = [] while True: l = self.lex.expect('LIST_ELT') rhs.append(l.data) if not self.lex.next_if(','): break def expect_one_value(loc, name, vals): error_if( len(vals) != 1, loc, f"too many values for attribute '{name}': " + ', '.join(vals)) if name in {'name', 'tracker_link', 'pr_link'}: expect_one_value(l.loc, name, rhs) val = rhs[0] elif name in {'start', 'finish'}: expect_one_value(l.loc, name, rhs) val, _ = PA.read_date(rhs[0], attr_loc) elif name == 'members': val = [] for rc_info in rhs: if not M.match(r'([A-Za-z][A-Za-z0-9_]*)\s*(?:\(([^\)]*)\))?', rc_info): error(attr_loc, f"failed to parse resource declaration: {rc_info}") rc_name, attrs = M.groups() rc = project.Resource(rc_name, attr_loc) if attrs: rc.add_attrs(re.split(r'\s*,\s*', attrs), attr_loc) val.append(rc) elif name == 'teams': val = [] for team_info in rhs: if not M.match(r'\s*([A-Za-z][A-Za-z0-9_]*)\s*\(([^)]*)\)$', team_info): error(attr_loc, f"invalid team declaration: {team_info}") team_name = M.group(1) rc_names = re.split(r'\s*,\s*', M.group(2).strip()) val.append(project.Team(team_name, rc_names, attr_loc)) elif name == 'holidays': val = [] for s in rhs: iv = PA.read_date2(s, attr_loc) val.append(iv) else: error(attr_loc, f"unknown project attribute: {name}") self.project_attrs[name] = val
def add_attrs(self, attrs, loc): attrs = add_common_attrs(loc, self, attrs) for a in attrs: if a.find('!') == 0: try: self.prio = Priority(int(a[1:])) except ValueError: error(loc, f"invalid priority value: {a}") continue if a.find('?') == 0: try: self.risk = Risk(int(a[1:])) except ValueError: error(loc, f"invalid risk value: {a}") continue if M.search(r'^I[0-9]+$', a): self.iter = int(a[1:]) continue if M.search(r'^[0-9]{4}-', a): self.completion_date, _ = PA.read_date(a, loc) continue if not M.search(r'^([a-z_0-9]+)\s*(.*)', a): error(loc, f"failed to parse goal attribute: {a}") k = M.group(1).strip() v = M.group(2).strip() if k == 'deadline': self.deadline, _ = PA.read_date(v, loc) continue if k == 'id': self.id = v continue error(loc, f"unknown goal attribute '{k}'")
def export(net, goal, duration, dump): """Generate gnuplot chart for all children of goal within time interval.""" counts = defaultdict(int) counts[duration.start] = 0 partial = [0] total_children = [0] def scan_completed(g): total_children[0] += 1 # TODO: skip milestones? if g.is_completed() and g.is_scheduled(): counts[g.completion_date] += 1 else: partial[0] += g.complete() / 100.0 net.visit_goals([goal], callback=scan_completed, hierarchical=True) # Also count partially completed tasks today = datetime.date.today() if today < duration.finish: counts[today] = int(partial[0]) sorted_dates = sorted(counts.keys()) prev = 0 for date in sorted_dates: counts[date] += prev prev = counts[date] p = PR.SourcePrinter(io.StringIO()) p.writeln('set terminal png') p.write(f''' reset set title "{goal.name} (burndown chart)" set timefmt "%Y-%m-%d" set xlabel "Days" set format x "%b-%d" set xdata time set xrange ["{duration.start}":"{duration.finish}"] set xtics nomirror set ylabel "#Goals" set yrange [0:{total_children[0]}] set ytics mirror plot "-" using 1:2 title 'Real' with lines, "-" using 1:2 title "Planned" with lines ''') for date in sorted_dates: n = counts[date] left = total_children[0] - n p.writeln(f' {date} {left}') p.writeln('e') p.writeln(f' {duration.start} {total_children[0]}') p.writeln(f' {duration.finish} {0}') p.writeln('e') if dump: print(p.out.getvalue()) else: png_file = 'burndown.png' try: with open(png_file, 'w') as f: if subprocess.call(['gnuplot', '-'], stdin=p.out, stdout=f) != 0: error( "failed to run gnuplot(1); do you have Gnuplot installed?" ) except FileNotFoundError: error("gnuplot program not found; do you have Gnuplot installed?") platform.open_file(png_file)
def export(prj, wbs, est, dump=False): """Generate TaskJuggler plan from declarative plan.""" today = datetime.date.today() p = PR.SourcePrinter(io.StringIO()) # Print header # (based upon http://www.taskjuggler.org/tj3/manual/Tutorial.html) p.write(f'''\ project "{prj.name}" {prj.start} - {prj.finish} {{ timeformat "{time_format}" now {today.strftime(time_format)} timezone "Europe/Moscow" currency "USD" extend task {{ reference JiraLink "Tracker link" }} }} flags internal ''') # Print holidays # TODO: additional holidays in plan for y in range(prj.start.year, prj.finish.year + 1): for name, dates in [ ('New year holidays', '01-01 + 8d'), ('Army day', '02-23'), ('Womens day', '03-08'), ('May holidays', '05-01 + 2d'), ('Victory day', '05-09'), ('Independence day', '06-12'), ('Unity day', '11-04')]: p.writeln(f'leaves holiday "{name} {y}" {y}-{dates}') p.writeln('') # Print resources p.writeln('resource dev "Developers" {') for dev in prj.members: p.writeln(f' resource {dev.name} "{dev.name}" {{') p.writeln(f' efficiency {dev.efficiency}') for iv in dev.vacations: p.writeln(f' vacation {iv.start} - {iv.finish}') p.writeln(' }') p.writeln('}') # Print WBS abs_ids = {} def cache_abs_id(task): if task.parent is None: abs_ids[task.id] = task.id else: parent_id = abs_ids[task.parent.id] abs_ids[task.id] = f'{parent_id}.{task.id}' wbs.visit_tasks(cache_abs_id) for task in wbs.tasks: _print_task(p, task, abs_ids, prj, est) # A hack to prevent TJ from scheduling unfinished tasks in the past p.write('''\ task now "Now" { milestone flags internal start ${now} } ''') # Print reports p.write(f'''\ taskreport gantt "GanttChart" {{ headline "{prj.name} - Gantt Chart" timeformat "%Y-%m-%d" formats html columns bsi {{ title 'ID' }}, name, JiraLink, start, end, effort, resources, chart {{ width 5000 }} loadunit weeks sorttasks tree hidetask (internal) }} resourcereport resources "ResourceGraph" {{ headline "{prj.name} - Resource Allocation Report" timeformat "%Y-%m-%d" formats html columns bsi, name, JiraLink, start, end, effort, chart {{ width 5000 }} # loadunit weeks sorttasks tree hidetask (internal | ~isleaf_()) hideresource ~isleaf() }} tracereport trace "TraceReport" {{ columns bsi, name, start, end timeformat "%Y-%m-%d" formats csv }} export msproject "{prj.name}" {{ formats mspxml }} ''') if dump: print(p.out.getvalue()) else: tjp_file = 'plan.tjp' tj_dir = './tj' with open(tjp_file, 'w') as f: f.write(p.out.getvalue()) if not os.path.exists(tj_dir): os.mkdir(tj_dir) if subprocess.call(['tj3', '-o', tj_dir, tjp_file]) != 0: error("failed to run tj3; do you have TaskJuggler installed?") platform.open_file(os.path.join(tj_dir, 'GanttChart.html')) platform.open_file(os.path.join(tj_dir, 'ResourceGraph.html'))
def _schedule_goal(self, goal, start, alloc, par, warn_if_past=True): logger.debug(f"_schedule_goal: scheduling goal '{goal.name}': " f"start={start}, alloc={alloc}, par={par}") if self.sched.is_completed(goal): return self.sched.get_completion_date(goal) if goal.completion_date is not None: logger.debug("_schedule_goal: goal already scheduled") if warn_if_past and goal.completion_date < start: warn( goal.loc, f"goal '{goal.name}' is completed on {goal.completion_date}, before {start}" ) # TODO: warn if completion_date < start self.sched.set_completion_date(goal, goal.completion_date) return goal.completion_date if goal.is_completed(): warn( goal.loc, f"unable to schedule completed goal '{goal.name}' with no completion date" ) self.sched.set_completion_date(goal, datetime.date.today()) return datetime.date.today() completion_date = start for act in goal.preds: logger.debug( f"_schedule_goal: scheduling activity '{act.name}' for goal '{goal.name}'" ) if act.duration is not None: # TODO: register spent time for devs if warn_if_past and act.duration.start < start: warn( act.loc, f"activity '{act.name}' started on {act.duration.start}, before {start}" ) completion_date = max(completion_date, act.duration.finish) continue act_start = start if act.head is not None: if self.sched.is_completed(act.head): act_start = max(act_start, self.sched.get_completion_date(act.head)) else: # For goals that are not specified by schedule we use default settings logger.debug( f"_schedule_goal: scheduling predecessor '{act.head.name}'" ) self._schedule_goal(act.head, datetime.date.today(), [], None, warn_if_past=False) if not act.overlaps: act_start = max( act_start, self.sched.get_completion_date(act.head)) else: for pred in act.head.preds: overlap = act.overlaps.get(pred.id) if overlap is not None: pred_iv = self.sched.get_duration(pred) span = (pred_iv.finish - pred_iv.start) * (1 - overlap) act_start = max(act_start, pred_iv.start + span) if act.is_instant(): completion_date = max(completion_date, act_start) continue plan_rcs = self.prj.get_resources(act.alloc) if alloc: rcs = self.prj.get_resources(alloc) if any(rc for rc in rcs if rc not in plan_rcs): allocs = '/'.join(alloc) assignees = '/'.join(rc.name for rc in plan_rcs) error( f"allocations defined in schedule ({allocs}) do not match " f"allocations defined in action ({assignees})") else: rcs = plan_rcs act_par = par if act_par is None: act_par = act.parallel act_effort, _ = self.est.estimate(act) act_effort *= 1 - act.effort.completion assignees = '/'.join(rc.name for rc in rcs) logger.debug( f"_schedule_goal: scheduling activity '{act.name}': " f"start={act_start}, effort={act_effort}, par={act_par}, rcs={assignees}" ) iv, assigned_rcs = self.sched.assign_best_rcs( rcs, act_start, act_effort, act_par) assignees = '/'.join(rc.name for rc in assigned_rcs) logger.debug( f"_schedule_goal: assignment for activity '{act.name}': " f"@{assignees}, duration {iv}") self.sched.set_duration(act, iv, assigned_rcs) completion_date = max(completion_date, iv.finish) logger.debug( f"_schedule_goal: scheduled goal '{goal.name}' for completion_date" ) self.sched.set_completion_date(goal, completion_date) if goal.deadline is not None and completion_date > goal.deadline: warn( f"failed to schedule goal '{goal.name}' before deadline goal.deadline" ) return completion_date