Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
</div>

```bash
pip install cognis-dealflow
pip install "git+https://github.com/cognis-digital/dealflow.git"
dealflow scan . # → prioritized findings in seconds
```

<!-- cognis:layman:start -->
## What is this?

`dealflow` is a small, self-contained command-line tool from the Cognis suite. It does one job well, runs locally with no account or cloud service required, and is built to be easy to install and read. See the usage below for what it can do.
<!-- cognis:layman:end -->

## Contents

- [Why dealflow?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
Expand All @@ -46,10 +52,56 @@ Pipeline-as-code: your forecast is a reproducible artifact in CI, so board decks
<div align="right"><a href="#top">↑ back to top</a></div>

<a name="quick-start"></a>
<!-- cognis:domains:start -->
## Domains

**Primary domain:** Revenue & Business · **JTF MERIDIAN division:** FOUNDRY · MASON

**Topics:** `cognis` `business` `saas` `revenue-ops`

Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together.
<!-- cognis:domains:end -->

<!-- cognis:install:start -->
## Install

`dealflow` is source-available (not published to PyPI) — every method below installs
straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
the best tool available on your machine.

**One-liner (Linux / macOS):**
```sh
curl -fsSL https://raw.githubusercontent.com/cognis-digital/dealflow/HEAD/install.sh | sh
```

**One-liner (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/cognis-digital/dealflow/HEAD/install.ps1 | iex
```

**Or install manually — any one of:**
```sh
pipx install "git+https://github.com/cognis-digital/dealflow.git" # isolated (recommended)
uv tool install "git+https://github.com/cognis-digital/dealflow.git" # uv
pip install "git+https://github.com/cognis-digital/dealflow.git" # pip
```

**From source:**
```sh
git clone https://github.com/cognis-digital/dealflow.git
cd dealflow && pip install .
```

Then run:
```sh
dealflow --help
```
<!-- cognis:install:end -->

## Quick start

```bash
pip install cognis-dealflow
pip install "git+https://github.com/cognis-digital/dealflow.git"
dealflow --version
dealflow scan . # scan current project
dealflow scan . --format json # machine-readable
Expand Down
12 changes: 12 additions & 0 deletions dealflow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ def main(argv=None) -> int:
return 2

if args.command == "forecast":
if args.min_win_rate is not None and not (0.0 <= args.min_win_rate <= 1.0):
print(
f"error: --min-win-rate must be between 0 and 1, got {args.min_win_rate}",
file=sys.stderr,
)
return 2
if args.min_forecast is not None and args.min_forecast < 0:
print(
f"error: --min-forecast must be non-negative, got {args.min_forecast}",
file=sys.stderr,
)
return 2
try:
pipeline = load_pipeline(args.pipeline)
deals = load_deals(args.deals)
Expand Down
18 changes: 15 additions & 3 deletions dealflow/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ class Deal:

@property
def current_stage(self) -> str:
if not self.history:
raise DealflowError(f"deal {self.deal_id!r} has no history")
return self.history[-1][0]


Expand Down Expand Up @@ -262,6 +264,7 @@ def parse_pipeline(text: str) -> Pipeline:
stages: list[Stage] = []
won_stage = None
lost_stages: set[str] = set()
seen_names: set[str] = set()
for i, item in enumerate(raw_stages):
if isinstance(item, str):
sname, terminal, won = item, False, False
Expand All @@ -274,7 +277,11 @@ def parse_pipeline(text: str) -> Pipeline:
terminal = won or stype in ("lost", "closed", "terminal") or bool(item.get("terminal"))
else:
raise DealflowError(f"stage #{i} must be a string or mapping")
st = Stage(name=str(sname), order=i, terminal=terminal, won=won)
sname_str = str(sname)
if sname_str in seen_names:
raise DealflowError(f"duplicate stage name {sname_str!r} at position {i}")
seen_names.add(sname_str)
st = Stage(name=sname_str, order=i, terminal=terminal, won=won)
stages.append(st)
if won:
won_stage = st.name
Expand Down Expand Up @@ -329,12 +336,17 @@ def load_deals(path_or_text: str, *, is_text: bool = False) -> list[Deal]:

events: dict[str, list[tuple[str, _dt.date]]] = {}
amounts: dict[str, float] = {}
for row in reader:
for row_num, row in enumerate(reader, start=2): # 2 = first data row (1=header)
did = (row[cols["deal_id"]] or "").strip()
if not did:
continue
stage = (row[cols["stage"]] or "").strip()
date = _parse_date(row[cols["date"]])
if not stage:
raise DealflowError(f"row {row_num}: stage is empty for deal_id {did!r}")
try:
date = _parse_date(row[cols["date"]])
except DealflowError as exc:
raise DealflowError(f"row {row_num}: {exc}") from exc
events.setdefault(did, []).append((stage, date))
if has_amount:
raw = (row[cols["amount"]] or "").strip().replace("$", "").replace(",", "")
Expand Down
29 changes: 29 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Comprehensive installer for cognis-digital/dealflow (Windows PowerShell).
# Tries: pipx -> uv -> pip (git+https) -> from source.
# dealflow is source-available and not on PyPI; all paths install from GitHub.
$ErrorActionPreference = "Stop"
$Repo = "dealflow"
$Url = "git+https://github.com/cognis-digital/dealflow.git"
$Git = "https://github.com/cognis-digital/dealflow.git"
function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }

if (-not (Have python) -and -not (Have py)) {
Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
}
if (Have pipx) {
Say "Installing with pipx (isolated, recommended)..."
pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 }
}
if (Have uv) {
Say "Installing with uv..."
uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 }
}
if (Have pip) {
Say "Installing with pip (user site)..."
pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 }
}
Say "No packaging tool worked; falling back to a source clone."
$Tmp = Join-Path $env:TEMP "$Repo-src"
git clone --depth 1 $Git $Tmp
Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
44 changes: 34 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
#!/usr/bin/env sh
# Universal installer for dealflow. Prefers uv > pipx > pip; installs from the repo.
set -e
SRC="git+https://github.com/cognis-digital/dealflow.git"
echo "Installing dealflow ..."
if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
else echo "Need uv, pipx, or python3+pip"; exit 1; fi
echo "Done. Run: dealflow --help"
#!/usr/bin/env sh
# Comprehensive installer for cognis-digital/dealflow (Linux / macOS).
# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
# dealflow is source-available and not on PyPI; all paths install from GitHub.
set -eu

