|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +Guidance for AI assistants (Claude Code) working in this repository. |
| 4 | + |
| 5 | +## What this project is |
| 6 | + |
| 7 | +**tm1cli** is a Python command-line interface for interacting with IBM Planning |
| 8 | +Analytics / TM1 servers, built on top of [TM1py](https://github.com/cubewise-code/tm1py) |
| 9 | +and [Typer](https://typer.tiangolo.com/). It's a small, published PyPI package |
| 10 | +(`pip install tm1cli`), currently at version `0.1.7` (see `pyproject.toml`). |
| 11 | + |
| 12 | +## Tech stack |
| 13 | + |
| 14 | +- Python `^3.10` |
| 15 | +- **Typer** — CLI framework (commands are plain functions decorated with `@app.command()`) |
| 16 | +- **TM1py** — TM1 REST API client, used inside a `with TM1Service(...) as tm1:` context manager |
| 17 | +- **Rich** — colored/table console output (`rich.print`, `rich.console.Console`, `rich.table.Table`) |
| 18 | +- **PyYAML** — for `databases.yaml` config and process dump/load YAML serialization |
| 19 | +- **Poetry** — dependency management and packaging (`poetry-core` build backend) |
| 20 | +- Dev tools: **pytest** + **pytest-mock**, **pylint**, **isort** |
| 21 | + |
| 22 | +## Repository layout |
| 23 | + |
| 24 | +``` |
| 25 | +tm1cli/ |
| 26 | + main.py # Typer app entrypoint; top-level commands (tm1-version, whoami, threads, version) |
| 27 | + # and database config resolution (databases.yaml vs TM1_* env vars) |
| 28 | + commands/ |
| 29 | + __init__.py # re-exports all command submodules |
| 30 | + cube.py # `tm1cli cube ...` |
| 31 | + dimension.py # `tm1cli dimension ...` |
| 32 | + process.py # `tm1cli process ...` (list/exists/clone/dump/load) |
| 33 | + subset.py # `tm1cli subset ...` |
| 34 | + view.py # `tm1cli view ...` |
| 35 | + utils/ |
| 36 | + cli_param.py # shared typer.Option definitions (DATABASE_OPTION, WATCH_OPTION, INTERVAL_OPTION) |
| 37 | + various.py # resolve_database(), print_error_and_exit() |
| 38 | + watch.py # @watch_option decorator for polling commands (--watch/-w, --interval) |
| 39 | + tm1yaml.py # custom YAML dump/load for TM1py Process objects (multi-line script sections) |
| 40 | +tests/ |
| 41 | + conftest.py # Mocked*Service classes + MockedTM1Service used to stub TM1py in tests |
| 42 | + test_tm1cli.py # tests for top-level commands (tm1-version, whoami, threads, process ...) |
| 43 | + test_cmd_cubes.py # cube command tests (uses mocker.patch to swap in MockedTM1Service) |
| 44 | + test_cmd_dimension.py |
| 45 | + test_cmd_subset.py |
| 46 | + test_cmd_view.py |
| 47 | +databases.yaml.template # example multi-database config; real databases.yaml is gitignored |
| 48 | +pyproject.toml # Poetry config, dependencies, `tm1cli` console script entry point |
| 49 | +requirements.txt # generated/plain pip requirements (kept in sync manually, used by pylint CI) |
| 50 | +.pylintrc # pylint config |
| 51 | +.github/workflows/ |
| 52 | + pylint.yml # runs pylint on tm1cli/**/*.py for Python 3.10 and 3.11 on every push |
| 53 | + pypi-publish.yml # builds and publishes to PyPI on GitHub release |
| 54 | +CHANGELOG.md # manually maintained, one section per version |
| 55 | +``` |
| 56 | + |
| 57 | +## Command architecture pattern |
| 58 | + |
| 59 | +Each resource (cube, dimension, process, subset, view) has its own Typer |
| 60 | +sub-app in `tm1cli/commands/<name>.py`, registered in `main.py` via: |
| 61 | + |
| 62 | +```python |
| 63 | +modules = [("process", commands.process), ("cube", commands.cube), ...] |
| 64 | +for name, module in modules: |
| 65 | + app.add_typer(module.app, name=name) |
| 66 | +``` |
| 67 | + |
| 68 | +A typical command function follows this shape: |
| 69 | + |
| 70 | +```python |
| 71 | +@app.command() |
| 72 | +@watch_option |
| 73 | +def exists( |
| 74 | + ctx: typer.Context, |
| 75 | + cube_name: str, |
| 76 | + database: Annotated[str, DATABASE_OPTION] = None, |
| 77 | + watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument |
| 78 | + interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument |
| 79 | +): |
| 80 | + """Check if cube exists""" |
| 81 | + with TM1Service(**resolve_database(ctx, database)) as tm1: |
| 82 | + print(tm1.cubes.exists(cube_name)) |
| 83 | +``` |
| 84 | + |
| 85 | +Conventions to follow when adding or modifying commands: |
| 86 | + |
| 87 | +- Always open the TM1py client as `with TM1Service(**resolve_database(ctx, database)) as tm1:` — |
| 88 | + never construct `TM1Service` outside a context manager. |
| 89 | +- Every command accepts an optional `database: Annotated[str, DATABASE_OPTION] = None` parameter |
| 90 | + and resolves it via `resolve_database(ctx, database)`. |
| 91 | +- `list` commands are also registered under the alias `ls` via a second |
| 92 | + `@app.command(name="ls", help="Alias for list")` decorator stacked above `@app.command(name="list")`. |
| 93 | + Follow this same double-decorator pattern for other aliases (e.g. `export`/`dump`, `import`/`load` |
| 94 | + in `process.py`). |
| 95 | +- Commands that make sense to poll (existence checks) are decorated with `@watch_option` from |
| 96 | + `tm1cli/utils/watch.py` and take `watch`/`interval` parameters — mark them |
| 97 | + `# pylint: disable=unused-argument` since the decorator consumes them via `**kwargs`, not the |
| 98 | + function body. |
| 99 | +- Use `rich.print` (imported `# pylint: disable=redefined-builtin`) for normal output, and |
| 100 | + `print_error_and_exit(msg)` from `tm1cli/utils/various.py` for user-facing errors — it prints in |
| 101 | + bold red and raises `typer.Exit(code=1)`. Don't raise raw exceptions for expected error paths. |
| 102 | +- Reuse `DATABASE_OPTION`, `WATCH_OPTION`, `INTERVAL_OPTION` from `tm1cli/utils/cli_param.py` instead |
| 103 | + of redefining `typer.Option(...)` inline. |
| 104 | + |
| 105 | +## Database configuration resolution |
| 106 | + |
| 107 | +`main.py`'s `@app.callback()` builds `ctx.obj` once per invocation: |
| 108 | + |
| 109 | +- If `databases.yaml` exists in the current working directory, it's parsed into |
| 110 | + `ctx.obj["configs"]` (keyed by database `name`) and `ctx.obj["default_db_config"]` (first entry). |
| 111 | +- Otherwise, environment variables prefixed `TM1_` (e.g. `TM1_ADDRESS`, `TM1_PORT`, `TM1_SSL`, |
| 112 | + `TM1_USER`, `TM1_PASSWORD`) are lower-cased and stripped of the prefix to build |
| 113 | + `default_db_config`; `configs` is empty in this mode. |
| 114 | +- `resolve_database(ctx, database_name)` in `tm1cli/utils/various.py` returns |
| 115 | + `default_db_config` when no `--database`/`-d` is given, otherwise looks up `database_name` in |
| 116 | + `configs` and calls `print_error_and_exit` if not found. |
| 117 | +- The `--database`/`-d` option and `process clone --from/--to` **only work with a `databases.yaml` |
| 118 | + file** (env-var mode has no named configs) — preserve this constraint if touching this logic. |
| 119 | + |
| 120 | +## Testing |
| 121 | + |
| 122 | +- Test framework: `pytest` with `typer.testing.CliRunner` (`runner.invoke(app, [...])`) and |
| 123 | + `pytest-mock`'s `mocker` fixture. |
| 124 | +- Two testing styles exist side by side: |
| 125 | + - `test_cmd_*.py` files mock TM1py entirely via `mocker.patch("tm1cli.commands.<module>.TM1Service", MockedTM1Service)` |
| 126 | + using the fake service classes in `tests/conftest.py` (`MockedCubeService`, `MockedViewService`, |
| 127 | + `MockedDimensionService`, `MockedSubsetService`, `MockedProcessService`, `MockedTM1Service`). |
| 128 | + These run without any real TM1 server and are the pattern to follow for new command tests. |
| 129 | + - `test_tm1cli.py` exercises top-level commands (`tm1-version`, `whoami`, `threads`, `process ...`) |
| 130 | + **without mocking TM1Service**, so it requires a real, reachable TM1 server configured via |
| 131 | + `databases.yaml` or `TM1_*` env vars (with a `mydb`/`remotedb` style config for the |
| 132 | + `--database`/`-d` parametrized cases). Don't assume these pass in a sandboxed environment with |
| 133 | + no TM1 server available. |
| 134 | +- Several test functions in `test_tm1cli.py` are intentionally empty stubs (`def test_...(): ...`) |
| 135 | + — placeholders for not-yet-implemented coverage (e.g. `test_process_clone_sucess`, |
| 136 | + `test_process_dump_invalid_format`). Don't delete them without reason; filling them in is welcome. |
| 137 | +- Run tests with `pytest` (mocked command tests only, if no TM1 server is available, target them |
| 138 | + explicitly, e.g. `pytest tests/test_cmd_cubes.py tests/test_cmd_dimension.py tests/test_cmd_subset.py tests/test_cmd_view.py`). |
| 139 | +- There is no test job in CI today — `.github/workflows/pylint.yml` only runs pylint on `tm1cli/**/*.py` |
| 140 | + (Python 3.10 and 3.11); pytest is not invoked by GitHub Actions. |
| 141 | + |
| 142 | +## Linting |
| 143 | + |
| 144 | +- `pylint` is configured via `.pylintrc`: `max-line-length=120`, and |
| 145 | + `missing-module-docstring` / `missing-class-docstring` / `missing-function-docstring` / |
| 146 | + `too-many-arguments` / `too-many-positional-arguments` are disabled project-wide. |
| 147 | +- Command docstrings are still used deliberately as Typer `--help` text (e.g. `"""List cubes"""`), |
| 148 | + so keep writing them even though pylint doesn't require them. |
| 149 | +- CI runs `pylint $(git ls-files 'tm1cli/**/*.py')` — only the `tm1cli/` package is linted, not `tests/`. |
| 150 | +- `isort` is a listed dev dependency for import ordering; keep imports grouped stdlib / third-party / |
| 151 | + local (`tm1cli...`), matching the existing files. |
| 152 | + |
| 153 | +## Adding a new resource/command |
| 154 | + |
| 155 | +1. Create `tm1cli/commands/<resource>.py` with its own `app = typer.Typer()`, following the pattern |
| 156 | + in an existing file (e.g. `cube.py` for a simple list/exists resource, `process.py` for a |
| 157 | + resource with more operations). |
| 158 | +2. Add the import and `__all__` entry in `tm1cli/commands/__init__.py`. |
| 159 | +3. Register it in the `modules` list in `tm1cli/main.py`. |
| 160 | +4. Add mocked-service tests in `tests/test_cmd_<resource>.py` plus a corresponding |
| 161 | + `Mocked<Resource>Service` class in `tests/conftest.py`, mirroring the existing ones. |
| 162 | +5. Add usage examples to the README's command list and a `CHANGELOG.md` entry under a new version |
| 163 | + heading (features / fixes / chore / docs sections, matching the existing style). |
| 164 | + |
| 165 | +## Releasing |
| 166 | + |
| 167 | +- Bump `version` in `pyproject.toml`. |
| 168 | +- Add a new dated entry at the top of `CHANGELOG.md` (format: `## vX.Y.Z - YYYY-MM-DD` with |
| 169 | + `### Features` / `### Fix` / `### Chore` / `### Docs` / `### Breaking changes` subsections as needed). |
| 170 | +- Publishing to PyPI happens via `.github/workflows/pypi-publish.yml`, triggered by creating a |
| 171 | + GitHub release (or manual `workflow_dispatch`) — it builds with `python -m build` and uses |
| 172 | + PyPI trusted publishing (no manual token needed). |
| 173 | + |
| 174 | +## Things to watch out for |
| 175 | + |
| 176 | +- `requirements.txt` is a separate, manually-tracked file from `poetry.lock`/`pyproject.toml` and is |
| 177 | + what the pylint CI workflow installs from — if you add/change a dependency in `pyproject.toml`, |
| 178 | + update `requirements.txt` too (or regenerate it) so CI installs the same set. |
| 179 | +- `databases.yaml` is gitignored — never commit real credentials; use `databases.yaml.template` as |
| 180 | + the checked-in example and keep it in sync with any config schema changes. |
| 181 | +- The custom YAML process (de)serialization in `tm1cli/utils/tm1yaml.py` renders TM1 process script |
| 182 | + sections (`PrologProcedure`, `MetadataProcedure`, `DataProcedure`, `EpilogProcedure`) as multi-line |
| 183 | + YAML block scalars specifically so process dumps are readable/diffable in git — preserve this |
| 184 | + behavior if touching `dump`/`load`. |
0 commit comments