greenlake-activate-cli 0.1.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.
glcli/__init__.py ADDED
File without changes
@@ -0,0 +1,67 @@
1
+ import os
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ import requests
6
+ from dotenv import dotenv_values
7
+
8
+ logger = logging.getLogger(__name__)
9
+ REQUEST_TIMEOUT = 30
10
+ ENVIRONMENT_VARIABLE = "CREDENTIAL_1"
11
+
12
+
13
+ def user_config_path() -> Path:
14
+ """Return the user-level configuration path for the current platform."""
15
+ config_home = os.environ.get("XDG_CONFIG_HOME")
16
+ if config_home:
17
+ return Path(config_home) / "greenlake-activate-cli" / ".env"
18
+ return Path.home() / ".config" / "greenlake-activate-cli" / ".env"
19
+
20
+
21
+ def credential_config_paths() -> tuple[Path, Path]:
22
+ """Return user-level and project-local credential file paths."""
23
+ return user_config_path(), Path.cwd() / ".env"
24
+
25
+
26
+ def load_credentials() -> str:
27
+ """Load credentials using environment, user config, then local config."""
28
+ credential = os.environ.get(ENVIRONMENT_VARIABLE, "").strip()
29
+ if credential:
30
+ return credential
31
+
32
+ user_path, local_path = credential_config_paths()
33
+ for path in (user_path, local_path):
34
+ if not path.is_file():
35
+ continue
36
+ credential = str(dotenv_values(path).get(ENVIRONMENT_VARIABLE) or "").strip()
37
+ if credential:
38
+ return credential
39
+
40
+ raise RuntimeError(
41
+ f"{ENVIRONMENT_VARIABLE} is not configured. Set it in the environment, "
42
+ f"or create {user_path} or {local_path} with {ENVIRONMENT_VARIABLE}=<token>."
43
+ )
44
+
45
+ def create_activate_session(credential_1:str) -> requests.Session:
46
+ """Login and return session."""
47
+ if not credential_1:
48
+ raise RuntimeError("CREDENTIAL_1 is not configured")
49
+
50
+ login_url = "https://activate.arubanetworks.com/LOGIN"
51
+ login_data = {
52
+ 'credential_0': "username",
53
+ 'credential_1': credential_1
54
+ }
55
+ logger.debug("Attempting to authenticate to: %s", login_url)
56
+ session = requests.session()
57
+ response = session.post(login_url, data=login_data, timeout=REQUEST_TIMEOUT)
58
+
59
+ logger.debug("Login status code: %s", response.status_code)
60
+ if response.status_code != 200:
61
+ logger.error("Login failed: %s", response.text)
62
+ raise RuntimeError("Activate login failed")
63
+
64
+ logger.info("Authenticated to Activate")
65
+ logger.debug("Full login response: %s", response.text)
66
+
67
+ return session
glcli/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from glcli.activate_login import create_activate_session, load_credentials
6
+ from glcli.data_parsing import parse_inventory_response
7
+ from glcli.query_inventory import query_inventory, read_identifiers_from_file
8
+ from glcli.query_folder import resolve_folder_ids
9
+ from glcli.display_data import display_inventory_sn
10
+ from rich.logging import RichHandler
11
+
12
+ logger = logging.getLogger(__name__)
13
+ app = typer.Typer(help="Interact with HPE GreenLake Activate via CLI.")
14
+ query_app = typer.Typer(help="Query Activate inventory.")
15
+ app.add_typer(query_app, name="query")
16
+
17
+ def setup_logging(verbose: bool):
18
+ logging.basicConfig(
19
+ level=logging.DEBUG if verbose else logging.INFO,
20
+ format="%(levelname)s %(name)s: %(message)s" if verbose else "%(message)s",
21
+ handlers=[RichHandler(rich_tracebacks=True, show_path=verbose)],
22
+ )
23
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
24
+
25
+
26
+ def _load_credential() -> str:
27
+ try:
28
+ return load_credentials()
29
+ except RuntimeError as exc:
30
+ typer.echo(f"Error: {exc}", err=True)
31
+ raise typer.Exit(code=1) from exc
32
+
33
+ def _query(identifier_type: str, identifiers: list[str], file: Path | None = None) -> None:
34
+ if file is not None:
35
+ identifiers = read_identifiers_from_file(file, identifier_type)
36
+
37
+ identifiers = [identifier.strip().upper() for identifier in identifiers if identifier.strip()]
38
+ if not identifiers:
39
+ raise typer.BadParameter("At least one identifier is required")
40
+
41
+ identifiers = list(dict.fromkeys(identifiers))
42
+ credential = _load_credential()
43
+ try:
44
+ session = create_activate_session(credential)
45
+ query_result, missing = query_inventory(session, identifier_type, identifiers)
46
+ finally:
47
+ if "session" in locals():
48
+ session.close()
49
+
50
+ extracted_data = parse_inventory_response(query_result)
51
+
52
+ if missing:
53
+ print(f"Not found: ({len(missing)}): {', '.join(missing)}")
54
+
55
+ display_inventory_sn(extracted_data)
56
+
57
+ if not extracted_data:
58
+ raise typer.Exit(code=1)
59
+ if missing:
60
+ raise typer.Exit(code=2)
61
+
62
+
63
+ def _query_folder(values: list[str]) -> None:
64
+ values = [value.strip() for value in values if value.strip()]
65
+ if not values:
66
+ raise typer.BadParameter("At least one folder ID or name is required")
67
+
68
+ values = list(dict.fromkeys(values))
69
+ credential = _load_credential()
70
+ session = None
71
+ try:
72
+ session = create_activate_session(credential)
73
+ try:
74
+ folder_ids = resolve_folder_ids(session, values)
75
+ except ValueError as exc:
76
+ raise typer.BadParameter(str(exc)) from exc
77
+ query_result, missing = query_inventory(session, "folder", folder_ids)
78
+ finally:
79
+ if session is not None:
80
+ session.close()
81
+
82
+ extracted_data = parse_inventory_response(query_result)
83
+ display_inventory_sn(extracted_data)
84
+
85
+ if not extracted_data:
86
+ raise typer.Exit(code=1)
87
+
88
+
89
+ @app.callback()
90
+ def main_callback(
91
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debugging output to console."),
92
+ ) -> None:
93
+ setup_logging(verbose)
94
+
95
+
96
+ @query_app.command("serial")
97
+ def query_serial(
98
+ serials: list[str] | None = typer.Argument(None, metavar="SERIAL"),
99
+ file: Path | None = typer.Option(None, "--file", "-f", help="CSV or text file containing serial numbers."),
100
+ ) -> None:
101
+ _query("serial", serials or [], file)
102
+
103
+
104
+ @query_app.command("mac")
105
+ def query_mac(
106
+ macs: list[str] | None = typer.Argument(None, metavar="MAC"),
107
+ file: Path | None = typer.Option(None, "--file", "-f", help="CSV or text file containing MAC addresses."),
108
+ ) -> None:
109
+ _query("mac", macs or [], file)
110
+
111
+
112
+ @query_app.command("folder")
113
+ def query_folder(
114
+ folders: list[str] = typer.Argument(..., metavar="FOLDER_ID_OR_NAME"),
115
+ ) -> None:
116
+ _query_folder(folders)
117
+
118
+
119
+ def main() -> None:
120
+ app()
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
glcli/data_parsing.py ADDED
@@ -0,0 +1,45 @@
1
+ import json
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ def parse_inventory_response(response:str) -> list[dict]:
7
+ """Function to extract the data that I want to get from Activate."""
8
+
9
+ try:
10
+ json_obj = json.loads(response)
11
+ except json.JSONDecodeError as exc:
12
+ raise ValueError("Inventory response is not valid JSON") from exc
13
+
14
+ if not isinstance(json_obj, dict):
15
+ raise ValueError("Inventory response must be a JSON object")
16
+
17
+ extracted_data: list[dict] = [] # each device is a dictionary
18
+ devices = json_obj.get("devices")
19
+
20
+ if isinstance(devices, list):
21
+ for device in devices:
22
+ if not isinstance(device, dict):
23
+ continue
24
+
25
+ serial = device.get("serialNumber")
26
+ mac = device.get("mac")
27
+ status = device.get("status")
28
+ additional_data = device.get("additionalData")
29
+ if not isinstance(additional_data, dict):
30
+ additional_data = {}
31
+ folder = additional_data.get("folder")
32
+ folder_id = additional_data.get("folderId")
33
+
34
+ device_dict = {
35
+ "serial":serial,
36
+ "mac":mac,
37
+ "status":status,
38
+ "folder":folder,
39
+ "folderId":folder_id,
40
+
41
+ }
42
+ extracted_data.append(device_dict)
43
+
44
+ logger.debug("Extracted data:\n%s", extracted_data)
45
+ return extracted_data
glcli/display_data.py ADDED
@@ -0,0 +1,14 @@
1
+ from rich import print
2
+ from rich.markup import escape
3
+
4
+ def display_inventory_sn(extracted_data:list[dict]):
5
+ """Function to display output on the CLI when querying by SN"""
6
+
7
+ indent = " " * 5
8
+ for device in extracted_data:
9
+ print()
10
+ print(f"[yellow]Serial[/yellow]: [white]{escape(str(device['serial']))}[/white]")
11
+ print(f"{indent}[yellow]MAC Address[/yellow]: [white]{escape(str(device['mac']))}[/white]")
12
+ print(f"{indent}[yellow]Status[/yellow]: [white]{escape(str(device['status']))}[/white]")
13
+ print(f"{indent}[yellow]Folder[/yellow]: [white]{escape(str(device['folder']))}[/white]")
14
+ print(f"{indent}[yellow]Folder ID[/yellow]: [white]{escape(str(device['folderId']))}[/white]")
glcli/query_folder.py ADDED
@@ -0,0 +1,74 @@
1
+ import requests
2
+ import json
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ logger = logging.getLogger(__name__)
7
+ FOLDER_URL = "https://activate.arubanetworks.com/api/ext/folder.json?action=query"
8
+ REQUEST_TIMEOUT = 30
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Folder:
13
+ folder_id: str
14
+ name: str
15
+
16
+
17
+ def list_folders(session: requests.Session) -> list[Folder]:
18
+ """Return all folders visible to the authenticated Activate session."""
19
+ response = session.post(FOLDER_URL, data="", timeout=REQUEST_TIMEOUT)
20
+ if response.status_code != 200:
21
+ logger.error("Folder query failure code: %s", response.status_code)
22
+ raise RuntimeError("Folder query failed.")
23
+
24
+ try:
25
+ payload = json.loads(response.text)
26
+ except json.JSONDecodeError as exc:
27
+ raise RuntimeError("Activate returned invalid folder JSON") from exc
28
+
29
+ if not isinstance(payload, dict) or not isinstance(payload.get("folders"), list):
30
+ raise RuntimeError("Activate returned an invalid folder response")
31
+
32
+ folders = []
33
+ for item in payload["folders"]:
34
+ if not isinstance(item, dict) or not item.get("id") or not item.get("folderName"):
35
+ continue
36
+ folders.append(Folder(str(item["id"]), str(item["folderName"])))
37
+
38
+ logger.info("Read %d folder(s) from Activate", len(folders))
39
+ return folders
40
+
41
+
42
+ def resolve_folder_ids(session: requests.Session, values: list[str]) -> list[str]:
43
+ """Resolve folder IDs and exact case-insensitive folder names."""
44
+ if all(value.isdigit() for value in values):
45
+ return list(dict.fromkeys(values))
46
+
47
+ folders = list_folders(session)
48
+ by_name: dict[str, list[Folder]] = {}
49
+ for folder in folders:
50
+ by_name.setdefault(folder.name.casefold(), []).append(folder)
51
+
52
+ resolved: list[str] = []
53
+ missing: list[str] = []
54
+ ambiguous: dict[str, list[str]] = {}
55
+ for value in values:
56
+ if value.isdigit():
57
+ resolved.append(value)
58
+ continue
59
+
60
+ matches = by_name.get(value.casefold(), [])
61
+ if not matches:
62
+ missing.append(value)
63
+ elif len(matches) > 1:
64
+ ambiguous[value] = [folder.folder_id for folder in matches]
65
+ else:
66
+ resolved.append(matches[0].folder_id)
67
+
68
+ if missing:
69
+ raise ValueError(f"Folder(s) not found: {', '.join(missing)}")
70
+ if ambiguous:
71
+ details = "; ".join(f"{name}: {', '.join(ids)}" for name, ids in ambiguous.items())
72
+ raise ValueError(f"Folder name(s) are ambiguous: {details}")
73
+
74
+ return list(dict.fromkeys(resolved))
@@ -0,0 +1,136 @@
1
+ import requests
2
+ import json
3
+ import logging
4
+ import csv
5
+ from pathlib import Path
6
+
7
+ logger = logging.getLogger(__name__)
8
+ REQUEST_TIMEOUT = 30
9
+
10
+ def query_inventory(session: requests.Session, identifier_type: str, identifiers: list[str]):
11
+ """Query GreenLake Activate inventory by serial number or MAC address."""
12
+ inventory_url = "https://activate.arubanetworks.com/api/ext/inventory.json?action=query"
13
+ if identifier_type == "serial":
14
+ payload = {"serialNumbers": identifiers}
15
+ response_key = "serialNumber"
16
+ elif identifier_type == "mac":
17
+ payload = {"devices": identifiers}
18
+ response_key = "mac"
19
+ elif identifier_type == "folder":
20
+ payload = {"folders": identifiers}
21
+ response_key = None
22
+ else:
23
+ raise ValueError(f"Unsupported inventory query type: {identifier_type}")
24
+
25
+ raw_data = f"json={json.dumps(payload)}"
26
+ logger.debug("Query string: %s", raw_data)
27
+
28
+ logger.debug("Attempting to query activate inventory: %s", inventory_url)
29
+ response = session.post(inventory_url, data=raw_data, timeout=REQUEST_TIMEOUT)
30
+
31
+ if response.status_code != 200:
32
+ logger.error("Query failure code: %s", response.status_code)
33
+ raise RuntimeError("Query failed.")
34
+
35
+ try:
36
+ json_response = json.loads(response.text)
37
+ except json.JSONDecodeError as exc:
38
+ raise RuntimeError("Activate returned invalid JSON") from exc
39
+
40
+ if not isinstance(json_response, dict) or not isinstance(json_response.get("devices", []), list):
41
+ raise RuntimeError("Activate returned an invalid inventory response")
42
+
43
+ if response_key is None:
44
+ missing = []
45
+ else:
46
+ found = {
47
+ str(device[response_key]).upper()
48
+ for device in json_response.get("devices", [])
49
+ if isinstance(device, dict) and device.get(response_key)
50
+ }
51
+ missing = [identifier for identifier in identifiers if identifier.upper() not in found]
52
+
53
+ if missing:
54
+ logger.warning("Not found in Activate inventory: %s", ", ".join(missing))
55
+
56
+ logger.info("Query succeeded!")
57
+ logger.debug("Full query response:\n%s", response.text)
58
+
59
+ return response.text, missing
60
+
61
+
62
+ def query_by_serial(session: requests.Session, serial_numbers: list[str]):
63
+ """Query GreenLake Activate inventory by serial number."""
64
+ return query_inventory(session, "serial", serial_numbers)
65
+
66
+
67
+ def query_by_mac(session: requests.Session, mac_addresses: list[str]):
68
+ """Query GreenLake Activate inventory by MAC address."""
69
+ return query_inventory(session, "mac", mac_addresses)
70
+
71
+
72
+ def query_by_folder(session: requests.Session, folder_ids: list[str]):
73
+ """Query GreenLake Activate inventory by folder ID."""
74
+ return query_inventory(session, "folder", folder_ids)
75
+
76
+ def read_identifiers_from_file(path: Path, identifier_type: str = "serial") -> list[str]:
77
+ """Read serial numbers or MAC addresses from a CSV or newline-delimited file."""
78
+ if identifier_type not in {"serial", "mac"}:
79
+ raise ValueError(f"Unsupported identifier type: {identifier_type}")
80
+
81
+ columns_by_type = {
82
+ "serial": {"serial", "serialnumber", "serial_number", "serial number", "sn"},
83
+ "mac": {"mac", "macaddress", "mac_address", "mac address", "ethernet address"},
84
+ }
85
+ if not path.exists():
86
+ raise FileNotFoundError(f"File not found: {path}")
87
+
88
+ with path.open(newline="", encoding="utf-8-sig") as fh:
89
+ rows = list(csv.reader(fh))
90
+
91
+ if not rows:
92
+ raise ValueError(f"File is empty: {path}")
93
+
94
+ header = [c.strip().lower() for c in rows[0]]
95
+ col = next((i for i, c in enumerate(header) if c in columns_by_type[identifier_type]), None)
96
+
97
+ if col is None:
98
+ logger.debug("No serial header found in %s; reading first column", path)
99
+ col = 0
100
+ data = rows
101
+ else:
102
+ logger.debug("Using column '%s' (index %d)", header[col], col)
103
+ data = rows[1:]
104
+
105
+ identifiers = [
106
+ row[col].strip().upper()
107
+ for row in data
108
+ if row and len(row) > col and row[col].strip()
109
+ ]
110
+
111
+ logger.info("Read %d %s(s) from %s", len(identifiers), identifier_type, path)
112
+ return identifiers
113
+
114
+
115
+ def read_serials_from_file(path: Path) -> list[str]:
116
+ """Read serial numbers from a CSV or newline-delimited text file."""
117
+ return read_identifiers_from_file(path, "serial")
118
+
119
+
120
+ def get_serials(args) -> list[str]:
121
+ """Resolve serial numbers from either --file or the positional args."""
122
+ if args.file:
123
+ serials = read_serials_from_file(args.file)
124
+ else:
125
+ serials = args.serials
126
+
127
+ serials = [s.strip().upper() for s in serials if s.strip()]
128
+
129
+ if not serials:
130
+ raise ValueError("No serial numbers provided.")
131
+
132
+ deduped = list(dict.fromkeys(serials))
133
+ if len(deduped) != len(serials):
134
+ logger.warning("Removed %d duplicate serial(s)", len(serials) - len(deduped))
135
+
136
+ return deduped
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: greenlake-activate-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI tool used to interact with HPE GreenLake Activate
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: python-dotenv>=1.2.3
8
+ Requires-Dist: requests>=2.34.2
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Dist: typer>=0.27.2
11
+
12
+ # GreenLake Activate CLI
13
+
14
+ The purpose of this is to help manage devices in HPE GreenLake Activate with an easy to use CLI interface.
15
+
16
+ # Setup
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.14 or newer
21
+ - An HPE GreenLake Activate API credential
22
+ - Network access to `activate.arubanetworks.com`
23
+
24
+ The CLI uses the following Python packages, which are installed automatically:
25
+
26
+ - Typer
27
+ - Requests
28
+ - Rich
29
+ - Python Dotenv
30
+
31
+ ## Install With uv
32
+
33
+ [Install uv](https://docs.astral.sh/uv/getting-started/installation/) if it is not
34
+ already available. For a published release, install the CLI as a global command:
35
+
36
+ ```sh
37
+ uv tool install greenlake-activate-cli
38
+ ```
39
+
40
+ This installs the CLI in an isolated environment and makes `glcli` available from
41
+ any directory. If uv reports that its tool directory is not on your `PATH`, run:
42
+
43
+ ```sh
44
+ uv tool update-shell
45
+ ```
46
+
47
+ Restart your shell, then verify the installation:
48
+
49
+ ```sh
50
+ glcli --help
51
+ ```
52
+
53
+ To upgrade or remove the installed CLI:
54
+
55
+ ```sh
56
+ uv tool upgrade greenlake-activate-cli
57
+ uv tool uninstall greenlake-activate-cli
58
+ ```
59
+
60
+ `pipx` is an alternative isolated installer:
61
+
62
+ ```sh
63
+ pipx install greenlake-activate-cli
64
+ ```
65
+
66
+ For development from a source checkout, clone the repository and run:
67
+
68
+ ```sh
69
+ git clone <repository-url>
70
+ cd greenlake_activate_cli
71
+ uv sync
72
+ ```
73
+
74
+ The `uv sync` command creates the project environment, installs the application and
75
+ its dependencies, and makes the `glcli` command available through `uv run`:
76
+
77
+ ```sh
78
+ uv run glcli --help
79
+ ```
80
+
81
+ To install the development dependencies, including the test suite, use:
82
+
83
+ ```sh
84
+ uv sync --dev
85
+ ```
86
+
87
+ ## Install With Python and pip
88
+
89
+ Create a virtual environment, activate it, and install the project from the cloned
90
+ repository:
91
+
92
+ ```sh
93
+ python3.14 -m venv .venv
94
+ . .venv/bin/activate
95
+ python -m pip install --upgrade pip
96
+ python -m pip install .
97
+ ```
98
+
99
+ The `glcli` command is then available directly:
100
+
101
+ ```sh
102
+ glcli --help
103
+ ```
104
+
105
+ ## Configure Credentials
106
+
107
+ The recommended configuration file is:
108
+
109
+ ```sh
110
+ mkdir -p ~/.config/greenlake-activate-cli
111
+ printf 'CREDENTIAL_1=your_api_key_here\n' \
112
+ > ~/.config/greenlake-activate-cli/.env
113
+ chmod 600 ~/.config/greenlake-activate-cli/.env
114
+ ```
115
+
116
+ The CLI also accepts the environment variable directly:
117
+
118
+ ```sh
119
+ export CREDENTIAL_1="your_api_key_here"
120
+ ```
121
+
122
+ Credential sources are checked in this order:
123
+
124
+ 1. A non-empty `CREDENTIAL_1` environment variable.
125
+ 2. `$XDG_CONFIG_HOME/greenlake-activate-cli/.env`, when `XDG_CONFIG_HOME` is set.
126
+ 3. `~/.config/greenlake-activate-cli/.env`.
127
+ 4. `.env` in the current directory, retained for development and backward compatibility.
128
+
129
+ The configuration file format is:
130
+
131
+ ```plain
132
+ CREDENTIAL_1=<your_api_key_here>
133
+ ```
134
+
135
+ Do not commit `.env` or share the credential. If no source contains `CREDENTIAL_1`,
136
+ the CLI reports the supported locations and exits before sending an inventory query.
137
+
138
+ # Usage
139
+
140
+ Query inventory by serial number:
141
+
142
+ ```sh
143
+ glcli query serial PHWLKAS02 PHWLKAS03
144
+ ```
145
+
146
+ Query inventory by MAC address:
147
+
148
+ ```sh
149
+ glcli query mac aa:bb:cc:00:11:22 dd:ee:ff:33:44:55
150
+ ```
151
+
152
+ Query inventory by folder ID or folder name:
153
+
154
+ ```sh
155
+ glcli query folder 5297450
156
+ glcli query folder SiteA-South
157
+ glcli query folder SiteA-South 5389522
158
+ ```
159
+
160
+ Folder names are matched case-insensitively and exactly. Numeric values are treated
161
+ as folder IDs. Folder names are resolved through Activate before the inventory query;
162
+ unknown or ambiguous names are rejected. Folder queries currently accept positional
163
+ values only and do not support `--file`.
164
+
165
+ Serial numbers and MAC addresses can also be loaded from CSV or newline-delimited files:
166
+
167
+ ```sh
168
+ glcli query serial --file serials.csv
169
+ glcli query mac --file mac_addresses.csv
170
+ ```
171
+
172
+ The file reader uses the first column. Serial files may use a `serial`, `serialNumber`,
173
+ `serial_number`, `serial number`, or `sn` header.
174
+
175
+ Use `--verbose` for diagnostic logging:
176
+
177
+ ```sh
178
+ glcli --verbose query serial PHWLKAS02
179
+ ```
180
+
181
+ Exit codes are `0` for a complete result, `1` when no devices are returned, and
182
+ `2` when at least one requested identifier is missing.
183
+
184
+ Run the offline tests with:
185
+
186
+ ```sh
187
+ uv run pytest
188
+ ```
@@ -0,0 +1,12 @@
1
+ glcli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ glcli/activate_login.py,sha256=198BfJ7YqvQS3ENyohTxE4JiU_xPCkfS-rVEnOCDLg0,2262
3
+ glcli/cli.py,sha256=Mh07tDDg34e0X8td9uqhlebnDKGBSQPuRWvpF8uyNcA,3959
4
+ glcli/data_parsing.py,sha256=7tVGEjq80pCS6Xlpnoid8WaHdL4FgTtXKADJdtFWErY,1396
5
+ glcli/display_data.py,sha256=OMwg7n4wEBGMoB3BZoF0VevYofe2T7IyR4NcvYC9qmA,737
6
+ glcli/query_folder.py,sha256=e_CKhEn0N6qqVGdCJ7NVRY_oTVrnCM1arRV4CNv0xLY,2543
7
+ glcli/query_inventory.py,sha256=nI3bDhXVQt4tXJf0U1FmOen0xIqNTyfMNtaMWdy2R2c,4893
8
+ greenlake_activate_cli-0.1.0.dist-info/METADATA,sha256=8dFj8JsKMbtZBb1PYHHzUUBRq3FgBPL3L8eMOzzy03s,4368
9
+ greenlake_activate_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ greenlake_activate_cli-0.1.0.dist-info/entry_points.txt,sha256=DL7gkYwMiDwSFNS_ZxwJp_OriwHg9D0MRNWNwdFkZVE,41
11
+ greenlake_activate_cli-0.1.0.dist-info/top_level.txt,sha256=oMA6AccGyVhCJGGli7wciMdS8wFsV79gQQdUMLPUetY,6
12
+ greenlake_activate_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ glcli = glcli.cli:main