Skip to content
Merged
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
26 changes: 24 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
name: Release

# When a pull request is merged into main, bump the version in pyproject.toml
# (and keep uv.lock in sync), commit it back to main, and create a matching tag.
# (and keep uv.lock in sync), commit it back to main, create a matching tag,
# then build and publish the package to PyPI.
#
# Bump level is patch by default; add a "minor" or "major" label to the PR to
# bump those instead. The version-bump commit and tag are pushed with the
# built-in GITHUB_TOKEN, which by design does NOT re-trigger workflows — so this
# never loops (the `[skip ci]` marker is belt-and-suspenders).
# never loops (the `[skip ci]` marker is belt-and-suspenders). Publishing runs
# in this same job (a separate tag-triggered workflow would never fire, since
# GITHUB_TOKEN-pushed tags don't trigger workflows).
#
# PyPI publishing requires a repo secret PYPI_API_TOKEN (a PyPI API token; set
# TWINE_USERNAME=__token__). Without it the publish step fails; the bump/tag
# still succeed.
on:
pull_request:
types: [closed]
Expand Down Expand Up @@ -49,6 +56,7 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Bump version and create tag
id: bump
run: |
set -euo pipefail
CUR=$(grep -m1 -E '^version *= *"' pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')
Expand All @@ -64,6 +72,7 @@ jobs:

if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag ${TAG} already exists — nothing to do."
echo "released=false" >> "$GITHUB_OUTPUT"
exit 0
fi

Expand All @@ -75,4 +84,17 @@ jobs:
git tag -a "${TAG}" -m "Release ${TAG} (PR #${PR_NUMBER}: ${PR_TITLE})"
git push origin HEAD:main
git push origin "${TAG}"
echo "released=true" >> "$GITHUB_OUTPUT"
echo "Released ${TAG}"

- name: Build distributions
if: steps.bump.outputs.released == 'true'
run: uv build # builds from the just-bumped pyproject.toml -> dist/*

- name: Publish to PyPI
if: steps.bump.outputs.released == 'true'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
# --skip-existing keeps re-runs idempotent if the version is already up.
run: uvx twine upload --non-interactive --skip-existing dist/*
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

[![Lint](https://img.shields.io/github/actions/workflow/status/picaultj/conflens/lint.yml?branch=main&logo=github&label=lint)](https://github.com/picaultj/conflens/actions/workflows/lint.yml)
[![Tests](https://img.shields.io/github/actions/workflow/status/picaultj/conflens/test.yml?branch=main&logo=github&label=tests)](https://github.com/picaultj/conflens/actions/workflows/test.yml)
[![Downloads](https://img.shields.io/github/downloads/picaultj/conflens/total?logo=github&label=downloads)](https://github.com/picaultj/conflens/releases)
[![Release](https://img.shields.io/github/v/release/picaultj/conflens?logo=github&label=release&sort=semver)](https://github.com/picaultj/conflens/releases)
[![PyPI version](https://img.shields.io/pypi/v/conflens?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/conflens/)
[![Downloads](https://pepy.tech/badge/conflens)](https://pepy.tech/project/conflens)
[![Python versions](https://img.shields.io/pypi/pyversions/conflens?logo=python&logoColor=white)](https://pypi.org/project/conflens/)
[![Stars](https://img.shields.io/github/stars/picaultj/conflens?logo=github&style=flat)](https://github.com/picaultj/conflens/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/picaultj/conflens?logo=github)](https://github.com/picaultj/conflens/commits/main)
[![Issues](https://img.shields.io/github/issues/picaultj/conflens?logo=github)](https://github.com/picaultj/conflens/issues)
[![License: MPL 2.0](https://img.shields.io/badge/license-MPL%202.0-brightgreen.svg)](LICENSE)
[![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg?logo=python&logoColor=white)](https://www.python.org/)
[![uv](https://img.shields.io/badge/managed%20by-uv-DE5FE9.svg?logo=uv&logoColor=white)](https://docs.astral.sh/uv/)
[![Built with NiceGUI](https://img.shields.io/badge/UI-NiceGUI-2b6cb0.svg)](https://nicegui.io)

Expand Down Expand Up @@ -50,7 +50,14 @@ A desktop-style web app (built with [NiceGUI](https://nicegui.io)) that:

## Quick start

Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
Install from PyPI (Python 3.13+) and run:

```bash
pip install conflens
conflens # → http://localhost:6868
```

Or from a clone, with [uv](https://docs.astral.sh/uv/):

```bash
uv sync # Claude, OpenAI, LiteLLM work out of the box
Expand All @@ -63,8 +70,20 @@ uv run conflens # or: uv run python run.py

Then open <http://localhost:6868>.

Keys are read from `.env` (loaded automatically) or the process environment;
you can also paste a key into the app's **API key** field at runtime.
**Configuration.** `conflens` needs an LLM provider key, supplied any of three
ways:

- **Environment variable** — `export ANTHROPIC_API_KEY=…` (or `OPENAI_API_KEY`,
plus `OPENAI_BASE_URL` for an OpenAI-compatible endpoint; `LITELLM_API_KEY`).
- **A `.env` file** in the directory you launch `conflens` from — same keys, one
`NAME=value` per line. It's loaded automatically from the current working
directory (from a clone, copy [`.env.example`](.env.example) as a starting
point). Real environment variables take precedence over `.env`.
- **The in-app “API key” field** at runtime.

OpenReview venues (ICLR / NeurIPS) additionally need `OPENREVIEW_TOKEN`, or
`OPENREVIEW_USERNAME` + `OPENREVIEW_PASSWORD` — set the same way, or typed into
the fields that appear in the UI when you pick the OpenReview source.

`uv` provisions the right Python automatically (pinned to 3.13 via
`.python-version`); you don't need to install it yourself.
Expand Down Expand Up @@ -294,7 +313,13 @@ stages use a fake client), so they're fast and deterministic.

Releases are automated (`.github/workflows/release.yml`): when a PR is **merged
into `main`**, the workflow bumps the version in `pyproject.toml`, commits it
back to `main`, and creates a matching `vX.Y.Z` **tag**. The bump is a **patch**
by default — add a **`minor`** or **`major`** label to the PR to bump those
instead.
back to `main`, creates a matching `vX.Y.Z` **tag**, then **builds and publishes
the package to [PyPI](https://pypi.org/project/conflens/)** (`pip install
conflens`). The bump is a **patch** by default — add a **`minor`** or
**`major`** label to the PR to bump those instead.

Publishing needs a repo secret **`PYPI_API_TOKEN`** (a PyPI API token; the
workflow uploads with `twine` as `__token__`). Add it under *Settings → Secrets
and variables → Actions*. Without it the version bump/tag still succeed and only
the publish step fails.

14 changes: 11 additions & 3 deletions conflens/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ def _index() -> None:


def _load_env() -> None:
"""Load variables from a local .env file, if present."""
"""Load a ``.env`` from the current working directory (or above), if present.

``find_dotenv(usecwd=True)`` searches from the directory the user runs
``conflens`` in — not the installed package location — so a pip-installed CLI
picks up a ``.env`` sitting next to where it's launched. Real environment
variables always take precedence (``load_dotenv`` doesn't override them).
"""
try:
from dotenv import load_dotenv
from dotenv import find_dotenv, load_dotenv
except ImportError: # python-dotenv is a dependency, but stay defensive
return
load_dotenv()
path = find_dotenv(usecwd=True)
if path:
load_dotenv(path)


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
Expand Down
18 changes: 17 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
[project]
name = "conference-analyzer"
name = "conflens"
version = "0.1.13"
description = "Browse, theme-classify, and topic-model conference papers with a NiceGUI UI."
readme = "README.md"
requires-python = ">=3.13"
license = { text = "MPL-2.0" }
authors = [
{ name = "Jérôme Picault", email = "jerome.picault@rte-france.com" },
]
maintainers = [
{ name = "Jérôme Picault", email = "jerome.picault@rte-france.com" },
]
keywords = ["topics", "conferences", "LLM", "topic analysis", "report"]
classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Development Status :: 4 - Beta",
]
dependencies = [
"nicegui>=2.0",
"anthropic>=0.40",
Expand All @@ -23,6 +34,11 @@ bertopic = ["bertopic>=0.16"]
conflens = "conflens.cli:main"
conference-analyzer = "conflens.cli:main" # backward-compatible alias

[project.urls]
homepage = "https://github.com/picaultj/conflens"
repository = "https://github.com/picaultj/conflens"


[dependency-groups]
# Installed by default with `uv sync`; used for tests + linting / CI.
dev = ["pytest>=8", "ruff>=0.6"]
Expand Down
34 changes: 34 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os

from conflens.cli import _load_env


def test_load_env_reads_dotenv_from_run_directory(tmp_path, monkeypatch):
"""A .env in the directory conflens is launched from is picked up.

Guards the pip-installed case: `find_dotenv(usecwd=True)` must search the
working directory, not the installed package location.
"""
(tmp_path / ".env").write_text("CONFLENS_ENV_PROBE=hello\n")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CONFLENS_ENV_PROBE", raising=False)
try:
_load_env()
assert os.environ.get("CONFLENS_ENV_PROBE") == "hello"
finally:
os.environ.pop("CONFLENS_ENV_PROBE", None) # load_dotenv sets os.environ directly


def test_load_env_no_dotenv_is_noop(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) # empty dir, no .env
monkeypatch.delenv("CONFLENS_ENV_PROBE", raising=False)
_load_env() # must not raise
assert os.environ.get("CONFLENS_ENV_PROBE") is None


def test_real_env_var_takes_precedence_over_dotenv(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("CONFLENS_ENV_PROBE=from_dotenv\n")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CONFLENS_ENV_PROBE", "from_real_env")
_load_env()
assert os.environ.get("CONFLENS_ENV_PROBE") == "from_real_env" # not overridden
38 changes: 19 additions & 19 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading