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
8 changes: 6 additions & 2 deletions novem/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from ..api_ref import NovemAPI
from ..utils import cl, colors, get_config_path, get_current_config
from ..version import __version__
from .common import grid, job, mail, plot
from .common import grid, job, mail, plot, user
from .config import check_if_profile_exists, update_config
from .group import group
from .invite import invite
Expand Down Expand Up @@ -299,6 +299,7 @@ def print_short(parser: Any) -> None:
print(" novem -g list your grids")
print(" novem -m list your mails")
print(" novem -j list your jobs")
print(" novem -u list your connections")


def run_cli_wrapped() -> None:
Expand Down Expand Up @@ -407,8 +408,11 @@ def run_cli_wrapped() -> None:
qpr = f"{qpr}cols={sz.columns},rows={sz.lines - prompt_lines}"
args["qpr"] = qpr

# operate on user listing (if -u with no argument)
if args and args.get("for_user") is None and "for_user" in args:
user(args)
# operate on plot
if args and args["plot"] != "":
elif args and args["plot"] != "":
plot(args)
elif args and args["mail"] != "":
mail(args)
Expand Down
24 changes: 23 additions & 1 deletion novem/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
from novem.api_ref import Novem404, NovemAPI
from novem.cli.editor import edit
from novem.cli.setup import Share, Tag
from novem.cli.vis import list_job_shares, list_job_tags, list_jobs, list_vis, list_vis_shares, list_vis_tags
from novem.cli.vis import (
list_job_shares,
list_job_tags,
list_jobs,
list_users,
list_vis,
list_vis_shares,
list_vis_tags,
)
from novem.utils import data_on_stdin
from novem.vis import NovemVisAPI

Expand Down Expand Up @@ -424,3 +432,17 @@ def job(args: Dict[str, Any]) -> None:
if out:
outp = j.api_read(f"/{out}")
print(outp, end="")


def user(args: Dict[str, Any]) -> None:
"""Handle -u flag: list users if no username specified, otherwise pass through."""
username = args.get("for_user")

# List all connected users if no username specified
if username is None:
list_users(args)
return

# If a username is specified, return False to indicate pass-through
# (the existing logic in __init__.py will handle it)
return
57 changes: 56 additions & 1 deletion novem/cli/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ class ColumnFilter:
"schedule": "schedule",
"steps": "job_steps",
"runs": "run_count",
# User-specific headers
"username": "username",
"conn": "conn",
"conn.": "conn",
"connection": "conn",
"groups": "groups",
"social": "social",
"bio": "bio",
"biography": "bio",
}


Expand Down Expand Up @@ -121,10 +130,23 @@ def get_triggers_display_value(triggers: List[str]) -> str:
return f"{mail} {sched} {api} {commit}"


def get_conn_display_value(item: Dict[str, Any]) -> str:
"""
Convert connection fields to display format for filtering.

Example: connected=True, follower=False, following=True -> "C - F -"
"""
connected = "C" if item.get("connected") else "-"
follower = "F" if item.get("follower") else "-"
following = "F" if item.get("following") else "-"
ignore = "-" # Not available yet
return f"{connected} {follower} {following} {ignore}"


def get_filter_value(item: Dict[str, Any], column: str) -> str:
"""
Get the filterable value for a column from an item.
Handles special columns like 'shared' and 'triggers'.
Handles special columns like 'shared', 'triggers', and 'conn'.
"""
value = item.get(column, "")

Expand All @@ -134,6 +156,9 @@ def get_filter_value(item: Dict[str, Any], column: str) -> str:
if column == "triggers" and isinstance(value, list):
return get_triggers_display_value(value)

if column == "conn":
return get_conn_display_value(item)

if value is None:
return ""

Expand Down Expand Up @@ -189,6 +214,22 @@ def matches_filter(item: Dict[str, Any], filter_obj: ColumnFilter) -> bool:
commit = "C" if "C" in pattern_upper else "-"
expected = f"{mail} {sched} {api} {commit}"
return value == expected
# Special handling for conn column: check exact flag combination
if filter_obj.column == "conn":
# For conn, exact match means "exactly these flags and no others"
# e.g., conn=C matches "C - - -" (only connected)
# e.g., conn=CF matches "C F - -" (connected + follower, no following)
pattern_upper = filter_obj.pattern.upper()
# Build expected display value from pattern
# Note: F appears twice (follower and following), use position to distinguish
connected = "C" if "C" in pattern_upper else "-"
# Count F's: first F = follower, second F = following
f_count = pattern_upper.count("F")
follower = "F" if f_count >= 1 else "-"
following = "F" if f_count >= 2 else "-"
ignore = "-"
expected = f"{connected} {follower} {following} {ignore}"
return value == expected
# Case-insensitive exact match for other columns
return value.lower() == filter_obj.pattern.lower()

