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
30 changes: 28 additions & 2 deletions agentstack/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@
# cool of you to allow telemetry <3
#
# - braelyn
import json
import os
import platform
import socket
import uuid
from pathlib import Path
from typing import Optional
import psutil
import requests
from agentstack import conf
from agentstack.auth import get_stored_token
from agentstack.utils import get_telemetry_opt_out, get_framework, get_version
from agentstack.utils import get_telemetry_opt_out, get_framework, get_version, get_base_dir

TELEMETRY_URL = 'https://api.agentstack.sh/telemetry'
USER_GUID_FILE_PATH = get_base_dir() / ".cli-user-guid"


def collect_machine_telemetry(command: str):
Expand All @@ -46,6 +50,7 @@
'cpu_count': psutil.cpu_count(logical=True),
'memory': psutil.virtual_memory().total,
'agentstack_version': get_version(),
'cli_user_id': _get_cli_user_guid()
}

if command != "init":
Expand Down Expand Up @@ -97,4 +102,25 @@
try:
requests.put(TELEMETRY_URL, json={"id": id, "result": result, "message": message})
except Exception:
pass
pass

Check warning on line 105 in agentstack/telemetry.py

View check run for this annotation

Codecov / codecov/patch

agentstack/telemetry.py#L105

Added line #L105 was not covered by tests

def _get_cli_user_guid() -> str:
if Path(USER_GUID_FILE_PATH).exists():
try:
with open(USER_GUID_FILE_PATH, 'r') as f:
return f.read()
except (json.JSONDecodeError, PermissionError):
return "unknown"

# make new cli user guid
try:
# Create directory if it doesn't exist
USER_GUID_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)

guid = str(uuid.uuid4())
with open(USER_GUID_FILE_PATH, 'w') as f:
f.write(guid)
return guid
except (OSError, PermissionError):
# Silently fail in CI or when we can't write
return "unknown"
20 changes: 3 additions & 17 deletions agentstack/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,8 @@
from packaging.version import parse as parse_version, Version
import inquirer
from agentstack import log
from agentstack.utils import term_color, get_version, get_framework
from agentstack.utils import term_color, get_version, get_framework, get_base_dir
from agentstack import packaging
from appdirs import user_data_dir


def _get_base_dir():
"""Try to get appropriate directory for storing update file"""
try:
base_dir = Path(user_data_dir("agentstack", "agency"))
# Test if we can write to directory
test_file = base_dir / '.test_write_permission'
test_file.touch()
test_file.unlink()
except (RuntimeError, OSError, PermissionError):
# In CI or when directory is not writable, use temp directory
base_dir = Path(os.getenv('TEMP', '/tmp'))
return base_dir


AGENTSTACK_PACKAGE = 'agentstack'
Expand All @@ -35,7 +20,8 @@ def _get_base_dir():
'TEAMCITY_VERSION',
]

LAST_CHECK_FILE_PATH = _get_base_dir() / ".cli-last-update"
LAST_CHECK_FILE_PATH = get_base_dir() / ".cli-last-update"
USER_GUID_FILE_PATH = get_base_dir() / ".cli-user-guid"
INSTALL_PATH = Path(sys.executable).parent.parent
ENDPOINT_URL = "https://pypi.org/simple"
CHECK_EVERY = 3600 # hour
Expand Down
14 changes: 14 additions & 0 deletions agentstack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import importlib.resources
from agentstack import conf
from inquirer import errors as inquirer_errors
from appdirs import user_data_dir


def get_version(package: str = 'agentstack'):
Expand Down Expand Up @@ -118,3 +119,16 @@
return True

return validator

def get_base_dir():
"""Try to get appropriate directory for storing update file"""
try:
base_dir = Path(user_data_dir("agentstack", "agency"))
# Test if we can write to directory
test_file = base_dir / '.test_write_permission'
test_file.touch()
test_file.unlink()

Check warning on line 130 in agentstack/utils.py

View check run for this annotation

Codecov / codecov/patch

agentstack/utils.py#L130

Added line #L130 was not covered by tests
except (RuntimeError, OSError, PermissionError):
# In CI or when directory is not writable, use temp directory
base_dir = Path(os.getenv('TEMP', '/tmp'))
return base_dir
72 changes: 71 additions & 1 deletion tests/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import os
import unittest
from agentstack.utils import get_telemetry_opt_out
import uuid
from unittest.mock import patch, mock_open

from agentstack.telemetry import _get_cli_user_guid
from agentstack.utils import get_telemetry_opt_out

class TelemetryTest(unittest.TestCase):
def test_telemetry_opt_out_env_var_set(self):
Expand All @@ -10,3 +13,70 @@ def test_telemetry_opt_out_env_var_set(self):

def test_telemetry_opt_out_set_in_test_environment(self):
assert get_telemetry_opt_out()

