Skip to content

Commit 2e6e7e5

Browse files
committed
parse_query: strip query
1 parent 26591fc commit 2e6e7e5

File tree

3 files changed

+355
-2
lines changed

3 files changed

+355
-2
lines changed

adminapi/CLAUDE.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
## Overview
2+
3+
`adminapi` is a Python library for interacting with Serveradmin, a system for querying and managing server attributes. It provides both a Python API and a CLI tool for server management operations.
4+
5+
Documentation: https://serveradmin.readthedocs.io/en/latest/python-api.html
6+
7+
## Development Commands
8+
9+
### Setup
10+
```bash
11+
# Install from parent directory, like
12+
cd ~/projects/serveradmin
13+
pip install -e .
14+
```
15+
16+
### Testing
17+
```bash
18+
# Run all tests
19+
python -m unittest discover adminapi/tests
20+
21+
# Run specific test file
22+
python -m unittest adminapi.tests.test_cli
23+
python -m unittest adminapi.tests.test_dataset
24+
25+
# Run single test
26+
python -m unittest adminapi.tests.test_cli.TestCommandlineInterface.test_one_argument
27+
```
28+
29+
### CLI Usage
30+
```bash
31+
# Query servers
32+
adminapi 'hostname=web01'
33+
adminapi 'project=adminapi' --attr hostname --attr state
34+
35+
# Update servers
36+
adminapi 'hostname=web01' --update state=maintenance
37+
```
38+
39+
### Python API Examples
40+
41+
**Query and modify servers**
42+
```python
43+
from adminapi.dataset import Query
44+
45+
# Query servers matching criteria
46+
servers = Query({
47+
'servertype': 'vm',
48+
'project': 'test_project',
49+
'state': 'offline',
50+
})
51+
52+
# Modify server attributes
53+
for server in servers:
54+
print(f"Bringing {server['hostname']} online")
55+
server['state'] = 'online'
56+
57+
# Commit changes to persist them
58+
servers.commit()
59+
```
60+
61+
**Query with restricted attributes**
62+
```python
63+
from adminapi.dataset import Query
64+
65+
# Only fetch specific attributes to reduce payload
66+
servers = Query(
67+
{'project': 'test_project', 'state': 'maintenance'},
68+
restrict=['hostname', 'state']
69+
)
70+
71+
for server in servers:
72+
print(f"{server['hostname']}: {server['state']}")
73+
```
74+
75+
**Get single server**
76+
```python
77+
from adminapi.dataset import Query
78+
79+
# Use get() when expecting exactly one result
80+
server = Query({'hostname': 'web01'}).get()
81+
server['state'] = 'maintenance'
82+
server.commit()
83+
```
84+
85+
## Architecture
86+
87+
### Core Components
88+
89+
**Query System** (`dataset.py`)
90+
- `Query`: Main class for filtering and fetching server objects from Serveradmin
91+
- `BaseQuery`: Base class with common query operations (filtering, ordering, committing)
92+
- Queries are lazy-loaded and cached until explicitly fetched
93+
- Always ensures `object_id` is included in restrict lists for correlation during commits
94+
95+
**Data Objects** (`dataset.py`)
96+
- `DatasetObject`: Dict-like wrapper for server data with change tracking
97+
- `MultiAttr`: Set-like wrapper for multi-value attributes with automatic change propagation
98+
- Track state: `created`, `changed`, `deleted`, or `consistent`
99+
- Old values stored in `old_values` dict for rollback/commit operations
100+
101+
**Filters** (`filters.py`)
102+
- `BaseFilter`: Exact match filter (default)
103+
- Comparison: `GreaterThan`, `LessThan`, `GreaterThanOrEquals`, `LessThanOrEquals`
104+
- Pattern: `Regexp`, `StartsWith`, `Contains`, `ContainedBy`, `Overlaps`
105+
- Logical: `Any` (OR), `All` (AND), `Not`
106+
- Special: `Empty` (null check), `ContainedOnlyBy` (IP network containment)
107+
108+
**Request Layer** (`request.py`)
109+
- Handles HTTP communication with Serveradmin server
110+
- Authentication via SSH keys (paramiko), SSH agent, or auth tokens (HMAC-SHA1)
111+
- Automatic retry logic (3 attempts, 5s interval) for network failures
112+
- Gzip compression support for responses
113+
- Environment variables: `SERVERADMIN_BASE_URL`, `SERVERADMIN_KEY_PATH`, `SERVERADMIN_TOKEN`
114+
115+
**Query Parser** (`parse.py`)
116+
- Converts string queries to filter dictionaries
117+
- Supports function syntax: `attribute=Function(value)`
118+
- Regex detection: triggers `Regexp` filter for patterns with `.*`, `.+`, `[`, `]`, etc.
119+
- Hostname shorthand: first token without `=` treated as hostname filter
120+
121+
**API Calls** (`api.py`)
122+
- `FunctionGroup`: Dynamic proxy for calling Serveradmin API functions
123+
- Pattern: `FunctionGroup('group_name').function_name(*args, **kwargs)`
124+
- Example: `api.get('nagios').commit('push', 'user', project='foo')`
125+
126+
### Data Flow
127+
128+
1. **Query Construction**: Create `Query` with filters dict, restrict list, order_by
129+
2. **Lazy Fetch**: Results fetched on first iteration/access via `_get_results()`
130+
3. **Modification**: Change `DatasetObject` attributes, tracked in `old_values`
131+
4. **Commit**: Build commit object with created/changed/deleted arrays, send to server
132+
5. **Confirm**: Clear `old_values`, update object state after successful commit
133+
134+
### Change Tracking
135+
136+
- **Single attributes**: Save original value on first modification to `old_values`
137+
- **Multi attributes**: `MultiAttr` operations create new sets, trigger parent `__setitem__`
138+
- **Validation**: Type checking against existing attribute types (bool, multi, datatype)
139+
- **Serialization**: Changed objects serialize with action (`update` or `multi`) and old/new values
140+
141+
### Authentication Flow
142+
143+
1. Check for `SERVERADMIN_KEY_PATH` → load private key file
144+
2. Else check for `SERVERADMIN_TOKEN` → use HMAC authentication
145+
3. Else try SSH agent (`paramiko.agent.Agent`)
146+
4. Sign timestamp + request body, include signature in `X-Signatures` header
147+
5. Server validates signature using stored public key
148+
149+
## Important Patterns
150+
151+
- Always use `commit()` after modifying objects to persist changes
152+
- Use `restrict` parameter to limit fetched attributes (reduces payload size)
153+
- `get()` expects exactly one result; use for single-server queries
154+
- Multi-value attributes must be modified via `MultiAttr` methods or reassignment
155+
- Query filters are immutable; changes create new filter instances
156+
- The API is marked as draft and may change in future versions

