greenlake-activate-cli 0.1.0__tar.gz

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.
@@ -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,177 @@
1
+ # GreenLake Activate CLI
2
+
3
+ The purpose of this is to help manage devices in HPE GreenLake Activate with an easy to use CLI interface.
4
+
5
+ # Setup
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.14 or newer
10
+ - An HPE GreenLake Activate API credential
11
+ - Network access to `activate.arubanetworks.com`
12
+
13
+ The CLI uses the following Python packages, which are installed automatically:
14
+
15
+ - Typer
16
+ - Requests
17
+ - Rich
18
+ - Python Dotenv
19
+
20
+ ## Install With uv
21
+
22
+ [Install uv](https://docs.astral.sh/uv/getting-started/installation/) if it is not
23
+ already available. For a published release, install the CLI as a global command:
24
+
25
+ ```sh
26
+ uv tool install greenlake-activate-cli
27
+ ```
28
+
29
+ This installs the CLI in an isolated environment and makes `glcli` available from
30
+ any directory. If uv reports that its tool directory is not on your `PATH`, run:
31
+
32
+ ```sh
33
+ uv tool update-shell
34
+ ```
35
+
36
+ Restart your shell, then verify the installation:
37
+
38
+ ```sh
39
+ glcli --help
40
+ ```
41
+
42
+ To upgrade or remove the installed CLI:
43
+
44
+ ```sh
45
+ uv tool upgrade greenlake-activate-cli
46
+ uv tool uninstall greenlake-activate-cli
47
+ ```
48
+
49
+ `pipx` is an alternative isolated installer:
50
+
51
+ ```sh
52
+ pipx install greenlake-activate-cli
53
+ ```
54
+
55
+ For development from a source checkout, clone the repository and run:
56
+
57
+ ```sh
58
+ git clone <repository-url>
59
+ cd greenlake_activate_cli
60
+ uv sync
61
+ ```
62
+
63
+ The `uv sync` command creates the project environment, installs the application and
64
+ its dependencies, and makes the `glcli` command available through `uv run`:
65
+
66
+ ```sh
67
+ uv run glcli --help
68
+ ```
69
+
70
+ To install the development dependencies, including the test suite, use:
71
+
72
+ ```sh
73
+ uv sync --dev
74
+ ```
75
+
76
+ ## Install With Python and pip
77
+
78
+ Create a virtual environment, activate it, and install the project from the cloned
79
+ repository:
80
+
81
+ ```sh
82
+ python3.14 -m venv .venv
83
+ . .venv/bin/activate
84
+ python -m pip install --upgrade pip
85
+ python -m pip install .
86
+ ```
87
+
88
+ The `glcli` command is then available directly:
89
+
90
+ ```sh
91
+ glcli --help
92
+ ```
93
+
94
+ ## Configure Credentials
95
+
96
+ The recommended configuration file is:
97
+
98
+ ```sh
99
+ mkdir -p ~/.config/greenlake-activate-cli
100
+ printf 'CREDENTIAL_1=your_api_key_here\n' \
101
+ > ~/.config/greenlake-activate-cli/.env
102
+ chmod 600 ~/.config/greenlake-activate-cli/.env
103
+ ```
104
+
105
+ The CLI also accepts the environment variable directly:
106
+
107
+ ```sh
108
+ export CREDENTIAL_1="your_api_key_here"
109
+ ```
110
+
111
+ Credential sources are checked in this order:
112
+
113
+ 1. A non-empty `CREDENTIAL_1` environment variable.
114
+ 2. `$XDG_CONFIG_HOME/greenlake-activate-cli/.env`, when `XDG_CONFIG_HOME` is set.
115
+ 3. `~/.config/greenlake-activate-cli/.env`.
116
+ 4. `.env` in the current directory, retained for development and backward compatibility.
117
+
118
+ The configuration file format is:
119
+
120
+ ```plain
121
+ CREDENTIAL_1=<your_api_key_here>
122
+ ```
123
+
124
+ Do not commit `.env` or share the credential. If no source contains `CREDENTIAL_1`,
125
+ the CLI reports the supported locations and exits before sending an inventory query.
126
+
127
+ # Usage
128
+
129
+ Query inventory by serial number:
130
+
131
+ ```sh
132
+ glcli query serial PHWLKAS02 PHWLKAS03
133
+ ```
134
+
135
+ Query inventory by MAC address:
136
+
137
+ ```sh
138
+ glcli query mac aa:bb:cc:00:11:22 dd:ee:ff:33:44:55
139
+ ```
140
+
141
+ Query inventory by folder ID or folder name:
142
+
143
+ ```sh
144
+ glcli query folder 5297450
145
+ glcli query folder SiteA-South
146
+ glcli query folder SiteA-South 5389522
147
+ ```
148
+
149
+ Folder names are matched case-insensitively and exactly. Numeric values are treated
150
+ as folder IDs. Folder names are resolved through Activate before the inventory query;
151
+ unknown or ambiguous names are rejected. Folder queries currently accept positional
152
+ values only and do not support `--file`.
153
+
154
+ Serial numbers and MAC addresses can also be loaded from CSV or newline-delimited files:
155
+
156
+ ```sh
157
+ glcli query serial --file serials.csv
158
+ glcli query mac --file mac_addresses.csv
159
+ ```
160
+
161
+ The file reader uses the first column. Serial files may use a `serial`, `serialNumber`,
162
+ `serial_number`, `serial number`, or `sn` header.
163
+
164
+ Use `--verbose` for diagnostic logging:
165
+
166
+ ```sh
167
+ glcli --verbose query serial PHWLKAS02
168
+ ```
169
+
170
+ Exit codes are `0` for a complete result, `1` when no devices are returned, and
171
+ `2` when at least one requested identifier is missing.
172
+
173
+ Run the offline tests with:
174
+
175
+ ```sh
176
+ uv run pytest
177
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "greenlake-activate-cli"
3
+ version = "0.1.0"
4
+ description = "A CLI tool used to interact with HPE GreenLake Activate"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "python-dotenv>=1.2.3",
9
+ "requests>=2.34.2",
10
+ "rich>=15.0.0",
11
+ "typer>=0.27.2",
12
+ ]
13
+
14
+ [project.scripts]
15
+ glcli = "glcli.cli:main"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.0.0",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=61"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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
@@ -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()
@@ -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
@@ -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]")