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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: CI

on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov

- name: Run tests
run: |
python -m pytest test_perplexity.py -v --cov=perplexity --cov-report=term-missing

- name: Syntax check
run: |
python -m py_compile perplexity.py

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install linting tools
run: |
python -m pip install --upgrade pip
pip install flake8

- name: Run flake8
run: |
flake8 perplexity.py --max-line-length=120 --ignore=E501,W503

security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install security tools
run: |
python -m pip install --upgrade pip
pip install bandit safety

- name: Run Bandit security scanner
run: |
bandit -r perplexity.py -ll

- name: Check dependencies for vulnerabilities
run: |
pip install -r requirements.txt
safety check --full-report || true
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,45 @@ Perplexity CLI is a simple and convenient command-line client for the Perplexity
- `requests` library

## Installation
No installation is required. You can run the script directly from the GitHub repository or install it locally.Example of local installation:
No installation is required. You can run the script directly from the GitHub repository or install it locally.

### Quick Installation (curl)
```bash
curl -s https://raw.githubusercontent.com/dawid-szewc/perplexity-cli/main/perplexity.py > ~/.local/bin/perplexity \
chmod +x ~/.local/bin/perplexity \
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc \
echo 'export PERPLEXITY_API_KEY="your-api-key"' >> ~/.bashrc
```

### Alternative Installation (Clone Method)
This method allows you to easily update the tool and keep track of changes. Similar to Oh My Zsh installation:

1. Clone the repository:
```bash
git clone https://github.com/dawid-szewc/perplexity-cli.git ~/.perplexity-cli
```

2. Add the following lines to your `~/.bashrc` or `~/.zshrc`:
```bash
# Perplexity CLI
export PERPLEXITY_API_KEY="your-api-key"
alias perplexity="python3 $HOME/.perplexity-cli/perplexity.py"
```

3. Apply the changes:
```bash
# For Bash
source ~/.bashrc

# For Zsh
source ~/.zshrc
```

4. To update Perplexity CLI in the future, simply run:
```bash
cd ~/.perplexity-cli && git pull
```

