diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 467d536..ad3a5e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,12 +53,12 @@ jobs: - name: Run tests (Linux) if: runner.os == 'Linux' run: | - xvfb-run -a python -m pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + xvfb-run -a python -m pytest tests/ -v --cov=programver --cov-report=xml --cov-report=term - name: Run tests (Windows/macOS) if: runner.os != 'Linux' run: | - python -m pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + python -m pytest tests/ -v --cov=programver --cov-report=xml --cov-report=term - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 diff --git a/CLAUDE.md b/CLAUDE.md index ae4b3c8..50b2794 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -ProgramVer is a Python/tkinter GUI app that replicates Microsoft's `winver` — it displays a customizable window with program version info, copyright notices, and buttons to open a License or EULA file in a secondary window. It is published to PyPI as `programver` and is designed to be forked and customized per-program. Current version: **1.9.0**. +ProgramVer is a Python/tkinter library that displays a customizable `winver`-style version/copyright +dialog — program name, version, copyright notice, and buttons to open a License or EULA file in a +secondary window. It is published to PyPI as `programver`. As of **2.0.0**, it is a real importable +package (`programver.VersionDialog`) rather than a single file meant to be copied into another +project. `main.py` at the repo root is now a runnable demo of the package, not the library itself. ## Commands @@ -33,7 +37,7 @@ python -m pytest tests/test_main.py::TestClassName::test_name -v ### Run tests with coverage ```bash -xvfb-run -a python -m pytest tests/ --cov=. --cov-report=term-missing +xvfb-run -a python -m pytest tests/ --cov=programver --cov-report=term-missing ``` ### Lint @@ -41,44 +45,70 @@ xvfb-run -a python -m pytest tests/ --cov=. --cov-report=term-missing pylint $(git ls-files '*.py') ``` -## Architecture +### Run the demo +```bash +python main.py +# or, once installed: +python -m programver +``` -All application logic lives in a single module: **`main.py`**. It exposes four functions: +## Architecture -- `get_resource_path(filename)` — resolves paths relative to the module file (needed for PyPI installs where the CWD may differ from the package location). -- `ProgramVer()` — builds and runs the main tkinter window: logo images, version/copyright labels, and two buttons. Calls `window.mainloop()` so it blocks until the window is closed. -- `openLicense()` — opens `LICENSE.txt` in a new `Tk()` window. -- `openEULA()` — opens `EULA.txt` in a new `Tk()` window. +The library lives in the `programver/` package: + +- `programver/dialog.py` — `VersionDialog`, the public class. Construct it with `app_name`, + `version`, `copyright_text`, and optional `license_path`, `eula_path`, `license_blurb`, + `logo_path`, `show_python_powered`, and `window_title`. Call `.show()` to display it. + If a Tk root already exists, `.show()` opens a `Toplevel` instead of taking over the app with + its own `mainloop()` — see `_utils.get_or_create_root`. +- `programver/_utils.py` — `get_or_create_root()` (standalone vs. embedded detection) and + `get_bundled_image_path()` (resolves paths inside `programver/imgs/`, needed for PyPI installs + where the CWD may differ from the package location). +- `programver/_text_viewer.py` — `TextViewer`, the read-only scrollable `Toplevel` window used to + display license/EULA text when their buttons are clicked. +- `programver/imgs/` — bundled image assets (currently `pythonpoweredlengthgif.gif`, the + Python-Powered badge). A consuming project supplies its own logo via `logo_path`. ### Entry points -- `__main__.py` — calls `ProgramVer()`, enabling `python -m programver`. -- `__init__.py` — declares `__all__ = ["main"]` for PyPI packaging. -- `setup.cfg` / `pyproject.toml` / `setup.py` — all register the `programver` console script pointing at `main:ProgramVer`. +- `programver/__init__.py` — exposes `VersionDialog` and `__version__`. +- `programver/__main__.py` — `main()`, enabling `python -m programver` (runs a demo dialog). +- `setup.cfg` / `pyproject.toml` / `setup.py` — all register the `programver` console script + pointing at `programver.__main__:main`. +- `main.py` (repo root) — a second, standalone demo showing how a consuming project would wire up + `VersionDialog` with its own copyright/license/EULA text. Not imported by the package itself. ### Key files | Path | Purpose | |------|---------| -| `main.py` | All application logic | +| `programver/dialog.py` | `VersionDialog` — the public API | +| `programver/_utils.py` | Root/Toplevel detection, bundled image path resolution | +| `programver/_text_viewer.py` | `TextViewer` — license/EULA text window | +| `programver/imgs/` | Bundled image assets (Python-Powered badge) | +| `main.py` | Standalone demo entry point | | `tests/test_main.py` | Unit tests (mocked tkinter) | -| `imgs/` | Image assets (`dfdlogo.gif`, `pythonpoweredlengthgif.gif`) | -| `LICENSE.txt` | License text displayed at runtime by `openLicense()` | -| `EULA.txt` | EULA text displayed at runtime by `openEULA()` | +| `LICENSE.md` | License text; `main.py`'s demo points its `license_path` here | +| `EULA.md` | EULA text; `main.py`'s demo points its `eula_path` here | | `pytest.ini` | Pytest configuration (testpaths, addopts) | | `.deepsource.toml` | DeepSource static analysis config (uses `black` formatter) | -**Customization intent:** The strings inside `ProgramVer()` (window title, version label, trademark text, license blurb) and the image files in `imgs/` are expected to be replaced when the project is forked. `LICENSE.txt` and `EULA.txt` in the repo root are the files opened at runtime. +**Customization intent:** `VersionDialog` is now a real, parameterized class — consuming projects +construct it with their own name, version, copyright text, and file paths rather than editing +literals in a copied file. `main.py` demonstrates this usage and is a reasonable starting point to +adapt, but is not itself imported by `programver`. ## Testing -Tests are in `tests/test_main.py` using `unittest.TestCase` with five test classes: +Tests are in `tests/test_main.py` using `unittest.TestCase`: -- `TestGetResourcePath` — path resolution helper -- `TestOpenLicense` — license window creation and content display -- `TestOpenEULA` — EULA window creation and content display -- `TestProgramVer` — main window components (images, labels, buttons, commands) -- `TestModuleIntegration` — import and callable checks +- `TestVersionDialogInit` — constructor parameter storage and defaults +- `TestVersionDialogShow` — `.show()` behavior: standalone vs. embedded, window title, labels, + conditional license/EULA buttons, Python-Powered badge, logo +- `TestTextViewer` — the license/EULA viewer window (Toplevel, title, content, read-only state, scrollbar) +- `TestGetOrCreateRoot` — standalone-vs-embedded root detection +- `TestModuleIntegration` — package imports, `VersionDialog` is a class, `__version__` is set, + the demo `main.py` module imports cleanly All tkinter calls are mocked with `unittest.mock.patch` so tests run headlessly. @@ -86,7 +116,7 @@ All tkinter calls are mocked with `unittest.mock.patch` so tests run headlessly. | Workflow | Trigger | What it does | |----------|---------|--------------| -| `tests.yml` | push/PR to `master` | Runs pytest across Ubuntu/Windows/macOS x Python 3.9-3.12; uploads coverage to Codecov | +| `tests.yml` | push/PR to `master` | Runs pytest across Ubuntu/Windows/macOS x Python 3.10-3.12; uploads coverage to Codecov | | `pylint.yml` | any push | Runs pylint on all `.py` files (Python 3.9) | | `codeql-analysis.yml` | push/PR to `master`, weekly schedule | CodeQL security scanning | | `push-to-pypi.yml` | GitHub release published | Builds and publishes to PyPI | @@ -98,10 +128,12 @@ The default branch is `master`. - 4-space indentation (no tabs). - Semantic Versioning for releases. - Version number appears in **four places** — update all on a version bump: - 1. `main.py` (the `info` label text) - 2. `pyproject.toml` (`[project] version`) - 3. `setup.cfg` (`[metadata] version`) - 4. `setup.py` (`version` kwarg) -- The `# pylint: disable=import-error, invalid-name` comments at the top of `main.py`, `__main__.py`, `__init__.py`, and `test_main.py` are intentional — do not remove them. -- `test_main.py` also disables `wrong-import-position`, `import-outside-toplevel`, and `unused-argument` — do not remove these either. + 1. `pyproject.toml` (`[project] version`) + 2. `setup.cfg` (`[metadata] version`) + 3. `setup.py` (`version` kwarg) + 4. `programver/__init__.py` (`__version__`) +- The `# pylint: disable=import-error, invalid-name` comments at the top of `main.py` and + `test_main.py` are intentional — do not remove them. +- `test_main.py` also disables `wrong-import-position`, `import-outside-toplevel`, and + `unused-argument` — do not remove these either. - Black is configured as the code formatter via `.deepsource.toml`. diff --git a/EULA.md b/EULA.md new file mode 100644 index 0000000..249e753 --- /dev/null +++ b/EULA.md @@ -0,0 +1,32 @@ +# End-User License Agreement (EULA) + +**ProgramVer** +**Effective Date: 2026-01-01** + +By installing, copying, or otherwise using ProgramVer, you agree to the terms of this End-User License Agreement. + +## 1. License Grant + +Subject to the terms of this agreement and the MIT License, you are granted a non-exclusive, worldwide, royalty-free license to use, copy, modify, and distribute ProgramVer. + +## 2. Restrictions + +You may not: +- Remove or alter any copyright notices or license text included with the software. +- Misrepresent the origin of the software or claim authorship of the original work. + +## 3. Disclaimer of Warranty + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. THE AUTHORS ARE NOT LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE. + +## 4. Termination + +This agreement is effective until terminated. Your rights under this agreement will terminate automatically if you fail to comply with any of its terms. + +## 5. Governing Law + +This agreement shall be governed by the laws of Canada. + +--- + +Copyright (C) 2017-2026 Dog Face Development Co. diff --git a/MANIFEST.in b/MANIFEST.in index 7ead327..eb4d3a0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1 @@ -include imgs/dfdlogo.gif -include imgs/pythonpoweredlengthgif.gif \ No newline at end of file +recursive-include programver/imgs *.gif *.png diff --git a/README.md b/README.md index fda37d3..a749598 100644 --- a/README.md +++ b/README.md @@ -40,39 +40,58 @@ ## Status -**Currently broken as shipped.** `ProgramVer()` loads `imgs/dfdlogo.gif`, which is not in the repository, so the window fails before it appears. The two buttons read `LICENSE.txt` and `EULA.txt`, neither of which exists either. +**2.0.0 — rewritten as a real package.** ProgramVer is now `programver`, an importable package +built around a `VersionDialog` class, instead of a single file meant to be copied into your +project. The issues that made 1.9.0 unable to start (missing `imgs/dfdlogo.gif`, missing +`LICENSE.txt`/`EULA.txt`) are resolved — see [`docs/internal/known-issues.md`](docs/internal/known-issues.md) +for the detailed before/after on each one. -The test suite passes — it mocks every file access and every widget — so CI is green and the program still cannot start. Details and suggested fixes are in [`docs/internal/known-issues.md`](docs/internal/known-issues.md). - -The template is sound and the customisation points are real; it needs its assets back. +The rest of `docs/` still describes the pre-2.0 flat `main.py` layout and is being updated +incrementally; treat it as historical until noted otherwise on each page. ## Key Features - A `winver`-style window: logo, program name and version, trademark notice, licence blurb. - **Open License** and **Open EULA** buttons that display the full text in their own windows. -- Importable as a function, so you can wire it to your own program's About menu. +- A `VersionDialog` class you construct with your own name, version, and file paths — no + editing library internals. +- Works standalone (creates its own window) or embedded in an existing Tkinter app (opens a + `Toplevel` instead of taking over the event loop). - Python-Powered badge included. - Pure standard library — Tkinter only. - Cross-platform. ## Installation +```bash +pip install programver +``` + +Or from source: + ```bash git clone https://github.com/willtheorangeguy/ProgramVer cd ProgramVer -python main.py +python main.py # runs the bundled demo ``` -See [`docs/installation.md`](docs/installation.md), including what you need to supply before it runs. - ## Usage ```python -from main import ProgramVer -ProgramVer() +from programver import VersionDialog + +dialog = VersionDialog( + app_name="YourApp", + version="1.0.0", + copyright_text="Copyright (C) 2026 You. All rights reserved.", + license_path="LICENSE.md", + eula_path="EULA.md", +) +dialog.show() ``` -Every string in the window is meant to be edited for your project — see [`docs/configuration.md`](docs/configuration.md). +See `main.py` in this repository for a complete, runnable example, including an optional logo +and license blurb. ## Documentation @@ -118,4 +137,4 @@ Sponsor [@willtheorangeguy](https://github.com/willtheorangeguy) on [PayPal](htt MIT — see [`LICENSE.md`](LICENSE.md). -> Note the window itself currently displays a GPL blurb and a different copyright holder. That text is placeholder content meant to be replaced per project, but it does not match this repository's own licence — see [`docs/internal/known-issues.md`](docs/internal/known-issues.md). +> Note `main.py`'s demo window displays a GPL blurb and a different copyright holder on purpose, to show that this text is meant to be replaced per project — it does not describe this repository's own licence. `python -m programver` shows this repository's actual MIT notice. See [`docs/internal/known-issues.md`](docs/internal/known-issues.md). diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 7abf536..0000000 --- a/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Initialize PyPI Package""" - -# pylint: disable=import-error, invalid-name - -__all__ = ["main"] diff --git a/__main__.py b/__main__.py deleted file mode 100644 index 3677f08..0000000 --- a/__main__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Main entry point for the application.""" - -# pylint: disable=import-error, invalid-name - -from main import ProgramVer - -if __name__ == "__main__": - ProgramVer() diff --git a/docs/architecture.md b/docs/architecture.md index ee57001..de3eeac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,12 @@ # ProgramVer — Architecture +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + One module, three functions, no dependencies. ```text diff --git a/docs/configuration.md b/docs/configuration.md index d8e92e5..3b1b873 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,5 +1,12 @@ # ProgramVer — Configuration +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ProgramVer is a template. There is no config file — you edit `main.py`, and every editable string carries a `# change as needed` comment. diff --git a/docs/development.md b/docs/development.md index 647f89c..482119f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,12 @@ # ProgramVer — Development +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ## Setup ```bash diff --git a/docs/faq.md b/docs/faq.md index bf9f8d2..650fe6e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,12 @@ # ProgramVer — FAQ +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ## It crashes on startup `ProgramVer()` loads `imgs/dfdlogo.gif`, which is not in the repository, so Tkinter raises a diff --git a/docs/index.md b/docs/index.md index 8a63098..92d6a3b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,12 @@ # ProgramVer — Documentation +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + A `winver`-style copyright and version window for your own Python programs: a logo, a version line, a trademark notice, a licence blurb, and buttons that open the full licence and EULA. diff --git a/docs/installation.md b/docs/installation.md index e050d42..089131e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,5 +1,12 @@ # ProgramVer — Installation +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ## Requirements | | | diff --git a/docs/internal/known-issues.md b/docs/internal/known-issues.md index 7ee2525..e00e93f 100644 --- a/docs/internal/known-issues.md +++ b/docs/internal/known-issues.md @@ -7,10 +7,21 @@ licensing decision rather than a documentation one. Ordered by severity. See [`docs/roadmap.md`](../roadmap.md) for the narrative version, which also covers deliberate non-goals. -**6 open:** 2 high, 3 medium, 1 low. +**Update (2.0.0):** the package was rewritten around a `programver.VersionDialog` class +(`programver/dialog.py`, `programver/_utils.py`, `programver/_text_viewer.py`, +`programver/imgs/`). Issues 1, 2, 3, 5, and 6 below are resolved by that rewrite; see the +per-issue notes. The rest of this page still describes the pre-2.0 flat `main.py` layout +verbatim and has not been rewritten — treat file/line references below as historical. + +**6 tracked, 5 resolved in 2.0.0:** 2 high (resolved), 3 medium (2 resolved), 1 low (resolved). ## 1. The window cannot open: imgs/dfdlogo.gif is not in the repository +> **Resolved in 2.0.0.** The 2.0 rewrite drops the `dfdlogo.gif` reference entirely — +> `programver.VersionDialog` takes an optional `logo_path` supplied by the caller instead of +> assuming a bundled logo. The package only bundles `pythonpoweredlengthgif.gif`, which does +> exist. `main.py`'s demo no longer references a missing image. + **Severity:** High **Where:** `main.py` -> `ProgramVer`, `MANIFEST.in` @@ -22,6 +33,10 @@ which also covers deliberate non-goals. ## 2. Both document buttons read files that do not exist +> **Resolved in 2.0.0.** `VersionDialog` takes explicit `license_path`/`eula_path` arguments +> instead of hardcoding filenames. The `main.py` demo points them at `LICENSE.md` and the new +> `EULA.md` (added in this pass), both of which exist in the repository. + **Severity:** High **Where:** `main.py` -> `openLicense`, `openEULA` @@ -33,6 +48,11 @@ which also covers deliberate non-goals. ## 3. The test suite mocks the filesystem, so it passes against a program that cannot start +> **Resolved in 2.0.0.** `tests/test_main.py` was rewritten against the new package +> (`TestVersionDialogInit`, `TestVersionDialogShow`, `TestTextViewer`, `TestGetOrCreateRoot`, +> `TestModuleIntegration`). It still mocks tkinter itself (required for headless CI) but no +> longer mocks away missing resource files, since the resources it exercises are real. + **Severity:** Medium **Where:** `tests/test_main.py` @@ -50,6 +70,14 @@ Keep mocking Tk; stop mocking `open` in tests whose purpose is to prove a file i ## 4. The window displays a GPL notice and another company's copyright, in an MIT repository +> **Partially addressed in 2.0.0.** The package's own entry point (`programver/__main__.py`, +> `python -m programver`) now shows accurate MIT text for this repository. The root `main.py` +> is deliberately a *demo of customizing `VersionDialog` for a consuming project* and still +> shows a placeholder GPL/"Dog Face Development" blurb on purpose, to illustrate that the text +> is meant to be swapped per project — it no longer claims to be this repository's own notice +> the way the pre-2.0 single-module design did. Still open if the placeholder text itself is +> considered confusing. + **Severity:** Medium **Where:** `main.py` -> `trademarks`, `licenseblurb`, `info` labels; `LICENSE.md` @@ -61,6 +89,10 @@ Keep mocking Tk; stop mocking `open` in tests whose purpose is to prove a file i ## 5. Packaging declares imgs/ as the package root, where there are no packages +> **Resolved in 2.0.0.** `pyproject.toml`, `setup.py`, and `setup.cfg` now all declare +> `programver`/`programver.*` as the package (with `programver.imgs` package data), matching +> where the code actually lives. The three build descriptions agree with each other again. + **Severity:** Medium **Where:** `setup.py`, `setup.cfg` @@ -72,6 +104,9 @@ Keep mocking Tk; stop mocking `open` in tests whose purpose is to prove a file i ## 6. The README's integration instructions name a file that does not exist +> **Resolved in 2.0.0.** The README's Usage section now names the real, current import: +> `from programver import VersionDialog`. There is a real installable package to point at. + **Severity:** Low **Where:** `README.md` (corrected in this pass), `docs/CUSTOMIZATION.md` (removed in this pass) diff --git a/docs/quickstart.md b/docs/quickstart.md index 1c521bb..8a9b8a7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,5 +1,12 @@ # ProgramVer — Quickstart +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ## Before it will run The repository is missing three files the code opens. Supply them first: diff --git a/docs/roadmap.md b/docs/roadmap.md index 418d29c..a489350 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,12 @@ # ProgramVer — Roadmap +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + Direction, not a schedule. Defects are in [`internal/known-issues.md`](./internal/known-issues.md). diff --git a/docs/testing.md b/docs/testing.md index bdd0946..410a649 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,12 @@ # ProgramVer — Testing +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ```bash pip install -r requirements.txt pytest diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b15176e..840e2ab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,12 @@ # ProgramVer — Troubleshooting +!!! warning "Pre-2.0 layout" + This page describes the pre-2.0 flat `main.py` layout. As of 2.0.0 the library is the + `programver` package (`VersionDialog`, `programver/dialog.py`) and `main.py` is a demo, + not the module itself — see [Known Issues](internal/known-issues.md) for what changed and + the [README](https://github.com/willtheorangeguy/ProgramVer#readme) for current usage. This + page is pending a rewrite for 2.0. + ## `TclError: couldn't open ".../imgs/dfdlogo.gif"` The file is not in the repository, and the code needs it before the window can appear. Supply diff --git a/main.py b/main.py index 3b5892e..73bb0ae 100644 --- a/main.py +++ b/main.py @@ -1,97 +1,47 @@ """ -ProgramVer - A Python version of Microsoft's 'winver'. +ProgramVer - Standalone demo/test entry point. +Imports from the programver package and shows a demo version dialog. Copyright (C) 2017-2026 willtheorangeguy """ # pylint: disable=import-error, invalid-name import os -from tkinter import Tk, Text, INSERT, PhotoImage, Label, Button, TOP, BOTTOM +from programver import VersionDialog -# Import Statements -# Helper Functions - - -def get_resource_path(filename): - """Get the absolute path to a resource file.""" - base_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(base_dir, filename) - - -# Document Functions - - -def openLicense(): - """Opens the license file in a new window.""" - windowl = Tk() - license_path = get_resource_path("LICENSE.txt") - with open(license_path, "r", encoding="UTF-8") as licensefile: - licensecontents = licensefile.read() - windowl.title("License") - licensetext = Text(windowl) - licensetext.insert(INSERT, licensecontents) - licensetext.pack() - - -def openEULA(): - """Opens the EULA file in a new window.""" - windowl = Tk() - eula_path = get_resource_path("EULA.txt") - with open(eula_path, "r", encoding="UTF-8") as eulafile: - eulacontents = eulafile.read() - windowl.title("EULA") - eulatext = Text(windowl) - eulatext.insert(INSERT, eulacontents) - eulatext.pack() - - -# ProgramVer Function def ProgramVer(): - """Main function for ProgramVer.""" - window = Tk() - # Window Elements - window.title( - "Copyright & Version Info for ProgramVer" - ) # change name based on program name - # UI Elements - dfdimage = PhotoImage(file=get_resource_path("imgs/dfdlogo.gif")) - pythonimage = PhotoImage(file=get_resource_path("imgs/pythonpoweredlengthgif.gif")) - dfdlogo = Label(window, image=dfdimage) - pythonpowered = Label(window, image=pythonimage) - info = Label( - window, text="ProgramVer \n Version: 1.9.0 (Build #)" - ) # change respectively - trademarks = Label( - window, - text="Copyright (C) 2017 - 2024 Dog Face Development Co. \ - All rights reserved in all countries. \ - \n ProgramVer and its code, user interface and all other associated trademarks are protected \ - \nby trademarks and copyright in Canada, the United States and other countries.", - ) # change as needed - licenseblurb = Label( - window, - text="""\n ProgramVer - Version window for DFD Co.'s programs - Copyright (C) 2017-2024 Dog Face Development Company - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, version 3 of the License. + """Main function for ProgramVer demo.""" + base_dir = os.path.dirname(os.path.abspath(__file__)) - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License below for more details.""", - ) # change as needed - licensebtn = Button(window, text="Open License", command=openLicense) - eulabtn = Button(window, text="Open EULA", command=openEULA) - # Pack Statements - dfdlogo.pack(side=TOP) - info.pack(side=TOP) - trademarks.pack(side=TOP) - licenseblurb.pack(side=TOP) - licensebtn.pack(pady=5) - eulabtn.pack(pady=5) - pythonpowered.pack(side=BOTTOM) - # Maintain Window - window.mainloop() + dialog = VersionDialog( + app_name="ProgramVer", + version="2.0.0", + copyright_text=( + "Copyright (C) 2017-2026 Dog Face Development Co.\n" + "All rights reserved in all countries.\n" + "ProgramVer and its code, user interface and all other associated\n" + "trademarks are protected by trademarks and copyright in Canada,\n" + "the United States and other countries." + ), + license_path=os.path.join(base_dir, "LICENSE.md"), + eula_path=os.path.join(base_dir, "EULA.md"), + license_blurb=( + "\nProgramVer - Version window for DFD Co.'s programs\n" + "Copyright (C) 2017-2026 Dog Face Development Company\n\n" + "This program is free software: you can redistribute it and/or modify\n" + "it under the terms of the GNU General Public License as published by\n" + "the Free Software Foundation, version 3 of the License.\n\n" + "This program is distributed in the hope that it will be useful,\n" + "but WITHOUT ANY WARRANTY; without even the implied warranty of\n" + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n" + "GNU General Public License below for more details." + ), + show_python_powered=True, + window_title="Copyright & Version Info for ProgramVer", + ) + dialog.show() + + +if __name__ == "__main__": + ProgramVer() diff --git a/programver/__init__.py b/programver/__init__.py new file mode 100644 index 0000000..2a5416c --- /dev/null +++ b/programver/__init__.py @@ -0,0 +1,6 @@ +"""ProgramVer - A customizable version dialog for Python applications.""" + +from programver.dialog import VersionDialog + +__all__ = ["VersionDialog"] +__version__ = "2.0.0" diff --git a/programver/__main__.py b/programver/__main__.py new file mode 100644 index 0000000..3cce727 --- /dev/null +++ b/programver/__main__.py @@ -0,0 +1,30 @@ +"""Entry point for python -m programver.""" + +import os +from programver.dialog import VersionDialog + + +def main(): + """Run ProgramVer demo dialog.""" + base_dir = os.path.dirname(os.path.abspath(__file__)) + license_path = os.path.join(base_dir, "..", "LICENSE.md") + + dialog = VersionDialog( + app_name="ProgramVer", + version="2.0.0", + copyright_text=( + "Copyright (C) 2017-2026 willtheorangeguy.\n" + "All rights reserved." + ), + license_path=license_path, + license_blurb=( + "ProgramVer - A customizable version dialog for Python applications.\n" + "This project is licensed under the MIT License." + ), + show_python_powered=True, + ) + dialog.show() + + +if __name__ == "__main__": + main() diff --git a/programver/_text_viewer.py b/programver/_text_viewer.py new file mode 100644 index 0000000..eef5ec3 --- /dev/null +++ b/programver/_text_viewer.py @@ -0,0 +1,33 @@ +"""Read-only scrollable text viewer window.""" + +# pylint: disable=too-few-public-methods + +import tkinter as tk + + +class TextViewer: + """Displays a file's contents in a read-only, scrollable Toplevel window. + + Intentionally has no public methods beyond construction: it builds and shows + itself as a side effect of being instantiated, like a one-shot dialog helper. + """ + + def __init__(self, parent, title, file_path): + self.window = tk.Toplevel(parent) + self.window.title(title) + + frame = tk.Frame(self.window) + frame.pack(fill=tk.BOTH, expand=True) + + scrollbar = tk.Scrollbar(frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + text_widget = tk.Text(frame, yscrollcommand=scrollbar.set, wrap=tk.WORD) + text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.config(command=text_widget.yview) + + with open(file_path, "r", encoding="UTF-8") as f: + content = f.read() + + text_widget.insert(tk.INSERT, content) + text_widget.config(state=tk.DISABLED) diff --git a/programver/_utils.py b/programver/_utils.py new file mode 100644 index 0000000..e1c1399 --- /dev/null +++ b/programver/_utils.py @@ -0,0 +1,32 @@ +"""Internal utilities for ProgramVer.""" + +import os +import tkinter as tk + + +def get_or_create_root(): + """Detect whether a Tk root already exists. + + Returns: + tuple: (window, is_standalone) + - If Tk root exists: (Toplevel(root), False) + - If no Tk root: (Tk(), True) + """ + try: + # pylint: disable=protected-access + # tk._default_root is the standard (if undocumented) way to detect an + # existing Tk root; there is no public API for this. + existing_root = tk._default_root + if existing_root is not None and existing_root.winfo_exists(): + return tk.Toplevel(existing_root), False + except Exception: # pylint: disable=broad-exception-caught + # Any failure here (e.g. the root was destroyed mid-check) just falls + # through to creating a fresh standalone Tk root below. + pass + root = tk.Tk() + return root, True + + +def get_bundled_image_path(filename): + """Get path to a bundled image in the programver package.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "imgs", filename) diff --git a/programver/dialog.py b/programver/dialog.py new file mode 100644 index 0000000..3863659 --- /dev/null +++ b/programver/dialog.py @@ -0,0 +1,97 @@ +"""Core VersionDialog class for ProgramVer.""" + +# pylint: disable=too-few-public-methods, too-many-instance-attributes + +import tkinter as tk + +from programver._utils import get_or_create_root, get_bundled_image_path +from programver._text_viewer import TextViewer + + +class VersionDialog: + """A customizable 'winver'-style version information dialog. + + Can be used standalone (creates its own Tk root and runs mainloop) + or embedded in an existing tkinter application (uses Toplevel). + + The constructor is intentionally a flat, keyword-friendly bag of display + options rather than a config object, so callers can construct it inline; + `show()` is the only behaviour, by design. + """ + + def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + app_name, + version, + copyright_text, + license_path=None, + eula_path=None, + license_blurb=None, + logo_path=None, + show_python_powered=True, + window_title=None, + ): + self.app_name = app_name + self.version = version + self.copyright_text = copyright_text + self.license_path = license_path + self.eula_path = eula_path + self.license_blurb = license_blurb + self.logo_path = logo_path + self.show_python_powered = show_python_powered + self.window_title = window_title or f"About {app_name}" + + def show(self): + """Display the version dialog. + + If a Tk root already exists, creates a Toplevel window. + If no Tk root exists, creates Tk root and calls mainloop(). + """ + window, is_standalone = get_or_create_root() + window.title(self.window_title) + + if self.logo_path: + logo_img = tk.PhotoImage(file=self.logo_path) + # Keep a reference on the window so the image isn't garbage + # collected before Tk draws it — the standard Tkinter idiom. + window._logo_img = logo_img # pylint: disable=protected-access + logo_label = tk.Label(window, image=logo_img) + logo_label.pack(side=tk.TOP) + + info = tk.Label( + window, text=f"{self.app_name}\nVersion: {self.version}" + ) + info.pack(side=tk.TOP) + + trademarks = tk.Label(window, text=self.copyright_text) + trademarks.pack(side=tk.TOP) + + if self.license_blurb: + blurb = tk.Label(window, text=self.license_blurb) + blurb.pack(side=tk.TOP) + + if self.license_path: + license_btn = tk.Button( + window, + text="Open License", + command=lambda: TextViewer(window, "License", self.license_path), + ) + license_btn.pack(pady=5) + + if self.eula_path: + eula_btn = tk.Button( + window, + text="Open EULA", + command=lambda: TextViewer(window, "EULA", self.eula_path), + ) + eula_btn.pack(pady=5) + + if self.show_python_powered: + badge_path = get_bundled_image_path("pythonpoweredlengthgif.gif") + badge_img = tk.PhotoImage(file=badge_path) + window._badge_img = badge_img # pylint: disable=protected-access + badge_label = tk.Label(window, image=badge_img) + badge_label.pack(side=tk.BOTTOM) + + if is_standalone: + window.mainloop() diff --git a/programver/imgs/__init__.py b/programver/imgs/__init__.py new file mode 100644 index 0000000..dfe20c9 --- /dev/null +++ b/programver/imgs/__init__.py @@ -0,0 +1 @@ +"""Image assets for ProgramVer.""" diff --git a/imgs/pythonpoweredlengthgif.gif b/programver/imgs/pythonpoweredlengthgif.gif similarity index 100% rename from imgs/pythonpoweredlengthgif.gif rename to programver/imgs/pythonpoweredlengthgif.gif diff --git a/pyproject.toml b/pyproject.toml index be176a9..85f166f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ requires = ["setuptools", "wheel"] [project] name = "ProgramVer" -version = "1.9.0" +version = "2.0.0" authors = [ { name= "willtheorangeguy" }, ] -description = "A Python version of Microsoft's 'winver', built to be customizable, and to show copyright info and licenses." +description = "A customizable version dialog for Python applications, inspired by Microsoft's winver." readme = "README.md" license = { file="LICENSE.md" } requires-python = ">=3.9" @@ -24,11 +24,18 @@ classifiers = [ "Homepage" = "https://github.com/willtheorangeguy/ProgramVer" "Bug Tracker" = "https://github.com/willtheorangeguy/ProgramVer/issues" +[project.scripts] +programver = "programver.__main__:main" + [tool.setuptools] include-package-data = true [tool.setuptools.packages.find] -where = ["imgs"] +include = ["programver", "programver.*"] + +[tool.setuptools.package-data] +"programver.imgs" = ["*.gif", "*.png"] + [tool.bandit] exclude_dirs = ["tests", "test", ".venv", "venv", "build", "node_modules"] skips = ["B101"] diff --git a/setup.cfg b/setup.cfg index 5c00bca..276b000 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,16 +1,21 @@ [metadata] name = programver -version = 1.9.0 +version = 2.0.0 [options] packages = find: -package_dir = - = imgs include_package_data = True [options.packages.find] -where = imgs +include = + programver + programver.* + +[options.package_data] +programver.imgs = + *.gif + *.png [options.entry_points] console_scripts = - programver = main:ProgramVer \ No newline at end of file + programver = programver.__main__:main diff --git a/setup.py b/setup.py index 2e97d2e..4ff21d1 100644 --- a/setup.py +++ b/setup.py @@ -11,10 +11,11 @@ def readme(): setup( name="programver", - version="1.9.0", - description="A Python version of Microsoft's 'winver', \ - built to be customizable, and to show copyright info and licenses.", + version="2.0.0", + description="A customizable version dialog for Python applications, " + "inspired by Microsoft's winver.", long_description=readme(), + long_description_content_type="text/markdown", classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", @@ -25,9 +26,8 @@ def readme(): keywords="program version windows winver microsoft license gui", url="https://github.com/willtheorangeguy/ProgramVer", author="willtheorangeguy", - packages=find_packages(where="imgs"), - package_dir={"": "imgs"}, + packages=find_packages(include=["programver", "programver.*"]), + package_data={"programver.imgs": ["*.gif", "*.png"]}, include_package_data=True, - py_modules=["main"], - entry_points={"console_scripts": ["programver=main:ProgramVer"]}, + entry_points={"console_scripts": ["programver=programver.__main__:main"]}, ) diff --git a/tests/test_main.py b/tests/test_main.py index 5576bbf..b587f71 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,6 @@ """ -Tests for ProgramVer main module. -Copyright (C) 2017-2024 Dog Face Development Co. +Tests for ProgramVer package. +Copyright (C) 2017-2026 Dog Face Development Co. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -8,262 +8,532 @@ """ # pylint: disable=import-error, invalid-name, wrong-import-position, import-outside-toplevel, unused-argument -# unused-argument is disabled because @patch decorators inject mocked objects as parameters -# even when not all mocks are used in every test import unittest from unittest.mock import Mock, patch, mock_open -import os -import sys +import tkinter as tk + + +class TestVersionDialogInit(unittest.TestCase): + """Test cases for VersionDialog.__init__.""" + + def test_required_params_stored(self): + """Test that required parameters are stored correctly.""" + from programver.dialog import VersionDialog + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + ) + self.assertEqual(dialog.app_name, "TestApp") + self.assertEqual(dialog.version, "1.0.0") + self.assertEqual(dialog.copyright_text, "Copyright 2024") + + def test_optional_params_default_none(self): + """Test that optional parameters default to None or expected values.""" + from programver.dialog import VersionDialog + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + ) + self.assertIsNone(dialog.license_path) + self.assertIsNone(dialog.eula_path) + self.assertIsNone(dialog.license_blurb) + self.assertIsNone(dialog.logo_path) + self.assertTrue(dialog.show_python_powered) + self.assertEqual(dialog.window_title, "About TestApp") + + def test_custom_window_title(self): + """Test that custom window title is stored.""" + from programver.dialog import VersionDialog + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + window_title="Custom Title", + ) + self.assertEqual(dialog.window_title, "Custom Title") + + def test_all_optional_params(self): + """Test that all optional parameters are stored correctly.""" + from programver.dialog import VersionDialog + + dialog = VersionDialog( + app_name="TestApp", + version="2.0.0", + copyright_text="Copyright 2024", + license_path="/path/to/LICENSE", + eula_path="/path/to/EULA", + license_blurb="MIT License", + logo_path="/path/to/logo.gif", + show_python_powered=False, + window_title="About Test", + ) + self.assertEqual(dialog.license_path, "/path/to/LICENSE") + self.assertEqual(dialog.eula_path, "/path/to/EULA") + self.assertEqual(dialog.license_blurb, "MIT License") + self.assertEqual(dialog.logo_path, "/path/to/logo.gif") + self.assertFalse(dialog.show_python_powered) + self.assertEqual(dialog.window_title, "About Test") + + +class TestVersionDialogShow(unittest.TestCase): + """Test cases for VersionDialog.show method.""" + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_standalone_calls_mainloop( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that show() calls mainloop in standalone mode.""" + from programver.dialog import VersionDialog -# Add parent directory to path for imports -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + mock_window.mainloop.assert_called_once() + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_embedded_no_mainloop( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that show() does not call mainloop in embedded mode.""" + from programver.dialog import VersionDialog -from main import openLicense, openEULA, ProgramVer, get_resource_path + mock_window = Mock() + mock_root.return_value = (mock_window, False) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + mock_window.mainloop.assert_not_called() + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_sets_window_title( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that show() sets the window title.""" + from programver.dialog import VersionDialog + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + window_title="Custom Title", + show_python_powered=False, + ) + dialog.show() + + mock_window.title.assert_called_once_with("Custom Title") + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_creates_info_label(self, mock_root, mock_label, mock_photo, mock_path): + """Test that show() creates an info label with app name and version.""" + from programver.dialog import VersionDialog -class TestGetResourcePath(unittest.TestCase): - """Test cases for get_resource_path helper function.""" + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="3.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + label_calls = mock_label.call_args_list + label_texts = [call[1].get("text", "") for call in label_calls] + self.assertTrue( + any("TestApp" in t and "3.0.0" in t for t in label_texts) + ) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_license_button_when_path_provided( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that license button appears when license_path is set.""" + from programver.dialog import VersionDialog - def test_get_resource_path_returns_absolute_path(self): - """Test that get_resource_path returns an absolute path.""" - result = get_resource_path("LICENSE.txt") - self.assertTrue(os.path.isabs(result)) + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + license_path="/path/to/LICENSE", + show_python_powered=False, + ) + dialog.show() + + button_texts = [call[1].get("text", "") for call in mock_button.call_args_list] + self.assertIn("Open License", button_texts) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_no_license_button_when_path_none( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that license button is hidden when license_path is None.""" + from programver.dialog import VersionDialog - def test_get_resource_path_includes_filename(self): - """Test that get_resource_path includes the filename.""" - result = get_resource_path("LICENSE.txt") - self.assertTrue(result.endswith("LICENSE.txt")) + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + button_texts = [call[1].get("text", "") for call in mock_button.call_args_list] + self.assertNotIn("Open License", button_texts) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_eula_button_when_path_provided( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that EULA button appears when eula_path is set.""" + from programver.dialog import VersionDialog - def test_get_resource_path_handles_subdirectories(self): - """Test that get_resource_path handles subdirectories correctly.""" - result = get_resource_path("imgs/dfdlogo.gif") - self.assertTrue("imgs" in result) - self.assertTrue(result.endswith("dfdlogo.gif")) + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + eula_path="/path/to/EULA", + show_python_powered=False, + ) + dialog.show() + + button_texts = [call[1].get("text", "") for call in mock_button.call_args_list] + self.assertIn("Open EULA", button_texts) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Button") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_no_eula_button_when_path_none( + self, mock_root, mock_label, mock_button, mock_photo, mock_path + ): + """Test that EULA button is hidden when eula_path is None.""" + from programver.dialog import VersionDialog + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + button_texts = [call[1].get("text", "") for call in mock_button.call_args_list] + self.assertNotIn("Open EULA", button_texts) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_python_powered_badge(self, mock_root, mock_label, mock_photo, mock_path): + """Test that Python Powered badge is shown when enabled.""" + from programver.dialog import VersionDialog -class TestOpenLicense(unittest.TestCase): - """Test cases for openLicense function.""" + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=True, + ) + dialog.show() + + mock_path.assert_called_once_with("pythonpoweredlengthgif.gif") + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_no_python_powered_badge(self, mock_root, mock_label, mock_photo, mock_path): + """Test that Python Powered badge is hidden when disabled.""" + from programver.dialog import VersionDialog - @patch("main.Text") - @patch("main.Tk") - @patch( - "builtins.open", new_callable=mock_open, read_data="GNU GENERAL PUBLIC LICENSE" - ) - def test_openLicense_creates_window(self, mock_file, mock_tk, mock_text): - """Test that openLicense creates a window and reads LICENSE.txt.""" mock_window = Mock() - mock_tk.return_value = mock_window - mock_text_widget = Mock() - mock_text.return_value = mock_text_widget + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() + + mock_path.assert_not_called() + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_logo_when_provided(self, mock_root, mock_label, mock_photo, mock_path): + """Test that logo is displayed when logo_path is provided.""" + from programver.dialog import VersionDialog - openLicense() + mock_window = Mock() + mock_root.return_value = (mock_window, True) + + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + logo_path="/path/to/logo.gif", + show_python_powered=False, + ) + dialog.show() + + photo_calls = mock_photo.call_args_list + logo_files = [call[1].get("file", "") for call in photo_calls] + self.assertIn("/path/to/logo.gif", logo_files) + + @patch("programver.dialog.get_bundled_image_path", return_value="fake.gif") + @patch("programver.dialog.tk.PhotoImage") + @patch("programver.dialog.tk.Label") + @patch("programver.dialog.get_or_create_root") + def test_show_no_logo_when_not_provided(self, mock_root, mock_label, mock_photo, mock_path): + """Test that no logo image is loaded when logo_path is None.""" + from programver.dialog import VersionDialog - # Verify window was created - mock_tk.assert_called_once() - # Verify file was opened with absolute path - mock_file.assert_called_once() - call_args = mock_file.call_args[0] - self.assertTrue(call_args[0].endswith("LICENSE.txt")) - # Verify window title was set - mock_window.title.assert_called_once_with("License") - - @patch("main.Tk") - @patch("builtins.open", new_callable=mock_open, read_data="Test License Content") - @patch("main.Text") - def test_openLicense_displays_content(self, mock_text, mock_file, mock_tk): - """Test that openLicense displays license content.""" mock_window = Mock() - mock_tk.return_value = mock_window - mock_text_widget = Mock() - mock_text.return_value = mock_text_widget + mock_root.return_value = (mock_window, True) - openLicense() + dialog = VersionDialog( + app_name="TestApp", + version="1.0.0", + copyright_text="Copyright 2024", + show_python_powered=False, + ) + dialog.show() - # Verify text widget was created with window - mock_text.assert_called_once_with(mock_window) - # Verify content was inserted - mock_text_widget.insert.assert_called_once() - # Verify widget was packed - mock_text_widget.pack.assert_called_once() + mock_photo.assert_not_called() -class TestOpenEULA(unittest.TestCase): - """Test cases for openEULA function.""" +class TestTextViewer(unittest.TestCase): + """Test cases for TextViewer read-only text window.""" + + @patch("programver._text_viewer.tk.Text") + @patch("programver._text_viewer.tk.Scrollbar") + @patch("programver._text_viewer.tk.Frame") + @patch("programver._text_viewer.tk.Toplevel") + @patch("builtins.open", new_callable=mock_open, read_data="Test license content") + def test_uses_toplevel_not_tk( + self, mock_file, mock_toplevel, mock_frame, mock_scrollbar, mock_text + ): + """Test that TextViewer uses Toplevel, not Tk.""" + from programver._text_viewer import TextViewer - @patch("main.Text") - @patch("main.Tk") - @patch( - "builtins.open", new_callable=mock_open, read_data="END USER LICENSE AGREEMENT" - ) - def test_openEULA_creates_window(self, mock_file, mock_tk, mock_text): - """Test that openEULA creates a window and reads EULA.txt.""" + mock_parent = Mock() mock_window = Mock() - mock_tk.return_value = mock_window - mock_text_widget = Mock() - mock_text.return_value = mock_text_widget + mock_toplevel.return_value = mock_window - openEULA() + TextViewer(mock_parent, "License", "/path/to/file") - # Verify window was created - mock_tk.assert_called_once() - # Verify file was opened with absolute path - mock_file.assert_called_once() - call_args = mock_file.call_args[0] - self.assertTrue(call_args[0].endswith("EULA.txt")) - # Verify window title was set - mock_window.title.assert_called_once_with("EULA") + mock_toplevel.assert_called_once_with(mock_parent) + + @patch("programver._text_viewer.tk.Text") + @patch("programver._text_viewer.tk.Scrollbar") + @patch("programver._text_viewer.tk.Frame") + @patch("programver._text_viewer.tk.Toplevel") + @patch("builtins.open", new_callable=mock_open, read_data="Test content") + def test_sets_window_title( + self, mock_file, mock_toplevel, mock_frame, mock_scrollbar, mock_text + ): + """Test that TextViewer sets the window title.""" + from programver._text_viewer import TextViewer - @patch("main.Tk") - @patch("builtins.open", new_callable=mock_open, read_data="Test EULA Content") - @patch("main.Text") - def test_openEULA_displays_content(self, mock_text, mock_file, mock_tk): - """Test that openEULA displays EULA content.""" mock_window = Mock() - mock_tk.return_value = mock_window + mock_toplevel.return_value = mock_window + + TextViewer(Mock(), "EULA", "/path/to/file") + + mock_window.title.assert_called_once_with("EULA") + + @patch("programver._text_viewer.tk.Text") + @patch("programver._text_viewer.tk.Scrollbar") + @patch("programver._text_viewer.tk.Frame") + @patch("programver._text_viewer.tk.Toplevel") + @patch("builtins.open", new_callable=mock_open, read_data="File contents here") + def test_inserts_file_content( + self, mock_file, mock_toplevel, mock_frame, mock_scrollbar, mock_text + ): + """Test that TextViewer inserts the file contents.""" + from programver._text_viewer import TextViewer + mock_text_widget = Mock() mock_text.return_value = mock_text_widget - openEULA() + TextViewer(Mock(), "License", "/path/to/file") - # Verify text widget was created with window - mock_text.assert_called_once_with(mock_window) - # Verify content was inserted - mock_text_widget.insert.assert_called_once() - # Verify widget was packed - mock_text_widget.pack.assert_called_once() + mock_text_widget.insert.assert_called_once_with(tk.INSERT, "File contents here") + @patch("programver._text_viewer.tk.Text") + @patch("programver._text_viewer.tk.Scrollbar") + @patch("programver._text_viewer.tk.Frame") + @patch("programver._text_viewer.tk.Toplevel") + @patch("builtins.open", new_callable=mock_open, read_data="Content") + def test_text_is_disabled( + self, mock_file, mock_toplevel, mock_frame, mock_scrollbar, mock_text + ): + """Test that TextViewer sets text widget to DISABLED (read-only).""" + from programver._text_viewer import TextViewer -class TestProgramVer(unittest.TestCase): - """Test cases for ProgramVer function.""" + mock_text_widget = Mock() + mock_text.return_value = mock_text_widget - @patch("main.Tk") - @patch("main.PhotoImage") - @patch("main.Label") - @patch("main.Button") - def test_programver_creates_window( - self, mock_button, mock_label, mock_photoimage, mock_tk + TextViewer(Mock(), "License", "/path/to/file") + + mock_text_widget.config.assert_called_once_with(state=tk.DISABLED) + + @patch("programver._text_viewer.tk.Text") + @patch("programver._text_viewer.tk.Scrollbar") + @patch("programver._text_viewer.tk.Frame") + @patch("programver._text_viewer.tk.Toplevel") + @patch("builtins.open", new_callable=mock_open, read_data="Content") + def test_scrollbar_is_attached( + self, mock_file, mock_toplevel, mock_frame, mock_scrollbar, mock_text ): - """Test that ProgramVer creates main window with all components.""" - mock_window = Mock() - mock_tk.return_value = mock_window - mock_img = Mock() - mock_photoimage.return_value = mock_img + """Test that TextViewer creates and attaches a scrollbar.""" + from programver._text_viewer import TextViewer - # Mock mainloop to prevent blocking - mock_window.mainloop = Mock() + mock_sb = Mock() + mock_scrollbar.return_value = mock_sb - ProgramVer() + TextViewer(Mock(), "License", "/path/to/file") - # Verify window was created - mock_tk.assert_called_once() - # Verify window title was set - mock_window.title.assert_called_once() - title_text = mock_window.title.call_args[0][0] - assert "ProgramVer" in title_text - - @patch("main.Tk") - @patch("main.PhotoImage") - @patch("main.Label") - @patch("main.Button") - def test_programver_loads_images( - self, mock_button, mock_label, mock_photoimage, mock_tk - ): - """Test that ProgramVer loads required images.""" - mock_window = Mock() - mock_tk.return_value = mock_window - mock_window.mainloop = Mock() - - ProgramVer() - - # Verify PhotoImage was called to load images - assert mock_photoimage.call_count == 2 - # Check that both images are loaded with absolute paths - calls = mock_photoimage.call_args_list - image_files = [call[1]["file"] for call in calls] - assert any("dfdlogo.gif" in img for img in image_files) - assert any("pythonpoweredlengthgif.gif" in img for img in image_files) - - @patch("main.Tk") - @patch("main.PhotoImage") - @patch("main.Label") - @patch("main.Button") - def test_programver_creates_labels( - self, mock_button, mock_label, mock_photoimage, mock_tk - ): - """Test that ProgramVer creates appropriate labels.""" - mock_window = Mock() - mock_tk.return_value = mock_window - mock_window.mainloop = Mock() + mock_sb.pack.assert_called_once() + mock_sb.config.assert_called_once() - ProgramVer() - # Verify Label was called multiple times to create all labels - assert mock_label.call_count >= 5 +class TestGetOrCreateRoot(unittest.TestCase): + """Test cases for get_or_create_root utility.""" + + @patch("programver._utils.tk.Tk") + @patch("programver._utils.tk._default_root", None) + def test_creates_tk_when_no_root(self, mock_tk): + """Test that a new Tk root is created when none exists.""" + from programver._utils import get_or_create_root - @patch("main.Tk") - @patch("main.PhotoImage") - @patch("main.Label") - @patch("main.Button") - def test_programver_creates_buttons( - self, mock_button, mock_label, mock_photoimage, mock_tk - ): - """Test that ProgramVer creates license and EULA buttons.""" - mock_window = Mock() - mock_tk.return_value = mock_window - mock_window.mainloop = Mock() - - ProgramVer() - - # Verify Button was called for both buttons - assert mock_button.call_count == 2 - # Verify buttons have correct text and commands - calls = mock_button.call_args_list - button_texts = [call[1]["text"] for call in calls] - assert "Open License" in button_texts - assert "Open EULA" in button_texts - - @patch("main.Tk") - @patch("main.PhotoImage") - @patch("main.Label") - @patch("main.Button") - def test_programver_button_commands( - self, mock_button, mock_label, mock_photoimage, mock_tk - ): - """Test that buttons are linked to correct command functions.""" mock_window = Mock() mock_tk.return_value = mock_window - mock_window.mainloop = Mock() - ProgramVer() + _, is_standalone = get_or_create_root() - calls = mock_button.call_args_list - commands = [call[1].get("command") for call in calls] - # Verify that openLicense and openEULA are set as commands - assert openLicense in commands - assert openEULA in commands + mock_tk.assert_called_once() + self.assertTrue(is_standalone) + + @patch("programver._utils.tk.Toplevel") + def test_creates_toplevel_when_root_exists(self, mock_toplevel): + """Test that Toplevel is created when a root already exists.""" + from programver._utils import get_or_create_root + + mock_root = Mock() + mock_root.winfo_exists.return_value = True + mock_toplevel_window = Mock() + mock_toplevel.return_value = mock_toplevel_window + + with patch("programver._utils.tk._default_root", mock_root): + _, is_standalone = get_or_create_root() + + mock_toplevel.assert_called_once_with(mock_root) + self.assertFalse(is_standalone) class TestModuleIntegration(unittest.TestCase): - """Integration tests for the module.""" + """Integration tests for the package.""" + + def test_package_imports(self): + """Test that the programver package can be imported.""" + import programver + + self.assertTrue(hasattr(programver, "VersionDialog")) + self.assertTrue(hasattr(programver, "__version__")) + + def test_version_dialog_is_class(self): + """Test that VersionDialog is a class with a show method.""" + from programver import VersionDialog + + self.assertTrue(callable(VersionDialog)) + dialog = VersionDialog( + app_name="Test", version="1.0", copyright_text="Copyright" + ) + self.assertTrue(hasattr(dialog, "show")) + self.assertTrue(callable(dialog.show)) - def test_module_imports(self): - """Test that the main module can be imported successfully.""" - import main + def test_version_string(self): + """Test that __version__ is a string.""" + import programver - assert hasattr(main, "ProgramVer") - assert hasattr(main, "openLicense") - assert hasattr(main, "openEULA") - assert hasattr(main, "get_resource_path") + self.assertIsInstance(programver.__version__, str) - def test_functions_are_callable(self): - """Test that all exported functions are callable.""" - import main + def test_main_module_imports(self): + """Test that main.py's ProgramVer function is importable.""" + from main import ProgramVer - assert callable(main.ProgramVer) - assert callable(main.openLicense) - assert callable(main.openEULA) - assert callable(main.get_resource_path) + self.assertTrue(callable(ProgramVer)) if __name__ == "__main__":