@patch('pathlib.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data='existing-guid')
def test_existing_guid_file(self, mock_file, mock_exists):
"""Test when GUID file exists and can be read successfully"""
mock_exists.return_value = True

result = _get_cli_user_guid()

self.assertEqual(result, 'existing-guid')
mock_exists.assert_called_once_with()

@patch('pathlib.Path.exists')
@patch('pathlib.Path.mkdir')
@patch('uuid.uuid4')
@patch('builtins.open', new_callable=mock_open)
def test_create_new_guid(self, mock_file, mock_uuid, mock_mkdir, mock_exists):
"""Test creation of new GUID when file doesn't exist"""
mock_exists.return_value = False
mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678')

result = _get_cli_user_guid()

self.assertEqual(result, '12345678-1234-5678-1234-567812345678')
mock_exists.assert_called_once_with()
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
handle = mock_file()
handle.write.assert_called_once_with('12345678-1234-5678-1234-567812345678')

@patch('pathlib.Path.exists')
@patch('builtins.open')
def test_permission_error_on_read(self, mock_file, mock_exists):
"""Test handling of PermissionError when reading file"""
mock_exists.return_value = True
mock_file.side_effect = PermissionError()

result = _get_cli_user_guid()

self.assertEqual(result, 'unknown')
mock_exists.assert_called_once_with()

@patch('pathlib.Path.exists')
@patch('pathlib.Path.mkdir')
@patch('builtins.open')
def test_permission_error_on_write(self, mock_file, mock_mkdir, mock_exists):
"""Test handling of PermissionError when writing new file"""
mock_exists.return_value = False
mock_file.side_effect = PermissionError()

result = _get_cli_user_guid()

self.assertEqual(result, 'unknown')
mock_exists.assert_called_once_with()
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)

@patch('pathlib.Path.exists')
@patch('pathlib.Path.mkdir')
def test_os_error_on_mkdir(self, mock_mkdir, mock_exists):
"""Test handling of OSError when creating directory"""
mock_exists.return_value = False
mock_mkdir.side_effect = OSError()

result = _get_cli_user_guid()

self.assertEqual(result, 'unknown')
mock_exists.assert_called_once_with()
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
27 changes: 0 additions & 27 deletions tests/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
_is_ci_environment,
CI_ENV_VARS,
should_update,
_get_base_dir,
get_latest_version,
AGENTSTACK_PACKAGE,
load_update_data,
Expand Down Expand Up @@ -46,32 +45,6 @@ def test_updates_disabled_by_env_var_in_test(self):
with patch.dict('os.environ', {'AGENTSTACK_UPDATE_DISABLE': 'true'}):
self.assertFalse(should_update())

@patch('agentstack.update.user_data_dir')
def test_get_base_dir_writable(self, mock_user_data_dir):
"""
Test that _get_base_dir() returns a writable Path when user_data_dir is accessible.
"""
mock_path = '/mock/user/data/dir'
mock_user_data_dir.return_value = mock_path

result = _get_base_dir()

self.assertIsInstance(result, Path)
self.assertTrue(result.is_absolute())

@patch('agentstack.update.user_data_dir')
def test_get_base_dir_not_writable(self, mock_user_data_dir):
"""
Test that _get_base_dir() falls back to a temporary directory when user_data_dir is not writable.
"""
mock_user_data_dir.side_effect = PermissionError

result = _get_base_dir()

self.assertIsInstance(result, Path)
self.assertTrue(result.is_absolute())
self.assertIn(str(result), ['/tmp', os.environ.get('TEMP', '/tmp')])

def test_get_latest_version(self):
"""
Test that get_latest_version returns a valid Version object from the actual PyPI.
Expand Down
35 changes: 34 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import os
import unittest
from pathlib import Path
from unittest.mock import patch

from agentstack.utils import clean_input, is_snake_case, validator_not_empty
from agentstack.utils import (
clean_input,
is_snake_case,
validator_not_empty,
get_base_dir
)
from inquirer import errors as inquirer_errors


Expand Down Expand Up @@ -37,3 +45,28 @@ def test_validator_not_empty(self):
with self.assertRaises(inquirer_errors.ValidationError):
validator(None, "ab")

@patch('agentstack.utils.user_data_dir')
def test_get_base_dir_not_writable(self, mock_user_data_dir):
"""
Test that get_base_dir() falls back to a temporary directory when user_data_dir is not writable.
"""
mock_user_data_dir.side_effect = PermissionError

result = get_base_dir()

self.assertIsInstance(result, Path)
self.assertTrue(result.is_absolute())
self.assertIn(str(result), ['/tmp', os.environ.get('TEMP', '/tmp')])

@patch('agentstack.utils.user_data_dir')
def test_get_base_dir_writable(self, mock_user_data_dir):
"""
Test that get_base_dir() returns a writable Path when user_data_dir is accessible.
"""
mock_path = '/mock/user/data/dir'
mock_user_data_dir.return_value = mock_path

result = get_base_dir()

self.assertIsInstance(result, Path)
self.assertTrue(result.is_absolute())
Loading