## Usage
```bash
perplexity "What is the meaning of life?"
Expand Down
11 changes: 6 additions & 5 deletions perplexity.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def __init__(self, args) -> None:
logger.debug("Api key not found on system!")
raise ApiKeyNotFoundException
else:
logger.debug(f"Api key found on system: {api_key}")
logger.debug("Api key found on system")
self.setup.api_key = api_key
else:
self.setup.api_key = args.api_key
Expand All @@ -107,18 +107,18 @@ def get_response(self, message) -> None:
"content-type": "application/json",
"Authorization": f"Bearer {self.setup.api_key}",
}
logger.debug(f"Headers: {headers}")
logger.debug("Request headers prepared (Authorization header redacted for security)")
query_data = {
"model": self.setup.model,
"messages": [
{"role": "system", "content": "Be precise and concise."},
{"role": "user", "content": message},
],
}
logger.debug(f"Query data: {query_data}")
logger.debug(f"Query data prepared for model: {self.setup.model}")

response = requests.post(
self.setup.api_url, headers=headers, data=json.dumps(query_data)
self.setup.api_url, headers=headers, data=json.dumps(query_data), timeout=30
)

if response.status_code == 200:
Expand Down Expand Up @@ -191,7 +191,8 @@ def main() -> None:
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger.debug(f"args: {args}")
# Avoid logging sensitive args like api_key and potentially sensitive query content
logger.debug(f"args: model={args.model}, verbose={args.verbose}, usage={args.usage}, citations={args.citations}, glow={args.glow}")
try:
perplexity = Perplexity(args)
perplexity.get_response(args.query)
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
requests
requests>=2.31.0
194 changes: 194 additions & 0 deletions test_perplexity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Tests for perplexity.py"""

import unittest
from unittest.mock import patch, MagicMock
import argparse
import os
import io
import sys

from perplexity import (
ModelValidator,
ApiKeyValidator,
Perplexity,
ApiKeyNotFoundException,
InvalidSelectedModelException,
display,
AVAILABLE_MODELS,
)


class TestModelValidator(unittest.TestCase):
"""Tests for ModelValidator class"""

def test_validate_valid_models(self):
"""Test that all available models are valid"""
for model in AVAILABLE_MODELS:
self.assertTrue(ModelValidator.validate(model))

def test_validate_invalid_model(self):
"""Test that invalid models are rejected"""
self.assertFalse(ModelValidator.validate("invalid-model"))
self.assertFalse(ModelValidator.validate(""))
self.assertFalse(ModelValidator.validate("gpt-4"))

def test_get_available_models(self):
"""Test that get_AVAILABLE_MODELS returns the correct list"""
models = ModelValidator.get_AVAILABLE_MODELS()
self.assertEqual(models, AVAILABLE_MODELS)
self.assertIn("sonar-pro", models)


class TestApiKeyValidator(unittest.TestCase):
"""Tests for ApiKeyValidator class"""

@patch.dict(os.environ, {"PERPLEXITY_API_KEY": "test-api-key"})
def test_get_api_key_from_system_exists(self):
"""Test getting API key when it exists"""
api_key = ApiKeyValidator.get_api_key_from_system()
self.assertEqual(api_key, "test-api-key")

@patch.dict(os.environ, {}, clear=True)
def test_get_api_key_from_system_not_exists(self):
"""Test getting API key when it doesn't exist"""
# Remove the key if it exists
os.environ.pop("PERPLEXITY_API_KEY", None)
api_key = ApiKeyValidator.get_api_key_from_system()
self.assertIsNone(api_key)


class TestPerplexityInit(unittest.TestCase):
"""Tests for Perplexity class initialization"""

def create_args(self, model="sonar-pro", api_key=None, usage=False, citations=False, glow=False):
"""Helper to create args namespace"""
return argparse.Namespace(
model=model,
api_key=api_key,
usage=usage,
citations=citations,
glow=glow
)

def test_invalid_model_raises_exception(self):
"""Test that invalid model raises InvalidSelectedModelException"""
args = self.create_args(model="invalid-model", api_key="test-key")
with self.assertRaises(InvalidSelectedModelException):
Perplexity(args)

@patch.dict(os.environ, {}, clear=True)
def test_missing_api_key_raises_exception(self):
"""Test that missing API key raises ApiKeyNotFoundException"""
os.environ.pop("PERPLEXITY_API_KEY", None)
args = self.create_args(api_key=None)
with self.assertRaises(ApiKeyNotFoundException):
Perplexity(args)

@patch.dict(os.environ, {"PERPLEXITY_API_KEY": "env-api-key"})
def test_api_key_from_env(self):
"""Test that API key is read from environment"""
args = self.create_args(api_key=None)
perplexity = Perplexity(args)
self.assertEqual(perplexity.setup.api_key, "env-api-key")

def test_api_key_from_args(self):
"""Test that API key from args takes precedence"""
args = self.create_args(api_key="arg-api-key")
perplexity = Perplexity(args)
self.assertEqual(perplexity.setup.api_key, "arg-api-key")


class TestPerplexityGetResponse(unittest.TestCase):
"""Tests for Perplexity.get_response method"""

def create_perplexity_instance(self):
"""Helper to create a Perplexity instance"""
args = argparse.Namespace(
model="sonar-pro",
api_key="test-api-key",
usage=False,
citations=False,
glow=False
)
return Perplexity(args)

@patch('perplexity.requests.post')
def test_request_has_timeout(self, mock_post):
"""Test that requests are made with a timeout (security fix)"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{"message": {"content": "Test response"}}],
"citations": [],
"usage": {}
}
mock_post.return_value = mock_response

perplexity = self.create_perplexity_instance()
perplexity.get_response("test query")

# Verify timeout was passed to requests.post
mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
self.assertIn('timeout', call_kwargs)
self.assertEqual(call_kwargs['timeout'], 30)

@patch('perplexity.requests.post')
def test_authorization_header_format(self, mock_post):
"""Test that Authorization header uses Bearer token"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{"message": {"content": "Test response"}}],
"citations": [],
"usage": {}
}
mock_post.return_value = mock_response

perplexity = self.create_perplexity_instance()
perplexity.get_response("test query")

call_kwargs = mock_post.call_args[1]
headers = call_kwargs['headers']
self.assertIn('Authorization', headers)
self.assertTrue(headers['Authorization'].startswith('Bearer '))


class TestDisplay(unittest.TestCase):
"""Tests for display function"""

def test_display_output(self):
"""Test that display produces output"""
captured_output = io.StringIO()
sys.stdout = captured_output
display("Test message", "white", False, "black")
sys.stdout = sys.__stdout__
output = captured_output.getvalue()
self.assertIn("Test message", output)


class TestSecurityNoSensitiveDataInLogs(unittest.TestCase):
"""Security tests to ensure sensitive data is not logged"""

@patch('perplexity.logger')
@patch.dict(os.environ, {"PERPLEXITY_API_KEY": "secret-api-key-12345"})
def test_api_key_not_logged_on_init(self, mock_logger):
"""Test that API key value is not logged during initialization"""
args = argparse.Namespace(
model="sonar-pro",
api_key=None,
usage=False,
citations=False,
glow=False
)
Perplexity(args)

# Check all debug calls don't contain the actual API key
for call in mock_logger.debug.call_args_list:
call_str = str(call)
self.assertNotIn("secret-api-key-12345", call_str)


if __name__ == "__main__":
unittest.main()