From 56ed2b28b697c32fc5ef7dff46dc95b89d3caa3a Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Tue, 10 Feb 2026 15:01:21 +0100 Subject: [PATCH 01/13] cli: support new comment feature --- novem/cli/common.py | 8 ++ novem/cli/gql.py | 267 +++++++++++++++++++++++++++++++++++++++++++- novem/cli/setup.py | 9 ++ 3 files changed, 283 insertions(+), 1 deletion(-) diff --git a/novem/cli/common.py b/novem/cli/common.py index 25b8d08..0c3b93f 100644 --- a/novem/cli/common.py +++ b/novem/cli/common.py @@ -5,6 +5,7 @@ from novem import Grid, Job, Mail, Plot from novem.api_ref import Novem404, NovemAPI from novem.cli.editor import edit +from novem.cli.gql import NovemGQL, fetch_topics_gql, render_topics from novem.cli.setup import Share, Tag from novem.cli.vis import ( list_job_shares, @@ -149,6 +150,13 @@ def __call__(self, args: Dict[str, Any]) -> None: print(ts) return + # --comments: show topics and comment threads + if args.get("comments"): + gql = NovemGQL(**args) + topics = fetch_topics_gql(gql, self.fragment, name) + print(render_topics(topics)) + return + # if we have the -e or edit flag then this takes presedence over all other # inputs if "edit" in args and args["edit"]: diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 47cb251..ce91137 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -5,13 +5,16 @@ while the core data operations remain REST-based. """ +import datetime import json import re +import shutil +import textwrap from typing import Any, Dict, List, Optional, Set import requests -from ..utils import API_ROOT, get_current_config +from ..utils import API_ROOT, cl, colors, get_current_config, parse_api_datetime def _get_gql_endpoint(api_root: str) -> str: @@ -1283,3 +1286,265 @@ def list_org_group_vis_gql(gql: NovemGQL, org_id: str, group_id: str, vis_type: variables = {"orgId": org_id} data = gql._query(LIST_ORG_GROUP_VIS_QUERY, variables) return _transform_org_group_vis_response(data, group_id, vis_type) + + +# --- Topics / Comments --- + +_COMMENT_FIELDS = """ + comment_id + slug + message + depth + deleted + edited + num_replies + likes + dislikes + created + updated + creator { username } +""" + + +def _build_comment_fragment(depth: int = 4) -> str: + """Build a nested comment/replies fragment to the given depth.""" + fragment = _COMMENT_FIELDS + for _ in range(depth): + fragment = f""" + {_COMMENT_FIELDS} + replies {{{fragment} + }}""" + return fragment + + +_TOPICS_QUERY_TPL = """ +query GetTopics($id: ID!) {{ + {vis_type}(id: $id) {{ + topics {{ + topic_id + slug + message + audience + status + num_comments + likes + dislikes + edited + created + updated + creator {{ username }} + comments {{{comment_fragment} + }} + }} + }} +}} +""" + + +def _build_topics_query(vis_type: str) -> str: + """Build a topics query for a given vis type (plots, grids, mails, etc.).""" + comment_fragment = _build_comment_fragment(4) + return _TOPICS_QUERY_TPL.format(vis_type=vis_type, comment_fragment=comment_fragment) + + +def fetch_topics_gql(gql: NovemGQL, vis_type: str, vis_id: str) -> List[Dict[str, Any]]: + """Fetch topics and comments for a visualisation.""" + query = _build_topics_query(vis_type) + data = gql._query(query, {"id": vis_id}) + items = data.get(vis_type, []) + if not items: + return [] + return items[0].get("topics", []) + + +def _relative_time(dt: datetime.datetime) -> str: + """Return a human-friendly relative time string.""" + now = datetime.datetime.now(datetime.timezone.utc) + delta = now - dt + seconds = int(delta.total_seconds()) + + if seconds < 60: + return "just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days < 30: + return f"{days}d ago" + return dt.strftime("%b %d, %Y") + + +def _visible_len(s: str) -> int: + """Return the visible length of a string, ignoring ANSI escape codes.""" + return len(re.sub(r"\033\[[0-9;]*m", "", s)) + + +def _wrap_text(text: str, prefix: str, width: int) -> List[str]: + """Wrap text to fit within width, prepending prefix to each line.""" + indent_width = _visible_len(prefix) + available = width - indent_width + if available < 20: + available = 20 + + result: List[str] = [] + for line in text.splitlines(): + if not line: + result.append(prefix) + else: + wrapped = textwrap.wrap(line, width=available) or [""] + for wl in wrapped: + result.append(f"{prefix}{wl}") + return result + + +def _get_term_width() -> int: + """Get terminal width, capped at 120.""" + return min(120, shutil.get_terminal_size().columns) + + +def _render_comment(comment: Dict[str, Any], prefix: str, connector: str, child_prefix: str, width: int = 0) -> str: + """Render a single comment and its replies as a tree.""" + if not width: + width = _get_term_width() + + lines: List[str] = [] + + username = comment.get("creator", {}).get("username", "?") + message = comment.get("message", "") or "" + deleted = comment.get("deleted", False) + edited = comment.get("edited", False) + created_str = comment.get("created", "") + + # Timestamp + ts = "" + dt = parse_api_datetime(created_str) + if dt: + ts = _relative_time(dt) + + # Markers + markers: List[str] = [] + if edited: + markers.append("edited") + if deleted: + markers.append("deleted") + marker_str = f" {cl.FGGRAY}({', '.join(markers)}){cl.ENDC}" if markers else "" + + # Reactions + reactions: List[str] = [] + likes = comment.get("likes", 0) + dislikes = comment.get("dislikes", 0) + if likes: + reactions.append(f"+{likes}") + if dislikes: + reactions.append(f"-{dislikes}") + reaction_str = f" {cl.FGGRAY}[{' '.join(reactions)}]{cl.ENDC}" if reactions else "" + + # Header line + header = ( + f"{prefix}{connector}" + f"{cl.OKCYAN}@{username}{cl.ENDC}" + f" {cl.FGGRAY}·{cl.ENDC} " + f"{cl.FGGRAY}{ts}{cl.ENDC}" + f"{marker_str}{reaction_str}" + ) + lines.append(header) + + # Message body + body_prefix = f"{prefix}{child_prefix}" + if deleted: + lines.append(f"{body_prefix}{cl.FGGRAY}[deleted]{cl.ENDC}") + elif message: + lines.extend(_wrap_text(message, body_prefix, width)) + + # Replies + replies = comment.get("replies", []) or [] + for i, reply in enumerate(replies): + is_last = i == len(replies) - 1 + rc = "└─ " if is_last else "├─ " + rp = " " if is_last else "│ " + lines.append(_render_comment(reply, f"{prefix}{child_prefix}", rc, rp, width)) + + return "\n".join(lines) + + +def render_topics(topics: List[Dict[str, Any]]) -> str: + """Render a list of topics with their comment trees.""" + colors() + + if not topics: + return f"{cl.FGGRAY}No topics{cl.ENDC}" + + width = _get_term_width() + parts: List[str] = [] + + for topic in topics: + lines: List[str] = [] + + username = topic.get("creator", {}).get("username", "?") + message = topic.get("message", "") or "" + audience = topic.get("audience", "") + status = topic.get("status", "") + num_comments = topic.get("num_comments", 0) + edited = topic.get("edited", False) + created_str = topic.get("created", "") + + # Timestamp + ts = "" + dt = parse_api_datetime(created_str) + if dt: + ts = _relative_time(dt) + + # Metadata tags + tags: List[str] = [] + if audience: + tags.append(audience) + if status and status != "active": + tags.append(status) + tag_str = f" {cl.FGGRAY}({', '.join(tags)}){cl.ENDC}" if tags else "" + + # Reactions + reactions: List[str] = [] + likes = topic.get("likes", 0) + dislikes = topic.get("dislikes", 0) + if likes: + reactions.append(f"+{likes}") + if dislikes: + reactions.append(f"-{dislikes}") + reaction_str = f" {cl.FGGRAY}[{' '.join(reactions)}]{cl.ENDC}" if reactions else "" + + edited_str = f" {cl.FGGRAY}(edited){cl.ENDC}" if edited else "" + + comment_count = f" {cl.FGGRAY}· {num_comments} comment{'s' if num_comments != 1 else ''}{cl.ENDC}" + + # Topic header + header = ( + f"{cl.BOLD}┌{cl.ENDC} " + f"{cl.OKCYAN}@{username}{cl.ENDC}" + f" {cl.FGGRAY}·{cl.ENDC} " + f"{cl.FGGRAY}{ts}{cl.ENDC}" + f"{tag_str}{edited_str}{reaction_str}{comment_count}" + ) + lines.append(header) + + # Topic body + body_prefix = f"{cl.BOLD}│{cl.ENDC} " + if message: + lines.extend(_wrap_text(message, body_prefix, width)) + + # Comments + comments = topic.get("comments", []) or [] + for i, comment in enumerate(comments): + is_last = i == len(comments) - 1 + connector = "├─ " if not is_last else "└─ " + child_prefix = "│ " if not is_last else " " + lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width)) + + if not comments: + lines.append(f"{cl.FGGRAY}(no comments){cl.ENDC}") + + parts.append("\n".join(lines)) + + return "\n\n".join(parts) diff --git a/novem/cli/setup.py b/novem/cli/setup.py index 2c17edb..3bfde60 100644 --- a/novem/cli/setup.py +++ b/novem/cli/setup.py @@ -299,6 +299,15 @@ def setup(raw_args: Any = None) -> Tuple[Any, Dict[str, str]]: help="specify entity to view vis for, @username, +org, @username~usergroup or +org~orggroup are supported", ) + vis.add_argument( + "--comments", + dest="comments", + action="store_true", + required=False, + default=False, + help="show topics and comments for the visualisation", + ) + vis.add_argument( "--tree", metavar=("PATH"), From f1b0d210ef0b23e3d9f7db9da4338b06b53da2a1 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 13:36:23 +0100 Subject: [PATCH 02/13] comment tests --- tests/test_cli_comments.py | 463 +++++++++++++++++++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 tests/test_cli_comments.py diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py new file mode 100644 index 0000000..fb0bffb --- /dev/null +++ b/tests/test_cli_comments.py @@ -0,0 +1,463 @@ +import json +import re + +from novem.cli.gql import ( + _build_comment_fragment, + _build_topics_query, + _get_gql_endpoint, + _relative_time, + _visible_len, + _wrap_text, + render_topics, +) +from novem.utils import API_ROOT + +from .utils import write_config + +gql_endpoint = _get_gql_endpoint(API_ROOT) + +auth_req = { + "username": "demouser", + "password": "demopass", + "token_name": "demotoken", + "token_description": "test token", +} + + +def _strip_ansi(s: str) -> str: + """Remove ANSI escape codes for easier assertion.""" + return re.sub(r"\033\[[0-9;]*m", "", s) + + +# --- Unit tests for helpers --- + + +class TestVisibleLen: + def test_plain_text(self) -> None: + assert _visible_len("hello") == 5 + + def test_with_ansi(self) -> None: + assert _visible_len("\033[96m@alice\033[0m") == 6 + + def test_empty(self) -> None: + assert _visible_len("") == 0 + + def test_multiple_codes(self) -> None: + s = "\033[1m┌\033[0m \033[96m@bob\033[0m \033[38;5;246m· 3h ago\033[0m" + assert _visible_len(s) == len("┌ @bob · 3h ago") + + +class TestRelativeTime: + def test_just_now(self) -> None: + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + assert _relative_time(now) == "just now" + + def test_minutes(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=5) + assert _relative_time(dt) == "5m ago" + + def test_hours(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=3) + assert _relative_time(dt) == "3h ago" + + def test_days(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) + assert _relative_time(dt) == "7d ago" + + def test_old_date(self) -> None: + import datetime + + dt = datetime.datetime(2024, 1, 15, tzinfo=datetime.timezone.utc) + result = _relative_time(dt) + assert "Jan 15, 2024" == result + + +class TestWrapText: + def test_short_text(self) -> None: + lines = _wrap_text("hello", "│ ", 80) + assert lines == ["│ hello"] + + def test_wraps_long_text(self) -> None: + text = "word " * 30 # 150 chars + lines = _wrap_text(text.strip(), "│ ", 40) + assert len(lines) > 1 + for line in lines: + assert line.startswith("│ ") + + def test_preserves_newlines(self) -> None: + lines = _wrap_text("line one\nline two", "│ ", 80) + assert lines == ["│ line one", "│ line two"] + + def test_empty_lines(self) -> None: + lines = _wrap_text("above\n\nbelow", "│ ", 80) + assert lines == ["│ above", "│ ", "│ below"] + + def test_minimum_width(self) -> None: + # Even with a very wide prefix, should use at least 20 chars + lines = _wrap_text("some text", "x" * 100, 50) + assert len(lines) >= 1 + + +# --- Unit tests for query building --- + + +class TestBuildCommentFragment: + def test_contains_comment_fields(self) -> None: + frag = _build_comment_fragment(1) + assert "comment_id" in frag + assert "message" in frag + assert "creator { username }" in frag + assert "replies" in frag + + def test_nesting_depth(self) -> None: + frag = _build_comment_fragment(3) + # Should have 3 levels of "replies {" + assert frag.count("replies {") == 3 + + +class TestBuildTopicsQuery: + def test_plots_query(self) -> None: + q = _build_topics_query("plots") + assert "plots(id: $id)" in q + assert "topics {" in q + assert "comments {" in q + + def test_grids_query(self) -> None: + q = _build_topics_query("grids") + assert "grids(id: $id)" in q + + def test_mails_query(self) -> None: + q = _build_topics_query("mails") + assert "mails(id: $id)" in q + + +# --- Unit tests for render_topics --- + + +def _make_topic( + topic_id: int = 1, + username: str = "alice", + message: str = "Hello world", + audience: str = "public", + status: str = "active", + num_comments: int = 0, + likes: int = 0, + dislikes: int = 0, + edited: bool = False, + created: str = "Mon, 10 Feb 2026 12:00:00 UTC", + comments: list = None, +) -> dict: + return { + "topic_id": topic_id, + "slug": f"topic-{topic_id}", + "message": message, + "audience": audience, + "status": status, + "num_comments": num_comments, + "likes": likes, + "dislikes": dislikes, + "edited": edited, + "created": created, + "updated": created, + "creator": {"username": username}, + "comments": comments or [], + } + + +def _make_comment( + comment_id: int = 1, + username: str = "bob", + message: str = "Nice post", + depth: int = 0, + deleted: bool = False, + edited: bool = False, + likes: int = 0, + dislikes: int = 0, + created: str = "Mon, 10 Feb 2026 12:05:00 UTC", + replies: list = None, +) -> dict: + return { + "comment_id": comment_id, + "slug": f"comment-{comment_id}", + "message": message, + "depth": depth, + "deleted": deleted, + "edited": edited, + "num_replies": len(replies) if replies else 0, + "likes": likes, + "dislikes": dislikes, + "created": created, + "updated": created, + "creator": {"username": username}, + "replies": replies or [], + } + + +class TestRenderTopics: + def test_no_topics(self) -> None: + result = _strip_ansi(render_topics([])) + assert result == "No topics" + + def test_topic_header(self) -> None: + topics = [_make_topic(username="alice", audience="public", num_comments=0)] + result = _strip_ansi(render_topics(topics)) + assert "@alice" in result + assert "(public)" in result + assert "0 comments" in result + + def test_topic_body(self) -> None: + topics = [_make_topic(message="This is the topic body")] + result = _strip_ansi(render_topics(topics)) + assert "This is the topic body" in result + + def test_no_comments_message(self) -> None: + topics = [_make_topic(comments=[])] + result = _strip_ansi(render_topics(topics)) + assert "(no comments)" in result + + def test_single_comment(self) -> None: + comment = _make_comment(username="bob", message="Great work") + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "@bob" in result + assert "Great work" in result + assert "1 comment" in result + # Should not say "1 comments" + assert "1 comments" not in result + + def test_plural_comments(self) -> None: + comments = [ + _make_comment(comment_id=1, username="bob", message="First"), + _make_comment(comment_id=2, username="carol", message="Second"), + ] + topics = [_make_topic(num_comments=2, comments=comments)] + result = _strip_ansi(render_topics(topics)) + assert "2 comments" in result + assert "@bob" in result + assert "@carol" in result + + def test_nested_replies(self) -> None: + reply = _make_comment(comment_id=2, username="carol", message="Reply here", depth=1) + comment = _make_comment(comment_id=1, username="bob", message="Top level", replies=[reply]) + topics = [_make_topic(num_comments=2, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "@bob" in result + assert "Top level" in result + assert "@carol" in result + assert "Reply here" in result + + def test_deep_nesting(self) -> None: + c3 = _make_comment(comment_id=3, username="dave", message="Deep reply", depth=2) + c2 = _make_comment(comment_id=2, username="carol", message="Mid reply", depth=1, replies=[c3]) + c1 = _make_comment(comment_id=1, username="bob", message="Top", replies=[c2]) + topics = [_make_topic(num_comments=3, comments=[c1])] + result = _strip_ansi(render_topics(topics)) + assert "@dave" in result + assert "Deep reply" in result + + def test_deleted_comment(self) -> None: + comment = _make_comment(deleted=True, message="secret") + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "[deleted]" in result + # Deleted message body should not be shown + assert "secret" not in result + + def test_edited_markers(self) -> None: + comment = _make_comment(edited=True) + topics = [_make_topic(edited=True, num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + # Both topic and comment should show edited + assert result.count("(edited)") == 2 + + def test_reactions(self) -> None: + comment = _make_comment(likes=5, dislikes=2) + topics = [_make_topic(likes=3, num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "[+3]" in result + assert "[+5 -2]" in result + + def test_archived_status(self) -> None: + topics = [_make_topic(status="archived")] + result = _strip_ansi(render_topics(topics)) + assert "archived" in result + + def test_active_status_not_shown(self) -> None: + topics = [_make_topic(status="active", audience="public")] + result = _strip_ansi(render_topics(topics)) + # "active" should be suppressed, only "public" shown + assert "active" not in result + + def test_multiple_topics(self) -> None: + topics = [ + _make_topic(topic_id=1, username="alice", message="First topic"), + _make_topic(topic_id=2, username="bob", message="Second topic"), + ] + result = _strip_ansi(render_topics(topics)) + assert "@alice" in result + assert "First topic" in result + assert "@bob" in result + assert "Second topic" in result + + def test_multiline_message(self) -> None: + topics = [_make_topic(message="Line one\nLine two\nLine three")] + result = _strip_ansi(render_topics(topics)) + assert "Line one" in result + assert "Line two" in result + assert "Line three" in result + + def test_no_bottom_separator_line(self) -> None: + comment = _make_comment() + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + # Should not have a full-width separator line + assert "──────" not in result + + def test_dense_output_no_blank_lines_between_siblings(self) -> None: + comments = [ + _make_comment(comment_id=1, username="bob", message="First"), + _make_comment(comment_id=2, username="carol", message="Second"), + ] + topics = [_make_topic(num_comments=2, comments=comments)] + result = _strip_ansi(render_topics(topics)) + lines = result.split("\n") + # No empty lines within the topic (except between topics) + for line in lines: + assert line.strip() != "" or line == "" # Allow only between topics + + +# --- CLI integration tests --- + + +def test_comments_empty(cli, requests_mock, fs) -> None: + """Test --comments on a plot with no topics.""" + write_config(auth_req) + + gql_response = {"data": {"plots": [{"topics": []}]}} + requests_mock.register_uri("POST", gql_endpoint, text=lambda r, c: json.dumps(gql_response)) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/my_plot", status_code=201) + + out, err = cli("-p", "my_plot", "--comments") + assert "No topics" in out + + +def test_comments_with_data(cli, requests_mock, fs) -> None: + """Test --comments shows topic and comment content.""" + write_config(auth_req) + + gql_response = { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "test-topic", + "message": "Discussion about the chart", + "audience": "public", + "status": "active", + "num_comments": 1, + "likes": 0, + "dislikes": 0, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "comment-1", + "message": "Looks good!", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 0, + "likes": 0, + "dislikes": 0, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [], + } + ], + } + ] + } + ] + } + } + requests_mock.register_uri("POST", gql_endpoint, text=lambda r, c: json.dumps(gql_response)) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/my_plot", status_code=201) + + out, err = cli("-p", "my_plot", "--comments") + plain = _strip_ansi(out) + assert "@alice" in plain + assert "Discussion about the chart" in plain + assert "@bob" in plain + assert "Looks good!" in plain + + +def test_comments_sends_correct_gql_query(cli, requests_mock, fs) -> None: + """Test --comments sends a properly formed GQL query with vis ID.""" + write_config(auth_req) + + captured_query = None + + def capture_gql(request, context): + nonlocal captured_query + body = request.json() + captured_query = body.get("query", "") + return json.dumps({"data": {"plots": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=capture_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/test_chart", status_code=201) + + cli("-p", "test_chart", "--comments") + + assert captured_query is not None + assert "plots(id: $id)" in captured_query + assert "topics" in captured_query + assert "comments" in captured_query + + +def test_comments_grid(cli, requests_mock, fs) -> None: + """Test --comments works with grids too.""" + write_config(auth_req) + + captured_query = None + + def capture_gql(request, context): + nonlocal captured_query + body = request.json() + captured_query = body.get("query", "") + return json.dumps({"data": {"grids": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=capture_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/grids/my_grid", status_code=201) + + out, err = cli("-g", "my_grid", "--comments") + assert "No topics" in out + assert captured_query is not None + assert "grids(id: $id)" in captured_query + + +def test_comments_mail(cli, requests_mock, fs) -> None: + """Test --comments works with mails too.""" + write_config(auth_req) + + def return_gql(request, context): + return json.dumps({"data": {"mails": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=return_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/mails/my_mail", status_code=201) + + out, err = cli("-m", "my_mail", "--comments") + assert "No topics" in out From 5695cef2cf6e932adb4680aaa07c3af090d9f89e Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 13:58:43 +0100 Subject: [PATCH 03/13] comments: get correct profile --- novem/cli/common.py | 4 +++- novem/cli/gql.py | 13 +++++++++---- tests/test_cli_comments.py | 10 +++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/novem/cli/common.py b/novem/cli/common.py index 0c3b93f..6825581 100644 --- a/novem/cli/common.py +++ b/novem/cli/common.py @@ -152,8 +152,10 @@ def __call__(self, args: Dict[str, Any]) -> None: # --comments: show topics and comment threads if args.get("comments"): + if "profile" in args and args["profile"]: + args["config_profile"] = args["profile"] gql = NovemGQL(**args) - topics = fetch_topics_gql(gql, self.fragment, name) + topics = fetch_topics_gql(gql, self.fragment, name, author=usr) print(render_topics(topics)) return diff --git a/novem/cli/gql.py b/novem/cli/gql.py index ce91137..c3d8083 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -1300,6 +1300,7 @@ def list_org_group_vis_gql(gql: NovemGQL, org_id: str, group_id: str, vis_type: num_replies likes dislikes + my_reaction created updated creator { username } @@ -1318,8 +1319,8 @@ def _build_comment_fragment(depth: int = 4) -> str: _TOPICS_QUERY_TPL = """ -query GetTopics($id: ID!) {{ - {vis_type}(id: $id) {{ +query GetTopics($id: ID!, $author: String) {{ + {vis_type}(id: $id, author: $author) {{ topics {{ topic_id slug @@ -1329,6 +1330,7 @@ def _build_comment_fragment(depth: int = 4) -> str: num_comments likes dislikes + my_reaction edited created updated @@ -1347,10 +1349,13 @@ def _build_topics_query(vis_type: str) -> str: return _TOPICS_QUERY_TPL.format(vis_type=vis_type, comment_fragment=comment_fragment) -def fetch_topics_gql(gql: NovemGQL, vis_type: str, vis_id: str) -> List[Dict[str, Any]]: +def fetch_topics_gql(gql: NovemGQL, vis_type: str, vis_id: str, author: Optional[str] = None) -> List[Dict[str, Any]]: """Fetch topics and comments for a visualisation.""" query = _build_topics_query(vis_type) - data = gql._query(query, {"id": vis_id}) + variables: Dict[str, Any] = {"id": vis_id} + if author: + variables["author"] = author + data = gql._query(query, variables) items = data.get(vis_type, []) if not items: return [] diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index fb0bffb..019cdf0 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -126,17 +126,17 @@ def test_nesting_depth(self) -> None: class TestBuildTopicsQuery: def test_plots_query(self) -> None: q = _build_topics_query("plots") - assert "plots(id: $id)" in q + assert "plots(id: $id, author: $author)" in q assert "topics {" in q assert "comments {" in q def test_grids_query(self) -> None: q = _build_topics_query("grids") - assert "grids(id: $id)" in q + assert "grids(id: $id, author: $author)" in q def test_mails_query(self) -> None: q = _build_topics_query("mails") - assert "mails(id: $id)" in q + assert "mails(id: $id, author: $author)" in q # --- Unit tests for render_topics --- @@ -423,7 +423,7 @@ def capture_gql(request, context): cli("-p", "test_chart", "--comments") assert captured_query is not None - assert "plots(id: $id)" in captured_query + assert "plots(id: $id, author: $author)" in captured_query assert "topics" in captured_query assert "comments" in captured_query @@ -446,7 +446,7 @@ def capture_gql(request, context): out, err = cli("-g", "my_grid", "--comments") assert "No topics" in out assert captured_query is not None - assert "grids(id: $id)" in captured_query + assert "grids(id: $id, author: $author)" in captured_query def test_comments_mail(cli, requests_mock, fs) -> None: From c241ed9b84427ce29bbcd4d61d37ecce3e858c48 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:06:01 +0100 Subject: [PATCH 04/13] comments: tighten spacing --- novem/cli/gql.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index c3d8083..9749657 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -1468,8 +1468,8 @@ def _render_comment(comment: Dict[str, Any], prefix: str, connector: str, child_ replies = comment.get("replies", []) or [] for i, reply in enumerate(replies): is_last = i == len(replies) - 1 - rc = "└─ " if is_last else "├─ " - rp = " " if is_last else "│ " + rc = "└ " if is_last else "├ " + rp = " " if is_last else "│ " lines.append(_render_comment(reply, f"{prefix}{child_prefix}", rc, rp, width)) return "\n".join(lines) @@ -1543,8 +1543,8 @@ def render_topics(topics: List[Dict[str, Any]]) -> str: comments = topic.get("comments", []) or [] for i, comment in enumerate(comments): is_last = i == len(comments) - 1 - connector = "├─ " if not is_last else "└─ " - child_prefix = "│ " if not is_last else " " + connector = "├ " if not is_last else "└ " + child_prefix = "│ " if not is_last else " " lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width)) if not comments: From 600b4fe55dfde43322e240e7df92d2c570ae7c3e Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:09:57 +0100 Subject: [PATCH 05/13] comment: terminate topic ends --- novem/cli/gql.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 9749657..9e4c718 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -1548,8 +1548,14 @@ def render_topics(topics: List[Dict[str, Any]]) -> str: lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width)) if not comments: - lines.append(f"{cl.FGGRAY}(no comments){cl.ENDC}") - - parts.append("\n".join(lines)) + lines.append(f"{cl.BOLD}└{cl.ENDC} {cl.FGGRAY}(no comments){cl.ENDC}") + + # Cap the last line: replace topic-level bold │ with bold └ + topic_text = "\n".join(lines) + all_lines = topic_text.split("\n") + bold_pipe = f"{cl.BOLD}│{cl.ENDC}" + bold_cap = f"{cl.BOLD}└{cl.ENDC}" + all_lines[-1] = all_lines[-1].replace(bold_pipe, bold_cap, 1) + parts.append("\n".join(all_lines)) return "\n\n".join(parts) From db326b9fdb738c2406982f4144b0260742d058e1 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:26:02 +0100 Subject: [PATCH 06/13] cli: add activity info --- novem/cli/gql.py | 32 +++++++++++++++++++++ novem/cli/vis.py | 38 +++++++++++++++++++++++++ tests/test_cli_comments.py | 58 ++++++++++++++++++++++++++++++++++++++ tests/test_cli_grids.py | 14 ++++++++- tests/test_cli_plots.py | 14 ++++++++- 5 files changed, 154 insertions(+), 2 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 9e4c718..eeb1660 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -107,6 +107,11 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + topics { + num_comments + likes + dislikes + } } } """ @@ -131,6 +136,11 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + topics { + num_comments + likes + dislikes + } } } """ @@ -155,6 +165,11 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + topics { + num_comments + likes + dislikes + } } } """ @@ -179,6 +194,11 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + topics { + num_comments + likes + dislikes + } last_run_status last_run_time run_count @@ -271,6 +291,16 @@ def _get_markers(tags: List[Dict[str, Any]]) -> str: return fav + like +def _aggregate_activity(item: Dict[str, Any]) -> Dict[str, int]: + """Sum topic-level comments, likes, and dislikes for a visualization.""" + topics = item.get("topics", []) or [] + return { + "_comments": sum(t.get("num_comments", 0) for t in topics), + "_likes": sum(t.get("likes", 0) for t in topics), + "_dislikes": sum(t.get("dislikes", 0) for t in topics), + } + + def _transform_vis_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Transform GraphQL visualization response to match REST format. @@ -291,6 +321,7 @@ def _transform_vis_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any]] "updated": item.get("updated", ""), "shared": _transform_shared(item.get("public", False), item.get("shared", [])), "fav": _get_markers(item.get("tags", [])), + **_aggregate_activity(item), } result.append(transformed) return result @@ -360,6 +391,7 @@ def _transform_jobs_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any] "current_step": item.get("current_step"), "schedule": item.get("schedule", ""), "triggers": item.get("triggers", []), + **_aggregate_activity(item), } result.append(transformed) return result diff --git a/novem/cli/vis.py b/novem/cli/vis.py index 90281eb..d668bed 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -24,6 +24,21 @@ ) +def _compact_num(n: int) -> str: + """Format a number compactly: 0→'-', 1–999 as-is, 1k, 1.2k, 1M, etc.""" + if not n: + return "-" + if n < 1000: + return str(n) + if n < 100_000: + v = n / 1000 + return f"{v:.1f}k".replace(".0k", "k") + if n < 1_000_000: + return f"{n // 1000}k" + v = n / 1_000_000 + return f"{v:.1f}M".replace(".0M", "M") + + def list_vis(args: Dict[str, Any], type: str) -> None: colors() # get current plot list @@ -166,6 +181,12 @@ def fav_fmt(markers: str, cl: cl) -> str: "fmt": share_fmt, "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -198,6 +219,11 @@ def fav_fmt(markers: str, cl: cl) -> str: if dt: p["updated"] = format_datetime_local(dt) + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + striped: bool = config.get("cli_striped", False) ppl = pretty_format(plist, ppo, striped=striped) @@ -831,6 +857,12 @@ def fav_fmt(markers: str, cl: cl) -> str: "fmt": share_fmt, "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, { "key": "triggers", "header": "Trigger", @@ -884,6 +916,12 @@ def fav_fmt(markers: str, cl: cl) -> str: # Last run - format last_run_time as relative time p["_last_run"] = _format_time_ago(p.get("last_run_time", "")) + # Activity + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + # Calculate max widths for right-aligned columns (must be at least header width) max_steps = max(max((len(p["_steps"]) for p in plist), default=0), len("Steps")) max_runs = max(max((len(p["run_count"]) for p in plist), default=0), len("Runs")) diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 019cdf0..5b9584b 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -2,6 +2,7 @@ import re from novem.cli.gql import ( + _aggregate_activity, _build_comment_fragment, _build_topics_query, _get_gql_endpoint, @@ -10,6 +11,7 @@ _wrap_text, render_topics, ) +from novem.cli.vis import _compact_num from novem.utils import API_ROOT from .utils import write_config @@ -461,3 +463,59 @@ def return_gql(request, context): out, err = cli("-m", "my_mail", "--comments") assert "No topics" in out + + +# --- Unit tests for _compact_num --- + + +class TestCompactNum: + def test_zero(self) -> None: + assert _compact_num(0) == "-" + + def test_small(self) -> None: + assert _compact_num(1) == "1" + assert _compact_num(42) == "42" + assert _compact_num(999) == "999" + + def test_thousands(self) -> None: + assert _compact_num(1000) == "1k" + assert _compact_num(1200) == "1.2k" + assert _compact_num(1050) == "1.1k" + assert _compact_num(9999) == "10k" + assert _compact_num(10000) == "10k" + assert _compact_num(99999) == "100k" + + def test_large(self) -> None: + assert _compact_num(100000) == "100k" + assert _compact_num(999999) == "999k" + assert _compact_num(1000000) == "1M" + assert _compact_num(1500000) == "1.5M" + assert _compact_num(10000000) == "10M" + + +# --- Unit tests for _aggregate_activity --- + + +class TestAggregateActivity: + def test_no_topics(self) -> None: + result = _aggregate_activity({}) + assert result == {"_comments": 0, "_likes": 0, "_dislikes": 0} + + def test_empty_topics(self) -> None: + result = _aggregate_activity({"topics": []}) + assert result == {"_comments": 0, "_likes": 0, "_dislikes": 0} + + def test_single_topic(self) -> None: + result = _aggregate_activity({"topics": [{"num_comments": 5, "likes": 3, "dislikes": 1}]}) + assert result == {"_comments": 5, "_likes": 3, "_dislikes": 1} + + def test_multiple_topics(self) -> None: + result = _aggregate_activity( + { + "topics": [ + {"num_comments": 2, "likes": 1, "dislikes": 0}, + {"num_comments": 3, "likes": 4, "dislikes": 2}, + ] + } + ) + assert result == {"_comments": 5, "_likes": 5, "_dislikes": 2} diff --git a/tests/test_cli_grids.py b/tests/test_cli_grids.py index dcbb379..2341530 100644 --- a/tests/test_cli_grids.py +++ b/tests/test_cli_grids.py @@ -1,7 +1,8 @@ from functools import partial from novem.cli.gql import _get_gql_endpoint -from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _compact_num +from novem.utils import API_ROOT, cl, colors, format_datetime_local, parse_api_datetime, pretty_format from .utils import write_config @@ -315,6 +316,12 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -341,11 +348,16 @@ def fav_fmt(markers, cl): "overflow": "truncate", }, ] + colors() plist = user_grid_list for p in plist: dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" expected = pretty_format(plist, ppo) + "\n" diff --git a/tests/test_cli_plots.py b/tests/test_cli_plots.py index aa9a7cb..22bdddc 100644 --- a/tests/test_cli_plots.py +++ b/tests/test_cli_plots.py @@ -4,7 +4,8 @@ import pytest from novem.cli.gql import _get_gql_endpoint -from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _compact_num +from novem.utils import API_ROOT, cl, colors, format_datetime_local, parse_api_datetime, pretty_format from tests.conftest import CliExit from .utils import write_config @@ -317,6 +318,12 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -343,11 +350,16 @@ def fav_fmt(markers, cl): "overflow": "truncate", }, ] + colors() plist = user_plot_list for p in plist: dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" ppl = pretty_format(plist, ppo) From 0a0f4149511f02b73c6162722e2c250789a7b9ea Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:34:43 +0100 Subject: [PATCH 07/13] comments: fix activity layout --- novem/cli/vis.py | 41 ++++++++++++++----- tests/test_cli_comments.py | 80 +++++++++++++++++++++++++++++++++++++- tests/test_cli_grids.py | 9 ++--- tests/test_cli_plots.py | 9 ++--- 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/novem/cli/vis.py b/novem/cli/vis.py index d668bed..159ccb7 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -39,6 +39,36 @@ def _compact_num(n: int) -> str: return f"{v:.1f}M".replace(".0M", "M") +def _format_activity(plist: List[Dict[str, Any]]) -> None: + """Pre-format the _activity column with right-aligned components.""" + # Compute compact strings for each row + rows = [] + for p in plist: + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + rows.append((c, lk, d)) + + if not rows: + return + + # Max width per component + mc = max(len(r[0]) for r in rows) + ml = max(len(r[1]) for r in rows) + md = max(len(r[2]) for r in rows) + + # Ensure total width is at least len("Activity") = 8 + total = mc + 1 + ml + 1 + md + pad = max(0, 8 - total) + mc += pad # add extra padding to leftmost component + + for p, (c, lk, d) in zip(plist, rows): + cs = c.rjust(mc) + ls = lk.rjust(ml) + ds = d.rjust(md) + p["_activity"] = f"{cs} {cl.OKBLUE}{ls}{cl.ENDFGC} {cl.FAIL}{ds}{cl.ENDFGC}" + + def list_vis(args: Dict[str, Any], type: str) -> None: colors() # get current plot list @@ -219,10 +249,7 @@ def fav_fmt(markers: str, cl: cl) -> str: if dt: p["updated"] = format_datetime_local(dt) - c = _compact_num(p.get("_comments", 0)) - lk = _compact_num(p.get("_likes", 0)) - d = _compact_num(p.get("_dislikes", 0)) - p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + _format_activity(plist) striped: bool = config.get("cli_striped", False) ppl = pretty_format(plist, ppo, striped=striped) @@ -916,11 +943,7 @@ def fav_fmt(markers: str, cl: cl) -> str: # Last run - format last_run_time as relative time p["_last_run"] = _format_time_ago(p.get("last_run_time", "")) - # Activity - c = _compact_num(p.get("_comments", 0)) - lk = _compact_num(p.get("_likes", 0)) - d = _compact_num(p.get("_dislikes", 0)) - p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + _format_activity(plist) # Calculate max widths for right-aligned columns (must be at least header width) max_steps = max(max((len(p["_steps"]) for p in plist), default=0), len("Steps")) diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 5b9584b..f962ab5 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -11,8 +11,8 @@ _wrap_text, render_topics, ) -from novem.cli.vis import _compact_num -from novem.utils import API_ROOT +from novem.cli.vis import _compact_num, _format_activity +from novem.utils import API_ROOT, colors from .utils import write_config @@ -519,3 +519,79 @@ def test_multiple_topics(self) -> None: } ) assert result == {"_comments": 5, "_likes": 5, "_dislikes": 2} + + +# --- Unit tests for _format_activity alignment --- + + +class TestFormatActivity: + def _plain(self, p: dict) -> str: + """Strip ANSI from the _activity value.""" + return _strip_ansi(p["_activity"]) + + def test_all_zeros(self) -> None: + colors() + plist = [{"_comments": 0, "_likes": 0, "_dislikes": 0}] + _format_activity(plist) + assert self._plain(plist[0]) == " - - -" # padded to "Activity" width + + def test_single_digits(self) -> None: + colors() + plist = [{"_comments": 1, "_likes": 2, "_dislikes": 3}] + _format_activity(plist) + assert self._plain(plist[0]) == " 1 2 3" # padded to "Activity" width + + def test_mixed_widths_align(self) -> None: + colors() + plist = [ + {"_comments": 1, "_likes": 50, "_dislikes": 0}, + {"_comments": 100, "_likes": 1, "_dislikes": 10}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + # All rows should have the same visible width + assert len(p0) == len(p1) + # Components right-aligned + assert p0 == " 1 50 -" + assert p1 == "100 1 10" + + def test_thousands(self) -> None: + colors() + plist = [ + {"_comments": 1000, "_likes": 50, "_dislikes": 0}, + {"_comments": 1, "_likes": 10000, "_dislikes": 5}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + assert len(p0) == len(p1) + assert "1k" in p0 + assert "10k" in p1 + + def test_millions(self) -> None: + colors() + plist = [ + {"_comments": 1000000, "_likes": 1500000, "_dislikes": 100}, + {"_comments": 50, "_likes": 1, "_dislikes": 10000000}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + assert len(p0) == len(p1) + assert "1M" in p0 + assert "1.5M" in p0 + assert "10M" in p1 + + def test_wide_values_no_header_padding(self) -> None: + """When values are wider than 'Activity', no extra padding needed.""" + colors() + plist = [{"_comments": 10000, "_likes": 10000, "_dislikes": 10000}] + _format_activity(plist) + p = self._plain(plist[0]) + assert p == "10k 10k 10k" # 11 chars > 8 ("Activity"), no extra padding + + def test_empty_list(self) -> None: + colors() + plist: list = [] + _format_activity(plist) # should not raise diff --git a/tests/test_cli_grids.py b/tests/test_cli_grids.py index 2341530..90e80ac 100644 --- a/tests/test_cli_grids.py +++ b/tests/test_cli_grids.py @@ -1,8 +1,8 @@ from functools import partial from novem.cli.gql import _get_gql_endpoint -from novem.cli.vis import _compact_num -from novem.utils import API_ROOT, cl, colors, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _format_activity +from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from .utils import write_config @@ -354,10 +354,7 @@ def fav_fmt(markers, cl): dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) - c = _compact_num(p.get("_comments", 0)) - lk = _compact_num(p.get("_likes", 0)) - d = _compact_num(p.get("_dislikes", 0)) - p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + _format_activity(plist) expected = pretty_format(plist, ppo) + "\n" diff --git a/tests/test_cli_plots.py b/tests/test_cli_plots.py index 22bdddc..1fc0337 100644 --- a/tests/test_cli_plots.py +++ b/tests/test_cli_plots.py @@ -4,8 +4,8 @@ import pytest from novem.cli.gql import _get_gql_endpoint -from novem.cli.vis import _compact_num -from novem.utils import API_ROOT, cl, colors, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _format_activity +from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from tests.conftest import CliExit from .utils import write_config @@ -356,10 +356,7 @@ def fav_fmt(markers, cl): dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) - c = _compact_num(p.get("_comments", 0)) - lk = _compact_num(p.get("_likes", 0)) - d = _compact_num(p.get("_dislikes", 0)) - p["_activity"] = f"{c} {cl.OKBLUE}{lk}{cl.ENDFGC} {cl.FAIL}{d}{cl.ENDFGC}" + _format_activity(plist) ppl = pretty_format(plist, ppo) From b502ea12a9202cffbab90881c3f803d2124c2cee Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:37:07 +0100 Subject: [PATCH 08/13] comment: spacing fixup --- novem/cli/vis.py | 18 ++++++++++++------ tests/test_cli_comments.py | 7 ++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/novem/cli/vis.py b/novem/cli/vis.py index 159ccb7..770ef1f 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -40,7 +40,7 @@ def _compact_num(n: int) -> str: def _format_activity(plist: List[Dict[str, Any]]) -> None: - """Pre-format the _activity column with right-aligned components.""" + """Pre-format the _activity column with right-aligned, evenly-spaced components.""" # Compute compact strings for each row rows = [] for p in plist: @@ -57,16 +57,22 @@ def _format_activity(plist: List[Dict[str, Any]]) -> None: ml = max(len(r[1]) for r in rows) md = max(len(r[2]) for r in rows) - # Ensure total width is at least len("Activity") = 8 - total = mc + 1 + ml + 1 + md - pad = max(0, 8 - total) - mc += pad # add extra padding to leftmost component + # Total width: at least header "Activity" (8), at least content + 2 gaps + total = max(8, mc + ml + md + 2) + + # Distribute leftover space evenly across the 2 gaps + gap_total = total - mc - ml - md + gap1 = (gap_total + 1) // 2 # first gap gets extra char if odd + gap2 = gap_total // 2 + + s1 = " " * gap1 + s2 = " " * gap2 for p, (c, lk, d) in zip(plist, rows): cs = c.rjust(mc) ls = lk.rjust(ml) ds = d.rjust(md) - p["_activity"] = f"{cs} {cl.OKBLUE}{ls}{cl.ENDFGC} {cl.FAIL}{ds}{cl.ENDFGC}" + p["_activity"] = f"{cs}{s1}{cl.OKBLUE}{ls}{cl.ENDFGC}{s2}{cl.FAIL}{ds}{cl.ENDFGC}" def list_vis(args: Dict[str, Any], type: str) -> None: diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index f962ab5..86d86eb 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -533,13 +533,14 @@ def test_all_zeros(self) -> None: colors() plist = [{"_comments": 0, "_likes": 0, "_dislikes": 0}] _format_activity(plist) - assert self._plain(plist[0]) == " - - -" # padded to "Activity" width + # 3 single-char components, total=8, gaps=5 → gap1=3, gap2=2 + assert self._plain(plist[0]) == "- - -" def test_single_digits(self) -> None: colors() plist = [{"_comments": 1, "_likes": 2, "_dislikes": 3}] _format_activity(plist) - assert self._plain(plist[0]) == " 1 2 3" # padded to "Activity" width + assert self._plain(plist[0]) == "1 2 3" def test_mixed_widths_align(self) -> None: colors() @@ -552,7 +553,7 @@ def test_mixed_widths_align(self) -> None: p1 = self._plain(plist[1]) # All rows should have the same visible width assert len(p0) == len(p1) - # Components right-aligned + # Components right-aligned within sub-columns, gaps evenly distributed assert p0 == " 1 50 -" assert p1 == "100 1 10" From c3a63bf82a26b256acf631584f4b782ce6131b3b Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 14:39:19 +0100 Subject: [PATCH 09/13] comments: use totals --- novem/cli/gql.py | 20 ++++++++++---------- tests/test_cli_comments.py | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index eeb1660..83bc7f4 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -109,8 +109,8 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict } topics { num_comments - likes - dislikes + num_likes + num_dislikes } } } @@ -138,8 +138,8 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict } topics { num_comments - likes - dislikes + num_likes + num_dislikes } } } @@ -167,8 +167,8 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict } topics { num_comments - likes - dislikes + num_likes + num_dislikes } } } @@ -196,8 +196,8 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict } topics { num_comments - likes - dislikes + num_likes + num_dislikes } last_run_status last_run_time @@ -296,8 +296,8 @@ def _aggregate_activity(item: Dict[str, Any]) -> Dict[str, int]: topics = item.get("topics", []) or [] return { "_comments": sum(t.get("num_comments", 0) for t in topics), - "_likes": sum(t.get("likes", 0) for t in topics), - "_dislikes": sum(t.get("dislikes", 0) for t in topics), + "_likes": sum(t.get("num_likes", 0) for t in topics), + "_dislikes": sum(t.get("num_dislikes", 0) for t in topics), } diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 86d86eb..f4c8276 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -506,15 +506,15 @@ def test_empty_topics(self) -> None: assert result == {"_comments": 0, "_likes": 0, "_dislikes": 0} def test_single_topic(self) -> None: - result = _aggregate_activity({"topics": [{"num_comments": 5, "likes": 3, "dislikes": 1}]}) + result = _aggregate_activity({"topics": [{"num_comments": 5, "num_likes": 3, "num_dislikes": 1}]}) assert result == {"_comments": 5, "_likes": 3, "_dislikes": 1} def test_multiple_topics(self) -> None: result = _aggregate_activity( { "topics": [ - {"num_comments": 2, "likes": 1, "dislikes": 0}, - {"num_comments": 3, "likes": 4, "dislikes": 2}, + {"num_comments": 2, "num_likes": 1, "num_dislikes": 0}, + {"num_comments": 3, "num_likes": 4, "num_dislikes": 2}, ] } ) From 25bf3136f4b690df9271f276190d152b9e938e8a Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 15:29:22 +0100 Subject: [PATCH 10/13] comments: cleanup --- novem/cli/gql.py | 43 +++++++-- tests/test_cli_comments.py | 184 ++++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 10 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 83bc7f4..7be8935 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -1375,23 +1375,48 @@ def _build_comment_fragment(depth: int = 4) -> str: """ -def _build_topics_query(vis_type: str) -> str: +def _build_topics_query(vis_type: str, depth: int = 3) -> str: """Build a topics query for a given vis type (plots, grids, mails, etc.).""" - comment_fragment = _build_comment_fragment(4) + comment_fragment = _build_comment_fragment(depth) return _TOPICS_QUERY_TPL.format(vis_type=vis_type, comment_fragment=comment_fragment) +def _has_truncated_replies(comments: List[Dict[str, Any]]) -> bool: + """Check if any comment has num_replies > 0 but empty replies list.""" + for c in comments: + replies = c.get("replies", []) or [] + if c.get("num_replies", 0) > 0 and not replies: + return True + if replies and _has_truncated_replies(replies): + return True + return False + + def fetch_topics_gql(gql: NovemGQL, vis_type: str, vis_id: str, author: Optional[str] = None) -> List[Dict[str, Any]]: - """Fetch topics and comments for a visualisation.""" - query = _build_topics_query(vis_type) + """Fetch topics and comments, deepening the query if threads are truncated.""" variables: Dict[str, Any] = {"id": vis_id} if author: variables["author"] = author - data = gql._query(query, variables) - items = data.get(vis_type, []) - if not items: - return [] - return items[0].get("topics", []) + + depth = 3 + max_depth = 12 + topics: List[Dict[str, Any]] = [] + + while depth <= max_depth: + query = _build_topics_query(vis_type, depth=depth) + data = gql._query(query, variables) + items = data.get(vis_type, []) + if not items: + return [] + topics = items[0].get("topics", []) + + # Check if any topic has truncated comment trees + truncated = any(_has_truncated_replies(t.get("comments", [])) for t in topics) + if not truncated: + break + depth += 3 + + return topics def _relative_time(dt: datetime.datetime) -> str: diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index f4c8276..227ae97 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -6,6 +6,7 @@ _build_comment_fragment, _build_topics_query, _get_gql_endpoint, + _has_truncated_replies, _relative_time, _visible_len, _wrap_text, @@ -140,6 +141,48 @@ def test_mails_query(self) -> None: q = _build_topics_query("mails") assert "mails(id: $id, author: $author)" in q + def test_custom_depth(self) -> None: + q = _build_topics_query("plots", depth=6) + assert q.count("replies {") == 6 + + def test_default_depth(self) -> None: + q = _build_topics_query("plots") + assert q.count("replies {") == 3 + + +# --- Unit tests for _has_truncated_replies --- + + +class TestHasTruncatedReplies: + def test_empty(self) -> None: + assert _has_truncated_replies([]) is False + + def test_no_truncation(self) -> None: + comments = [_make_comment(num_replies=0, replies=[])] + assert _has_truncated_replies(comments) is False + + def test_replies_present(self) -> None: + reply = _make_comment(comment_id=2, num_replies=0, replies=[]) + comments = [_make_comment(num_replies=1, replies=[reply])] + assert _has_truncated_replies(comments) is False + + def test_truncated_leaf(self) -> None: + # num_replies > 0 but no replies data + comments = [_make_comment(num_replies=3, replies=[])] + assert _has_truncated_replies(comments) is True + + def test_truncated_nested(self) -> None: + # Truncation deep in the tree + deep = _make_comment(comment_id=3, num_replies=2, replies=[]) + mid = _make_comment(comment_id=2, num_replies=1, replies=[deep]) + comments = [_make_comment(num_replies=1, replies=[mid])] + assert _has_truncated_replies(comments) is True + + def test_mixed(self) -> None: + ok = _make_comment(comment_id=1, num_replies=0, replies=[]) + truncated = _make_comment(comment_id=2, num_replies=5, replies=[]) + assert _has_truncated_replies([ok, truncated]) is True + # --- Unit tests for render_topics --- @@ -183,6 +226,7 @@ def _make_comment( edited: bool = False, likes: int = 0, dislikes: int = 0, + num_replies: int = -1, created: str = "Mon, 10 Feb 2026 12:05:00 UTC", replies: list = None, ) -> dict: @@ -193,7 +237,7 @@ def _make_comment( "depth": depth, "deleted": deleted, "edited": edited, - "num_replies": len(replies) if replies else 0, + "num_replies": num_replies if num_replies >= 0 else (len(replies) if replies else 0), "likes": likes, "dislikes": dislikes, "created": created, @@ -465,6 +509,144 @@ def return_gql(request, context): assert "No topics" in out +def test_comments_adaptive_depth(cli, requests_mock, fs) -> None: + """Test --comments re-fetches with deeper query when replies are truncated.""" + write_config(auth_req) + + call_count = 0 + + def adaptive_gql(request, context): + nonlocal call_count + call_count += 1 + body = request.json() + query = body.get("query", "") + depth = query.count("replies {") + + if depth <= 3: + # First fetch: return truncated comment (num_replies > 0 but no replies) + return json.dumps( + { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "t1", + "message": "Topic", + "audience": "public", + "status": "active", + "num_comments": 2, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "c1", + "message": "Deep thread", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 1, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [], + } + ], + } + ] + } + ] + } + } + ) + else: + # Deeper fetch: return full tree with replies resolved + return json.dumps( + { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "t1", + "message": "Topic", + "audience": "public", + "status": "active", + "num_comments": 2, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "c1", + "message": "Deep thread", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 1, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [ + { + "comment_id": 2, + "slug": "c2", + "message": "Deep reply", + "depth": 1, + "deleted": False, + "edited": False, + "num_replies": 0, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:10:00 UTC", + "updated": "Mon, 10 Feb 2026 12:10:00 UTC", + "creator": {"username": "carol"}, + "replies": [], + } + ], + } + ], + } + ] + } + ] + } + } + ) + + requests_mock.register_uri("POST", gql_endpoint, text=adaptive_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/deep_plot", status_code=201) + + out, err = cli("-p", "deep_plot", "--comments") + plain = _strip_ansi(out) + + # Should have fetched twice (depth=3 truncated, depth=6 resolved) + assert call_count == 2 + # Deep reply should be present in output + assert "Deep reply" in plain + assert "@carol" in plain + + # --- Unit tests for _compact_num --- From e1da5dc734f032061270ac152dd4c139de613dbf Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 15:36:58 +0100 Subject: [PATCH 11/13] comments: highlight me --- novem/cli/common.py | 3 ++- novem/cli/gql.py | 16 ++++++++----- tests/test_cli_comments.py | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/novem/cli/common.py b/novem/cli/common.py index 6825581..6496fcf 100644 --- a/novem/cli/common.py +++ b/novem/cli/common.py @@ -156,7 +156,8 @@ def __call__(self, args: Dict[str, Any]) -> None: args["config_profile"] = args["profile"] gql = NovemGQL(**args) topics = fetch_topics_gql(gql, self.fragment, name, author=usr) - print(render_topics(topics)) + me = gql._config.get("username", "") + print(render_topics(topics, me=me)) return # if we have the -e or edit flag then this takes presedence over all other diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 7be8935..8d1302b 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -1467,7 +1467,9 @@ def _get_term_width() -> int: return min(120, shutil.get_terminal_size().columns) -def _render_comment(comment: Dict[str, Any], prefix: str, connector: str, child_prefix: str, width: int = 0) -> str: +def _render_comment( + comment: Dict[str, Any], prefix: str, connector: str, child_prefix: str, width: int = 0, me: str = "" +) -> str: """Render a single comment and its replies as a tree.""" if not width: width = _get_term_width() @@ -1505,9 +1507,10 @@ def _render_comment(comment: Dict[str, Any], prefix: str, connector: str, child_ reaction_str = f" {cl.FGGRAY}[{' '.join(reactions)}]{cl.ENDC}" if reactions else "" # Header line + user_color = cl.WARNING if me and username == me else cl.OKCYAN header = ( f"{prefix}{connector}" - f"{cl.OKCYAN}@{username}{cl.ENDC}" + f"{user_color}@{username}{cl.ENDC}" f" {cl.FGGRAY}·{cl.ENDC} " f"{cl.FGGRAY}{ts}{cl.ENDC}" f"{marker_str}{reaction_str}" @@ -1527,12 +1530,12 @@ def _render_comment(comment: Dict[str, Any], prefix: str, connector: str, child_ is_last = i == len(replies) - 1 rc = "└ " if is_last else "├ " rp = " " if is_last else "│ " - lines.append(_render_comment(reply, f"{prefix}{child_prefix}", rc, rp, width)) + lines.append(_render_comment(reply, f"{prefix}{child_prefix}", rc, rp, width, me=me)) return "\n".join(lines) -def render_topics(topics: List[Dict[str, Any]]) -> str: +def render_topics(topics: List[Dict[str, Any]], me: str = "") -> str: """Render a list of topics with their comment trees.""" colors() @@ -1582,9 +1585,10 @@ def render_topics(topics: List[Dict[str, Any]]) -> str: comment_count = f" {cl.FGGRAY}· {num_comments} comment{'s' if num_comments != 1 else ''}{cl.ENDC}" # Topic header + user_color = cl.WARNING if me and username == me else cl.OKCYAN header = ( f"{cl.BOLD}┌{cl.ENDC} " - f"{cl.OKCYAN}@{username}{cl.ENDC}" + f"{user_color}@{username}{cl.ENDC}" f" {cl.FGGRAY}·{cl.ENDC} " f"{cl.FGGRAY}{ts}{cl.ENDC}" f"{tag_str}{edited_str}{reaction_str}{comment_count}" @@ -1602,7 +1606,7 @@ def render_topics(topics: List[Dict[str, Any]]) -> str: is_last = i == len(comments) - 1 connector = "├ " if not is_last else "└ " child_prefix = "│ " if not is_last else " " - lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width)) + lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width, me=me)) if not comments: lines.append(f"{cl.BOLD}└{cl.ENDC} {cl.FGGRAY}(no comments){cl.ENDC}") diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 227ae97..4c198e9 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -379,6 +379,55 @@ def test_dense_output_no_blank_lines_between_siblings(self) -> None: for line in lines: assert line.strip() != "" or line == "" # Allow only between topics + def test_me_topic_creator_highlighted(self) -> None: + """Current user's username in topic header should use WARNING color.""" + colors() + from novem.utils import cl + + topics = [_make_topic(username="alice")] + result = render_topics(topics, me="alice") + # The topic creator should be colored with WARNING (orange) + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + + def test_me_comment_creator_highlighted(self) -> None: + """Current user's username in comment header should use WARNING color.""" + colors() + from novem.utils import cl + + comment = _make_comment(username="alice", message="My comment") + topics = [_make_topic(username="bob", num_comments=1, comments=[comment])] + result = render_topics(topics, me="alice") + # Comment by "me" should be orange + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + # Topic by someone else should be cyan + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + + def test_me_nested_reply_highlighted(self) -> None: + """Current user's username in nested reply should use WARNING color.""" + colors() + from novem.utils import cl + + reply = _make_comment(comment_id=2, username="alice", message="My reply", depth=1) + comment = _make_comment(comment_id=1, username="bob", message="Top", replies=[reply]) + topics = [_make_topic(username="carol", num_comments=2, comments=[comment])] + result = render_topics(topics, me="alice") + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + assert f"{cl.OKCYAN}@carol{cl.ENDC}" in result + + def test_no_me_all_cyan(self) -> None: + """When me is empty, all usernames should use OKCYAN.""" + colors() + from novem.utils import cl + + comment = _make_comment(username="bob") + topics = [_make_topic(username="alice", num_comments=1, comments=[comment])] + result = render_topics(topics, me="") + assert f"{cl.OKCYAN}@alice{cl.ENDC}" in result + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + # WARNING escape code should not appear (check raw code, not cl.WARNING which may be empty) + assert "\033[93m" not in result + # --- CLI integration tests --- From 13e352e443a97ba1be4dea9b7fd2111ce30200f8 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 17:34:27 +0100 Subject: [PATCH 12/13] cli: include views as well --- novem/cli/gql.py | 14 ++++++++++++ novem/cli/vis.py | 26 +++++++++++++++++++++ tests/test_cli_comments.py | 46 +++++++++++++++++++++++++++++++++++++- tests/test_cli_grids.py | 15 ++++++++++++- tests/test_cli_plots.py | 15 ++++++++++++- 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 8d1302b..0d1fdbe 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -107,6 +107,9 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } topics { num_comments num_likes @@ -136,6 +139,9 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } topics { num_comments num_likes @@ -165,6 +171,9 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } topics { num_comments num_likes @@ -194,6 +203,9 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } topics { num_comments num_likes @@ -321,6 +333,7 @@ def _transform_vis_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any]] "updated": item.get("updated", ""), "shared": _transform_shared(item.get("public", False), item.get("shared", [])), "fav": _get_markers(item.get("tags", [])), + "_views": (item.get("social") or {}).get("views", 0), **_aggregate_activity(item), } result.append(transformed) @@ -384,6 +397,7 @@ def _transform_jobs_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any] "updated": item.get("updated", ""), "shared": _transform_shared(item.get("public", False), item.get("shared", [])), "fav": _get_markers(item.get("tags", [])), + "_views": (item.get("social") or {}).get("views", 0), "last_run_status": item.get("last_run_status", ""), "last_run_time": item.get("last_run_time", ""), "run_count": item.get("run_count", 0), diff --git a/novem/cli/vis.py b/novem/cli/vis.py index 770ef1f..94c627c 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -75,6 +75,18 @@ def _format_activity(plist: List[Dict[str, Any]]) -> None: p["_activity"] = f"{cs}{s1}{cl.OKBLUE}{ls}{cl.ENDFGC}{s2}{cl.FAIL}{ds}{cl.ENDFGC}" +def _format_views(plist: List[Dict[str, Any]]) -> None: + """Pre-format the _views column as a right-aligned compact number.""" + if not plist: + return + + rows = [_compact_num(p.get("_views", 0)) for p in plist] + mw = max(max(len(r) for r in rows), len("Views")) + + for p, r in zip(plist, rows): + p["_views_fmt"] = r.rjust(mw) + + def list_vis(args: Dict[str, Any], type: str) -> None: colors() # get current plot list @@ -223,6 +235,12 @@ def fav_fmt(markers: str, cl: cl) -> str: "type": "text", "overflow": "keep", }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -256,6 +274,7 @@ def fav_fmt(markers: str, cl: cl) -> str: p["updated"] = format_datetime_local(dt) _format_activity(plist) + _format_views(plist) striped: bool = config.get("cli_striped", False) ppl = pretty_format(plist, ppo, striped=striped) @@ -896,6 +915,12 @@ def fav_fmt(markers: str, cl: cl) -> str: "type": "text", "overflow": "keep", }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "triggers", "header": "Trigger", @@ -950,6 +975,7 @@ def fav_fmt(markers: str, cl: cl) -> str: p["_last_run"] = _format_time_ago(p.get("last_run_time", "")) _format_activity(plist) + _format_views(plist) # Calculate max widths for right-aligned columns (must be at least header width) max_steps = max(max((len(p["_steps"]) for p in plist), default=0), len("Steps")) diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 4c198e9..8be59da 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -12,7 +12,7 @@ _wrap_text, render_topics, ) -from novem.cli.vis import _compact_num, _format_activity +from novem.cli.vis import _compact_num, _format_activity, _format_views from novem.utils import API_ROOT, colors from .utils import write_config @@ -827,3 +827,47 @@ def test_empty_list(self) -> None: colors() plist: list = [] _format_activity(plist) # should not raise + + +# --- Unit tests for _format_views --- + + +class TestFormatViews: + def test_zero_views(self) -> None: + colors() + plist = [{"_views": 0}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "-" + + def test_small_views(self) -> None: + colors() + plist = [{"_views": 42}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "42" + + def test_thousands(self) -> None: + colors() + plist = [{"_views": 1200}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "1.2k" + + def test_right_aligned(self) -> None: + colors() + plist = [{"_views": 5}, {"_views": 1200}] + _format_views(plist) + # Both should have same width (right-aligned) + assert len(plist[0]["_views_fmt"]) == len(plist[1]["_views_fmt"]) + assert plist[0]["_views_fmt"].endswith("5") + assert plist[1]["_views_fmt"].endswith("1.2k") + + def test_min_header_width(self) -> None: + """Column should be at least as wide as 'Views' (5 chars).""" + colors() + plist = [{"_views": 1}] + _format_views(plist) + assert len(plist[0]["_views_fmt"]) >= len("Views") + + def test_empty_list(self) -> None: + colors() + plist: list = [] + _format_views(plist) # should not raise diff --git a/tests/test_cli_grids.py b/tests/test_cli_grids.py index 90e80ac..50bbd07 100644 --- a/tests/test_cli_grids.py +++ b/tests/test_cli_grids.py @@ -1,7 +1,7 @@ from functools import partial from novem.cli.gql import _get_gql_endpoint -from novem.cli.vis import _format_activity +from novem.cli.vis import _format_activity, _format_views from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from .utils import write_config @@ -174,6 +174,7 @@ def test_grid_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic countries." " Data from IMFs World Economic Oulook published in October 2021" " Chart last updated as of 25 January 2022", + "_views": 0, }, { "id": "covid_us_state_breakdown", @@ -187,6 +188,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend", @@ -200,6 +202,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend_region", @@ -213,6 +216,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "en_letter_frequency", @@ -226,6 +230,7 @@ def test_grid_list(cli, requests_mock, fs): " as published by the compilers. The chart above represents data" " taken from Pavel Micka's website, which cites Robert Lewand's" " Cryptological Mathematics.", + "_views": 0, }, { "id": "unemployment_noridc", @@ -238,6 +243,7 @@ def test_grid_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic " "countries. Data from IMFs World Economic Oulook published in" " October 2021 Chart last updated as of 25 January 2022", + "_views": 0, }, ] @@ -322,6 +328,12 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -355,6 +367,7 @@ def fav_fmt(markers, cl): if dt: p["updated"] = format_datetime_local(dt) _format_activity(plist) + _format_views(plist) expected = pretty_format(plist, ppo) + "\n" diff --git a/tests/test_cli_plots.py b/tests/test_cli_plots.py index 1fc0337..3d1a903 100644 --- a/tests/test_cli_plots.py +++ b/tests/test_cli_plots.py @@ -4,7 +4,7 @@ import pytest from novem.cli.gql import _get_gql_endpoint -from novem.cli.vis import _format_activity +from novem.cli.vis import _format_activity, _format_views from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from tests.conftest import CliExit @@ -176,6 +176,7 @@ def test_plot_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic countries." " Data from IMFs World Economic Oulook published in October 2021" " Chart last updated as of 25 January 2022", + "_views": 0, }, { "id": "covid_us_state_breakdown", @@ -189,6 +190,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend", @@ -202,6 +204,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend_region", @@ -215,6 +218,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "en_letter_frequency", @@ -228,6 +232,7 @@ def test_plot_list(cli, requests_mock, fs): " as published by the compilers. The chart above represents data" " taken from Pavel Micka's website, which cites Robert Lewand's" " Cryptological Mathematics.", + "_views": 0, }, { "id": "unemployment_noridc", @@ -240,6 +245,7 @@ def test_plot_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic " "countries. Data from IMFs World Economic Oulook published in" " October 2021 Chart last updated as of 25 January 2022", + "_views": 0, }, ] @@ -324,6 +330,12 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -357,6 +369,7 @@ def fav_fmt(markers, cl): if dt: p["updated"] = format_datetime_local(dt) _format_activity(plist) + _format_views(plist) ppl = pretty_format(plist, ppo) From 2513c84ef2fd6cd20f3c684373cb0df7d0c0eea6 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Thu, 12 Feb 2026 17:47:30 +0100 Subject: [PATCH 13/13] cli: include topics in activity count --- novem/cli/gql.py | 2 +- tests/test_cli_comments.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 0d1fdbe..e6affbb 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -307,7 +307,7 @@ def _aggregate_activity(item: Dict[str, Any]) -> Dict[str, int]: """Sum topic-level comments, likes, and dislikes for a visualization.""" topics = item.get("topics", []) or [] return { - "_comments": sum(t.get("num_comments", 0) for t in topics), + "_comments": sum(t.get("num_comments", 0) for t in topics) + len(topics), "_likes": sum(t.get("num_likes", 0) for t in topics), "_dislikes": sum(t.get("num_dislikes", 0) for t in topics), } diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 8be59da..b455c7c 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -738,7 +738,7 @@ def test_empty_topics(self) -> None: def test_single_topic(self) -> None: result = _aggregate_activity({"topics": [{"num_comments": 5, "num_likes": 3, "num_dislikes": 1}]}) - assert result == {"_comments": 5, "_likes": 3, "_dislikes": 1} + assert result == {"_comments": 6, "_likes": 3, "_dislikes": 1} def test_multiple_topics(self) -> None: result = _aggregate_activity( @@ -749,7 +749,7 @@ def test_multiple_topics(self) -> None: ] } ) - assert result == {"_comments": 5, "_likes": 5, "_dislikes": 2} + assert result == {"_comments": 7, "_likes": 5, "_dislikes": 2} # --- Unit tests for _format_activity alignment ---