Expand Down Expand Up @@ -227,6 +268,20 @@ def matches_filter(item: Dict[str, Any], filter_obj: ColumnFilter) -> bool:
if "C" in pattern_upper and "C" not in value:
return False
return True
# Special handling for conn column: check if flags are present (subset match)
if filter_obj.column == "conn":
# For conn regex, check if all specified flags are present
# e.g., conn~C matches any connected user (could have others)
# e.g., conn~CF matches any connected AND follower
pattern_upper = filter_obj.pattern.upper()
if all(c in "CF" for c in pattern_upper):
# Pattern is just flags, do subset match
if "C" in pattern_upper and "C" not in value:
return False
# F means at least one F (follower or following) is present
if "F" in pattern_upper and "F" not in value:
return False
return True
# Case-insensitive regex match
try:
regex = re.compile(filter_obj.pattern, re.I)
Expand Down
93 changes: 93 additions & 0 deletions novem/cli/gql.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,48 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict
"""


LIST_USERS_QUERY = """
query ListUsers($limit: Int, $offset: Int) {
users(limit: $limit, offset: $offset) {
username
name
type
bio
relationship {
orgs
groups
follower
connected
following
}
social {
followers
following
connections
}
plots {
id
}
grids {
id
}
mails {
id
}
docs {
id
}
repos {
id
}
jobs {
id
}
}
}
"""


def _transform_shared(public: bool, shared: List[Dict[str, Any]]) -> List[str]:
"""
Transform GraphQL shared format to REST format.
Expand Down Expand Up @@ -308,3 +350,54 @@ def list_jobs_gql(gql: NovemGQL, author: Optional[str] = None, limit: Optional[i
data = gql._query(LIST_JOBS_QUERY, variables)
jobs = data.get("jobs", [])
return _transform_jobs_response(jobs)


def _transform_users_response(users: List[Dict[str, Any]], me_type: str) -> List[Dict[str, Any]]:
"""
Transform GraphQL users response for user listing.

Includes user fields: username, name, type, bio, relationship, social, and content counts.
"""
result = []
for user in users:
relationship = user.get("relationship", {}) or {}
social = user.get("social", {}) or {}

transformed = {
"username": user.get("username", ""),
"name": user.get("name", "") or "",
"type": user.get("type", ""),
"bio": user.get("bio", "") or "",
# Relationship fields
"connected": relationship.get("connected", False),
"follower": relationship.get("follower", False),
"following": relationship.get("following", False),
"orgs": relationship.get("orgs", 0) or 0,
"groups": relationship.get("groups", 0) or 0,
# Social fields
"social_connections": social.get("connections", 0) or 0,
"social_followers": social.get("followers", 0) or 0,
"social_following": social.get("following", 0) or 0,
# Content counts
"plots": len(user.get("plots", []) or []),
"grids": len(user.get("grids", []) or []),
"mails": len(user.get("mails", []) or []),
"docs": len(user.get("docs", []) or []),
"repos": len(user.get("repos", []) or []),
"jobs": len(user.get("jobs", []) or []),
# Verified status (VERIFIED or NOVEM type)
"verified": user.get("type", "") in ["VERIFIED", "NOVEM", "SYSTEM"],
}
result.append(transformed)
return result


def list_users_gql(gql: NovemGQL, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""List all users via GraphQL, returning transformed format."""
variables: Dict[str, Any] = {}
if limit:
variables["limit"] = limit

data = gql._query(LIST_USERS_QUERY, variables if variables else None)
users = data.get("users", [])
return _transform_users_response(users, "")
5 changes: 3 additions & 2 deletions novem/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,11 @@ def setup(raw_args: Any = None) -> Tuple[Any, Dict[str, str]]:
"-u",
metavar=("USER"),
dest="for_user",
default=ap.SUPPRESS,
default="",
action="store",
required=False,
help="specify user to view shared visualisation from",
nargs="?",
help="specify user to view shared visualisation from, no parameter will list users you are connected to",
)

vis.add_argument(
Expand Down
Loading