adminapi/parse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111

1212
def parse_query(term, hostname=None): # NOQA: C901
13-
# Ignore newlines to allow queries across multiple lines
14-
term = term.replace('\n', '')
13+
# Replace newlines with spaces to allow queries across multiple lines
14+
term = term.replace('\n', ' ').strip()
1515

1616
parsed_args = parse_function_string(term, strict=True)
1717
if not parsed_args:

adminapi/tests/test_parse.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import unittest
2+
3+
from adminapi.datatype import DatatypeError
4+
from adminapi.filters import (
5+
BaseFilter,
6+
Regexp,
7+
Any,
8+
GreaterThan,
9+
)
10+
from adminapi.parse import parse_query, parse_function_string
11+
12+
13+
def assert_filters_equal(test_case, result, expected):
14+
"""Compare filter dictionaries by their repr, which includes structure and values."""
15+
test_case.assertEqual(sorted(result.keys()), sorted(expected.keys()))
16+
for key in expected:
17+
test_case.assertEqual(repr(result[key]), repr(expected[key]))
18+
19+
20+
class TestParseQuery(unittest.TestCase):
21+
def test_simple_attribute(self):
22+
result = parse_query("hostname=web01")
23+
expected = {"hostname": BaseFilter("web01")}
24+
assert_filters_equal(self, result, expected)
25+
26+
def test_whitespace_handling(self):
27+
result = parse_query(" hostname=test ")
28+
expected = {"hostname": BaseFilter("test")}
29+
assert_filters_equal(self, result, expected)
30+
31+
def test_multiple_attributes(self):
32+
result = parse_query("hostname=web01 state=online")
33+
expected = {
34+
"hostname": BaseFilter("web01"),
35+
"state": BaseFilter("online"),
36+
}
37+
assert_filters_equal(self, result, expected)
38+
39+
def test_hostname_shorthand(self):
40+
result = parse_query("web01 state=online")
41+
expected = {
42+
"hostname": BaseFilter("web01"),
43+
"state": BaseFilter("online"),
44+
}
45+
assert_filters_equal(self, result, expected)
46+
47+
def test_hostname_shorthand_with_regexp(self):
48+
# Hostname shortcuts automatically detect regex patterns
49+
result = parse_query("web.*")
50+
expected = {"hostname": Regexp("web.*")}
51+
assert_filters_equal(self, result, expected)
52+
53+
def test_regexp_pattern_as_literal(self):
54+
# Regex patterns in attribute values are treated as literals
55+
# Use Regexp() function for actual regex filtering
56+
result = parse_query("hostname=web.*")
57+
expected = {"hostname": BaseFilter("web.*")}
58+
assert_filters_equal(self, result, expected)
59+
60+
def test_explicit_regexp_function(self):
61+
# Use explicit Regexp() function for regex filtering
62+
result = parse_query("hostname=Regexp(web.*)")
63+
expected = {"hostname": Regexp("web.*")}
64+
assert_filters_equal(self, result, expected)
65+
66+
def test_function_filter(self):
67+
result = parse_query("num_cores=GreaterThan(4)")
68+
expected = {"num_cores": GreaterThan(4)}
69+
assert_filters_equal(self, result, expected)
70+
71+
def test_function_with_multiple_args(self):
72+
result = parse_query("hostname=Any(web01 web02)")
73+
expected = {"hostname": Any("web01", "web02")}
74+
assert_filters_equal(self, result, expected)
75+
76+
def test_empty_query(self):
77+
result = parse_query("")
78+
self.assertEqual(result, {})
79+
80+
def test_whitespace_only_query(self):
81+
result = parse_query(" ")
82+
self.assertEqual(result, {})
83+
84+
def test_newline_in_query(self):
85+
result = parse_query("hostname=web01\nstate=online")
86+
expected = {
87+
"hostname": BaseFilter("web01"),
88+
"state": BaseFilter("online"),
89+
}
90+
assert_filters_equal(self, result, expected)
91+
92+
def test_any_filter_with_duplicate_hostname(self):
93+
# Hostname shorthand triggers regex, but explicit attribute assignment doesn't
94+
# Order: explicit assignment value comes first, then shorthand value
95+
result = parse_query("web.* hostname=db.*")
96+
expected = {"hostname": Any(BaseFilter("db.*"), Regexp("web.*"))}
97+
assert_filters_equal(self, result, expected)
98+
99+
def test_invalid_function(self):
100+
with self.assertRaisesRegex(DatatypeError, r"Invalid function InvalidFunc"):
101+
parse_query("hostname=InvalidFunc(test)")
102+
103+
104+
def test_top_level_literal_error(self):
105+
with self.assertRaisesRegex(
106+
DatatypeError, r"Invalid term: Top level literals are not allowed"
107+
):
108+
parse_query("hostname=test value")
109+
110+
def test_top_level_function_as_hostname(self):
111+
# Function syntax without key is treated as hostname shorthand
112+
result = parse_query("GreaterThan(4)")
113+
expected = {"hostname": BaseFilter("GreaterThan(4)")}
114+
assert_filters_equal(self, result, expected)
115+
116+
def test_garbled_hostname_error(self):
117+
with self.assertRaisesRegex(DatatypeError, r"Garbled hostname: db01"):
118+
parse_query("web01", hostname="db01")
119+
120+
121+
class TestParseFunctionString(unittest.TestCase):
122+
def test_simple_key_value(self):
123+
result = parse_function_string("hostname=web01")
124+
self.assertEqual(result, [("key", "hostname"), ("literal", "web01")])
125+
126+
def test_quoted_string(self):
127+
result = parse_function_string('hostname="web 01"')
128+
self.assertEqual(result, [("key", "hostname"), ("literal", "web 01")])
129+
130+
def test_single_quoted_string(self):
131+
result = parse_function_string("hostname='web 01'")
132+
self.assertEqual(result, [("key", "hostname"), ("literal", "web 01")])
133+
134+
def test_escaped_quote(self):
135+
result = parse_function_string('hostname="web\\"01"')
136+
# Note: string_buf stores the actual chars, but the result is the original slice
137+
self.assertEqual(result[1][0], "literal")
138+
139+
def test_function_call(self):
140+
result = parse_function_string("num_cores=GreaterThan(4)")
141+
expected = [
142+
("key", "num_cores"),
143+
("func", "GreaterThan"),
144+
("literal", 4),
145+
("endfunc", ""),
146+
]
147+
self.assertEqual(result, expected)
148+
149+
def test_nested_function(self):
150+
result = parse_function_string("attr=Func1(Func2(value))")
151+
self.assertEqual(result[0], ("key", "attr"))
152+
self.assertEqual(result[1], ("func", "Func1"))
153+
self.assertEqual(result[2], ("func", "Func2"))
154+
155+
def test_multiple_values(self):
156+
result = parse_function_string("host1 host2 host3")
157+
expected = [
158+
("literal", "host1"),
159+
("literal", "host2"),
160+
("literal", "host3"),
161+
]
162+
self.assertEqual(result, expected)
163+
164+
def test_datatype_conversion(self):
165+
result = parse_function_string("count=42")
166+
self.assertEqual(result, [("key", "count"), ("literal", 42)])
167+
168+
def test_unterminated_string(self):
169+
with self.assertRaisesRegex(DatatypeError, r"Unterminated string"):
170+
parse_function_string('hostname="web01', strict=True)
171+
172+
def test_invalid_escape(self):
173+
with self.assertRaisesRegex(DatatypeError, r"Invalid escape"):
174+
parse_function_string('hostname="web\\01"', strict=True)
175+
176+
177+
def test_empty_string(self):
178+
result = parse_function_string("")
179+
self.assertEqual(result, [])
180+
181+
def test_whitespace_only(self):
182+
result = parse_function_string(" ")
183+
self.assertEqual(result, [])
184+
185+
def test_parentheses_handling(self):
186+
result = parse_function_string("func(a b)")
187+
expected = [
188+
("func", "func"),
189+
("literal", "a"),
190+
("literal", "b"),
191+
("endfunc", ""),
192+
]
193+
self.assertEqual(result, expected)
194+
195+
196+
if __name__ == "__main__":
197+
unittest.main()

0 commit comments

Comments
 (0)