REPO="dealflow"
URL="git+https://github.com/cognis-digital/dealflow.git"
GITURL="https://github.com/cognis-digital/dealflow.git"

say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }

if ! have python3 && ! have python; then
say "Python 3.9+ is required but was not found. Install Python first."; exit 1
fi

if have pipx; then
say "Installing with pipx (isolated, recommended)..."
pipx install "$URL" && { say "Done. Run: dealflow"; exit 0; }
fi
if have uv; then
say "Installing with uv..."
uv tool install "$URL" && { say "Done. Run: dealflow"; exit 0; }
fi
if have pip3 || have pip; then
PIP="$(command -v pip3 || command -v pip)"
say "Installing with pip (user site)..."
"$PIP" install --user "$URL" && { say "Done. Run: dealflow"; exit 0; }
fi

say "No packaging tool worked; falling back to a source clone."
TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
2 changes: 1 addition & 1 deletion integrations/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Usage: <tool> scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
import argparse, json, sys, urllib.request
import argparse, sys, urllib.request

def main() -> int:
ap = argparse.ArgumentParser()
Expand Down
146 changes: 146 additions & 0 deletions tests/test_hardening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Hardening tests for DEALFLOW — edge cases, bad input, and error paths."""
from __future__ import annotations

import os
import pytest

from dealflow.core import DealflowError, Deal, parse_pipeline, load_deals, analyze
from dealflow.cli import main

_DEMO = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"demos", "01-basic",
)
_PIPELINE = os.path.join(_DEMO, "pipeline.yml")
_DEALS = os.path.join(_DEMO, "deals.csv")

_DUP_STAGES_YAML = """
name: Broken
stages:
- name: lead
type: open
- name: lead
type: open
- name: won
type: won
"""

_SIMPLE_PIPELINE_YAML = """
name: Simple
stages:
- name: lead
type: open
- name: won
type: won
"""


# ---------------------------------------------------------------------------
# parse_pipeline: duplicate stage names
# ---------------------------------------------------------------------------

def test_duplicate_stage_names_raise():
with pytest.raises(DealflowError, match="duplicate stage name"):
parse_pipeline(_DUP_STAGES_YAML)


# ---------------------------------------------------------------------------
# load_deals: row-number context in error messages
# ---------------------------------------------------------------------------

def test_bad_date_includes_row_number():
csv_text = "\n".join([
"deal_id,stage,date,amount",
"D1,lead,NOT-A-DATE,100",
"",
])
with pytest.raises(DealflowError, match=r"row 2"):
load_deals(csv_text, is_text=True)


def test_bad_date_on_third_row_includes_row_three():
csv_text = "\n".join([
"deal_id,stage,date,amount",
"D1,lead,2026-01-01,100",
"D2,lead,BADDATE,200",
"",
])
with pytest.raises(DealflowError, match=r"row 3"):
load_deals(csv_text, is_text=True)


def test_empty_stage_field_raises():
csv_text = "\n".join([
"deal_id,stage,date,amount",
"D1,,2026-01-01,100",
"",
])
with pytest.raises(DealflowError, match="stage is empty"):
load_deals(csv_text, is_text=True)


# ---------------------------------------------------------------------------
# Deal.current_stage: guard against empty history
# ---------------------------------------------------------------------------

def test_deal_empty_history_raises():
d = Deal(deal_id="X", amount=100.0, history=[])
with pytest.raises(DealflowError, match="no history"):
_ = d.current_stage


# ---------------------------------------------------------------------------
# CLI: --min-win-rate out of range
# ---------------------------------------------------------------------------

def test_cli_min_win_rate_above_one_returns_error(capsys):
rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-win-rate", "1.5"])
assert rc == 2
err = capsys.readouterr().err
assert "0 and 1" in err


def test_cli_min_win_rate_negative_returns_error(capsys):
rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-win-rate", "-0.1"])
assert rc == 2
err = capsys.readouterr().err
assert "0 and 1" in err


def test_cli_min_forecast_negative_returns_error(capsys):
rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-forecast", "-500"])
assert rc == 2
err = capsys.readouterr().err
assert "non-negative" in err


# ---------------------------------------------------------------------------
# CLI: missing file -> exit 2
# ---------------------------------------------------------------------------

def test_cli_missing_pipeline_file_exits_2(capsys):
rc = main(["forecast", "-p", "/no/such/pipeline.yml", "-d", _DEALS])
assert rc == 2
err = capsys.readouterr().err
assert "error:" in err


def test_cli_missing_deals_file_exits_2(capsys):
rc = main(["forecast", "-p", _PIPELINE, "-d", "/no/such/deals.csv"])
assert rc == 2
err = capsys.readouterr().err
assert "error:" in err


# ---------------------------------------------------------------------------
# analyze: empty deals list is well-defined (zero totals)
# ---------------------------------------------------------------------------

def test_analyze_empty_deals():
pipe = parse_pipeline(_SIMPLE_PIPELINE_YAML)
rep = analyze(pipe, [])
assert rep.total_deals == 0
assert rep.won_deals == 0
assert rep.open_deals == 0
assert rep.weighted_forecast == 0.0
assert rep.overall_win_rate == 0.0
Loading