clarity-api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clarity_api/__init__.py +77 -0
- clarity_api/__main__.py +4 -0
- clarity_api/agent_data/IDENTITY.md +23 -0
- clarity_api/agent_server.py +86 -0
- clarity_api/api/__init__.py +14 -0
- clarity_api/api/api_client_base.py +125 -0
- clarity_api/api/api_client_insights.py +59 -0
- clarity_api/api_client.py +26 -0
- clarity_api/auth.py +81 -0
- clarity_api/clarity_api.py +12 -0
- clarity_api/clarity_models.py +161 -0
- clarity_api/decorators.py +20 -0
- clarity_api/exceptions.py +41 -0
- clarity_api/main_agent.json +13 -0
- clarity_api/mcp/__init__.py +10 -0
- clarity_api/mcp/mcp_insights.py +67 -0
- clarity_api/mcp_config.json +3 -0
- clarity_api/mcp_server.py +91 -0
- clarity_api/models.py +21 -0
- clarity_api/services/__init__.py +9 -0
- clarity_api/services/insights_service.py +40 -0
- clarity_api/version.py +5 -0
- clarity_api-1.0.0.dist-info/METADATA +330 -0
- clarity_api-1.0.0.dist-info/RECORD +45 -0
- clarity_api-1.0.0.dist-info/WHEEL +5 -0
- clarity_api-1.0.0.dist-info/entry_points.txt +3 -0
- clarity_api-1.0.0.dist-info/licenses/LICENSE +20 -0
- clarity_api-1.0.0.dist-info/top_level.txt +3 -0
- scripts/security_sanitizer.py +160 -0
- scripts/verify_api_integration.py +237 -0
- tests/__init__.py +0 -0
- tests/conftest.py +64 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_data_export_integration.py +62 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_api_client.py +71 -0
- tests/unit/test_auth.py +56 -0
- tests/unit/test_clarity_api.py +28 -0
- tests/unit/test_clarity_models.py +86 -0
- tests/unit/test_concept_parity.py +114 -0
- tests/unit/test_errors.py +75 -0
- tests/unit/test_init_dynamics.py +36 -0
- tests/unit/test_insights_service.py +47 -0
- tests/unit/test_mcp_registration.py +38 -0
- tests/unit/test_startup.py +35 -0
clarity_api/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Clarity API
|
|
3
|
+
|
|
4
|
+
A Python library, MCP server, and A2A agent for exporting data from
|
|
5
|
+
Microsoft Clarity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from clarity_api.version import __author__, __credits__, __version__
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = ["__version__", "__author__", "__credits__"]
|
|
15
|
+
|
|
16
|
+
CORE_MODULES: list[str] = [
|
|
17
|
+
"clarity_api.clarity_models",
|
|
18
|
+
"clarity_api.api_client",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
OPTIONAL_MODULES = {
|
|
22
|
+
"clarity_api.agent_server": "agent",
|
|
23
|
+
"clarity_api.mcp_server": "mcp",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _expose_members(module):
|
|
28
|
+
"""Expose public classes and functions from a module into globals and __all__."""
|
|
29
|
+
for name, obj in inspect.getmembers(module):
|
|
30
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
31
|
+
"_"
|
|
32
|
+
):
|
|
33
|
+
globals()[name] = obj
|
|
34
|
+
if name not in __all__:
|
|
35
|
+
__all__.append(name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Eagerly import core modules (keeps API wrappers fast & light)
|
|
39
|
+
for module_name in CORE_MODULES:
|
|
40
|
+
if module_name:
|
|
41
|
+
_module = importlib.import_module(module_name)
|
|
42
|
+
_expose_members(_module)
|
|
43
|
+
|
|
44
|
+
# Dynamic/lazy loading of optional modules (agent_server, mcp_server)
|
|
45
|
+
_loaded_optional_modules: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _import_module_safely(module_name: str):
|
|
49
|
+
"""Try to import a module and return it, or None if not available."""
|
|
50
|
+
try:
|
|
51
|
+
return importlib.import_module(module_name)
|
|
52
|
+
except ImportError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str) -> Any:
|
|
57
|
+
if name == "_MCP_AVAILABLE":
|
|
58
|
+
return _import_module_safely("clarity_api.mcp_server") is not None
|
|
59
|
+
if name == "_AGENT_AVAILABLE":
|
|
60
|
+
return _import_module_safely("clarity_api.agent_server") is not None
|
|
61
|
+
|
|
62
|
+
for module_name in OPTIONAL_MODULES:
|
|
63
|
+
if module_name not in _loaded_optional_modules:
|
|
64
|
+
module = _import_module_safely(module_name)
|
|
65
|
+
if module is not None:
|
|
66
|
+
_loaded_optional_modules[module_name] = module
|
|
67
|
+
_expose_members(module)
|
|
68
|
+
|
|
69
|
+
module = _loaded_optional_modules.get(module_name)
|
|
70
|
+
if module is not None and hasattr(module, name):
|
|
71
|
+
return getattr(module, name)
|
|
72
|
+
|
|
73
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __dir__() -> list[str]:
|
|
77
|
+
return sorted(list(globals().keys()) + __all__)
|
clarity_api/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[default]
|
|
2
|
+
name = "Clarity Analytics Manager"
|
|
3
|
+
description = "AI agent for Microsoft Clarity analytics data export and insight interpretation."
|
|
4
|
+
emoji = "📊"
|
|
5
|
+
|
|
6
|
+
# System Prompt
|
|
7
|
+
You are the **Clarity Analytics Manager**, a high-fidelity AI agent specialized in working with Microsoft Clarity dashboard data.
|
|
8
|
+
|
|
9
|
+
## Role & Expertise
|
|
10
|
+
- **Data Export**: You retrieve live insights from the Microsoft Clarity Data Export API over a configurable date range (the last 24, 48, or 72 hours).
|
|
11
|
+
- **Dimensional Analysis**: You break down insights by up to three dimensions: Browser, Device, Country, OS, Source, Medium, Campaign, Channel, and URL.
|
|
12
|
+
- **Interpretation**: You summarize traffic, session counts, bot activity, and pages-per-session metrics into clear, actionable insights.
|
|
13
|
+
|
|
14
|
+
## Operational Instructions
|
|
15
|
+
1. **Always list skills first**: Use `list_skills` to understand your available tool domains.
|
|
16
|
+
2. **Use the insights tool**: Call the `clarity_insights` tool with `action="get_data_export"` and the desired `number_of_days` (1, 2, or 3) plus any dimensions.
|
|
17
|
+
3. **Validate dimensions**: Only use supported dimension values (Browser, Device, Country, OS, Source, Medium, Campaign, Channel, URL).
|
|
18
|
+
4. **Be Proactive**: When trends stand out (e.g., a spike in bot sessions), call them out.
|
|
19
|
+
5. **Safety**: Never expose API tokens in responses.
|
|
20
|
+
|
|
21
|
+
## Preferred Style
|
|
22
|
+
- Professional, efficient, and data-driven.
|
|
23
|
+
- Provide concise summaries of the metrics retrieved.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO,
|
|
11
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
12
|
+
handlers=[logging.StreamHandler()],
|
|
13
|
+
)
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_AGENT_NAME = None
|
|
18
|
+
DEFAULT_AGENT_DESCRIPTION = None
|
|
19
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agent_server():
|
|
23
|
+
"""Start the Pydantic-AI A2A agent server (CONCEPT:CLA-005)."""
|
|
24
|
+
from agent_utilities import (
|
|
25
|
+
build_system_prompt_from_workspace,
|
|
26
|
+
create_agent_parser,
|
|
27
|
+
create_agent_server,
|
|
28
|
+
initialize_workspace,
|
|
29
|
+
load_identity,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
global DEFAULT_AGENT_NAME, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_SYSTEM_PROMPT
|
|
33
|
+
initialize_workspace()
|
|
34
|
+
meta = load_identity()
|
|
35
|
+
DEFAULT_AGENT_NAME = os.getenv(
|
|
36
|
+
"DEFAULT_AGENT_NAME", meta.get("name", "Clarity Api")
|
|
37
|
+
)
|
|
38
|
+
DEFAULT_AGENT_DESCRIPTION = os.getenv(
|
|
39
|
+
"AGENT_DESCRIPTION",
|
|
40
|
+
meta.get(
|
|
41
|
+
"description",
|
|
42
|
+
"AI agent for Microsoft Clarity analytics data export.",
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = os.getenv(
|
|
46
|
+
"AGENT_SYSTEM_PROMPT",
|
|
47
|
+
meta.get("content") or build_system_prompt_from_workspace(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
51
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
|
|
52
|
+
|
|
53
|
+
print(f"{DEFAULT_AGENT_NAME} v{__version__}", file=sys.stderr)
|
|
54
|
+
parser = create_agent_parser()
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
if args.debug:
|
|
58
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
59
|
+
logger.debug("Debug mode enabled")
|
|
60
|
+
|
|
61
|
+
# Start server using the auto-discovery pattern (from mcp_config.json)
|
|
62
|
+
create_agent_server(
|
|
63
|
+
mcp_url=args.mcp_url,
|
|
64
|
+
mcp_config=args.mcp_config or "mcp_config.json",
|
|
65
|
+
host=args.host,
|
|
66
|
+
port=args.port,
|
|
67
|
+
provider=args.provider,
|
|
68
|
+
model_id=args.model_id,
|
|
69
|
+
router_model=args.model_id,
|
|
70
|
+
agent_model=args.model_id,
|
|
71
|
+
base_url=args.base_url,
|
|
72
|
+
api_key=args.api_key,
|
|
73
|
+
custom_skills_directory=args.custom_skills_directory,
|
|
74
|
+
enable_web_ui=args.web,
|
|
75
|
+
enable_otel=args.otel,
|
|
76
|
+
otel_endpoint=args.otel_endpoint,
|
|
77
|
+
otel_headers=args.otel_headers,
|
|
78
|
+
otel_public_key=args.otel_public_key,
|
|
79
|
+
otel_secret_key=args.otel_secret_key,
|
|
80
|
+
otel_protocol=args.otel_protocol,
|
|
81
|
+
debug=args.debug,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
agent_server()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Clarity API client package.
|
|
3
|
+
|
|
4
|
+
Exposes the HTTP base client and the per-domain client mixins that compose the
|
|
5
|
+
top-level :class:`clarity_api.api_client.Api` facade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from clarity_api.api.api_client_base import ClarityApiBase
|
|
9
|
+
from clarity_api.api.api_client_insights import ClarityApiInsights
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ClarityApiBase",
|
|
13
|
+
"ClarityApiInsights",
|
|
14
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""HTTP/REST base client for the Microsoft Clarity Data Export API.
|
|
3
|
+
|
|
4
|
+
Provides the shared ``requests.Session``, bearer-token authentication header,
|
|
5
|
+
SSL-verify handling, base-URL normalization, and credential validation that all
|
|
6
|
+
domain client mixins build on. Validation hits ``GET /projects`` during
|
|
7
|
+
``__init__`` to fail fast on bad credentials — preserving the original
|
|
8
|
+
``clarity_api.clarity_api.Api`` contract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
import urllib3
|
|
15
|
+
|
|
16
|
+
from clarity_api.exceptions import (
|
|
17
|
+
AuthError,
|
|
18
|
+
MissingParameterError,
|
|
19
|
+
ParameterError,
|
|
20
|
+
UnauthorizedError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClarityApiBase:
|
|
27
|
+
"""Base HTTP client for Microsoft Clarity.
|
|
28
|
+
|
|
29
|
+
CONCEPT:CLA-004 — REST Base Client. Owns the shared ``requests.Session``,
|
|
30
|
+
bearer-token header, SSL-verify handling, and fail-fast credential
|
|
31
|
+
validation against ``GET /projects``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
url: Base URL of the Clarity instance (e.g. ``https://www.clarity.ms``).
|
|
35
|
+
token: Bearer API token generated from the Clarity project settings.
|
|
36
|
+
verify: Whether to verify TLS certificates. Defaults to ``True``.
|
|
37
|
+
debug: Enable verbose logging when ``True``.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
MissingParameterError: If ``url`` or ``token`` is not provided.
|
|
41
|
+
UnauthorizedError: If the token is rejected (HTTP 403).
|
|
42
|
+
AuthError: If authentication fails (HTTP 401).
|
|
43
|
+
ParameterError: If the validation endpoint is not found (HTTP 404).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
url: str | None = None,
|
|
49
|
+
token: str | None = None,
|
|
50
|
+
verify: bool = True,
|
|
51
|
+
debug: bool = False,
|
|
52
|
+
):
|
|
53
|
+
"""Initialize the session and validate credentials (CONCEPT:CLA-004)."""
|
|
54
|
+
if debug:
|
|
55
|
+
logger.setLevel(logging.DEBUG)
|
|
56
|
+
logger.debug("Debug mode enabled")
|
|
57
|
+
else:
|
|
58
|
+
logger.setLevel(logging.ERROR)
|
|
59
|
+
|
|
60
|
+
if url is None:
|
|
61
|
+
raise MissingParameterError
|
|
62
|
+
|
|
63
|
+
self._session = requests.Session()
|
|
64
|
+
self.url = url.rstrip("/")
|
|
65
|
+
self.headers: dict | None = None
|
|
66
|
+
self.verify = verify
|
|
67
|
+
self.debug = debug
|
|
68
|
+
|
|
69
|
+
if self.verify is False:
|
|
70
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
71
|
+
|
|
72
|
+
if token:
|
|
73
|
+
self.headers = {
|
|
74
|
+
"Authorization": f"Bearer {token}",
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
else:
|
|
78
|
+
raise MissingParameterError
|
|
79
|
+
|
|
80
|
+
response = self._session.get(
|
|
81
|
+
url=f"{self.url}/projects",
|
|
82
|
+
headers=self.headers,
|
|
83
|
+
verify=self.verify,
|
|
84
|
+
timeout=10,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 403:
|
|
88
|
+
logger.error(f"Unauthorized Error: {response.content!r}")
|
|
89
|
+
raise UnauthorizedError
|
|
90
|
+
elif response.status_code == 401:
|
|
91
|
+
logger.error(f"Authentication Error: {response.content!r}")
|
|
92
|
+
raise AuthError
|
|
93
|
+
elif response.status_code == 404:
|
|
94
|
+
logger.error(f"Parameter Error: {response.content!r}")
|
|
95
|
+
raise ParameterError
|
|
96
|
+
|
|
97
|
+
def api_request(
|
|
98
|
+
self,
|
|
99
|
+
method: str = "GET",
|
|
100
|
+
endpoint: str = "/",
|
|
101
|
+
params: dict | None = None,
|
|
102
|
+
json: dict | None = None,
|
|
103
|
+
) -> requests.Response:
|
|
104
|
+
"""Execute an arbitrary REST request against the Clarity instance.
|
|
105
|
+
|
|
106
|
+
CONCEPT:CLA-004 — REST Base Client.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
method: HTTP method (GET, POST, PUT, DELETE, PATCH).
|
|
110
|
+
endpoint: Path appended to the base URL (e.g. ``/projects``).
|
|
111
|
+
params: Optional query parameters.
|
|
112
|
+
json: Optional JSON body payload.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The raw ``requests.Response`` object.
|
|
116
|
+
"""
|
|
117
|
+
url = f"{self.url}/{endpoint.lstrip('/')}"
|
|
118
|
+
return self._session.request(
|
|
119
|
+
method=method.upper(),
|
|
120
|
+
url=url,
|
|
121
|
+
params=params,
|
|
122
|
+
json=json,
|
|
123
|
+
headers=self.headers,
|
|
124
|
+
verify=self.verify,
|
|
125
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Insights / Data Export domain client for the Microsoft Clarity API.
|
|
3
|
+
|
|
4
|
+
Wraps the ``GET /export-data/api/v1/project-live-insights`` endpoint, exposing
|
|
5
|
+
``get_data_export`` while preserving the original behavior of
|
|
6
|
+
``clarity_api.clarity_api.Api.get_data_export``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from clarity_api.api.api_client_base import ClarityApiBase
|
|
13
|
+
from clarity_api.clarity_models import InputModel
|
|
14
|
+
from clarity_api.decorators import require_auth
|
|
15
|
+
from clarity_api.exceptions import ParameterError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClarityApiInsights(ClarityApiBase):
|
|
19
|
+
"""Domain client for Clarity Data Export / Live Insights operations."""
|
|
20
|
+
|
|
21
|
+
@require_auth
|
|
22
|
+
def get_data_export(
|
|
23
|
+
self, api_parameters: dict | None = None, **kwargs
|
|
24
|
+
) -> requests.Response:
|
|
25
|
+
"""Retrieve dashboard data insights for a project.
|
|
26
|
+
|
|
27
|
+
CONCEPT:CLA-001 — Data Export / Live Insights. Implements the
|
|
28
|
+
``GET /export-data/api/v1/project-live-insights`` call backing the
|
|
29
|
+
``clarity_insights`` MCP tool.
|
|
30
|
+
|
|
31
|
+
Accepts either a pre-built ``api_parameters`` dict or keyword arguments
|
|
32
|
+
(``number_of_days``/``numOfDays``, ``dimension_1``/``dimension1``, etc.),
|
|
33
|
+
which are validated and normalized via :class:`InputModel`.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
api_parameters: Pre-built query parameter dict. When omitted, the
|
|
37
|
+
parameters are constructed from ``kwargs`` via ``InputModel``.
|
|
38
|
+
**kwargs: Convenience keyword arguments forwarded to ``InputModel``.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The ``requests.Response`` object from the GET request.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ParameterError: If the provided parameters fail validation.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
if api_parameters is None:
|
|
48
|
+
model = InputModel(**kwargs)
|
|
49
|
+
api_parameters = model.api_parameters
|
|
50
|
+
|
|
51
|
+
response = self._session.get(
|
|
52
|
+
url=f"{self.url}/export-data/api/v1/project-live-insights",
|
|
53
|
+
params=api_parameters,
|
|
54
|
+
headers=self.headers,
|
|
55
|
+
verify=self.verify,
|
|
56
|
+
)
|
|
57
|
+
except ValidationError as e:
|
|
58
|
+
raise ParameterError(f"Invalid parameters: {e.errors()}") from e
|
|
59
|
+
return response
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Top-level Clarity API client facade.
|
|
3
|
+
|
|
4
|
+
Composes the per-domain client mixins from :mod:`clarity_api.api` into a single
|
|
5
|
+
``Api`` class. This preserves the original ``clarity_api.clarity_api.Api``
|
|
6
|
+
contract (constructor ``Api(url, token, verify=True)`` validating credentials
|
|
7
|
+
against ``GET /projects``, plus ``get_data_export``) while routing the actual
|
|
8
|
+
implementation through the modular ``api/`` sub-package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from clarity_api.api.api_client_insights import ClarityApiInsights
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Api(ClarityApiInsights):
|
|
15
|
+
"""Microsoft Clarity Data Export API client.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
url: Base URL of the Clarity instance (e.g. ``https://www.clarity.ms``).
|
|
19
|
+
token: Bearer API token from the Clarity project settings.
|
|
20
|
+
verify: Whether to verify TLS certificates. Defaults to ``True``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__slots__ = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["Api"]
|
clarity_api/auth.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Clarity Authentication Module.
|
|
2
|
+
|
|
3
|
+
Authentication priority:
|
|
4
|
+
1. **OIDC Delegation** — If delegation is active, exchanges the IdP-issued
|
|
5
|
+
user token for a downstream Clarity access token via RFC 8693 Token Exchange
|
|
6
|
+
using the shared ``delegated_auth`` helper.
|
|
7
|
+
2. **Fixed Credentials** — Falls back to the ``CLARITY_TOKEN`` env var.
|
|
8
|
+
|
|
9
|
+
Environment variables:
|
|
10
|
+
- ``CLARITY_URL`` — base URL of the Clarity instance (default ``https://www.clarity.ms``).
|
|
11
|
+
- ``CLARITY_TOKEN`` — bearer API token.
|
|
12
|
+
- ``CLARITY_SSL_VERIFY`` — whether to verify TLS certificates (default ``True``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
from agent_utilities.base_utilities import get_logger, to_boolean
|
|
19
|
+
from agent_utilities.core.exceptions import AuthError, UnauthorizedError
|
|
20
|
+
|
|
21
|
+
local = threading.local()
|
|
22
|
+
from clarity_api.api_client import Api
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_client(
|
|
28
|
+
instance: str = os.getenv("CLARITY_URL", "https://www.clarity.ms"),
|
|
29
|
+
token: str | None = os.getenv("CLARITY_TOKEN", None),
|
|
30
|
+
verify: bool = to_boolean(string=os.getenv("CLARITY_SSL_VERIFY", "True")),
|
|
31
|
+
config: dict | None = None,
|
|
32
|
+
) -> Api:
|
|
33
|
+
"""Factory function to create the Clarity ``Api`` client.
|
|
34
|
+
|
|
35
|
+
CONCEPT:CLA-002 — Credential & Auth Factory. Supports OIDC delegation
|
|
36
|
+
(RFC 8693 token exchange) and fixed credentials (``CLARITY_TOKEN``). Used as
|
|
37
|
+
the ``Depends(get_client)`` dependency for the MCP tools.
|
|
38
|
+
"""
|
|
39
|
+
from agent_utilities.mcp.delegated_auth import (
|
|
40
|
+
get_delegated_token,
|
|
41
|
+
get_user_identity,
|
|
42
|
+
is_delegation_enabled,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
delegation_enabled = is_delegation_enabled(config)
|
|
46
|
+
|
|
47
|
+
# --- Path 1: OIDC Delegation (RFC 8693 Token Exchange) ---
|
|
48
|
+
if delegation_enabled:
|
|
49
|
+
try:
|
|
50
|
+
delegated_token = get_delegated_token(
|
|
51
|
+
config=config,
|
|
52
|
+
audience=(config or {}).get("audience", instance),
|
|
53
|
+
scopes=(config or {}).get("delegated_scopes", "api"),
|
|
54
|
+
verify=verify,
|
|
55
|
+
)
|
|
56
|
+
identity = get_user_identity()
|
|
57
|
+
logger.info(
|
|
58
|
+
"Using OIDC delegated token for Clarity API",
|
|
59
|
+
extra={
|
|
60
|
+
"user_email": identity.get("email"),
|
|
61
|
+
"instance": instance,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
return Api(url=instance, token=delegated_token, verify=verify)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(
|
|
67
|
+
"OIDC delegation failed for Clarity",
|
|
68
|
+
extra={"error_type": type(e).__name__, "error_message": str(e)},
|
|
69
|
+
)
|
|
70
|
+
raise RuntimeError(f"Token exchange failed: {str(e)}") from e
|
|
71
|
+
|
|
72
|
+
# --- Path 2: Fixed Credentials (CLARITY_TOKEN) ---
|
|
73
|
+
logger.info("Using fixed credentials for Clarity API")
|
|
74
|
+
try:
|
|
75
|
+
return Api(url=instance, token=token, verify=verify)
|
|
76
|
+
except (AuthError, UnauthorizedError) as e:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"AUTHENTICATION ERROR: The Clarity credentials provided are not valid for '{instance}'. "
|
|
79
|
+
f"Please check your CLARITY_TOKEN and CLARITY_URL environment variables. "
|
|
80
|
+
f"Error details: {str(e)}"
|
|
81
|
+
) from e
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Backward-compatibility shim for ``clarity_api.clarity_api.Api``.
|
|
3
|
+
|
|
4
|
+
The real implementation now lives in the modular :mod:`clarity_api.api`
|
|
5
|
+
sub-package and is composed by :mod:`clarity_api.api_client`. This module
|
|
6
|
+
re-exports ``Api`` so that existing imports
|
|
7
|
+
(``from clarity_api.clarity_api import Api``) keep working unchanged.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from clarity_api.api_client import Api
|
|
11
|
+
|
|
12
|
+
__all__ = ["Api"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
AliasChoices,
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
Field,
|
|
10
|
+
field_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=logging.ERROR, format="%(asctime)s - %(levelname)s - %(message)s"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InputModel(BaseModel):
|
|
19
|
+
"""Validated query parameters for the Clarity Data Export endpoint.
|
|
20
|
+
|
|
21
|
+
CONCEPT:CLA-003 — Input Validation & Parameter Modeling. Normalizes and
|
|
22
|
+
validates the ``number_of_days`` date range and the up-to-three breakdown
|
|
23
|
+
dimensions before they are sent to the Clarity API.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
numOfDays (Union[int, str]): The number of days to return.
|
|
27
|
+
dimension1 (str, optional): The first dimension parameters.
|
|
28
|
+
dimension2 (str, optional): The second dimension parameters.
|
|
29
|
+
dimension3 (str, optional): The third dimension parameters.
|
|
30
|
+
api_parameters (str): Additional API parameters for the group.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
35
|
+
|
|
36
|
+
numOfDays: int | str | None = Field(
|
|
37
|
+
description="Number of days to save",
|
|
38
|
+
validation_alias=AliasChoices("numOfDays", "number_of_days"),
|
|
39
|
+
default=None,
|
|
40
|
+
)
|
|
41
|
+
dimension1: str | None = Field(
|
|
42
|
+
description="Dimension 1",
|
|
43
|
+
validation_alias=AliasChoices("dimension1", "dimension_1"),
|
|
44
|
+
default=None,
|
|
45
|
+
)
|
|
46
|
+
dimension2: str | None = Field(
|
|
47
|
+
description="Dimension 2",
|
|
48
|
+
validation_alias=AliasChoices("dimension2", "dimension_2"),
|
|
49
|
+
default=None,
|
|
50
|
+
)
|
|
51
|
+
dimension3: str | None = Field(
|
|
52
|
+
description="Dimension 3",
|
|
53
|
+
validation_alias=AliasChoices("dimension3", "dimension_3"),
|
|
54
|
+
default=None,
|
|
55
|
+
)
|
|
56
|
+
api_parameters: dict | None = Field(description="API Parameters", default=None)
|
|
57
|
+
|
|
58
|
+
def model_post_init(self, __context):
|
|
59
|
+
"""Build the validated ``api_parameters`` dict from the input fields.
|
|
60
|
+
|
|
61
|
+
CONCEPT:CLA-003 — Input Validation & Parameter Modeling.
|
|
62
|
+
"""
|
|
63
|
+
self.api_parameters = {}
|
|
64
|
+
if self.numOfDays:
|
|
65
|
+
self.api_parameters["numOfDays"] = self.numOfDays
|
|
66
|
+
if self.dimension1:
|
|
67
|
+
self.api_parameters["dimension1"] = self.dimension1
|
|
68
|
+
if self.dimension2:
|
|
69
|
+
self.api_parameters["dimension2"] = self.dimension2
|
|
70
|
+
if self.dimension3:
|
|
71
|
+
self.api_parameters["dimension3"] = self.dimension3
|
|
72
|
+
|
|
73
|
+
@field_validator("numOfDays", mode="before")
|
|
74
|
+
def validate_number_of_days(cls, v):
|
|
75
|
+
"""
|
|
76
|
+
Validate the 'number_of_days' parameter to ensure it is a valid integer.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
- v: The value of 'number_of_days'.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
- int: The validated 'number_of_days'.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
- ParameterError: If 'number_of_days' is not a valid integer.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
v = int(v)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
raise e
|
|
91
|
+
return v
|
|
92
|
+
|
|
93
|
+
@field_validator("dimension1", "dimension2", "dimension3", mode="before")
|
|
94
|
+
def validate_dimensions(cls, v):
|
|
95
|
+
"""
|
|
96
|
+
Validate the 'dimensions' parameter to ensure it is a valid option.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
- v: The value of 'dimensions'.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
- str: The validated 'dimensions'.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
- ParameterError: If 'dimensions' is not a valid option.
|
|
106
|
+
"""
|
|
107
|
+
if v:
|
|
108
|
+
valid_dimensions = {
|
|
109
|
+
"browser": "Browser",
|
|
110
|
+
"device": "Device",
|
|
111
|
+
"country": "Country",
|
|
112
|
+
"os": "OS",
|
|
113
|
+
"source": "Source",
|
|
114
|
+
"medium": "Medium",
|
|
115
|
+
"campaign": "Campaign",
|
|
116
|
+
"channel": "Channel",
|
|
117
|
+
"url": "URL",
|
|
118
|
+
}
|
|
119
|
+
try:
|
|
120
|
+
return valid_dimensions[v.lower()]
|
|
121
|
+
except KeyError:
|
|
122
|
+
raise ValueError("Invalid dimension") from None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Information(BaseModel):
|
|
126
|
+
model_config = ConfigDict(extra="allow")
|
|
127
|
+
totalSessionCount: str | None = Field(
|
|
128
|
+
default=None, description="The total number of sessions."
|
|
129
|
+
)
|
|
130
|
+
totalBotSessionCount: str | None = Field(
|
|
131
|
+
default=None, description="The total number of bot sessions."
|
|
132
|
+
)
|
|
133
|
+
distantUserCount: str | None = Field(
|
|
134
|
+
default=None, description="The distant user count."
|
|
135
|
+
)
|
|
136
|
+
PagesPerSessionPercentage: float | None = Field(
|
|
137
|
+
default=None, description="The pages per session percentage."
|
|
138
|
+
)
|
|
139
|
+
OS: str | None = Field(default=None, description="The operating system.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Metric(BaseModel):
|
|
143
|
+
model_config = ConfigDict(extra="allow")
|
|
144
|
+
metricName: str | None = Field(
|
|
145
|
+
default=None, description="The name of the returned metric."
|
|
146
|
+
)
|
|
147
|
+
information: list[Information] | None = Field(
|
|
148
|
+
default=None, description="Result containing available responses."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Response(BaseModel):
|
|
153
|
+
data: list[Metric] | None = Field(default=None, description="Metrics returned.")
|
|
154
|
+
error: Any | None = Field(default=None, description="Response error code")
|
|
155
|
+
status_code: str | int | None = Field(
|
|
156
|
+
default=None, description="Response status code"
|
|
157
|
+
)
|
|
158
|
+
json_output: list | dict | None = Field(
|
|
159
|
+
default=None, description="Response JSON data"
|
|
160
|
+
)
|
|
161
|
+
raw_output: bytes | None = Field(default=None, description="Response Raw bytes")
|