diff --git a/.agents/skills/apple-container/SKILL.md b/.agents/skills/apple-container/SKILL.md new file mode 100644 index 00000000000..fed7ce1e260 --- /dev/null +++ b/.agents/skills/apple-container/SKILL.md @@ -0,0 +1,63 @@ +--- +name: apple-container +description: Run RustPython tests in Apple's `container` CLI Linux environment. Use when user asks to run or compare Linux test results on macOS. +allowed-tools: Bash(container:*) Bash(cargo:*) Read Grep Glob +--- + +# Run Tests in Linux Container (Apple `container` CLI) + +Run RustPython tests inside a Linux container using Apple's `container` CLI. +NEVER use Docker, Podman, or any other container runtime. Only use the `container` command. + +## Arguments + +- Test command to run (examples: `test_io`, `test_codecs -v`, `test_io -v -m "test_errors"`) + +## Prerequisites + +- The `container` CLI is installed via `brew install container`. +- The dev image `rustpython-dev` is already built. + +## Workflow + +1. Check whether the test container is already running: + + ```shell + container list 2>/dev/null | grep rustpython-test + ``` + +2. Start the container if it is not running: + + ```shell + container run -d --name rustpython-test -m 8G -c 4 \ + --mount type=bind,source="$(pwd)",target=/workspace \ + -w /workspace rustpython-dev sleep infinity + ``` + +3. Run the requested test command inside the container: + + ```shell + container exec rustpython-test cargo run --release -- -m test + ``` + +4. Report results: + +- Show pass/fail summary and expected failures / unexpected successes. +- Highlight new failures compared to macOS results, if available. +- Do not stop or remove the container after testing. + +## Notes + +- The workspace is bind-mounted, so local code changes are immediately available in the container. +- Use `container exec rustpython-test sh -c "..."` for any command inside the container. +- Rebuild after code changes with: + + ```shell + container exec rustpython-test sh -c "cargo build --release" + ``` + +- Stop the container when explicitly requested: + + ```shell + container rm -f rustpython-test + ``` diff --git a/.agents/skills/investigate-test-failure/SKILL.md b/.agents/skills/investigate-test-failure/SKILL.md new file mode 100644 index 00000000000..e63dfeeec1b --- /dev/null +++ b/.agents/skills/investigate-test-failure/SKILL.md @@ -0,0 +1,53 @@ +--- +name: investigate-test-failure +description: Investigate a failing RustPython test, compare with CPython behavior, and decide whether to implement a fix or prepare incompatibility issue details. +allowed-tools: Bash(python3:*) Bash(cargo run:*) Bash(gh issue create:*) Read Grep Glob Bash(git add:*) Bash(git commit:*) Bash(cargo fmt:*) Bash(git diff:*) Task +--- + +# Investigate Test Failure + +Investigate why a specific test is failing and determine whether it can be fixed now or should be reported as an incompatibility issue. + +## Arguments + +- Failed test identifier (example: `test_inspect.TestGetSourceBase.test_getsource_reload`) + +## Workflow + +1. Analyze the failure cause: + +- Read the test code. +- Analyze failure message and traceback. +- Check related RustPython implementation code. + +2. Verify behavior in CPython: + +- Run the test with `python3 -m unittest`. +- Document expected behavior/output. + +3. Determine fix feasibility: + +- Simple fix (import issues, small logic bugs): implement fix, run formatting and checks, review changes, and commit. +- Complex fix (major missing feature): gather issue information and report to user. + +Pre-commit review process: + +- Run `git diff` to review local changes. +- Use a focused subagent review to compare implementation against CPython behavior and check for missed edge cases. +- Commit only after review passes. + +4. For complex issues, collect issue information using `.github/ISSUE_TEMPLATE/report-incompatibility.md`: + +- Feature: missing or broken Python feature. +- Minimal reproduction code. +- CPython behavior (`python3`). +- RustPython behavior (`cargo run`). +- Python documentation reference link. + +Report the collected information to the user. Create an issue only when explicitly requested. + +Example issue command: + +```shell +gh issue create --template report-incompatibility.md --title "..." --body "..." +``` diff --git a/.agents/skills/rustpython-capi-expansion/SKILL.md b/.agents/skills/rustpython-capi-expansion/SKILL.md new file mode 100644 index 00000000000..5b56929a825 --- /dev/null +++ b/.agents/skills/rustpython-capi-expansion/SKILL.md @@ -0,0 +1,60 @@ +--- +name: rustpython-capi-expansion +description: Implement missing RustPython C-API functions in crates/capi using the pyo3-ffi header split mapping (`pyo3-ffi/src/*.rs`, mirroring CPython C API headers). Use this whenever the user asks to add or port C-API functions (for example from setobject.h, dictobject.h, unicodeobject.h) or add capi tests. +--- + +# RustPython C-API Expansion + +Use this workflow for adding missing C-API functions to RustPython. + +## Source of truth for target files + +- Use this mapping source: `pyo3-ffi/src/*.rs`, which mirrors the CPython header split used by the C API. +- Map requested header APIs to `crates/capi/src/.rs` using that split. Examples: + - `setobject.h` -> `crates/capi/src/setobject.rs` + - `dictobject.h` -> `crates/capi/src/dictobject.rs` + - `unicodeobject.h` -> `crates/capi/src/unicodeobject.rs` +- Do not invent alternate target modules when the header split implies a direct target. +- If the target file is not present yet, create it and wire it in `crates/capi/src/lib.rs`. + +## Implementation workflow + +1. Identify requested missing APIs from the user request and their originating C API header. +2. Open nearby capi modules in `crates/capi/src/` and follow existing style and patterns. +3. Implement only the requested functions in the mapped target file. +4. Keep behavior aligned with CPython C-API contracts. +5. Prefer using existing `rustpython-vm` functionality as much as possible instead of re-implementing behavior in capi. +6. If a needed `rustpython-vm` helper exists but is private, make it public with a minimal, focused visibility change. +7. Prefer direct contract assumptions over defensive null checks unless required by the established local style. +8. Add basic tests only; do not overfit with very specific edge-case clutter. +9. In tests, use `pyo3` only as a safe wrapper over the API. Avoid raw pointer-heavy direct FFI-style tests. +10. Run tests from `crates/capi`. + +## Testing rules + +- Run test commands with working directory set to `crates/capi`. +- Prefer targeted tests first (module/function filter), then broader capi tests if needed. +- Keep test names concise (no required `test_` prefix). + +## Style rules + +- Follow existing RustPython capi coding style in neighboring files. +- Reuse `rustpython-vm` methods and types first; avoid duplicating VM logic in capi wrappers. +- When exposing previously private VM helpers, keep the API surface minimal and avoid unrelated refactors. +- Only expose and implement ABI-stable C-API surface needed for `abi3` / `abi3t`. +- Add comments only when they explain non-obvious behavior. +- Keep edits minimal and focused on requested API expansion. + +## Completion checklist + +- [ ] All requested functions implemented in mapped target file. +- [ ] New module exported in `crates/capi/src/lib.rs` when applicable. +- [ ] Basic safe-wrapper `pyo3` tests added/updated. +- [ ] Tests executed from `crates/capi` and passing for changed area. +- [ ] Final response includes changed file paths and test command summary. + +## Example prompts this skill should handle + +- "Implement these missing functions from `dictobject.h`." +- "Add `setobject.h` C-API functions in RustPython and include basic tests." +- "Port the listed `unicodeobject.h` APIs in capi, follow existing style, and run tests from `crates/capi`." diff --git a/.agents/skills/upgrade-pylib-next/SKILL.md b/.agents/skills/upgrade-pylib-next/SKILL.md new file mode 100644 index 00000000000..e68e7316509 --- /dev/null +++ b/.agents/skills/upgrade-pylib-next/SKILL.md @@ -0,0 +1,34 @@ +--- +name: upgrade-pylib-next +description: Pick the next CPython stdlib module ready for upgrade based on update_lib todo output and skip modules with open upgrade PRs. +allowed-tools: Skill(upgrade-pylib) Bash(gh pr list:*) Bash(cargo run:*) +--- + +# Upgrade Next Python Library + +Find the next Python library module ready for upgrade and then run the `upgrade-pylib` workflow for it. + +## Workflow + +1. Get current TODO status: + +```shell +cargo run --release -- scripts/update_lib todo 2>/dev/null +``` + +2. Get open upgrade PRs: + +```shell +gh pr list --search "Update in:title" --json number,title --template '{{range .}}#{{.number}} {{.title}}{{"\\n"}}{{end}}' +``` + +3. From TODO output, select modules in this priority order: + +- `[ ] [no deps]` +- `[ ] [0/n]` where dependencies are all upgraded (for example `[0/3]`, `[0/5]`) + +4. Skip modules that already have an open upgrade PR. + +5. Run upgrade for selected module using the `upgrade-pylib` workflow. + +If no modules match, report that eligible modules are blocked by dependencies. diff --git a/.agents/skills/upgrade-pylib/SKILL.md b/.agents/skills/upgrade-pylib/SKILL.md new file mode 100644 index 00000000000..84400e5b7a2 --- /dev/null +++ b/.agents/skills/upgrade-pylib/SKILL.md @@ -0,0 +1,102 @@ +--- +name: upgrade-pylib +description: Upgrade a Python standard library module from CPython into RustPython using scripts/update_lib and then triage and mark remaining failures. +allowed-tools: Bash(git add:*) Bash(git commit:*) Bash(git diff:*) Bash(cargo run:*) Bash(python3 scripts/update_lib quick:*) Bash(python3 scripts/update_lib auto-mark:*) +--- + +# Upgrade Python Library from CPython + +Upgrade a Python standard library module from CPython to RustPython. + +## Arguments + +- Library name or path (examples: `inspect`, `asyncio`, `json`, `cpython/Lib/inspect.py`, `cpython/Lib/json/`) + +## Important: Report Tool Issues First + +If `scripts/update_lib` shows a bug, missing automation, or unexpected behavior, stop the upgrade and report the tooling issue first, including: + +1. What you were trying to do: + +- Library name. +- Full command used. + +2. What went wrong or what is missing. +3. Expected vs actual behavior. + +## Workflow + +1. Run quick upgrade with update_lib: + +- `python3 scripts/update_lib quick ` +- or `python3 scripts/update_lib quick cpython/Lib/.py` +- or `python3 scripts/update_lib quick cpython/Lib//` + +This step copies library files, patches tests while preserving markers where possible, runs tests, auto-marks new failures, removes stale `@unittest.expectedFailure`, and creates a commit. + +If warnings like `WARNING: TestCFoo does not exist in remote file` appear, class structure changed and some markers must be restored manually. + +2. Review diff and restore RustPython-specific changes: + +- Run `git diff Lib/test/test_`. +- Restore only changes with explicit `RUSTPYTHON` comments. +- Do not restore unrelated upstream CPython changes. + +3. Investigate failing dependent tests: + +- Get dependencies: + + ```shell + cargo run --release -- scripts/update_lib deps + ``` + +- Find direct dependent test modules from the `- [ ] :` line. +- Run dependent tests and collect failures: + + ```shell + cargo run --release -- -m test 2>&1 | grep -E "^(FAIL|ERROR):" + ``` + +- For each failing test identifier, investigate with the `investigate-test-failure` workflow. + +4. Mark remaining failures with auto-mark: + +- `python3 scripts/update_lib auto-mark Lib/test/test_.py --mark-failure` +- or `python3 scripts/update_lib auto-mark Lib/test/test_/ --mark-failure` + +Note: `--mark-failure` marks all current failures including regressions; review carefully. + +5. Handle panics manually: + +- For tests that panic/crash/hang, use `@unittest.skip("TODO: RUSTPYTHON; ...")` rather than expectedFailure. + +6. Handle class-specific failures: + +- If only one subclass fails (for example `TestCFoo` but not `TestPyFoo`), move marker to the failing subclass override. + +7. Commit test-fix markers as a separate commit: + +```shell +git add -u && git commit -m "Mark failing tests" +``` + +## Example usage + +```text +upgrade-pylib inspect +upgrade-pylib json +upgrade-pylib asyncio +upgrade-pylib cpython/Lib/inspect.py +upgrade-pylib cpython/Lib/json/ +``` + +## Notes + +- The `cpython/` directory should contain the CPython source used for sync. +- `scripts/update_lib` modes: + - `quick`: patch + auto-mark + - `migrate`: patch only + - `auto-mark`: mark failures only + - `copy-lib`: copy library files only +- Patching transfers `@unittest.expectedFailure` and `@unittest.skip` with `TODO: RUSTPYTHON` markers where possible. +- The tool does not preserve every RustPython-specific change; always review and restore explicit RUSTPYTHON-marked logic. diff --git a/.cargo/config.toml b/.cargo/config.toml index 635229119f8..4e54d1d3b18 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,3 +6,16 @@ rustflags = "-C link-args=-Wl,--stack,8000000" [target.wasm32-unknown-unknown] rustflags = ["--cfg=getrandom_backend=\"wasm_js\""] + +# Enforce a 1 MB stack limit for WASI targets. +# (Runaway heap growth is fixed by the smaller DataStack chunk +# and the size‑optimized wasm‑release profile.) +[target.wasm32-wasip1] +rustflags = [ + "-C", "link-arg=-zstack-size=1048576", +] + +[target.wasm32-wasip2] +rustflags = [ + "-C", "link-arg=-zstack-size=1048576", +] diff --git a/.claude/commands/apple-container.md b/.claude/commands/apple-container.md deleted file mode 100644 index ca6876ad530..00000000000 --- a/.claude/commands/apple-container.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -allowed-tools: Bash(container *), Bash(cargo *), Read, Grep, Glob ---- - -# Run Tests in Linux Container (Apple `container` CLI) - -Run RustPython tests inside a Linux container using Apple's `container` CLI. -**NEVER use Docker, Podman, or any other container runtime.** Only use the `container` command. - -## Arguments -- `$ARGUMENTS`: Test command to run (e.g., `test_io`, `test_codecs -v`, `test_io -v -m "test_errors"`) - -## Prerequisites - -The `container` CLI is installed via `brew install container`. -The dev image `rustpython-dev` is already built. - -## Steps - -1. **Check if the container is already running** - ```shell - container list 2>/dev/null | grep rustpython-test - ``` - -2. **Start the container if not running** - ```shell - container run -d --name rustpython-test -m 8G -c 4 \ - --mount type=bind,source=/Users/al03219714/Projects/RustPython3,target=/workspace \ - -w /workspace rustpython-dev sleep infinity - ``` - -3. **Run the test inside the container** - ```shell - container exec rustpython-test sh -c "cargo run --release -- -m test $ARGUMENTS" - ``` - -4. **Report results** - - Show test summary (pass/fail counts, expected failures, unexpected successes) - - Highlight any new failures compared to macOS results if available - - Do NOT stop or remove the container after testing (keep it for reuse) - -## Notes -- The workspace is bind-mounted, so local code changes are immediately available -- Use `container exec rustpython-test sh -c "..."` for any command inside the container -- To rebuild after code changes, run: `container exec rustpython-test sh -c "cargo build --release"` -- To stop the container when done: `container rm -f rustpython-test` diff --git a/.claude/commands/investigate-test-failure.md b/.claude/commands/investigate-test-failure.md deleted file mode 100644 index e9d6b2d2d2c..00000000000 --- a/.claude/commands/investigate-test-failure.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -allowed-tools: Bash(python3:*), Bash(cargo run:*), Read, Grep, Glob, Bash(git add:*), Bash(git commit:*), Bash(cargo fmt:*), Bash(git diff:*), Task ---- - -# Investigate Test Failure - -Investigate why a specific test is failing and determine if it can be fixed or needs an issue. - -## Arguments -- `$ARGUMENTS`: Failed test identifier (e.g., `test_inspect.TestGetSourceBase.test_getsource_reload`) - -## Steps - -1. **Analyze failure cause** - - Read the test code - - Analyze failure message/traceback - - Check related RustPython code - -2. **Verify behavior in CPython** - - Run the test with `python3 -m unittest` to confirm expected behavior - - Document the expected output - -3. **Determine fix feasibility** - - **Simple fix** (import issues, small logic bugs): Fix code → Run `cargo fmt --all` → Pre-commit review → Commit - - **Complex fix** (major unimplemented features): Collect issue info and report to user - - **Pre-commit review process**: - - Run `git diff` to see the changes - - Use Task tool with `general-purpose` subagent to review: - - Compare implementation against cpython/ source code - - Verify the fix aligns with CPython behavior - - Check for any missed edge cases - - Proceed to commit only after review passes - -4. **For complex issues - Collect issue information** - Following `.github/ISSUE_TEMPLATE/report-incompatibility.md` format: - - - **Feature**: Description of missing/broken Python feature - - **Minimal reproduction code**: Smallest code that reproduces the issue - - **CPython behavior**: Result when running with python3 - - **RustPython behavior**: Result when running with cargo run - - **Python Documentation link**: Link to relevant CPython docs - - Report collected information to the user. Issue creation is done only upon user request. - - Example issue creation command: - ``` - gh issue create --template report-incompatibility.md --title "..." --body "..." - ``` diff --git a/.claude/commands/upgrade-pylib-next.md b/.claude/commands/upgrade-pylib-next.md deleted file mode 100644 index 712b79433b3..00000000000 --- a/.claude/commands/upgrade-pylib-next.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -allowed-tools: Skill(upgrade-pylib), Bash(gh pr list:*) ---- - -# Upgrade Next Python Library - -Find the next Python library module ready for upgrade and run `/upgrade-pylib` for it. - -## Current TODO Status - -!`cargo run --release -- scripts/update_lib todo 2>/dev/null` - -## Open Upgrade PRs - -!`gh pr list --search "Update in:title" --json number,title --template '{{range .}}#{{.number}} {{.title}}{{"\n"}}{{end}}'` - -## Instructions - -From the TODO list above, find modules matching these patterns (in priority order): - -1. `[ ] [no deps]` - Modules with no dependencies (can be upgraded immediately) -2. `[ ] [0/n]` - Modules where all dependencies are already upgraded (e.g., `[0/3]`, `[0/5]`) - -These patterns indicate modules that are ready to upgrade without blocking dependencies. - -**Important**: Skip any modules that already have an open PR in the "Open Upgrade PRs" list above. - -**After identifying a suitable module**, run: -``` -/upgrade-pylib -``` - -If no modules match these criteria, inform the user that all eligible modules have dependencies that need to be upgraded first. diff --git a/.claude/commands/upgrade-pylib.md b/.claude/commands/upgrade-pylib.md deleted file mode 100644 index d54305d2616..00000000000 --- a/.claude/commands/upgrade-pylib.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -allowed-tools: Bash(git add:*), Bash(git commit:*), Bash(python3 scripts/update_lib quick:*), Bash(python3 scripts/update_lib auto-mark:*) ---- - -# Upgrade Python Library from CPython - -Upgrade a Python standard library module from CPython to RustPython. - -## Arguments -- `$ARGUMENTS`: Library name to upgrade (e.g., `inspect`, `asyncio`, `json`) - -## Important: Report Tool Issues First - -If during the upgrade process you encounter any of the following issues with `scripts/update_lib`: -- A feature that should be automated but isn't supported -- A bug or unexpected behavior in the tool -- Missing functionality that would make the upgrade easier - -**STOP the upgrade and report the issue first.** Describe: -1. What you were trying to do - - Library name - - The full command executed (e.g. python scripts/update_lib quick cpython/Lib/$ARGUMENTS.py) -2. What went wrong or what's missing -3. Expected vs actual behavior - -This helps improve the tooling for future upgrades. - -## Steps - -1. **Run quick upgrade with update_lib** - - Run: `python3 scripts/update_lib quick $ARGUMENTS` (module name) - - Or: `python3 scripts/update_lib quick cpython/Lib/$ARGUMENTS.py` (library file path) - - Or: `python3 scripts/update_lib quick cpython/Lib/$ARGUMENTS/` (library directory path) - - This will: - - Copy library files (delete existing `Lib/$ARGUMENTS.py` or `Lib/$ARGUMENTS/`, then copy from `cpython/Lib/`) - - Patch test files preserving existing RustPython markers - - Run tests and auto-mark new test failures (not regressions) - - Remove `@unittest.expectedFailure` from tests that now pass - - Create a git commit with the changes - - **Handle warnings**: If you see warnings like `WARNING: TestCFoo does not exist in remote file`, it means the class structure changed and markers couldn't be transferred automatically. These need to be manually restored in step 2 or added in step 3. - -2. **Review git diff and restore RUSTPYTHON-specific changes** - - Run `git diff Lib/test/test_$ARGUMENTS` to review all changes - - **Only restore changes that have explicit `RUSTPYTHON` comments**. Look for: - - `# XXX: RUSTPYTHON` or `# XXX RUSTPYTHON` - Comments marking RustPython-specific code modifications - - `# TODO: RUSTPYTHON` - Comments marking tests that need work - - Code changes with inline `# ... RUSTPYTHON` comments - - **Do NOT restore other diff changes** - these are likely upstream CPython changes, not RustPython-specific modifications - - When restoring, preserve the original context and formatting - -3. **Investigate test failures with subagent** - - First, get dependent tests using the deps command: - ``` - cargo run --release -- scripts/update_lib deps $ARGUMENTS - ``` - - Look for the line `- [ ] $ARGUMENTS: test_xxx test_yyy ...` to get the direct dependent tests - - Run those tests to collect failures: - ``` - cargo run --release -- -m test test_xxx test_yyy ... 2>&1 | grep -E "^(FAIL|ERROR):" - ``` - - For example, if deps output shows `- [ ] linecache: test_bdb test_inspect test_linecache test_traceback test_zipimport`, run: - ``` - cargo run --release -- -m test test_bdb test_inspect test_linecache test_traceback test_zipimport 2>&1 | grep -E "^(FAIL|ERROR):" - ``` - - For each failure, use the Task tool with `general-purpose` subagent to investigate: - - Subagent should follow the `/investigate-test-failure` skill workflow - - Pass the failed test identifier as the argument (e.g., `test_inspect.TestGetSourceBase.test_getsource_reload`) - - If subagent can fix the issue easily: fix and commit - - If complex issue: subagent collects issue info and reports back (issue creation on user request only) - - Using subagent prevents context pollution in the main conversation - -4. **Mark remaining test failures with auto-mark** - - Run: `python3 scripts/update_lib auto-mark Lib/test/test_$ARGUMENTS.py --mark-failure` - - Or for directory: `python3 scripts/update_lib auto-mark Lib/test/test_$ARGUMENTS/ --mark-failure` - - This will: - - Run tests and mark ALL failing tests with `@unittest.expectedFailure` - - Remove `@unittest.expectedFailure` from tests that now pass - - **Note**: The `--mark-failure` flag marks all failures including regressions. Review the changes before committing. - -5. **Handle panics manually** - - If any tests cause panics/crashes (not just assertion failures), they need `@unittest.skip` instead: - ```python - @unittest.skip("TODO: RUSTPYTHON; panics with 'index out of bounds'") - def test_crashes(self): - ... - ``` - - auto-mark cannot detect panics automatically - check the test output for crash messages - -6. **Handle class-specific failures** - - If a test fails only in the C implementation (TestCFoo) but passes in the Python implementation (TestPyFoo), or vice versa, move the marker to the specific subclass: - ```python - # Base class - no marker here - class TestFoo: - def test_something(self): - ... - - class TestPyFoo(TestFoo, PyTest): pass - - class TestCFoo(TestFoo, CTest): - # TODO: RUSTPYTHON - @unittest.expectedFailure - def test_something(self): - return super().test_something() - ``` - -7. **Commit the test fixes** - - Run: `git add -u && git commit -m "Mark failing tests"` - - This creates a separate commit for the test markers added in steps 2-6 - -## Example Usage -``` -# Using module names (recommended) -/upgrade-pylib inspect -/upgrade-pylib json -/upgrade-pylib asyncio - -# Using library paths (alternative) -/upgrade-pylib cpython/Lib/inspect.py -/upgrade-pylib cpython/Lib/json/ -``` - -## Example: Restoring RUSTPYTHON changes - -When git diff shows removed RUSTPYTHON-specific code like: -```diff --# XXX RUSTPYTHON: we don't import _json as fresh since... --cjson = import_helper.import_fresh_module('json') #, fresh=['_json']) -+cjson = import_helper.import_fresh_module('json', fresh=['_json']) -``` - -You should restore the RustPython version: -```python -# XXX RUSTPYTHON: we don't import _json as fresh since... -cjson = import_helper.import_fresh_module('json') #, fresh=['_json']) -``` - -## Notes -- The cpython/ directory should contain the CPython source that we're syncing from -- `scripts/update_lib` package handles patching and auto-marking: - - `quick` - Combined patch + auto-mark (recommended) - - `migrate` - Only migrate (patch), no test running - - `auto-mark` - Only run tests and mark failures - - `copy-lib` - Copy library files (not tests) -- The patching: - - Transfers `@unittest.expectedFailure` and `@unittest.skip` decorators with `TODO: RUSTPYTHON` markers - - Adds `import unittest # XXX: RUSTPYTHON` if needed for the decorators - - **Limitation**: If a class was restructured (e.g., method overrides removed), update_lib will warn and skip those markers -- The smart auto-mark: - - Marks NEW test failures automatically (tests that didn't exist before) - - Does NOT mark regressions (existing tests that now fail) - these are warnings - - Removes `@unittest.expectedFailure` from tests that now pass -- The script does NOT preserve all RustPython-specific changes - you must review `git diff` and restore them -- Common RustPython markers to look for: - - `# XXX: RUSTPYTHON` or `# XXX RUSTPYTHON` - Inline comments for code modifications - - `# TODO: RUSTPYTHON` - Test skip/failure markers - - Any code with `RUSTPYTHON` in comments that was removed in the diff -- **Important**: Not all changes in the git diff need to be restored. Only restore changes that have explicit `RUSTPYTHON` comments. Other changes are upstream CPython updates. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 22f0a9a8a01..00000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "bash .claude/scripts/setup-env.sh" - } - ] - } - ] - } -} diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index c756a93c9b9..da85c312898 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -4,7 +4,9 @@ argdefs argtypes asdl asname +ASNATIVEBYTES atopen +atext attro augassign badcert @@ -36,6 +38,7 @@ CFWS CLASSDEREF classdict cmpop +CNOTAB codedepth CODEUNIT CONIN @@ -57,7 +60,9 @@ dictoffset distpoint dynload elts +eooh eofs +EOOH evalloop excepthandler exceptiontable @@ -95,27 +100,34 @@ HASUNION heaptype hexdigit HIGHRES +ialloc IFUNC IMMUTABLETYPE INCREF inlinedepth inplace inpos +ioffset +isbytecode +ishidden ismine ISPOINTER isoctal iteminfo Itertool +iused keeped kwnames kwonlyarg kwonlyargs +kwonlydefaults lasti libffi linearise lineful lineiterator linetable +LNOTAB loadfast localsplus localspluskinds @@ -128,19 +140,27 @@ metavars miscompiles mult multibytecodec +mystricmp +mystrnicmp nameobj nameop +nargsf +nblocks ncells +ncellsused +ncellvars nconsts newargs newfree NEWLOCALS newsemlockobject +nextop nfrees nkwargs nkwelts nlocalsplus nointerrupt +noffsets Nondescriptor noninteger nops @@ -148,9 +168,11 @@ noraise nseen NSIGNALS numer +nvars opname opnames orelse +osmodule outparam outparm paramfunc @@ -160,6 +182,7 @@ patma peepholer phcount platstdlib +ploc posonlyarg posonlyargs prec @@ -170,11 +193,14 @@ pycore pyinner pydecimal pyerrors +pyframe Pyfunc pylifecycle pymain +pymem pyrepl pystate +pystrcmp PYTHONTRACEMALLOC PYTHONUTF8 pythonw @@ -205,12 +231,14 @@ staticbase stginfo storefast stringlib +stringized structseq subkwargs subparams subscr sval swappedbytes +swaptimize sysdict tbstderr templatelib @@ -231,6 +259,7 @@ uncollectable Unhandle unparse unparser +untargeted untracking VARKEYWORDS varkwarg diff --git a/.cspell.dict/python-more.txt b/.cspell.dict/python-more.txt index 934529a7165..7ea660a5d1f 100644 --- a/.cspell.dict/python-more.txt +++ b/.cspell.dict/python-more.txt @@ -67,6 +67,7 @@ fillchar fillvalue finallyhandler firstiter +fobj firstlineno fnctl frombytes @@ -111,12 +112,14 @@ idfunc idiv idxs impls +infd indexgroup infj inittab Inittab instancecheck instanceof +instrs interpchannels interpqueues irepeat @@ -175,6 +178,7 @@ Nonprintable onceregistry origname ospath +outfd pendingcr phello platlibdir @@ -185,6 +189,7 @@ posonlyargcount prepending profilefunc pycache +pycapsule pycodecs pycs pydatetime diff --git a/.cspell.dict/rust-more.txt b/.cspell.dict/rust-more.txt index c4457723c6c..b53639c3b41 100644 --- a/.cspell.dict/rust-more.txt +++ b/.cspell.dict/rust-more.txt @@ -50,6 +50,7 @@ modpow msvc muldiv nanos +noalias nonoverlapping objclass peekable diff --git a/.cspell.dict/rustpython.txt b/.cspell.dict/rustpython.txt index 8cd08358019..07099bbb171 100644 --- a/.cspell.dict/rustpython.txt +++ b/.cspell.dict/rustpython.txt @@ -27,6 +27,7 @@ pystr pystruct pystructseq pytype +qsbr rustix struc zelf diff --git a/.cspell.json b/.cspell.json index f05f2adcd65..57f7baab3f7 100644 --- a/.cspell.json +++ b/.cspell.json @@ -49,14 +49,18 @@ "ignorePaths": [ "**/__pycache__/**", "target/**", - "Lib/**" + "Lib/**", + "crates/host_env/**" ], // words - list of words to be always considered correct // (compound words like pyarg, baseclass, microbenchmark are handled by allowCompoundWords) "words": [ "aiterable", "alnum", + "csock", "coro", + "contig", + "Crnl", "dedentations", "dedents", "deduped", @@ -64,7 +68,13 @@ "deoptimize", "emscripten", "excs", + "fdigits", + "flufl", "fnfe", + "fsdefault", + "ifexp", + "implicits", + "inity", "interps", "jitted", "jitting", @@ -73,12 +83,18 @@ "mcache", "oparg", "opargs", + "pointee", "pyc", + "qmark", "reborrow", + "reborrows", + "reparenting", "reraises", "reraising", "significand", "summands", + "TESTFN", + "TZPATH", "unraisable", "wasi", "weaked", diff --git a/.gitattributes b/.gitattributes index d076a34f977..95aed96eb3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,13 @@ Lib/** linguist-vendored Cargo.lock linguist-generated *.snap linguist-generated -merge -vm/src/stdlib/ast/gen.rs linguist-generated -merge Lib/*.py text working-tree-encoding=UTF-8 eol=LF **/*.rs text working-tree-encoding=UTF-8 eol=LF -crates/rustpython_doc_db/src/*.inc.rs linguist-generated=true +crates/vm/src/stdlib/ast/gen.rs linguist-generated -merge +crates/doc/src/data.inc.rs generated +crates/compiler-core/src/bytecode/opcode_metadata.rs generated + +.github/workflows/*.lock.yml linguist-generated=true merge=ours # Binary data types *.aif binary @@ -45,10 +48,11 @@ Lib/venv/scripts/posix/* text eol=lf # Language aware diff headers # https://tekin.co.uk/2020/10/better-git-diff-output-for-ruby-python-elixir-and-more # https://gist.github.com/tekin/12500956bd56784728e490d8cef9cb81 -*.css diff=css -*.html diff=html -*.py diff=python -*.md diff=markdown +*.css diff=css +*.html diff=html +*.py diff=python +*.md diff=markdown +*.inc.rs diff=rust # Generated files # https://github.com/github/linguist/blob/master/docs/overrides.md @@ -58,13 +62,11 @@ Lib/venv/scripts/posix/* text eol=lf # [attr]generated linguist-generated=true diff=generated -Lib/_opcode_metadata.py generated -Lib/keyword.py generated -Lib/idlelib/help.html generated -Lib/test/certdata/*.pem generated -Lib/test/certdata/*.0 generated -Lib/test/levenshtein_examples.json generated -Lib/test/test_stable_abi_ctypes.py generated -Lib/token.py generated - -.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file +Lib/_opcode_metadata.py generated +Lib/keyword.py generated +Lib/idlelib/help.html generated +Lib/test/certdata/*.pem generated +Lib/test/certdata/*.0 generated +Lib/test/levenshtein_examples.json generated +Lib/test/test_stable_abi_ctypes.py generated +Lib/token.py generated diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..18ba1b6951f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ + + +- [ ] Closes #xxxx +- [ ] This PR follows our [AI policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md) + +## Summary + + diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index 7900060fb29..c2f1b20f2d9 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -29,6 +29,10 @@ inputs: description: Install gcc-aarch64-linux-gnu (gcc-aarch64-linux-gnu) required: false default: "false" + gcc-mingw-w64-x86-64: + description: Install gcc-mingw-w64-x86-64 (gcc-mingw-w64-x86-64) + required: false + default: "false" clang: description: Install clang (clang) required: false @@ -39,11 +43,40 @@ runs: - name: Install Linux dependencies shell: bash if: ${{ runner.os == 'Linux' }} - run: > - sudo apt-get update + env: + GCC_MULTILIB: ${{ inputs.gcc-multilib }} + MUSL_TOOLS: ${{ inputs.musl-tools }} + CLANG: ${{ inputs.clang }} + GCC_AARCH64_LINUX_GNU: ${{ inputs.gcc-aarch64-linux-gnu }} + GCC_MINGW_W64_X86_64: ${{ inputs.gcc-mingw-w64-x86-64 }} + run: | + if ! sudo apt-get update; then + echo "::warning::apt-get update failed; disabling nonessential Microsoft apt sources and retrying" + for source in /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*; do + if [ -e "$source" ]; then + sudo mv "$source" "$source.disabled" + fi + done + sudo apt-get update + fi + + packages=() + if [[ "$GCC_MULTILIB" == "true" ]]; then + packages+=(gcc-multilib) + fi + if [[ "$MUSL_TOOLS" == "true" ]]; then + packages+=(musl-tools) + fi + if [[ "$CLANG" == "true" ]]; then + packages+=(clang) + fi + if [[ "$GCC_AARCH64_LINUX_GNU" == "true" ]]; then + packages+=(gcc-aarch64-linux-gnu linux-libc-dev-arm64-cross libc6-dev-arm64-cross) + fi + if [[ "$GCC_MINGW_W64_X86_64" == "true" ]]; then + packages+=(gcc-mingw-w64-x86-64) + fi - sudo apt-get install --no-install-recommends - ${{ fromJSON(inputs.gcc-multilib) && 'gcc-multilib' || '' }} - ${{ fromJSON(inputs.musl-tools) && 'musl-tools' || '' }} - ${{ fromJSON(inputs.clang) && 'clang' || '' }} - ${{ fromJSON(inputs.gcc-aarch64-linux-gnu) && 'gcc-aarch64-linux-gnu linux-libc-dev-arm64-cross libc6-dev-arm64-cross' || '' }} + if ((${#packages[@]})); then + sudo apt-get install --no-install-recommends "${packages[@]}" + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e3f9ba3b7ab..e9e2041d482 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,7 @@ updates: - "sha1" - "sha2" - "sha3" + - "shake" - "blake2" - "hmac" - "pbkdf2" @@ -85,10 +86,10 @@ updates: - "quote-use*" random: patterns: - - "ahash" - "getrandom" - "mt19937" - "rand*" + - "chacha20" rayon: patterns: - "rayon*" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6261296c74b..5a8daae06ef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ concurrency: cancel-in-progress: true env: - CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls,host_env + CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,host_env # Crates excluded from workspace builds: # - rustpython_wasm: requires wasm target @@ -33,8 +33,8 @@ env: CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_RELEASE_DEBUG: 0 CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # TODO: Remove on 2026/06/02 CI: true + FORCE_COLOR: 1 jobs: determine_changes: @@ -44,7 +44,7 @@ jobs: # Flag that is raised when any rust code is changed. rust_code: ${{ steps.check_rust_code.outputs.changed }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -88,14 +88,14 @@ jobs: os: [macos-latest, ubuntu-latest, windows-2025] fail-fast: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -123,9 +123,6 @@ jobs: run: cargo test if: runner.os != 'Windows' # Requires pyo3 0.29+ on Windows - - run: cargo doc --locked - if: runner.os == 'Linux' - - name: check compilation without host_env (sandbox mode) run: | cargo check -p rustpython-vm --no-default-features --features compiler @@ -143,6 +140,10 @@ jobs: run: cargo build --no-default-features --features ssl-openssl if: runner.os == 'Linux' + - name: Test vendored OpenSSL build + run: cargo build --no-default-features --features ssl-openssl-vendor + if: runner.os == 'Linux' + # - name: Install tk-dev for tkinter build # run: sudo apt-get update && sudo apt-get install -y tk-dev # if: runner.os == 'Linux' @@ -189,6 +190,7 @@ jobs: skip_ssl: true - os: ubuntu-latest target: wasm32-wasip2 + skip_ssl: true - os: ubuntu-latest target: x86_64-unknown-freebsd skip_ssl: true @@ -196,13 +198,17 @@ jobs: target: aarch64-unknown-linux-gnu dependencies: gcc-aarch64-linux-gnu: true + - os: ubuntu-latest + target: x86_64-pc-windows-gnu + dependencies: + gcc-mingw-w64-x86-64: true - os: macos-latest target: aarch64-apple-ios - os: macos-latest target: x86_64-apple-darwin fail-fast: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -214,9 +220,10 @@ jobs: gcc-multilib: ${{ matrix.dependencies.gcc-multilib || false }} musl-tools: ${{ matrix.dependencies.musl-tools || false }} gcc-aarch64-linux-gnu: ${{ matrix.dependencies.gcc-aarch64-linux-gnu || false }} + gcc-mingw-w64-x86-64: ${{ matrix.dependencies.gcc-mingw-w64-x86-64 || false }} - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -293,34 +300,40 @@ jobs: - os: macos-latest extra_test_args: - '-u all' - env_polluting_tests: [] + - '--timeout 600' + - '--dont-add-python-opts' + env_polluting_tests: + - test_set skips: [] timeout: 50 - os: ubuntu-latest extra_test_args: - '-u all' - env_polluting_tests: [] + - '--timeout 600' + - '--dont-add-python-opts' + env_polluting_tests: + - test_set skips: [] timeout: 60 - os: windows-2025 - extra_test_args: [] # TODO: Enable '-u all' - env_polluting_tests: [] - skips: - - test_rlcompleter - - test_pathlib # panic by surrogate chars - - test_posixpath # OSError: (22, 'The filename, directory name, or volume label syntax is incorrect. (os error 123)') - - test_venv # couple of failing tests + extra_test_args: + - '-u all' + - '--timeout 600' + - '--dont-add-python-opts' + env_polluting_tests: + - test_set + skips: [] timeout: 50 fail-fast: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -335,13 +348,14 @@ jobs: # Windows runners randomly crashes, https://github.com/actions/cache/issues/1754 continue-on-error: true - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: Install macOS dependencies uses: ./.github/actions/install-macos-deps with: openssl: true + # Keep features in sync with update-caches.yml CARGO_ARGS. - name: build rustpython run: cargo build --release --verbose --features=threading,jit ${{ env.CARGO_ARGS }} @@ -358,7 +372,7 @@ jobs: - name: Run CPython tests run: | - target/release/rustpython -m test -j ${{ steps.cores.outputs.cores }} ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v -x ${{ env.FLAKY_MP_TESTS }} ${{ join(matrix.skips, ' ') }} + target/release/rustpython -u -m test --slow-ci -j ${{ steps.cores.outputs.cores }} ${{ join(matrix.extra_test_args, ' ') }} -x ${{ env.FLAKY_MP_TESTS }} ${{ join(matrix.skips, ' ') }} timeout-minutes: ${{ matrix.timeout }} env: RUSTPYTHON_SKIP_ENV_POLLUTERS: true @@ -369,7 +383,7 @@ jobs: echo "::group::Attempt ${attempt}" set +e - target/release/rustpython -m test -j 1 ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v ${{ env.FLAKY_MP_TESTS }} + target/release/rustpython -u -m test --slow-ci -j 1 ${{ join(matrix.extra_test_args, ' ') }} ${{ env.FLAKY_MP_TESTS }} status=$? set -e @@ -394,7 +408,7 @@ jobs: for thing in "${target_array[@]}"; do for i in $(seq 1 10); do set +e - target/release/rustpython -m test -j 1 --slowest --fail-env-changed --timeout 600 -v "${thing}" + target/release/rustpython -u -m test --slow-ci -u all -j 1 --timeout 600 --dont-add-python-opts "${thing}" exit_code=$? set -e if [ "${exit_code}" -eq 3 ]; then @@ -429,6 +443,17 @@ jobs: target/release/rustpython -m ensurepip target/release/rustpython -c "import pip" + - if: runner.os == 'Windows' + name: Check pip HTTPS with the Windows trust store + run: >- + target/release/rustpython -m pip download + --disable-pip-version-check + --no-cache-dir + --no-deps + --only-binary=:all: + --dest "$env:RUNNER_TEMP\rustpython-pip-smoke" + six + - if: runner.os != 'Windows' name: Check if pip inside venv is functional run: | @@ -457,7 +482,7 @@ jobs: - ubuntu-latest - windows-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -466,7 +491,7 @@ jobs: components: clippy - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -495,13 +520,13 @@ jobs: needs.determine_changes.outputs.rust_code == 'true' || github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable - - uses: cargo-bins/cargo-binstall@4852a15cf01e4f33958ce547326406fe78f27c38 # v1.19.0 + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: cargo shear run: | @@ -518,49 +543,72 @@ jobs: pull-requests: write security-events: write # for zizmor steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: actionlint - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + uses: reviewdog/action-actionlint@d63ba7532e0942965320cd8d73cbae4c7b3c5283 # v1.73.1 - name: zizmor - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 - name: restore prek cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek - # TODO: Remove on 2026/06/02 when node24 is the default - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - package-manager-cache: false - node-version: "24" - - - name: prek + - name: install prek id: prek - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: cache: false show-verbose-logs: false + install-only: true + + - name: prek run + run: prek run --show-diff-on-failure --color=always --all-files + + - name: Get target CPython version + id: cpython-version + run: | + version=$(cat .python-version) + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Clone CPython + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: python/cpython + path: cpython + ref: "v${{ steps.cpython-version.outputs.version }}" + persist-credentials: false + + - name: prek run (manual stage) + run: prek run --show-diff-on-failure --color=always --all-files --hook-stage manual + env: + CPYTHON_ROOT: ${{ github.workspace }}/cpython - name: save prek cache if: ${{ github.ref == 'refs/heads/main' }} # only save on main - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek + - name: restore git permissions + if: ${{ !cancelled() }} + run: sudo chown -R "$(id -u):$(id -g)" .git + - name: reviewdog if: ${{ !cancelled() }} - uses: reviewdog/action-suggester@aa38384ceb608d00f84b4690cacc83a5aba307ff # v1.24.0 + uses: reviewdog/action-suggester@2558ba17e65a9039e73764a73009fc05fef28a46 # v1.24.3 with: level: warning fail_level: error @@ -573,7 +621,7 @@ jobs: env: NIGHTLY_CHANNEL: nightly steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -583,7 +631,7 @@ jobs: components: miri - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -600,7 +648,10 @@ jobs: env: # miri-ignore-leaks because the type-object circular reference means that there will always be # a memory leak, at least until we have proper cyclic gc - MIRIFLAGS: "-Zmiri-ignore-leaks" + # miri-permissive-provenance because function pointer identity checks (slot comparisons) + # cast fn pointers to usize, which strips provenance — this is the standard pattern for + # fn pointer comparison in Rust and not a soundness issue + MIRIFLAGS: "-Zmiri-ignore-leaks -Zmiri-permissive-provenance" wasm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} @@ -608,7 +659,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -617,7 +668,7 @@ jobs: components: clippy - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -642,12 +693,12 @@ jobs: mkdir geckodriver tar -xzf geckodriver-v0.36.0-linux64.tar.gz -C geckodriver - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: python -m pip install -r requirements.txt working-directory: ./wasm/tests - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: package-manager-cache: false @@ -657,7 +708,7 @@ jobs: run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" - name: Restore npm cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # don't restore on main or release if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/release' with: @@ -702,7 +753,7 @@ jobs: - name: Deploy demo to Github Pages if: success() && github.ref == 'refs/heads/release' - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 env: ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEMO_DEPLOY_KEY }} PUBLISH_DIR: ./wasm/demo/dist @@ -712,7 +763,7 @@ jobs: - name: Save npm cache # Save only on main or release if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.npm-cache-dir.outputs.dir }} key: node-${{ runner.os }}-wasm-demo-${{ hashFiles('wasm/demo/package-lock.json') }} @@ -723,7 +774,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -732,7 +783,7 @@ jobs: target: wasm32-wasip1 - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -756,8 +807,46 @@ jobs: clang: true - name: build rustpython - run: cargo build --release --target wasm32-wasip1 --features freeze-stdlib,stdlib --verbose + run: cargo build --profile wasm-release --target wasm32-wasip1 --no-default-features --features freeze-stdlib,stdlib,stdio,importlib,host_env --verbose - name: run snippets - run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" + run: | + wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" + wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_time.py" - name: run cpython unittest - run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py" + run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py" + + cargo_doc: + needs: + - determine_changes + if: | + ( + !contains(github.event.pull_request.labels.*.name, 'skip:ci') && + needs.determine_changes.outputs.rust_code == 'true' + ) || github.ref == 'refs/heads/main' + env: + RUST_BACKTRACE: full + name: cargo doc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + + - name: Restore cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ hashFiles('**/Cargo.toml') }}- + restore-keys: | + ${{ runner.os }}-stable--${{ hashFiles('**/Cargo.toml') }}- + ${{ runner.os }}-stable-- + + - name: cargo doc + run: cargo doc --locked diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index b9e237e300a..e3557e3e05f 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -14,8 +14,7 @@ on: - .github/workflows/cron-ci.yaml env: - CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,ssl-rustls,jit,host_env - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' # TODO: Remove on 2026/06/02 + CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,ssl-rustls-aws-lc,jit,host_env jobs: # codecov collects code coverage data from the rust tests, python snippets and python test suite. @@ -25,23 +24,25 @@ jobs: runs-on: ubuntu-latest # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} + env: + INSTA_WORKSPACE_ROOT: ${{ github.workspace }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2.75.22 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 with: tool: cargo-llvm-cov - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: sudo apt-get update && sudo apt-get -y install lcov - name: Run cargo-llvm-cov with Rust tests. - run: cargo llvm-cov --no-report --workspace --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --verbose --no-default-features --features stdlib,importlib,stdio,encodings,ssl-rustls,jit,host_env + run: cargo llvm-cov --no-report --workspace --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --verbose --no-default-features --features stdlib,importlib,stdio,encodings,ssl-rustls-aws-lc,jit,host_env - name: Run cargo-llvm-cov with Python snippets. run: python scripts/cargo-llvm-cov.py @@ -56,7 +57,7 @@ jobs: - name: Upload to Codecov if: ${{ github.event_name != 'pull_request' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./codecov.lcov @@ -66,7 +67,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true @@ -84,7 +85,6 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} - GITHUB_ACTOR: ${{ github.actor }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -94,7 +94,7 @@ jobs: cd website cp ../extra_tests/cpython_tests_results.json ./_data/regrtests_results.json git add ./_data/regrtests_results.json - if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update regression test results" --author="$GITHUB_ACTOR"; then + if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update regression test results"; then git push fi @@ -104,13 +104,13 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: build rustpython run: cargo build --release --verbose @@ -126,7 +126,6 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} - GITHUB_ACTOR: ${{ github.actor }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -157,7 +156,7 @@ jobs: } EOF git add -A - if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update what is left results" --author="$GITHUB_ACTOR"; then + if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update what is left results"; then git push fi @@ -167,13 +166,13 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: cargo install cargo-criterion @@ -203,6 +202,8 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} + COMMIT_SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -214,8 +215,8 @@ jobs: cp -r ../target/criterion ./assets/criterion printf '{\n "generated_at": "%s",\n "rustpython_commit": "%s",\n "rustpython_ref": "%s"\n}\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - "${{ github.sha }}" \ - "${{ github.ref_name }}" > ./_data/criterion-metadata.json + "$COMMIT_SHA" \ + "$REF_NAME" > ./_data/criterion-metadata.json git add ./assets/criterion ./_data/criterion-metadata.json if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update benchmark results"; then git push diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index 66ecd46d73d..6f147542ee6 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -6,6 +6,8 @@ on: paths: - "Lib/**" +permissions: {} + concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -18,7 +20,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Use base branch for scripts (security: don't run PR code with elevated permissions) ref: ${{ github.event.pull_request.base.ref }} @@ -26,13 +28,17 @@ jobs: persist-credentials: false - name: Fetch PR head + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - git fetch origin ${{ github.event.pull_request.head.sha }} + git fetch origin "$PR_HEAD_SHA" - name: Checkout PR Lib files + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Checkout only Lib/ directory from PR head for accurate comparison - git checkout ${{ github.event.pull_request.head.sha }} -- Lib/ + git checkout "$PR_HEAD_SHA" -- Lib/ - name: Get target CPython version id: cpython-version @@ -41,7 +47,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Checkout CPython - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: python/cpython path: cpython @@ -51,14 +57,17 @@ jobs: - name: Get changed Lib files id: all-changed-files + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Get the list of changed files under Lib/ { echo 'changed<> "$GITHUB_OUTPUT" - name: Parse changed files @@ -98,7 +107,7 @@ jobs: - name: Setup Python if: steps.changed-files.outputs.modules != '' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: Run deps check if: steps.changed-files.outputs.modules != '' @@ -114,7 +123,7 @@ jobs: - name: Post comment if: steps.deps-check.outputs.deps_output != '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: lib-deps-check number: ${{ github.event.pull_request.number }} @@ -131,7 +140,7 @@ jobs: - name: Remove comment if no Lib changes if: steps.changed-files.outputs.modules == '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: lib-deps-check number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/oscca-pr.yml b/.github/workflows/oscca-pr.yml new file mode 100644 index 00000000000..38b67625ce0 --- /dev/null +++ b/.github/workflows/oscca-pr.yml @@ -0,0 +1,70 @@ +name: Manage OSCCA pull requests + +on: + pull_request_target: + types: [opened] + +permissions: {} + +jobs: + label-and-assign: + name: Label and assign OSCCA pull request + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - name: Label and assign pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const osccaUsers = new Set( + [ + "2jiyong", + "chestnut1717", + "devyubin", + "fregataa", + "hyoinandout", + "HyoJongPark", + "JaceJung-dev", + "jinmay", + "jiwahn", + "kangdora", + "kim-jaedeok", + "kyokuping", + "leehanjeong", + "lms0806", + "lsahn-gh", + "moreal", + "name-of-okja", + "rlaisqls", + "seungje0612", + "shAn-kor", + "sigmaith", + "teddygood", + "widehyo1", + "YangSiJun528", + "zzarbttoo", + ].map((login) => login.toLowerCase()), + ); + const pullRequest = context.payload.pull_request; + const author = pullRequest.user.login; + + if (!osccaUsers.has(author.toLowerCase())) { + core.info(`${author} is not an OSCCA participant; skipping.`); + return; + } + + const issue = { + ...context.repo, + issue_number: pullRequest.number, + }; + + await github.rest.issues.addLabels({ + ...issue, + labels: ["z-ca-2026"], + }); + await github.rest.issues.addAssignees({ + ...issue, + assignees: [author], + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 703b2acb9ef..5957666de38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # target: aarch64-pc-windows-msvc fail-fast: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -68,7 +68,7 @@ jobs: libtool: true - name: Build RustPython - run: cargo build --release --target=${{ matrix.target }} --verbose --no-default-features --features stdlib,stdio,importlib,encodings,sqlite,host_env,ssl-rustls,threading,jit + run: cargo build --release --target=${{ matrix.target }} --verbose --no-default-features --features stdlib,stdio,importlib,encodings,sqlite,host_env,ssl-rustls-aws-lc,threading,jit - name: Rename Binary run: cp target/${{ matrix.target }}/release/rustpython target/rustpython-release-${{ runner.os }}-${{ matrix.target }} @@ -91,7 +91,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -114,7 +114,7 @@ jobs: - name: install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: package-manager-cache: false @@ -141,7 +141,7 @@ jobs: - name: Deploy demo to Github Pages if: ${{ github.repository == 'RustPython/RustPython' }} - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: deploy_key: ${{ secrets.ACTIONS_DEMO_DEPLOY_KEY }} publish_dir: ./wasm/demo/dist @@ -156,7 +156,7 @@ jobs: permissions: contents: write # for creating a release steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index 585563c45e6..32c8e1a62de 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -19,7 +19,8 @@ env: CARGO_PROFILE_TEST_DEBUG: 0 CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_RELEASE_DEBUG: 0 - CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls,host_env,threading,jit + # Keep feature list in sync with CI's release build in .github/workflows/ci.yaml. + CARGO_ARGS: --workspace --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env,threading,jit --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher jobs: build-caches: @@ -39,14 +40,14 @@ jobs: target: "" steps: - name: Checkout RustPython main branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: RustPython/RustPython ref: main persist-credentials: false - name: Setup Rust - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.toolchain }} target: ${{ matrix.target }} @@ -66,7 +67,7 @@ jobs: run: cargo build --profile release ${{ env.CARGO_ARGS }} - name: Save cache - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index e173075575f..c42235c86df 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -8,7 +8,7 @@ on: python-version: description: Target python version to generate doc db for type: string - default: "3.14.3" + default: "3.14.7" base-ref: description: Base branch to create the update branch from type: string @@ -30,13 +30,13 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: | crates/doc - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} @@ -58,7 +58,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true ref: ${{ inputs.base-ref }} diff --git a/.github/workflows/update-libs-status.yaml b/.github/workflows/update-libs-status.yaml index db32c7a3e94..c1c656d2068 100644 --- a/.github/workflows/update-libs-status.yaml +++ b/.github/workflows/update-libs-status.yaml @@ -21,7 +21,7 @@ jobs: if: ${{ github.repository == 'RustPython/RustPython' }} steps: - name: Clone RustPython - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: rustpython persist-credentials: false @@ -37,7 +37,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Clone CPython - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: python/cpython path: cpython diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 1be61c4bc92..3ac44585943 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@2f2a6f572b9038823081cb9d408f235e1a109a0b # v0.71.3 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,22 +99,22 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@2f2a6f572b9038823081cb9d408f235e1a109a0b # v0.71.3 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh # Cache configuration from frontmatter processed below - name: Cache (cpython-lib-${{ env.PYTHON_VERSION }}) - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: cpython-lib-${{ env.PYTHON_VERSION }} path: cpython @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@2f2a6f572b9038823081cb9d408f235e1a109a0b # v0.71.3 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@2f2a6f572b9038823081cb9d408f235e1a109a0b # v0.71.3 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@2f2a6f572b9038823081cb9d408f235e1a109a0b # v0.71.3 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -1061,7 +1061,7 @@ jobs: path: /tmp/gh-aw/ - name: Checkout repository if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ github.token }} persist-credentials: false diff --git a/.github/workflows/upgrade-pylib.md b/.github/workflows/upgrade-pylib.md index 058fc067493..cd05eb77734 100644 --- a/.github/workflows/upgrade-pylib.md +++ b/.github/workflows/upgrade-pylib.md @@ -52,7 +52,7 @@ cache: - cpython-lib- env: - PYTHON_VERSION: "v3.14.4" + PYTHON_VERSION: "v3.14.7" ISSUE_ID: "6839" --- diff --git a/.github/zizmor.yml b/.github/zizmor.yml index f22f76b70d8..02ceb805c2c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,14 @@ rules: + dangerous-triggers: + ignore: + # pull_request_target is needed to label and assign PRs from forks with issues: write. + # The workflow does not check out or execute pull request code. + - oscca-pr.yml:3 + excessive-permissions: + ignore: + # pull_request_target is needed to post PR comments with pull-requests: write. + # Workflow-level permissions: {} restricts defaults; only the job has write access. + - lib-deps-check.yaml:3 unpinned-uses: config: policies: diff --git a/.gitignore b/.gitignore index 338a6437ca2..cb8548877c2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,9 @@ __pycache__/ .repl_history.txt .vscode/ wasm-pack.log -.idea/ +.idea/* +!.idea/icon.svg +!.idea/vcs.xml .envrc flame-graph.html @@ -27,4 +29,5 @@ Lib/site-packages/* Lib/test/data/* !Lib/test/data/README cpython/ - +.claude/ +docs/superpowers/ \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 00000000000..84e5f593a6d --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000000..82bc0911775 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8778283dd5f..750f649403e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 + rev: v0.16.2 hooks: - id: ruff-format priority: 0 @@ -40,17 +40,30 @@ repos: types: [rust] priority: 0 - - id: generate-opcode-metadata - name: generate opcode metadata - entry: python scripts/generate_opcode_metadata.py - files: '^(crates/compiler-core/src/bytecode/instruction\.rs|scripts/generate_opcode_metadata\.py)$' + - id: generate-rs-opcode-metadata + name: generate rust opcode metadata + entry: python3 tools/opcode_metadata/generate_rs_opcode_metadata.py + files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' pass_filenames: false language: system require_serial: true priority: 1 # so rustfmt runs first + stages: + - manual + + - id: generate-py-opcode-metadata + name: generate python opcode metadata + entry: python3 tools/opcode_metadata/generate_py_opcode_metadata.py + files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' + pass_filenames: false + language: system + require_serial: true + priority: 1 # so rustfmt runs first + stages: + - manual - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v10.0.0 + rev: v10.0.1 hooks: - id: cspell types: [rust] @@ -64,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.8.3 + rev: v3.9.6 hooks: - id: prettier files: '^wasm/.*$' diff --git a/.python-version b/.python-version index a6d9ada03ee..a128d5c0d97 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.5 +3.14.7 diff --git a/AGENTS.md b/AGENTS.md index b407328cffb..694fc7be65c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# GitHub Copilot Instructions for RustPython +# AI Agent Instructions for RustPython -This document provides guidelines for working with GitHub Copilot when contributing to the RustPython project. +This document provides guidelines for AI coding agents (GitHub Copilot, Claude Code, Gemini, etc.) contributing to the RustPython project. ## Project Overview @@ -13,31 +13,27 @@ RustPython is a Python 3 interpreter written in Rust, implementing Python 3.14.0 ## Repository Structure -- `src/` - Top-level code for the RustPython binary -- `vm/` - The Python virtual machine implementation - - `builtins/` - Python built-in types and functions - - `stdlib/` - Essential standard library modules implemented in Rust, required to run the Python core -- `compiler/` - Python compiler components - - `parser/` - Parser for converting Python source to AST - - `core/` - Bytecode representation in Rust structures - - `codegen/` - AST to bytecode compiler -- `Lib/` - CPython's standard library in Python (copied from CPython). **IMPORTANT**: Do not edit this directory directly; The only allowed operation is copying files from CPython. -- `derive/` - Rust macros for RustPython -- `common/` - Common utilities -- `extra_tests/` - Integration tests and snippets -- `stdlib/` - Non-essential Python standard library modules implemented in Rust (useful but not required for core functionality) -- `wasm/` - WebAssembly support -- `jit/` - Experimental JIT compiler implementation -- `pylib/` - Python standard library packaging (do not modify this directory directly - its contents are generated automatically) +See the "Code organization" section in [CONTRIBUTING.md](CONTRIBUTING.md#code-organization) for the current directory layout. ## AI Agent Rules +**CRITICAL: AI Policy** + +- Follow RustPython's [AI Policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md) for every AI-assisted contribution. +- Disclose AI assistance in commit messages with an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer. Use one trailer per AI tool, and never use `Co-authored-by` for an AI assistant. + **CRITICAL: Git Operations** - NEVER create pull requests directly without explicit user permission - NEVER push commits to remote without explicit user permission - Always ask the user before performing any git operations that affect the remote repository - Commits can be created locally when requested, but pushing and PR creation require explicit approval +**CRITICAL: Commit Hooks and Validation** +- Install the repository's pre-commit hook with `prek install` (or `pre-commit install`) after cloning the repository. +- Every commit must run the configured pre-commit hook. NEVER bypass it with `--no-verify`. Automated workflows that use a normal `git commit`, such as `scripts/update_lib quick`, should be allowed to create local commits through the hook. +- If a hook auto-fixes files (e.g. `ruff-format`, `rustfmt`), re-stage the fixes and retry the commit. Do not amend or force a failing commit through. +- Before completing a task, run the tests appropriate for the change. Test commands are documented in the [Testing](#testing) section below. At minimum run `cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi`, then run `cargo test` from `crates/capi`; if the change touches `extra_tests/snippets/` run `pytest -v` there too, and if it touches `Lib/` or interpreter behavior, run the relevant `cargo run --release -- -m test ` modules. + ## Important Development Notes ### Running Python Code @@ -81,6 +77,35 @@ The `Lib/` directory contains Python standard library files copied from the CPyt - `unittest.skip("TODO: RustPython ")` - `unittest.expectedFailure` with `# TODO: RUSTPYTHON ` comment +#### Choosing the right marker + +When marking a test that fails on RustPython, prefer one of the following forms: + +```python +@unittest.expectedFailure # TODO: RUSTPYTHON; +# or +@unittest.expectedFailureIf(, "TODO: RUSTPYTHON; ") +``` + +If the test would crash the interpreter (segfault, Rust panic, abort, infinite loop), use `skip` instead so the rest of the suite can still run: + +```python +@unittest.skip("TODO: RUSTPYTHON; ") +# or +@unittest.skipIf(, "TODO: RUSTPYTHON; ") +``` + +**When to use which:** + +- **Prefer `expectedFailure` / `expectedFailureIf`** by default. The test body still runs, so if RustPython is later fixed, the unexpected pass surfaces immediately and the decorator can be removed. Use the conditional `*If` form when the failure is environment-specific (e.g., a platform or build flag). +- **Use `skip` / `skipIf` only when running the test would take down the test process** — segfaults, Rust panics, aborts, or hangs that block subsequent tests. Skipping keeps the suite usable; `expectedFailure` cannot help here, because the test body still executes. + +To find WIP entries that are partly modified and may need follow-up: + +```bash +grep -d recurse 'TODO: RUSTPYTHON' Lib/test/ +``` + ### Clean Build When you modify bytecode instructions, a full clean is required: @@ -93,7 +118,10 @@ rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r ```bash # Run Rust unit tests -cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher +cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi + +# Run C-API tests from their directory so their separate Cargo config applies +(cd crates/capi && cargo test) # Run Python snippets tests (debug mode recommended for faster compilation) cargo run -- extra_tests/snippets/builtin_bytes.py @@ -129,6 +157,7 @@ Run `./scripts/whats_left.py` to get a list of unimplemented methods, which is h - Do not delete or rewrite existing comments unless they are factually wrong or directly contradict the new code. - Do not add decorative section separators (e.g. `// -----------`, `// ===`, `/* *** */`). Use `///` doc-comments or short `//` comments only when they add value. +- Do not put `///` doc comments on items annotated with `#[pyattr]`, `#[pyclass]`, or `#[pyfunction]`. The derive macros pull authoritative docstrings from CPython via the `rustpython-doc` crate; a Rust doc comment overrides that source, and on `#[pyattr]` it is silently dropped. #### Avoid Duplicate Code in Branches @@ -229,12 +258,10 @@ cargo run --features jit ### Linux Build and Debug on macOS -See the "Testing on Linux from macOS" section in [DEVELOPMENT.md](DEVELOPMENT.md#testing-on-linux-from-macos). +See the "Testing on Linux from macOS" section in [CONTRIBUTING.md](CONTRIBUTING.md#testing-on-linux-from-macos). ### Building venvlauncher (Windows) -See DEVELOPMENT.md "CPython Version Upgrade Checklist" section. - **IMPORTANT**: All 4 venvlauncher binaries use the same source code. Do NOT add multiple `[[bin]]` entries to Cargo.toml. Build once and copy with different names. ## Test Code Modification Rules @@ -258,9 +285,14 @@ See DEVELOPMENT.md "CPython Version Upgrade Checklist" section. - Document that it requires PEP 695 support - Focus on tests that can be fixed through Rust code changes only +## CI Workflows + +If you modify any file under `.github/workflows/`, the change must pass a [zizmor](https://docs.zizmor.sh/) scan in CI. + ## Documentation - Check the [architecture document](/architecture/architecture.md) for a high-level overview -- Read the [development guide](/DEVELOPMENT.md) for detailed setup instructions +- Read the [development guide](/CONTRIBUTING.md) for detailed setup instructions - Generate documentation with `cargo doc --no-deps --all` - Online documentation is available at [docs.rs/rustpython](https://docs.rs/rustpython/) +- [How to update test files](https://github.com/RustPython/RustPython/wiki/How-to-update-test-files#checkout-cpython-source-code-initial-setup) — guide for syncing test cases from upstream CPython into the `Lib/` directory diff --git a/DEVELOPMENT.md b/CONTRIBUTING.md similarity index 86% rename from DEVELOPMENT.md rename to CONTRIBUTING.md index 7573f0f2640..637ecf0993a 100644 --- a/DEVELOPMENT.md +++ b/CONTRIBUTING.md @@ -1,4 +1,28 @@ -# RustPython Development Guide and Tips +# Contributing to RustPython + +Contributions are more than welcome, and in many cases we are happy to guide +contributors through PRs or on [**Discord**](https://discord.gg/vru8NypEhv). + +## Finding ways to help + +We label issues that would be good for a first time contributor as [`good first issue`](https://github.com/RustPython/RustPython/issues?q=label%3A%22good+first+issue%22+is%3Aissue+is%3Aopen+). +Also checkout the [issue tracker](https://github.com/RustPython/RustPython/issues) for all open issues. + +You can enhance CPython compatibility by increasing our unittest coverage, you can see [This pinned issue](https://github.com/RustPython/RustPython/issues/6839) to see which libs and tests need be updated to our current supported python version. + +Another approach is to checkout the source code: builtin functions and object +methods are often the simplest and easiest way to contribute. + +You can also simply run `python -I scripts/whats_left.py` to assist in finding any unimplemented method. + +## Use of AI + +We **require all use of AI in contributions to follow our +[AI Policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md)**. + +If your contribution does not follow the policy, it will be closed. + +## RustPython Development Guide and Tips RustPython attracts developers with interest and experience in Rust, Python, or WebAssembly. Whether you are familiar with Rust, Python, or @@ -65,7 +89,15 @@ $ pytest -v Rust unit tests can be run with `cargo`: ```shell -$ cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher +$ cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi +``` + +`rustpython-capi` needs to be tested from inside its own directory, since it has a +separate `cargo` config that only applies there: + +```shell +$ cd crates/capi +$ cargo test ``` Python unit tests can be run by compiling RustPython and running the test module: diff --git a/Cargo.lock b/Cargo.lock index 4e9b54bb8aa..fa5e7b52236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,28 +14,39 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "aes" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cfg-if", "cipher", + "cpubits", "cpufeatures", ] [[package]] -name = "ahash" -version = "0.8.12" +name = "aes-gcm" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", ] [[package]] @@ -62,15 +73,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anes" version = "0.1.6" @@ -94,9 +96,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -129,9 +131,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -144,9 +146,9 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -165,9 +167,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -175,7 +177,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -187,7 +189,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -199,7 +201,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -222,7 +224,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -238,51 +240,52 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn", + "syn 2.0.119", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-fips-sys" -version = "0.13.13" +version = "0.13.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bce4948d2520386c6d92a6ea2d472300257702242e5a1d01d6add52bd2e7c1" +checksum = "6c0e6249c249b8916c98ebae7bc06216c8dcab3002f32872b4abe642d17063b1" dependencies = [ "bindgen 0.72.1", "cc", "cmake", "dunce", "fs_extra", + "pkg-config", "regex", ] [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-fips-sys", "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -303,7 +306,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -313,8 +316,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn", + "shlex 1.3.0", + "syn 2.0.119", ] [[package]] @@ -323,7 +326,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -333,8 +336,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn", + "shlex 1.3.0", + "syn 2.0.119", ] [[package]] @@ -345,9 +348,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitflagset" @@ -363,62 +366,62 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ "digest", ] [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bzip2" @@ -429,15 +432,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "caseless" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] - [[package]] name = "cast" version = "0.3.0" @@ -455,23 +449,23 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] [[package]] name = "cc" -version = "1.2.54" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -496,16 +490,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.44" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", ] [[package]] @@ -537,10 +529,11 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ + "block-buffer", "crypto-common", "inout", ] @@ -558,18 +551,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -577,9 +570,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -592,13 +585,19 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "collection_literals" version = "1.0.3" @@ -607,9 +606,9 @@ checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -623,9 +622,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -637,9 +636,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -656,12 +655,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -670,9 +663,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "constant_time_eq" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "1a1ec0cfec728a79a5109075543131387f911cb4d07716436d7ae20533657a96" [[package]] name = "core-foundation" @@ -700,20 +693,26 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "cranelift" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe07d3554a07c7e60c112ddf4aa5eeab69a4eb632881a59c2add0fdc4596cc7" +checksum = "69c8702ad42c0aac8d585f1c3ffe8039bcd996898615c8948a1943e0c9661232" dependencies = [ "cranelift-codegen", "cranelift-frontend", @@ -722,27 +721,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" +checksum = "3d521bdbc6098937af83ef4ab6d5c07398126bc71878f7ef4ea9499977978ef5" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" +checksum = "3dde0b83164d4a497860af4236271178bc640512b067101e0853e4f74eaa4df5" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" +checksum = "0111d110b72b4efad69a372e29e21628652fd0bcab66967e5c8350ab679affd5" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -750,18 +749,18 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" +checksum = "cf01ecc92fc5499789d79c3b817299d5a8ddd828d31dcf0f9cc3cc66f38dbb36" dependencies = [ "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" +checksum = "6cd2563bead0090c3879a7ff7327f7550c9d59bb5d05ecc8cd7886977aa3125a" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -773,7 +772,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "libm", "log", "regalloc2", @@ -786,9 +785,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" +checksum = "9640d250d26f9381a73dc7f9862b27928d4809119aec73be0e556612e67b6a99" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -798,24 +797,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" +checksum = "a07f156b90efc94371ddb3536f76e4ab671ad2e093bfa5511e10198cadbb0c47" [[package]] name = "cranelift-control" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" +checksum = "d75a76fd9dd37dcbc3d2d2e00abe3281a7e88adc035fba3b8114cc69981576ec" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" +checksum = "2eea22522144ba08c7e7ef94bfc25d474ef016c2771974b8ab1986734ac85965" dependencies = [ "cranelift-bitset", "wasmtime-internal-core", @@ -823,9 +822,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" +checksum = "efa2826c80dff1d93b19b3cfbcaf9fae44c78d739a98d146dd5bd89c51e14367" dependencies = [ "cranelift-codegen", "log", @@ -835,15 +834,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" +checksum = "e238a69b95c5415456f22189494a12db94f313bb72b6ec9cc88ddf2f1056e28e" [[package]] name = "cranelift-jit" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4070d2acc5c976887c10c273d38dd2bebebb472fd1ba65fa17b548adf5f56350" +checksum = "a7def01f2b14f97421558d1db33e396b97b97ab2ed40dedc8165ba00d13088e6" dependencies = [ "anyhow", "cranelift-codegen", @@ -853,6 +852,7 @@ dependencies = [ "cranelift-native", "libc", "log", + "memmap2 0.2.3", "region", "target-lexicon", "wasmtime-internal-jit-icache-coherence", @@ -861,9 +861,9 @@ dependencies = [ [[package]] name = "cranelift-module" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052a396328dbc7dadc29d1bd27d4aa57d9e9e493ead8ef6e2ab3b75c1bf9644c" +checksum = "985df7eceefc91cb75bf7f51b32b7f1739d0fc0e25ddf023b1de407cb3c93d77" dependencies = [ "anyhow", "cranelift-codegen", @@ -872,9 +872,9 @@ dependencies = [ [[package]] name = "cranelift-native" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" +checksum = "eceb0ebd8d6aef6bb287e8d532a164b0db1c0062961211e9c22a91f67521c098" dependencies = [ "cranelift-codegen", "libc", @@ -883,9 +883,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.131.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" +checksum = "004643f39a7bec553de5263d650db30e5b9caec1d5cbe065fd732b7ec9ae40d0" [[package]] name = "crc32fast" @@ -933,9 +933,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -943,18 +943,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -964,12 +964,11 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] @@ -981,33 +980,71 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "der" -version = "0.7.10" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "const-oid 0.9.6", - "der_derive", - "flagset", - "pem-rfc7468 0.7.0", - "zeroize", + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", ] [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", "zeroize", ] @@ -1027,76 +1064,74 @@ dependencies = [ [[package]] name = "der_derive" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive-where" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", - "subtle", + "ctutils", ] [[package]] -name = "dirs-next" -version = "2.0.0" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "cfg-if", - "dirs-sys-next", + "dirs-sys", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", + "option-ext", "redox_users", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1125,9 +1160,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -1143,9 +1178,9 @@ checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" [[package]] name = "env_filter" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -1153,9 +1188,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -1194,15 +1229,15 @@ checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flagset" @@ -1231,7 +1266,7 @@ checksum = "7693d9dd1ec1c54f52195dfe255b627f7cec7da33b679cd56de949e662b3db10" dependencies = [ "flame", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1274,7 +1309,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared 0.1.1", + "foreign-types-shared", ] [[package]] @@ -1283,12 +1318,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "fs_extra" version = "1.3.0" @@ -1297,39 +1326,28 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "get-size-derive2" version = "0.7.4" @@ -1338,7 +1356,7 @@ checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" dependencies = [ "attribute-derive", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1391,13 +1409,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "gimli" version = "0.33.0" @@ -1416,6 +1455,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "graviola" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8596c4fa98466aae2fcf4c72a665bc0e021c0aaab1e47d82044d3dc3e309a76" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", +] + [[package]] name = "half" version = "2.7.1" @@ -1427,12 +1476,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" - [[package]] name = "hashbrown" version = "0.16.1" @@ -1444,9 +1487,12 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -1474,9 +1520,9 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ "digest", ] @@ -1491,27 +1537,12 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "hybrid-array" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "cc", + "typenum", ] [[package]] @@ -1653,24 +1684,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ "block-padding", - "generic-array", + "hybrid-array", ] [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -1693,7 +1724,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1720,34 +1751,74 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ + "defmt", + "jiff-core", "jiff-static", + "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", ] [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -1762,7 +1833,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror", "walkdir", "windows-link", ] @@ -1777,7 +1848,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -1796,34 +1867,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "junction" -version = "1.4.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", "windows-sys 0.61.2", @@ -1831,10 +1903,11 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.6" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ + "cfg-if", "cpufeatures", ] @@ -1883,21 +1956,21 @@ checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" [[package]] name = "libbz2-rs-sys" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libffi" -version = "5.1.0" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0498fe5655f857803e156523e644dcdcdc3b3c7edda42ea2afdae2e09b2db87b" +checksum = "ed185dbb87539a100c1b36c219e16e71572c6d4d4fed3ded898140f755adeaaf" dependencies = [ "libc", "libffi-sys", @@ -1905,9 +1978,9 @@ dependencies = [ [[package]] name = "libffi-sys" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d4f1d4ce15091955144350b75db16a96d4a63728500122706fb4d29a26afbb" +checksum = "25831b230b6a90bdea9f28339c1d00d59773a1c492e8ca09b1ad80e56394c261" dependencies = [ "cc", ] @@ -1932,26 +2005,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "liblzma" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libm" version = "0.2.16" @@ -1960,19 +2013,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.11.0", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -1981,9 +2033,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1116a951fd9d5110720bb2ae66f72ce5d7b10bd8aa8744924677157089ce13f" +checksum = "03dcace986b149f29509af6ca70e6182bccce916b644424ecf484faa8ddc899a" dependencies = [ "zlib-rs", ] @@ -1996,9 +2048,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2011,15 +2063,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lz4_flex" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" dependencies = [ "twox-hash", ] @@ -2045,9 +2097,9 @@ dependencies = [ [[package]] name = "malachite-base" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8b6f86fdbb1eb9955946be91775239dfcb0acdb1a51bb07d5fc9b8c854f5ccd" +checksum = "c6b9d4679f346f85a8f466d0171478304dab8b0e944dd38086411ab5f6100a17" dependencies = [ "hashbrown 0.16.1", "itertools 0.14.0", @@ -2057,9 +2109,9 @@ dependencies = [ [[package]] name = "malachite-bigint" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fcd6e504ffc67db2b3c6d5e90e08054646e2b04f42115a5460bf1c1e37d3bc" +checksum = "5064cf3abe01ff3b80b0349936ebad6c52f7c793182d9c7992bf79ece18c0d22" dependencies = [ "malachite-base", "malachite-nz", @@ -2070,9 +2122,9 @@ dependencies = [ [[package]] name = "malachite-nz" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0197a2f5cfee19d59178e282985c6ca79a9233e26a2adcf40acb693896aa09f6" +checksum = "8a6821ab988221c35d421ba16c4f8dca101efe5ae1cbfa9831f1fdb1596c755e" dependencies = [ "itertools 0.14.0", "libm", @@ -2082,11 +2134,12 @@ dependencies = [ [[package]] name = "malachite-q" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2add95162aede090c48f0ee51bea7d328847ce3180aa44588111f846cc116b" +checksum = "3cf7894cd9617e43ef5d9824633f7dfc1bffd0298880dd668f20bdffb7a6e8ea" dependencies = [ "itertools 0.14.0", + "libm", "malachite-base", "malachite-nz", ] @@ -2100,7 +2153,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2114,17 +2167,11 @@ dependencies = [ "quote", ] -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", "digest", @@ -2132,15 +2179,24 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "723e3ebdcdc5c023db1df315364573789f8857c11b631a2fdfad7c00f5c046b4" +dependencies = [ + "libc", +] [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2172,11 +2228,11 @@ dependencies = [ [[package]] name = "mt19937" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56bc7ea7924ea1a79a9e817d0483e39295424cf2b1276cf2b968f9a6c9b63b54" +checksum = "25b4ab1a6f4b7820876af86b84adf545d53a52f59c5374856225aad9562d903e" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -2194,7 +2250,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2203,11 +2259,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2226,9 +2282,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2245,15 +2301,15 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2295,7 +2351,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2318,9 +2374,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2336,11 +2392,11 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -2356,7 +2412,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2367,18 +2423,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.4+3.5.4" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2387,6 +2443,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "optional" version = "0.5.0" @@ -2395,9 +2457,9 @@ checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" [[package]] name = "ordermap" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa78c92071bbd3628c22b1a964f7e0eb201dc1456555db072beb1662ecd6715" +checksum = "7f7476a5b122ff1fce7208e7ee9dccd0a516e835f5b8b19b8f3c98a34cf757c1" dependencies = [ "indexmap", ] @@ -2425,7 +2487,7 @@ dependencies = [ [[package]] name = "parking_lot_core" version = "0.9.12" -source = "git+https://github.com/youknowone/parking_lot?branch=rustpython#4392edbe879acc9c0dd94eda53d2205d3ab912c9" +source = "git+https://github.com/youknowone/parking_lot?branch=rustpython#f4ee53a7b803354a8f0a6de2f28e93fc141240bf" dependencies = [ "cfg-if", "libc", @@ -2442,23 +2504,14 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest", "hmac", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -2479,12 +2532,12 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] @@ -2505,30 +2558,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2542,35 +2595,31 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs5" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ "aes", + "aes-gcm", "cbc", - "der 0.7.10", + "der", "pbkdf2", + "rand_core 0.10.1", "scrypt", "sha2", "spki", @@ -2578,21 +2627,21 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.7.10", + "der", "pkcs5", - "rand_core 0.6.4", + "rand_core 0.10.1", "spki", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plotters" @@ -2630,29 +2679,40 @@ checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures", + "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "serde_core", "writeable", @@ -2681,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -2697,18 +2757,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -2730,9 +2790,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.28.3" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "libc", "once_cell", @@ -2744,18 +2804,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.28.3" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.28.3" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -2763,34 +2823,33 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.28.3" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pyo3-macros-backend" -version = "0.28.3" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2814,7 +2873,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2823,6 +2882,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "1.1.1" @@ -2844,23 +2909,24 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2873,16 +2939,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2894,18 +2950,24 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rapidhash" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ - "getrandom 0.3.4", + "rustversion", ] [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2933,18 +2995,18 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -2964,18 +3026,18 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "regalloc2" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "log", "rustc-hash", "smallvec", @@ -2983,9 +3045,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2995,9 +3057,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3006,9 +3068,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "region" @@ -3040,7 +3102,7 @@ dependencies = [ "pmutil", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3053,15 +3115,15 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3087,7 +3149,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3096,9 +3158,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "once_cell", @@ -3108,11 +3170,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-graviola" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf5d0a370be690f7f1aa4e1b912fde3f5f9a5c53285062fd2d6ac1a52ab3e883" +dependencies = [ + "graviola", + "rustls", +] + [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3131,9 +3203,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -3174,7 +3246,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -3182,7 +3254,7 @@ name = "rustpython" version = "0.5.0" dependencies = [ "criterion", - "dirs-next", + "dirs", "env_logger", "flame", "flamescope", @@ -3190,6 +3262,8 @@ dependencies = [ "libc", "log", "pyo3", + "rustls", + "rustls-graviola", "rustpython-capi", "rustpython-compiler", "rustpython-pylib", @@ -3204,7 +3278,13 @@ dependencies = [ name = "rustpython-capi" version = "0.5.0" dependencies = [ + "bitflags 2.13.1", + "itertools 0.15.0", + "libc", + "malachite-bigint", + "num-complex", "pyo3", + "rustpython-pylib", "rustpython-stdlib", "rustpython-vm", ] @@ -3213,23 +3293,23 @@ dependencies = [ name = "rustpython-codegen" version = "0.5.0" dependencies = [ - "ahash", - "bitflags 2.11.0", + "bitflags 2.13.1", "indexmap", - "itertools 0.14.0", + "itertools 0.15.0", "log", "malachite-bigint", "memchr", "num-complex", "num-traits", + "rapidhash", "rustpython-compiler-core", "rustpython-literal", "rustpython-ruff_python_ast", "rustpython-ruff_python_parser", "rustpython-ruff_text_size", + "rustpython-unicode", "rustpython-wtf8", - "thiserror 2.0.18", - "unicode_names2 2.0.0", + "thiserror", ] [[package]] @@ -3237,9 +3317,9 @@ name = "rustpython-common" version = "0.5.0" dependencies = [ "ascii", - "bitflags 2.11.0", - "getrandom 0.3.4", - "itertools 0.14.0", + "bitflags 2.13.1", + "getrandom 0.4.3", + "itertools 0.15.0", "libc", "lock_api", "malachite-base", @@ -3250,9 +3330,9 @@ dependencies = [ "parking_lot", "radium", "rustpython-literal", + "rustpython-unicode", "rustpython-wtf8", "siphasher", - "unicode_names2 2.0.0", ] [[package]] @@ -3265,19 +3345,20 @@ dependencies = [ "rustpython-ruff_python_parser", "rustpython-ruff_source_file", "rustpython-ruff_text_size", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "rustpython-compiler-core" version = "0.5.0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bitflagset", - "itertools 0.14.0", + "itertools 0.15.0", "lz4_flex", "malachite-bigint", "num-complex", + "num-traits", "rustpython-ruff_source_file", "rustpython-wtf8", ] @@ -3296,20 +3377,19 @@ version = "0.5.0" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", - "syn", + "syn 2.0.119", ] [[package]] name = "rustpython-derive-impl" version = "0.5.0" dependencies = [ - "itertools 0.14.0", - "maplit", + "itertools 0.15.0", "proc-macro2", "quote", "rustpython-compiler-core", "rustpython-doc", - "syn", + "syn 2.0.119", "syn-ext", "textwrap", ] @@ -3318,18 +3398,38 @@ dependencies = [ name = "rustpython-doc" version = "0.5.0" dependencies = [ - "phf 0.13.1", + "phf 0.14.0", ] [[package]] name = "rustpython-host_env" version = "0.5.0" dependencies = [ + "bitflags 2.13.1", + "cc", + "dns-lookup", + "gethostname", + "getrandom 0.4.3", + "junction", "libc", - "nix 0.31.2", + "libffi", + "libloading 0.9.0", + "mac_address", + "memchr", + "memmap2 0.9.11", + "nix 0.31.3", "num-traits", + "num_cpus", + "parking_lot", + "paste", + "rustix", "rustpython-wtf8", + "rustyline", + "schannel", + "socket2", + "system-configuration", "termios", + "which", "widestring", "windows-sys 0.61.2", ] @@ -3347,7 +3447,7 @@ dependencies = [ "rustpython-compiler-core", "rustpython-derive", "rustpython-wtf8", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -3355,11 +3455,11 @@ name = "rustpython-literal" version = "0.5.0" dependencies = [ "hexf-parse", - "icu_properties", "is-macro", "lexical-parse-float", "num-traits", - "rand 0.9.4", + "rand 0.10.2", + "rustpython-unicode", "rustpython-wtf8", ] @@ -3374,12 +3474,11 @@ dependencies = [ [[package]] name = "rustpython-ruff_python_ast" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "aho-corasick", - "bitflags 2.11.0", + "bitflags 2.13.1", "compact_str", "get-size2", "is-macro", @@ -3388,16 +3487,15 @@ dependencies = [ "rustpython-ruff_python_trivia", "rustpython-ruff_source_file", "rustpython-ruff_text_size", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "rustpython-ruff_python_parser" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bstr", "compact_str", "get-size2", @@ -3414,9 +3512,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_python_trivia" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e7cfd1056f3a02ff0d2d0e4474286ca963260782f878b7b81c1dd87432e682" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "itertools 0.14.0", "rustpython-ruff_source_file", @@ -3426,9 +3523,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_source_file" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "948107aad62ddb12a11fc7bf68a49e52a0b0a3737d415a2505e54f5a9edac737" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "memchr", "rustpython-ruff_text_size", @@ -3436,9 +3532,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_text_size" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8291ee0f5a779e54ccd4e0151a0c426f8b49a123f99b5b6545db17ccdd4277aa" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "get-size2", ] @@ -3447,11 +3542,11 @@ dependencies = [ name = "rustpython-sre_engine" version = "0.5.0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "criterion", - "icu_properties", "num_enum", "optional", + "rustpython-unicode", "rustpython-wtf8", ] @@ -3460,44 +3555,33 @@ name = "rustpython-stdlib" version = "0.5.0" dependencies = [ "adler32", - "ahash", "ascii", - "aws-lc-rs", "base64", "blake2", "bzip2", - "chrono", "constant_time_eq", "crc32fast", "crossbeam-utils", "csv-core", - "der 0.8.0", + "der", "digest", - "dns-lookup", "dyn-clone", "flame", "flate2", - "foreign-types-shared 0.3.1", - "gethostname", + "foreign-types-shared", "hex", "hmac", - "icu_normalizer", - "icu_properties", "indexmap", "insta", - "itertools 0.14.0", + "itertools 0.15.0", + "jiff", "libc", - "liblzma", - "liblzma-sys", "libsqlite3-sys", "libz-rs-sys", - "mac_address", "malachite-bigint", "md-5", "memchr", - "memmap2", "mt19937", - "nix 0.31.2", "num-complex", "num-traits", "num_enum", @@ -3505,16 +3589,15 @@ dependencies = [ "openssl", "openssl-probe", "openssl-sys", - "page_size", "parking_lot", "paste", "pbkdf2", - "pem-rfc7468 1.0.0", - "phf 0.13.1", + "pem-rfc7468", + "phf 0.14.0", "pkcs8", "pymath", - "rand_core 0.9.5", - "rustix", + "rand 0.10.2", + "rapidhash", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -3527,26 +3610,36 @@ dependencies = [ "rustpython-ruff_python_parser", "rustpython-ruff_source_file", "rustpython-ruff_text_size", + "rustpython-unicode", "rustpython-vm", - "schannel", - "sha-1", + "scopeguard", + "sha1", "sha2", "sha3", - "socket2", - "system-configuration", + "shake", "tcl-sys", - "termios", "tk-sys", - "ucd", - "unic-ucd-age", - "unicode_names2 2.0.0", "uuid", "webpki-roots", "widestring", - "windows-sys 0.61.2", "x509-cert", "x509-parser", "xml", + "xz", + "xz-sys", +] + +[[package]] +name = "rustpython-unicode" +version = "0.5.0" +dependencies = [ + "icu_casemap", + "icu_locale", + "icu_normalizer", + "icu_properties", + "rustpython-wtf8", + "unicode_names2 3.1.0", + "writeable", ] [[package]] @@ -3557,47 +3650,36 @@ version = "0.5.0" name = "rustpython-vm" version = "0.5.0" dependencies = [ - "ahash", "ascii", - "bitflags 2.11.0", + "bitflags 2.13.1", "bstr", - "caseless", - "chrono", "constant_time_eq", "crossbeam-utils", - "errno", "exitcode", "flame", "flamer", - "getrandom 0.3.4", "glob", "half", "hex", - "icu_casemap", - "icu_locale", - "icu_properties", "indexmap", "is-macro", - "itertools 0.14.0", - "junction", + "itertools 0.15.0", + "itoa", + "jiff", "libc", - "libffi", - "libloading 0.9.0", "log", "malachite-bigint", "memchr", - "nix 0.31.2", "num-complex", "num-integer", "num-traits", - "num_cpus", "num_enum", "optional", "parking_lot", "paste", "psm", + "rapidhash", "result-like", - "rustix", "rustpython-codegen", "rustpython-common", "rustpython-compiler", @@ -3610,19 +3692,16 @@ dependencies = [ "rustpython-ruff_python_parser", "rustpython-ruff_text_size", "rustpython-sre_engine", - "rustyline", + "rustpython-unicode", "scopeguard", "serde_core", "static_assertions", "strum", "strum_macros", - "thiserror 2.0.18", - "timsort", + "thin-vec", + "thiserror", "wasm-bindgen", - "which", "widestring", - "windows-sys 0.61.2", - "writeable", ] [[package]] @@ -3631,7 +3710,7 @@ version = "0.5.0" dependencies = [ "ascii", "bstr", - "itertools 0.14.0", + "itertools 0.15.0", "memchr", ] @@ -3643,6 +3722,7 @@ dependencies = [ "js-sys", "rustpython-common", "rustpython-pylib", + "rustpython-ruff_text_size", "rustpython-stdlib", "rustpython-vm", "serde-wasm-bindgen", @@ -3654,24 +3734,24 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rustyline" -version = "18.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "clipboard-win", "home", "libc", "log", "memchr", - "nix 0.31.2", + "nix 0.31.3", "radix_trie", "unicode-segmentation", "unicode-width", @@ -3681,9 +3761,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" @@ -3696,10 +3776,11 @@ dependencies = [ [[package]] name = "salsa20" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ + "cfg-if", "cipher", ] @@ -3729,10 +3810,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrypt" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ + "cfg-if", "pbkdf2", "salsa20", "sha2", @@ -3740,11 +3822,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3753,9 +3835,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3805,14 +3887,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3823,18 +3905,18 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] -name = "sha-1" -version = "0.10.1" +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures", @@ -3842,10 +3924,10 @@ dependencies = [ ] [[package]] -name = "sha1" -version = "0.10.6" +name = "sha2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", @@ -3853,24 +3935,25 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" +name = "sha3" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "cfg-if", - "cpufeatures", "digest", + "keccak", + "sponge-cursor", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "shake" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest", "keccak", + "sponge-cursor", ] [[package]] @@ -3887,20 +3970,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" @@ -3926,27 +4015,27 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3954,14 +4043,20 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3989,7 +4084,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4000,9 +4095,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -4017,7 +4123,7 @@ checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4028,7 +4134,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4037,7 +4143,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4054,9 +4160,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tcl-sys" @@ -4069,9 +4175,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -4096,43 +4202,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" [[package]] -name = "thiserror" -version = "1.0.69" +name = "thin-vec" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" [[package]] name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -4148,12 +4240,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4163,26 +4254,20 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", ] -[[package]] -name = "timsort" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" - [[package]] name = "tinystr" version = "0.8.3" @@ -4206,9 +4291,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4246,14 +4331,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "toml" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -4266,27 +4351,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "twox-hash" @@ -4296,62 +4381,15 @@ checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" [[package]] name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4fa6e588762366f1eb4991ce59ad1b93651d0b769dfb4e4d1c5c4b943d1159" - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-age" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8cfdfe71af46b871dc6af2c24fcd360e2f3392ee4c5111877f2947f311671c" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -4364,9 +4402,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4386,12 +4424,12 @@ dependencies = [ [[package]] name = "unicode_names2" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +checksum = "82c3e18d850bb6ebd57735e5654f0af65e572b05c2397e7b2b1a7c6a792cc29c" dependencies = [ "phf 0.11.3", - "unicode_names2_generator 2.0.0", + "unicode_names2_generator 3.1.0", ] [[package]] @@ -4403,24 +4441,28 @@ dependencies = [ "getopts", "log", "phf_codegen", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] name = "unicode_names2_generator" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +checksum = "849744a58c479122ff24910d7ca57f312b62613ef0412dc327776ae6b235d16a" dependencies = [ "phf_codegen", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] -name = "untrusted" -version = "0.7.1" +name = "universal-hash" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common", + "ctutils", +] [[package]] name = "untrusted" @@ -4448,9 +4490,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", "js-sys", @@ -4487,18 +4529,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4509,23 +4551,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4533,41 +4571,41 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wasmtime-internal-core" -version = "44.0.1" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" +checksum = "3073c03f97f871fe6400e68621c863b93ba79296f8e570285231952d23fcc804" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", "libm", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "44.0.1" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" +checksum = "37dee05e8c35759826f6b926cd949c51f771b6a58619d8e60c63c3f3d3e5e59b" dependencies = [ "cfg-if", "libc", @@ -4577,9 +4615,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4587,36 +4625,36 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "8.0.2" +version = "8.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" dependencies = [ "libc", ] [[package]] name = "wide" -version = "1.1.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac11b009ebeae802ed758530b6496784ebfee7a87b9abfbcaf3bbe25b814eb25" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -4659,65 +4697,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4876,9 +4861,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "winresource" @@ -4892,9 +4877,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "write16" @@ -4904,18 +4889,18 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x509-cert" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" dependencies = [ - "const-oid 0.9.6", - "der 0.7.10", + "const-oid", + "der", "sha1", "signature", "spki", @@ -4935,21 +4920,51 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.18", + "thiserror", "time", ] [[package]] name = "xml" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "xz" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af8851c229f574c5e4f4e9b6d90ca06c43974a563f7f42de4b88d651bf8f19c" +dependencies = [ + "xz-core", +] + +[[package]] +name = "xz-core" +version = "0.1.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4d12cf9c122b2523cce22d6ab3083f4eb56d1221e5d30ddcc70fbaac553589" +dependencies = [ + "libc", + "memchr", + "windows-sys 0.61.2", +] + +[[package]] +name = "xz-sys" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114e3a14e5bb2d46513305741650595041b7e7e1677aa5c9715a09b7a8da08fd" +dependencies = [ + "libc", + "xz-core", +] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4964,69 +4979,69 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.34" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.34" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5061,17 +5076,17 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.17" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 80976b1e1c7..3b489a88687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true [features] capi = ["dep:rustpython-capi", "threading"] -default = ["threading", "stdlib", "stdio", "importlib", "ssl-rustls", "host_env"] +default = ["threading", "stdlib", "stdio", "importlib", "ssl-rustls-aws-lc", "host_env"] host_env = ["rustpython-vm/host_env", "rustpython-stdlib?/host_env"] importlib = ["rustpython-vm/importlib"] encodings = ["rustpython-vm/encodings"] @@ -22,10 +22,12 @@ freeze-stdlib = ["stdlib", "rustpython-vm/freeze-stdlib", "rustpython-pylib?/fre jit = ["rustpython-vm/jit"] threading = ["rustpython-vm/threading", "rustpython-stdlib/threading"] sqlite = ["rustpython-stdlib/sqlite"] -ssl = [] +ssl = ["host_env"] ssl-rustls = ["ssl", "rustpython-stdlib/ssl-rustls"] +ssl-rustls-aws-lc = ["ssl-rustls", "dep:rustls", "rustls/aws_lc_rs"] +ssl-rustls-aws-lc-fips = ["ssl-rustls-aws-lc", "rustls/fips"] ssl-openssl = ["ssl", "rustpython-stdlib/ssl-openssl"] -ssl-vendor = ["ssl-openssl", "rustpython-stdlib/ssl-vendor"] +ssl-openssl-vendor = ["ssl-openssl", "rustpython-stdlib/ssl-openssl-vendor"] tkinter = ["rustpython-stdlib/tkinter"] [build-dependencies] @@ -42,10 +44,12 @@ log = { workspace = true } flame = { workspace = true, optional = true } lexopt = "0.3" -dirs = { package = "dirs-next", version = "2.0" } +dirs = "6" env_logger = "0.11" flamescope = { version = "0.1.2", optional = true } +rustls = { workspace = true, optional = true } + [target.'cfg(windows)'.dependencies] libc = { workspace = true } @@ -55,6 +59,7 @@ rustyline = { workspace = true } [dev-dependencies] criterion = { workspace = true } pyo3 = { workspace = true, features = ["auto-initialize"] } +rustls-graviola = { workspace = true } rustpython-stdlib = { workspace = true } ruff_python_parser = { workspace = true } @@ -70,6 +75,16 @@ harness = false name = "rustpython" path = "src/main.rs" +[[example]] +name = "custom_tls_providers" +path = "examples/custom_tls_providers.rs" +required-features = [ + "rustls/ring", + "rustpython-pylib/freeze-stdlib", + "rustpython-stdlib/ssl-rustls", + "rustpython-vm/freeze-stdlib", +] + [profile.dev.package."*"] opt-level = 3 @@ -101,6 +116,14 @@ opt-level = 3 [profile.release] lto = "thin" +[profile.wasm-release] +inherits = "release" +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" + [patch.crates-io] parking_lot_core = { git = "https://github.com/youknowone/parking_lot", branch = "rustpython" } # REDOX START, Uncomment when you want to compile/check with redoxer @@ -156,117 +179,107 @@ rustpython-vm = { path = "crates/vm", default-features = false, version = "0.5.0 rustpython-pylib = { path = "crates/pylib", version = "0.5.0" } rustpython-stdlib = { path = "crates/stdlib", default-features = false, version = "0.5.0" } rustpython-sre_engine = { path = "crates/sre_engine", version = "0.5.0" } +rustpython-unicode = { path = "crates/unicode", version = "0.5.0" } rustpython-wtf8 = { path = "crates/wtf8", version = "0.5.0" } rustpython-doc = { path = "crates/doc", version = "0.5.0" } -# Use RustPython-packaged Ruff crates from the published fork while keeping -# existing crate names in the codebase. -ruff_python_parser = { package = "rustpython-ruff_python_parser", version = "0.15.8" } -ruff_python_ast = { package = "rustpython-ruff_python_ast", version = "0.15.8" } -ruff_text_size = { package = "rustpython-ruff_text_size", version = "0.15.8" } -ruff_source_file = { package = "rustpython-ruff_source_file", version = "0.15.8" } -# To update ruff crates, comment out the above lines and uncomment the following lines to pull directly from the Ruff repository at the specified commit hash. -# Ruff tag 0.15.8 is based on commit c2a8815842f9dc5d24ec19385eae0f1a7188b0d9 -# at the time of this capture. We use the commit hash to ensure reproducible builds. -# ruff_python_parser = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_python_ast = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_text_size = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_source_file = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } +# Use the RustPython Ruff fork for RustPython public `_ast` metadata. +ruff_python_parser = { package = "rustpython-ruff_python_parser", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_python_ast = { package = "rustpython-ruff_python_ast", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_text_size = { package = "rustpython-ruff_text_size", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_source_file = { package = "rustpython-ruff_source_file", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } der = { version = "0.8", features = ["alloc", "oid", "pem", "zeroize"] } -phf = { version = "0.13.1", default-features = false, features = ["macros"]} +phf = { version = "0.14.0", default-features = false, features = ["macros"]} adler32 = "1.2.0" -ahash = "0.8.12" approx = "0.5.1" -ascii = "1.1" -aws-lc-rs = "1.16.3" +ascii = { version = "1.1", default-features = false } base64 = "0.22" -blake2 = "0.10.4" +blake2 = "0.11.0-rc.6" bitflags = "2.11.0" bitflagset = "0.0.3" -bstr = "1" +bstr = { version = "1", default-features = false, features = ["unicode"] } bzip2 = "0.6" -caseless = "0.2.2" -chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } console_error_panic_hook = "0.1" -constant_time_eq = "0.4" -cranelift = "0.131.1" -cranelift-jit = "0.131.1" -cranelift-module = "0.131.0" +constant_time_eq = "0.5" +cranelift = "0.132.0" +cranelift-jit = "0.132.0" +cranelift-module = "0.132.0" crc32fast = "1.3.2" criterion = { version = "0.8", features = ["html_reports"] } crossbeam-utils = "0.8.21" csv-core = "0.1.11" -digest = "0.10.7" +digest = "0.11" dns-lookup = "3.0" dyn-clone = "1.0.10" exitcode = "1.1.2" -errno = "0.3" flame = "0.2.2" flamer = "0.5" flate2 = { version = "1.1.9", default-features = false } -foreign-types-shared = "0.3.1" +# Bump only when the openssl crate bumps it +foreign-types-shared = "0.1" gethostname = "1.0.2" -getrandom = { version = "0.3", features = ["std"] } +getrandom = { version = "0.4", features = ["std"] } glob = "0.3" half = "2" hex = "0.4.3" hexf-parse = "0.2.1" -hmac = "0.12" +hmac = "0.13" indexmap = { version = "2.14.0", features = ["std"] } insta = "1.47" -itertools = "0.14.0" +itertools = { version = "0.15.0", default-features = false, features = ["use_alloc"] } +itoa = "1" is-macro = "0.3.7" +jiff = "0.2" js-sys = "0.3" -junction = "1.4.2" +junction = "2.0.0" lexical-parse-float = "1.0.6" libc = "0.2.186" libffi = "5" libloading = "0.9" -liblzma = "0.4" -liblzma-sys = "0.4" -libsqlite3-sys = "0.37" +xz = "0.4.6-rc.0" +xz-sys = "0.4.6-rc.0" +libsqlite3-sys = "0.38" libz-rs-sys = "0.6" lock_api = "0.4" -log = "0.4.29" +log = "0.4.30" lz4_flex = "0.13" nix = { version = "0.31", features = ["fs", "user", "process", "term", "time", "signal", "ioctl", "socket", "sched", "zerocopy", "dir", "hostname", "net", "poll"] } mac_address = "1.1.3" -malachite-bigint = "0.9.1" -malachite-q = "0.9.1" -malachite-base = "0.9.1" -maplit = "1.0.2" -md-5 = "0.10.1" -memchr = "2.8.0" +malachite-bigint = "0.10.0" +malachite-q = "0.10.0" +malachite-base = "0.10.0" +md-5 = "0.11" +memchr = { version = "2.8.1", default-features = false, features = ["alloc"] } memmap2 = "0.9.10" -mt19937 = "<=3.2" # upgrade it once rand is upgraded +mt19937 = "3.3" num-complex = "0.4.6" num-integer = "0.1.46" num-traits = "0.2" num_cpus = "1.17.0" num_enum = { version = "0.7", default-features = false } oid-registry = "0.8" -openssl = "0.10.79" +openssl = "0.10.80" openssl-sys = "0.9.110" openssl-probe = "0.2.1" optional = "0.5" -page_size = "0.6" parking_lot = "0.12.3" paste = "1.0.15" -pbkdf2 = "0.12" +pbkdf2 = "0.13" pem-rfc7468 = "1.0" -pkcs8 = "0.10" +pkcs8 = "0.11" proc-macro2 = "1.0.105" psm = "0.1" pymath = { version = "0.2.0", features = ["mul_add", "malachite-bigint", "complex"] } -pyo3 = "0.28" +pyo3 = "0.29" quote = "1.0.45" radium = "1.1.1" -rand = "0.9" -rand_core = { version = "0.9", features = ["os_rng"] } +rand = "0.10" +rapidhash = "4.4.1" result-like = "0.5.0" -rustix = { version = "1.1", features = ["event", "system"] } +rustix = { version = "1.1", features = ["event", "fs", "param", "system"] } rustls = { version = "0.23.39", default-features = false } +rustls-graviola = "0.4" rustls-native-certs = "0.8" rustls-pemfile = "2.2" rustls-platform-verifier = "0.7" @@ -275,11 +288,12 @@ serde = { package = "serde_core", version = "1.0.225", default-features = false, schannel = "0.1.29" scopeguard = "1" serde-wasm-bindgen = "0.6.5" -sha-1 = "0.10.0" -sha2 = "0.10.2" -sha3 = "0.10.1" +sha1 = "0.11" +sha2 = "0.11" +sha3 = "0.12" +shake = "0.1" siphasher = "1" -socket2 = "0.6.3" +socket2 = "0.6.4" static_assertions = "1.1" strum = "0.28" strum_macros = "0.28" @@ -290,16 +304,14 @@ tcl-sys = { git = "https://github.com/arihant2math/tkinter.git", tag = "v0.2.0" textwrap = { version = "0.16.2", default-features = false } termios = "0.3.3" thiserror = "2.0" -timsort = "0.1.2" +thin-vec = "0.2.14" tk-sys = { git = "https://github.com/arihant2math/tkinter.git", tag = "v0.2.0" } icu_casemap = "2" icu_locale = "2" icu_properties = "2" icu_normalizer = "2" -uuid = "1.23.1" -ucd = "0.1.1" -unic-ucd-age = "0.9.0" -unicode_names2 = "2.0.0" +uuid = "1.23.2" +unicode_names2 = { version = "3", default-features = false, features = ["no_std"] } widestring = "1.2.0" windows-sys = "0.61.2" wasm-bindgen = "0.2.106" @@ -307,9 +319,9 @@ wasm-bindgen-futures = "0.4" web-sys = "0.3" webpki-roots = "1.0" which = "8" -x509-cert = "0.2.5" +x509-cert = "0.3.0" x509-parser = "0.18" -xml = "1.2" +xml = "1.3" writeable = "0.6" # Lints @@ -337,27 +349,50 @@ similar_names = "allow" # restriction lints alloc_instead_of_core = "warn" +cfg_not_test = "warn" +iter_over_hash_type = "warn" +redundant_test_prefix = "warn" std_instead_of_alloc = "warn" std_instead_of_core = "warn" +tests_outside_test_module = "warn" # nursery lints to enforce gradually +collection_is_never_read = "warn" debug_assert_with_mut_call = "warn" derive_partial_eq_without_eq = "warn" imprecise_flops = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +literal_string_with_formatting_args = "warn" +needless_collect = "warn" or_fun_call = "warn" redundant_clone = "warn" search_is_some = "warn" +significant_drop_in_scrutinee = "warn" single_option_map = "warn" trait_duplication_in_bounds = "warn" +tuple_array_conversions = "warn" +type_repetition_in_bounds = "warn" +unnecessary_struct_initialization = "warn" unused_peekable = "warn" unused_rounding = "warn" use_self = "warn" useless_let_if_seq = "warn" +while_float = "warn" # pedantic lints to enforce gradually +assigning_clones = "warn" +bool_to_int_with_if = "warn" +checked_conversions = "warn" cloned_instead_of_copied = "warn" collapsible_else_if = "warn" comparison_chain = "warn" +copy_iterator = "warn" +doc_link_with_quotes = "warn" +duration_suboptimal_units = "warn" +elidable_lifetime_names = "warn" +enum_glob_use = "warn" +explicit_deref_methods = "warn" explicit_into_iter_loop = "warn" explicit_iter_loop = "warn" filter_map_next = "warn" @@ -365,11 +400,37 @@ flat_map_option = "warn" format_collect = "warn" from_iter_instead_of_collect = "warn" inconsistent_struct_constructor = "warn" +index_refutable_slice = "warn" inefficient_to_string = "warn" +ip_constant = "warn" +iter_filter_is_ok = "warn" +iter_filter_is_some = "warn" +large_futures = "warn" +large_types_passed_by_value = "warn" +manual_assert = "warn" +manual_instant_elapsed = "warn" manual_is_variant_and = "warn" map_unwrap_or = "warn" +match_bool = "warn" +mismatching_type_param_order = "warn" must_use_candidate = "warn" +mut_mut = "warn" +needless_bitwise_bool = "warn" +needless_for_each = "warn" +non_std_lazy_statics = "warn" +option_as_ref_cloned = "warn" +ptr_offset_by_literal = "warn" +range_minus_one = "warn" +range_plus_one = "warn" redundant_else = "warn" +ref_option = "warn" +return_self_not_must_use = "warn" +same_functions_in_if_condition = "warn" +single_char_pattern = "warn" +trivially_copy_pass_by_ref = "warn" +unchecked_time_subtraction = "warn" uninlined_format_args = "warn" +unnecessary_box_returns = "warn" +unnecessary_join = "warn" unnecessary_wraps = "warn" unnested_or_patterns = "warn" diff --git a/Lib/_collections_abc.py b/Lib/_collections_abc.py index 241d40d5740..996b094377a 100644 --- a/Lib/_collections_abc.py +++ b/Lib/_collections_abc.py @@ -461,8 +461,8 @@ def __subclasshook__(cls, C): class _CallableGenericAlias(GenericAlias): """ Represent `Callable[argtypes, resulttype]`. - This sets ``__args__`` to a tuple containing the flattened ``argtypes`` - followed by ``resulttype``. + This sets ``__args__`` to a tuple containing the flattened + ``argtypes`` followed by ``resulttype``. Example: ``Callable[[int, str], float]`` sets ``__args__`` to ``(int, str, float)``. @@ -927,8 +927,9 @@ def __delitem__(self, key): __marker = object() def pop(self, key, default=__marker): - '''D.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised. + '''D.pop(k[,d]) -> v, remove specified key and return the corresponding + value. If key is not found, d is returned if given, otherwise + KeyError is raised. ''' try: value = self[key] @@ -962,9 +963,12 @@ def clear(self): def update(self, other=(), /, **kwds): ''' D.update([E, ]**F) -> None. Update D from mapping/iterable E and F. - If E present and has a .keys() method, does: for k in E.keys(): D[k] = E[k] - If E present and lacks .keys() method, does: for (k, v) in E: D[k] = v - In either case, this is followed by: for k, v in F.items(): D[k] = v + If E present and has a .keys() method, does: + for k in E.keys(): D[k] = E[k] + If E present and lacks .keys() method, does: + for (k, v) in E: D[k] = v + In either case, this is followed by: + for k, v in F.items(): D[k] = v ''' if isinstance(other, Mapping): for key in other: @@ -1029,8 +1033,8 @@ def __reversed__(self): yield self[i] def index(self, value, start=0, stop=None): - '''S.index(value, [start, [stop]]) -> integer -- return first index of value. - Raises ValueError if the value is not present. + '''S.index(value, [start, [stop]]) -> integer -- return first index of + value. Raises ValueError if the value is not present. Supporting start and stop arguments is optional, but recommended. @@ -1138,15 +1142,16 @@ def reverse(self): self[i], self[n-i-1] = self[n-i-1], self[i] def extend(self, values): - 'S.extend(iterable) -- extend sequence by appending elements from the iterable' + """S.extend(iterable) -- extend sequence by appending elements from the + iterable""" if values is self: values = list(values) for v in values: self.append(v) def pop(self, index=-1): - '''S.pop([index]) -> item -- remove and return item at index (default last). - Raise IndexError if list is empty or index is out of range. + '''S.pop([index]) -> item -- remove and return item at index (default + last). Raise IndexError if list is empty or index is out of range. ''' v = self[index] del self[index] diff --git a/Lib/_opcode_metadata.py b/Lib/_opcode_metadata.py index 4da6e507736..001a016621c 100644 --- a/Lib/_opcode_metadata.py +++ b/Lib/_opcode_metadata.py @@ -1,4 +1,4 @@ -# This file is generated by scripts/generate_opcode_metadata.py +# This file is generated by tools/opcode_metadata/generate_py_opcode_metadata.py # for RustPython bytecode format (CPython 3.14 compatible opcode numbers). # Do not edit! diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 97a629fe92c..2277578df54 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -104,8 +104,8 @@ class DecimalException(ArithmeticError): anything, though. handle -- Called when context._raise_error is called and the - trap_enabler is not set. First argument is self, second is the - context. More arguments can be given, those being after + trap_enabler is not set. First argument is self, second is + the context. More arguments can be given, those being after the explanation in _raise_error (For example, context._raise_error(NewError, '(-x)!', self._sign) would call NewError().handle(context, self._sign).) @@ -222,11 +222,12 @@ class InvalidContext(InvalidOperation): """Invalid context. Unknown rounding, for example. This occurs and signals invalid-operation if an invalid context was - detected during an operation. This can occur if contexts are not checked - on creation and either the precision exceeds the capability of the - underlying concrete representation or an unknown or unsupported rounding - was specified. These aspects of the context need only be checked when - the values are required to be used. The result is [0,qNaN]. + detected during an operation. This can occur if contexts are not + checked on creation and either the precision exceeds the capability of + the underlying concrete representation or an unknown or unsupported + rounding was specified. These aspects of the context need only be + checked when the values are required to be used. The result is + [0,qNaN]. """ def handle(self, context, *args): @@ -319,8 +320,9 @@ class FloatOperation(DecimalException, TypeError): Decimal.from_float() or context.create_decimal_from_float() do not set the flag. - Otherwise (the signal is trapped), only equality comparisons and explicit - conversions are silent. All other mixed operations raise FloatOperation. + Otherwise (the signal is trapped), only equality comparisons and + explicit conversions are silent. All other mixed operations raise + FloatOperation. """ # List of public traps and flags @@ -2898,8 +2900,8 @@ def compare_total(self, other, context=None): """Compares self to other using the abstract representations. This is not like the standard compare, which use their numerical - value. Note that a total ordering is defined for all possible abstract - representations. + value. Note that a total ordering is defined for all possible + abstract representations. """ other = _convert_other(other, raiseit=True) @@ -2970,7 +2972,8 @@ def compare_total(self, other, context=None): def compare_total_mag(self, other, context=None): """Compares self to other using abstract repr., ignoring sign. - Like compare_total, but with operand's sign ignored and assumed to be 0. + Like compare_total, but with operand's sign ignored and assumed to + be 0. """ other = _convert_other(other, raiseit=True) @@ -4107,9 +4110,9 @@ def create_decimal_from_float(self, f): def abs(self, a): """Returns the absolute value of the operand. - If the operand is negative, the result is the same as using the minus - operation on the operand. Otherwise, the result is the same as using - the plus operation on the operand. + If the operand is negative, the result is the same as using the + minus operation on the operand. Otherwise, the result is the same + as using the plus operation on the operand. >>> ExtendedContext.abs(Decimal('2.1')) Decimal('2.1') @@ -4165,16 +4168,17 @@ def canonical(self, a): def compare(self, a, b): """Compares values numerically. - If the signs of the operands differ, a value representing each operand - ('-1' if the operand is less than zero, '0' if the operand is zero or - negative zero, or '1' if the operand is greater than zero) is used in - place of that operand for the comparison instead of the actual - operand. + If the signs of the operands differ, a value representing each + operand ('-1' if the operand is less than zero, '0' if the operand + is zero or negative zero, or '1' if the operand is greater than + zero) is used in place of that operand for the comparison instead of + the actual operand. - The comparison is then effected by subtracting the second operand from - the first and then returning a value according to the result of the - subtraction: '-1' if the result is less than zero, '0' if the result is - zero or negative zero, or '1' if the result is greater than zero. + The comparison is then effected by subtracting the second operand + from the first and then returning a value according to the result of + the subtraction: '-1' if the result is less than zero, '0' if the + result is zero or negative zero, or '1' if the result is greater + than zero. >>> ExtendedContext.compare(Decimal('2.1'), Decimal('3')) Decimal('-1') @@ -4237,8 +4241,8 @@ def compare_total(self, a, b): """Compares two operands using their abstract representation. This is not like the standard compare, which use their numerical - value. Note that a total ordering is defined for all possible abstract - representations. + value. Note that a total ordering is defined for all possible + abstract representations. >>> ExtendedContext.compare_total(Decimal('12.73'), Decimal('127.9')) Decimal('-1') @@ -4265,7 +4269,8 @@ def compare_total(self, a, b): def compare_total_mag(self, a, b): """Compares two operands using their abstract representation ignoring sign. - Like compare_total, but with operand's sign ignored and assumed to be 0. + Like compare_total, but with operand's sign ignored and assumed to + be 0. """ a = _convert_other(a, raiseit=True) return a.compare_total_mag(b) @@ -4923,8 +4928,8 @@ def multiply(self, a, b): If either operand is a special value then the general rules apply. Otherwise, the operands are multiplied together - ('long multiplication'), resulting in a number which may be as long as - the sum of the lengths of the two operands. + ('long multiplication'), resulting in a number which may be as long + as the sum of the lengths of the two operands. >>> ExtendedContext.multiply(Decimal('1.20'), Decimal('3')) Decimal('3.60') @@ -5200,19 +5205,19 @@ def quantize(self, a, b): """Returns a value equal to 'a' (rounded), having the exponent of 'b'. The coefficient of the result is derived from that of the left-hand - operand. It may be rounded using the current rounding setting (if the - exponent is being increased), multiplied by a positive power of ten (if - the exponent is being decreased), or is unchanged (if the exponent is - already equal to that of the right-hand operand). + operand. It may be rounded using the current rounding setting (if + the exponent is being increased), multiplied by a positive power of + ten (if the exponent is being decreased), or is unchanged (if the + exponent is already equal to that of the right-hand operand). Unlike other operations, if the length of the coefficient after the quantize operation would be greater than precision then an Invalid - operation condition is raised. This guarantees that, unless there is - an error condition, the exponent of the result of a quantize is always - equal to that of the right-hand operand. + operation condition is raised. This guarantees that, unless there + is an error condition, the exponent of the result of a quantize is + always equal to that of the right-hand operand. - Also unlike other operations, quantize will never raise Underflow, even - if the result is subnormal and inexact. + Also unlike other operations, quantize will never raise Underflow, + even if the result is subnormal and inexact. >>> ExtendedContext.quantize(Decimal('2.17'), Decimal('0.001')) Decimal('2.170') @@ -5266,13 +5271,13 @@ def remainder(self, a, b): """Returns the remainder from integer division. The result is the residue of the dividend after the operation of - calculating integer division as described for divide-integer, rounded - to precision digits if necessary. The sign of the result, if - non-zero, is the same as that of the original dividend. + calculating integer division as described for divide-integer, + rounded to precision digits if necessary. The sign of the result, + if non-zero, is the same as that of the original dividend. - This operation will fail under the same conditions as integer division - (that is, if integer division on the same two operands would fail, the - remainder cannot be calculated). + This operation will fail under the same conditions as integer + division (that is, if integer division on the same two operands + would fail, the remainder cannot be calculated). >>> ExtendedContext.remainder(Decimal('2.1'), Decimal('3')) Decimal('2.1') @@ -5306,9 +5311,9 @@ def remainder_near(self, a, b): is chosen). If the result is equal to 0 then its sign will be the sign of a. - This operation will fail under the same conditions as integer division - (that is, if integer division on the same two operands would fail, the - remainder cannot be calculated). + This operation will fail under the same conditions as integer + division (that is, if integer division on the same two operands + would fail, the remainder cannot be calculated). >>> ExtendedContext.remainder_near(Decimal('2.1'), Decimal('3')) Decimal('-0.9') @@ -5366,8 +5371,8 @@ def rotate(self, a, b): def same_quantum(self, a, b): """Returns True if the two operands have the same exponent. - The result is never affected by either the sign or the coefficient of - either operand. + The result is never affected by either the sign or the coefficient + of either operand. >>> ExtendedContext.same_quantum(Decimal('2.17'), Decimal('0.001')) False @@ -5439,8 +5444,8 @@ def shift(self, a, b): def sqrt(self, a): """Square root of a non-negative number to context precision. - If the result must be inexact, it is rounded using the round-half-even - algorithm. + If the result must be inexact, it is rounded using the + round-half-even algorithm. >>> ExtendedContext.sqrt(Decimal('0')) Decimal('0') diff --git a/Lib/_pyio.py b/Lib/_pyio.py index 116ce4f37ec..d0adef08e84 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -83,27 +83,28 @@ def open(file, mode="r", buffering=-1, encoding=None, errors=None, wrapped. (If a file descriptor is given, it is closed when the returned I/O object is closed, unless closefd is set to False.) - mode is an optional string that specifies the mode in which the file is - opened. It defaults to 'r' which means open for reading in text mode. Other - common values are 'w' for writing (truncating the file if it already - exists), 'x' for exclusive creation of a new file, and 'a' for appending - (which on some Unix systems, means that all writes append to the end of the - file regardless of the current seek position). In text mode, if encoding is - not specified the encoding used is platform dependent. (For reading and - writing raw bytes use binary mode and leave encoding unspecified.) The - available modes are: - - ========= =============================================================== + mode is an optional string that specifies the mode in which the file + is opened. It defaults to 'r' which means open for reading in text + mode. Other common values are 'w' for writing (truncating the file if + it already exists), 'x' for exclusive creation of a new file, and + 'a' for appending (which on some Unix systems, means that all writes + append to the end of the file regardless of the current seek position). + In text mode, if encoding is not specified the encoding used is platform + dependent. (For reading and writing raw bytes use binary mode and leave + encoding unspecified.) The available modes are: + + ========= ========================================================== Character Meaning - --------- --------------------------------------------------------------- + --------- ---------------------------------------------------------- 'r' open for reading (default) 'w' open for writing, truncating the file first 'x' create a new file and open it for writing - 'a' open for writing, appending to the end of the file if it exists + 'a' open for writing, appending to the end of the file if it + exists 'b' binary mode 't' text mode (default) '+' open a disk file for updating (reading and writing) - ========= =============================================================== + ========= ========================================================== The default mode is 'rt' (open for reading text). For binary random access, the mode 'w+b' opens and truncates the file to 0 bytes, while @@ -111,23 +112,23 @@ def open(file, mode="r", buffering=-1, encoding=None, errors=None, raises an `FileExistsError` if the file already exists. Python distinguishes between files opened in binary and text modes, - even when the underlying operating system doesn't. Files opened in + even when the underlying operating system doesn't. Files opened in binary mode (appending 'b' to the mode argument) return contents as - bytes objects without any decoding. In text mode (the default, or when + bytes objects without any decoding. In text mode (the default, or when 't' is appended to the mode argument), the contents of the file are returned as strings, the bytes having been first decoded using a platform-dependent encoding or using the specified encoding if given. buffering is an optional integer used to set the buffering policy. - Pass 0 to switch buffering off (only allowed in binary mode), 1 to select - line buffering (only usable in text mode), and an integer > 1 to indicate - the size of a fixed-size chunk buffer. When no buffering argument is - given, the default buffering policy works as follows: + Pass 0 to switch buffering off (only allowed in binary mode), 1 to + select line buffering (only usable in text mode), and an integer > 1 to + indicate the size of a fixed-size chunk buffer. When no buffering + argument is given, the default buffering policy works as follows: - * Binary files are buffered in fixed-size chunks; the size of the buffer - is max(min(blocksize, 8 MiB), DEFAULT_BUFFER_SIZE) - when the device block size is available. - On most systems, the buffer will typically be 128 kilobytes long. + * Binary files are buffered in fixed-size chunks; the size of the buffer + is max(min(blocksize, 8 MiB), DEFAULT_BUFFER_SIZE) when the device + block size is available. + On most systems, the buffer will typically be 128 kilobytes long. * "Interactive" text files (files for which isatty() returns True) use line buffering. Other text files use the policy described above @@ -147,8 +148,8 @@ def open(file, mode="r", buffering=-1, encoding=None, errors=None, encoding error strings. newline is a string controlling how universal newlines works (it only - applies to text mode). It can be None, '', '\n', '\r', and '\r\n'. It works - as follows: + applies to text mode). It can be None, '', '\n', '\r', and '\r\n'. It + works as follows: * On input, if newline is None, universal newlines mode is enabled. Lines in the input can end in '\n', '\r', or '\r\n', and @@ -164,17 +165,17 @@ def open(file, mode="r", buffering=-1, encoding=None, errors=None, other legal values, any '\n' characters written are translated to the given string. - closedfd is a bool. If closefd is False, the underlying file descriptor will - be kept open when the file is closed. This does not work when a file name is - given and must be True in that case. + closedfd is a bool. If closefd is False, the underlying file descriptor + will be kept open when the file is closed. This does not work when + a file name is given and must be True in that case. The newly created file is non-inheritable. A custom opener can be used by passing a callable as *opener*. The - underlying file descriptor for the file object is then obtained by calling - *opener* with (*file*, *flags*). *opener* must return an open file - descriptor (passing os.open as *opener* results in functionality similar to - passing None). + underlying file descriptor for the file object is then obtained by + calling *opener* with (*file*, *flags*). *opener* must return an open + file descriptor (passing os.open as *opener* results in functionality + similar to passing None). open() returns a file object whose type depends on the mode, and through which the standard file operations such as reading and writing @@ -351,10 +352,12 @@ def seek(self, pos, whence=0): interpreted relative to the position indicated by whence. Values for whence are ints: - * 0 -- start of stream (the default); offset should be zero or positive + * 0 -- start of stream (the default); offset should be zero or + positive * 1 -- current stream position; offset may be negative * 2 -- end of stream; offset is usually negative - Some operating systems / file systems could provide additional values. + Some operating systems / file systems could provide additional + values. Return an int indicating the new absolute position. """ @@ -367,8 +370,8 @@ def tell(self): def truncate(self, pos=None): """Truncate file to size bytes. - Size defaults to the current IO position as reported by tell(). Return - the new size. + Size defaults to the current IO position as reported by tell(). + Return the new size. """ self._unsupported("truncate") @@ -492,7 +495,8 @@ def __exit__(self, *args): def fileno(self): """Returns underlying file descriptor (an int) if one exists. - An OSError is raised if the IO object does not use a file descriptor. + An OSError is raised if the IO object does not use a file + descriptor. """ self._unsupported("fileno") @@ -1485,17 +1489,22 @@ class FileIO(RawIOBase): _closefd = True def __init__(self, file, mode='r', closefd=True, opener=None): - """Open a file. The mode can be 'r' (default), 'w', 'x' or 'a' for reading, - writing, exclusive creation or appending. The file will be created if it - doesn't exist when opened for writing or appending; it will be truncated - when opened for writing. A FileExistsError will be raised if it already - exists when opened for creating. Opening a file for creating implies - writing so this mode behaves in a similar way to 'w'. Add a '+' to the mode - to allow simultaneous reading and writing. A custom opener can be used by - passing a callable as *opener*. The underlying file descriptor for the file - object is then obtained by calling opener with (*name*, *flags*). - *opener* must return an open file descriptor (passing os.open as *opener* - results in functionality similar to passing None). + """Open a file. + + The mode can be 'r' (default), 'w', 'x' or 'a' for reading, + writing, exclusive creation or appending. The file will be created + if it doesn't exist when opened for writing or appending; it will be + truncated when opened for writing. A FileExistsError will be raised + if it already exists when opened for creating. Opening a file for + creating implies writing so this mode behaves in a similar way to + 'w'. Add a '+' to the mode to allow simultaneous reading and + writing. + + A custom opener can be used by passing a callable as *opener*. + The underlying file descriptor for the file object is then obtained + by calling opener with (*name*, *flags*). *opener* must return + an open file descriptor (passing os.open as *opener* results in + functionality similar to passing None). """ if self._fd >= 0: # Have to close the existing file first. @@ -1733,8 +1742,8 @@ def write(self, b): """Write bytes b to file, return number written. Only makes one system call, so not all of the data may be written. - The number of bytes actually written is returned. In non-blocking mode, - returns None if the write would block. + The number of bytes actually written is returned. In non-blocking + mode, returns None if the write would block. """ self._checkClosed() self._checkWritable() @@ -1746,11 +1755,12 @@ def write(self, b): def seek(self, pos, whence=SEEK_SET): """Move to new file position. - Argument offset is a byte count. Optional argument whence defaults to - SEEK_SET or 0 (offset from start of file, offset should be >= 0); other values - are SEEK_CUR or 1 (move relative to current position, positive or negative), - and SEEK_END or 2 (move relative to end of file, usually negative, although - many platforms allow seeking beyond the end of a file). + Argument offset is a byte count. Optional argument whence defaults + to SEEK_SET or 0 (offset from start of file, offset should be >= 0); + other values are SEEK_CUR or 1 (move relative to current position, + positive or negative), and SEEK_END or 2 (move relative to end of + file, usually negative, although many platforms allow seeking beyond + the end of a file). Note that not all file objects are seekable. """ @@ -1783,8 +1793,8 @@ def truncate(self, size=None): def close(self): """Close the file. - A closed file cannot be used for further I/O operations. close() may be - called more than once without error. + A closed file cannot be used for further I/O operations. + close() may be called more than once without error. """ if not self.closed: self._stat_atopen = None @@ -1879,8 +1889,8 @@ class TextIOBase(IOBase): def read(self, size=-1): """Read at most size characters from stream, where size is an int. - Read from underlying buffer until we have size characters or we hit EOF. - If size is negative or omitted, read until EOF. + Read from underlying buffer until we have size characters or we hit + EOF. If size is negative or omitted, read until EOF. Returns a string. """ diff --git a/Lib/_pyrepl/__main__.py b/Lib/_pyrepl/__main__.py index 3fa992eee8e..9c66812e13a 100644 --- a/Lib/_pyrepl/__main__.py +++ b/Lib/_pyrepl/__main__.py @@ -1,6 +1,10 @@ # Important: don't add things to this module, as they will end up in the REPL's # default globals. Use _pyrepl.main instead. +# Avoid caching this file by linecache and incorrectly report tracebacks. +# See https://github.com/python/cpython/issues/129098. +__spec__ = __loader__ = None + if __name__ == "__main__": from .main import interactive_console as __pyrepl_interactive_console __pyrepl_interactive_console() diff --git a/Lib/_pyrepl/_minimal_curses.py b/Lib/_pyrepl/_minimal_curses.py deleted file mode 100644 index d884f880f50..00000000000 --- a/Lib/_pyrepl/_minimal_curses.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Minimal '_curses' module, the low-level interface for curses module -which is not meant to be used directly. - -Based on ctypes. It's too incomplete to be really called '_curses', so -to use it, you have to import it and stick it in sys.modules['_curses'] -manually. - -Note that there is also a built-in module _minimal_curses which will -hide this one if compiled in. -""" - -import ctypes -import ctypes.util - - -class error(Exception): - pass - - -def _find_clib() -> str: - trylibs = ["ncursesw", "ncurses", "curses"] - - for lib in trylibs: - path = ctypes.util.find_library(lib) - if path: - return path - raise ModuleNotFoundError("curses library not found", name="_pyrepl._minimal_curses") - - -_clibpath = _find_clib() -clib = ctypes.cdll.LoadLibrary(_clibpath) - -clib.setupterm.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] -clib.setupterm.restype = ctypes.c_int - -clib.tigetstr.argtypes = [ctypes.c_char_p] -clib.tigetstr.restype = ctypes.c_ssize_t - -clib.tparm.argtypes = [ctypes.c_char_p] + 9 * [ctypes.c_int] # type: ignore[operator] -clib.tparm.restype = ctypes.c_char_p - -OK = 0 -ERR = -1 - -# ____________________________________________________________ - - -def setupterm(termstr, fd): - err = ctypes.c_int(0) - result = clib.setupterm(termstr, fd, ctypes.byref(err)) - if result == ERR: - raise error("setupterm() failed (err=%d)" % err.value) - - -def tigetstr(cap): - if not isinstance(cap, bytes): - cap = cap.encode("ascii") - result = clib.tigetstr(cap) - if result == ERR: - return None - return ctypes.cast(result, ctypes.c_char_p).value - - -def tparm(str, i1=0, i2=0, i3=0, i4=0, i5=0, i6=0, i7=0, i8=0, i9=0): - result = clib.tparm(str, i1, i2, i3, i4, i5, i6, i7, i8, i9) - if result is None: - raise error("tparm() returned NULL") - return result diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py new file mode 100644 index 00000000000..2098d0a54ab --- /dev/null +++ b/Lib/_pyrepl/_module_completer.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import importlib +import os +import pkgutil +import sys +import token +import tokenize +from importlib.machinery import FileFinder +from io import StringIO +from contextlib import contextmanager +from dataclasses import dataclass +from itertools import chain +from tokenize import TokenInfo + +TYPE_CHECKING = False + +if TYPE_CHECKING: + from typing import Any, Iterable, Iterator, Mapping + + +HARDCODED_SUBMODULES = { + # Standard library submodules that are not detected by pkgutil.iter_modules + # but can be imported, so should be proposed in completion + "collections": ["abc"], + "os": ["path"], + "xml.parsers.expat": ["errors", "model"], +} + + +def make_default_module_completer() -> ModuleCompleter: + # Inside pyrepl, __package__ is set to None by default + return ModuleCompleter(namespace={'__package__': None}) + + +class ModuleCompleter: + """A completer for Python import statements. + + Examples: + - import + - import foo + - import foo. + - import foo as bar, baz + + - from + - from foo + - from foo import + - from foo import bar + - from foo import (bar as baz, qux + """ + + def __init__(self, namespace: Mapping[str, Any] | None = None) -> None: + self.namespace = namespace or {} + self._global_cache: list[pkgutil.ModuleInfo] = [] + self._curr_sys_path: list[str] = sys.path[:] + self._stdlib_path = os.path.dirname(importlib.__path__[0]) + + def get_completions(self, line: str) -> list[str] | None: + """Return the next possible import completions for 'line'.""" + result = ImportParser(line).parse() + if not result: + return None + try: + return self.complete(*result) + except Exception: + # Some unexpected error occurred, make it look like + # no completions are available + return [] + + def complete(self, from_name: str | None, name: str | None) -> list[str]: + if from_name is None: + # import x.y.z + assert name is not None + path, prefix = self.get_path_and_prefix(name) + modules = self.find_modules(path, prefix) + return [self.format_completion(path, module) for module in modules] + + if name is None: + # from x.y.z + path, prefix = self.get_path_and_prefix(from_name) + modules = self.find_modules(path, prefix) + return [self.format_completion(path, module) for module in modules] + + # from x.y import z + return self.find_modules(from_name, name) + + def find_modules(self, path: str, prefix: str) -> list[str]: + """Find all modules under 'path' that start with 'prefix'.""" + modules = self._find_modules(path, prefix) + # Filter out invalid module names + # (for example those containing dashes that cannot be imported with 'import') + return [mod for mod in modules if mod.isidentifier()] + + def _find_modules(self, path: str, prefix: str) -> list[str]: + if not path: + # Top-level import (e.g. `import foo`` or `from foo`)` + builtin_modules = [name for name in sys.builtin_module_names + if self.is_suggestion_match(name, prefix)] + third_party_modules = [module.name for module in self.global_cache + if self.is_suggestion_match(module.name, prefix)] + return sorted(builtin_modules + third_party_modules) + + if path.startswith('.'): + # Convert relative path to absolute path + package = self.namespace.get('__package__', '') + path = self.resolve_relative_name(path, package) # type: ignore[assignment] + if path is None: + return [] + + modules: Iterable[pkgutil.ModuleInfo] = self.global_cache + imported_module = sys.modules.get(path.split('.')[0]) + if imported_module: + # Filter modules to those who name and specs match the + # imported module to avoid invalid suggestions + spec = imported_module.__spec__ + if spec: + modules = [mod for mod in modules + if mod.name == spec.name + and mod.module_finder.find_spec(mod.name, None) == spec] + else: + modules = [] + + is_stdlib_import: bool | None = None + for segment in path.split('.'): + modules = [mod_info for mod_info in modules + if mod_info.ispkg and mod_info.name == segment] + if is_stdlib_import is None: + # Top-level import decide if we import from stdlib or not + is_stdlib_import = all( + self._is_stdlib_module(mod_info) for mod_info in modules + ) + modules = self.iter_submodules(modules) + + module_names = [module.name for module in modules] + if is_stdlib_import: + module_names.extend(HARDCODED_SUBMODULES.get(path, ())) + return [module_name for module_name in module_names + if self.is_suggestion_match(module_name, prefix)] + + def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool: + return (isinstance(module_info.module_finder, FileFinder) + and module_info.module_finder.path == self._stdlib_path) + + def is_suggestion_match(self, module_name: str, prefix: str) -> bool: + if prefix: + return module_name.startswith(prefix) + # For consistency with attribute completion, which + # does not suggest private attributes unless requested. + return not module_name.startswith("_") + + def iter_submodules(self, parent_modules: list[pkgutil.ModuleInfo]) -> Iterator[pkgutil.ModuleInfo]: + """Iterate over all submodules of the given parent modules.""" + specs = [info.module_finder.find_spec(info.name, None) + for info in parent_modules if info.ispkg] + search_locations = set(chain.from_iterable( + getattr(spec, 'submodule_search_locations', []) + for spec in specs if spec + )) + return pkgutil.iter_modules(search_locations) + + def get_path_and_prefix(self, dotted_name: str) -> tuple[str, str]: + """ + Split a dotted name into an import path and a + final prefix that is to be completed. + + Examples: + 'foo.bar' -> 'foo', 'bar' + 'foo.' -> 'foo', '' + '.foo' -> '.', 'foo' + """ + if '.' not in dotted_name: + return '', dotted_name + if dotted_name.startswith('.'): + stripped = dotted_name.lstrip('.') + dots = '.' * (len(dotted_name) - len(stripped)) + if '.' not in stripped: + return dots, stripped + path, prefix = stripped.rsplit('.', 1) + return dots + path, prefix + path, prefix = dotted_name.rsplit('.', 1) + return path, prefix + + def format_completion(self, path: str, module: str) -> str: + if path == '' or path.endswith('.'): + return f'{path}{module}' + return f'{path}.{module}' + + def resolve_relative_name(self, name: str, package: str) -> str | None: + """Resolve a relative module name to an absolute name. + + Example: resolve_relative_name('.foo', 'bar') -> 'bar.foo' + """ + # taken from importlib._bootstrap + level = 0 + for character in name: + if character != '.': + break + level += 1 + bits = package.rsplit('.', level - 1) + if len(bits) < level: + return None + base = bits[0] + name = name[level:] + return f'{base}.{name}' if name else base + + @property + def global_cache(self) -> list[pkgutil.ModuleInfo]: + """Global module cache""" + if not self._global_cache or self._curr_sys_path != sys.path: + self._curr_sys_path = sys.path[:] + self._global_cache = list(pkgutil.iter_modules()) + return self._global_cache + + +class ImportParser: + """ + Parses incomplete import statements that are + suitable for autocomplete suggestions. + + Examples: + - import foo -> Result(from_name=None, name='foo') + - import foo. -> Result(from_name=None, name='foo.') + - from foo -> Result(from_name='foo', name=None) + - from foo import bar -> Result(from_name='foo', name='bar') + - from .foo import ( -> Result(from_name='.foo', name='') + + Note that the parser works in reverse order, starting from the + last token in the input string. This makes the parser more robust + when parsing multiple statements. + """ + _ignored_tokens = { + token.INDENT, token.DEDENT, token.COMMENT, + token.NL, token.NEWLINE, token.ENDMARKER + } + _keywords = {'import', 'from', 'as'} + + def __init__(self, code: str) -> None: + self.code = code + tokens = [] + try: + for t in tokenize.generate_tokens(StringIO(code).readline): + if t.type not in self._ignored_tokens: + tokens.append(t) + except tokenize.TokenError as e: + if 'unexpected EOF' not in str(e): + # unexpected EOF is fine, since we're parsing an + # incomplete statement, but other errors are not + # because we may not have all the tokens so it's + # safer to bail out + tokens = [] + except SyntaxError: + tokens = [] + self.tokens = TokenQueue(tokens[::-1]) + + def parse(self) -> tuple[str | None, str | None] | None: + if not (res := self._parse()): + return None + return res.from_name, res.name + + def _parse(self) -> Result | None: + with self.tokens.save_state(): + return self.parse_from_import() + with self.tokens.save_state(): + return self.parse_import() + + def parse_import(self) -> Result: + if self.code.rstrip().endswith('import') and self.code.endswith(' '): + return Result(name='') + if self.tokens.peek_string(','): + name = '' + else: + if self.code.endswith(' '): + raise ParseError('parse_import') + name = self.parse_dotted_name() + if name.startswith('.'): + raise ParseError('parse_import') + while self.tokens.peek_string(','): + self.tokens.pop() + self.parse_dotted_as_name() + if self.tokens.peek_string('import'): + return Result(name=name) + raise ParseError('parse_import') + + def parse_from_import(self) -> Result: + stripped = self.code.rstrip() + if stripped.endswith('import') and self.code.endswith(' '): + return Result(from_name=self.parse_empty_from_import(), name='') + if stripped.endswith('from') and self.code.endswith(' '): + return Result(from_name='') + if self.tokens.peek_string('(') or self.tokens.peek_string(','): + return Result(from_name=self.parse_empty_from_import(), name='') + if self.code.endswith(' '): + raise ParseError('parse_from_import') + name = self.parse_dotted_name() + if '.' in name: + self.tokens.pop_string('from') + return Result(from_name=name) + if self.tokens.peek_string('from'): + return Result(from_name=name) + from_name = self.parse_empty_from_import() + return Result(from_name=from_name, name=name) + + def parse_empty_from_import(self) -> str: + if self.tokens.peek_string(','): + self.tokens.pop() + self.parse_as_names() + if self.tokens.peek_string('('): + self.tokens.pop() + self.tokens.pop_string('import') + return self.parse_from() + + def parse_from(self) -> str: + from_name = self.parse_dotted_name() + self.tokens.pop_string('from') + return from_name + + def parse_dotted_as_name(self) -> str: + self.tokens.pop_name() + if self.tokens.peek_string('as'): + self.tokens.pop() + with self.tokens.save_state(): + return self.parse_dotted_name() + + def parse_dotted_name(self) -> str: + name = [] + if self.tokens.peek_string('.'): + name.append('.') + self.tokens.pop() + if (self.tokens.peek_name() + and (tok := self.tokens.peek()) + and tok.string not in self._keywords): + name.append(self.tokens.pop_name()) + if not name: + raise ParseError('parse_dotted_name') + while self.tokens.peek_string('.'): + name.append('.') + self.tokens.pop() + if (self.tokens.peek_name() + and (tok := self.tokens.peek()) + and tok.string not in self._keywords): + name.append(self.tokens.pop_name()) + else: + break + + while self.tokens.peek_string('.'): + name.append('.') + self.tokens.pop() + return ''.join(name[::-1]) + + def parse_as_names(self) -> None: + self.parse_as_name() + while self.tokens.peek_string(','): + self.tokens.pop() + self.parse_as_name() + + def parse_as_name(self) -> None: + self.tokens.pop_name() + if self.tokens.peek_string('as'): + self.tokens.pop() + self.tokens.pop_name() + + +class ParseError(Exception): + pass + + +@dataclass(frozen=True) +class Result: + from_name: str | None = None + name: str | None = None + + +class TokenQueue: + """Provides helper functions for working with a sequence of tokens.""" + + def __init__(self, tokens: list[TokenInfo]) -> None: + self.tokens: list[TokenInfo] = tokens + self.index: int = 0 + self.stack: list[int] = [] + + @contextmanager + def save_state(self) -> Any: + try: + self.stack.append(self.index) + yield + except ParseError: + self.index = self.stack.pop() + else: + self.stack.pop() + + def __bool__(self) -> bool: + return self.index < len(self.tokens) + + def peek(self) -> TokenInfo | None: + if not self: + return None + return self.tokens[self.index] + + def peek_name(self) -> bool: + if not (tok := self.peek()): + return False + return tok.type == token.NAME + + def pop_name(self) -> str: + tok = self.pop() + if tok.type != token.NAME: + raise ParseError('pop_name') + return tok.string + + def peek_string(self, string: str) -> bool: + if not (tok := self.peek()): + return False + return tok.string == string + + def pop_string(self, string: str) -> str: + tok = self.pop() + if tok.string != string: + raise ParseError('pop_string') + return tok.string + + def pop(self) -> TokenInfo: + if not self: + raise ParseError('pop') + tok = self.tokens[self.index] + self.index += 1 + return tok diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py new file mode 100644 index 00000000000..0589a0f437e --- /dev/null +++ b/Lib/_pyrepl/base_eventqueue.py @@ -0,0 +1,110 @@ +# Copyright 2000-2008 Michael Hudson-Doyle +# Armin Rigo +# +# All Rights Reserved +# +# +# Permission to use, copy, modify, and distribute this software and +# its documentation for any purpose is hereby granted without fee, +# provided that the above copyright notice appear in all copies and +# that both that copyright notice and this permission notice appear in +# supporting documentation. +# +# THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, +# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" +OS-independent base for an event and VT sequence scanner + +See unix_eventqueue and windows_eventqueue for subclasses. +""" + +from collections import deque + +from . import keymap +from .console import Event +from .trace import trace + +class BaseEventQueue: + def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: + self.compiled_keymap = keymap.compile_keymap(keymap_dict) + self.keymap = self.compiled_keymap + trace("keymap {k!r}", k=self.keymap) + self.encoding = encoding + self.events: deque[Event] = deque() + self.buf = bytearray() + + def get(self) -> Event | None: + """ + Retrieves the next event from the queue. + """ + if self.events: + return self.events.popleft() + else: + return None + + def empty(self) -> bool: + """ + Checks if the queue is empty. + """ + return not self.events + + def flush_buf(self) -> bytearray: + """ + Flushes the buffer and returns its contents. + """ + old = self.buf + self.buf = bytearray() + return old + + def insert(self, event: Event) -> None: + """ + Inserts an event into the queue. + """ + trace('added event {event}', event=event) + self.events.append(event) + + def push(self, char: int | bytes) -> None: + """ + Processes a character by updating the buffer and handling special key mappings. + """ + assert isinstance(char, (int, bytes)) + ord_char = char if isinstance(char, int) else ord(char) + char = ord_char.to_bytes() + self.buf.append(ord_char) + + if char in self.keymap: + if self.keymap is self.compiled_keymap: + # sanity check, buffer is empty when a special key comes + assert len(self.buf) == 1 + k = self.keymap[char] + trace('found map {k!r}', k=k) + if isinstance(k, dict): + self.keymap = k + else: + self.insert(Event('key', k, bytes(self.flush_buf()))) + self.keymap = self.compiled_keymap + + elif self.buf and self.buf[0] == 27: # escape + # escape sequence not recognized by our keymap: propagate it + # outside so that i can be recognized as an M-... key (see also + # the docstring in keymap.py + trace('unrecognized escape sequence, propagating...') + self.keymap = self.compiled_keymap + self.insert(Event('key', '\033', b'\033')) + for _c in self.flush_buf()[1:]: + self.push(_c) + + else: + try: + decoded = bytes(self.buf).decode(self.encoding) + except UnicodeError: + return + else: + self.insert(Event('key', decoded, bytes(self.flush_buf()))) + self.keymap = self.compiled_keymap diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 503ca1da329..10127e58897 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -21,6 +21,7 @@ from __future__ import annotations import os +import time # Categories of actions: # killing @@ -31,6 +32,7 @@ # finishing # [completion] +from .trace import trace # types if False: @@ -368,6 +370,13 @@ def do(self) -> None: r = self.reader text = self.event * r.get_arg() r.insert(text) + if r.paste_mode: + data = "" + ev = r.console.getpending() + data += ev.data + if data: + r.insert(data) + r.last_refresh_cache.invalidated = True class insert_nl(EditCommand): @@ -411,14 +420,17 @@ class delete(EditCommand): def do(self) -> None: r = self.reader b = r.buffer - if ( - r.pos == 0 - and len(b) == 0 # this is something of a hack - and self.event[-1] == "\004" - ): - r.update_screen() - r.console.finish() - raise EOFError + if self.event[-1] == "\004": + if b and b[-1].endswith("\n"): + self.finish = True + elif ( + r.pos == 0 + and len(b) == 0 # this is something of a hack + ): + r.update_screen() + r.console.finish() + raise EOFError + for i in range(r.get_arg()): if r.pos != len(b): del b[r.pos] @@ -437,7 +449,7 @@ def do(self) -> None: import _sitebuiltins with self.reader.suspend(): - self.reader.msg = _sitebuiltins._Helper()() # type: ignore[assignment, call-arg] + self.reader.msg = _sitebuiltins._Helper()() # type: ignore[assignment] class invalid_key(Command): @@ -456,7 +468,7 @@ def do(self) -> None: class show_history(Command): def do(self) -> None: from .pager import get_pager - from site import gethistoryfile # type: ignore[attr-defined] + from site import gethistoryfile history = os.linesep.join(self.reader.history[:]) self.reader.console.restore() @@ -471,19 +483,23 @@ def do(self) -> None: class paste_mode(Command): - def do(self) -> None: self.reader.paste_mode = not self.reader.paste_mode self.reader.dirty = True -class enable_bracketed_paste(Command): - def do(self) -> None: - self.reader.paste_mode = True - self.reader.in_bracketed_paste = True - -class disable_bracketed_paste(Command): - def do(self) -> None: - self.reader.paste_mode = False - self.reader.in_bracketed_paste = False - self.reader.dirty = True +class perform_bracketed_paste(Command): + def do(self) -> None: + done = "\x1b[201~" + data = "" + start = time.time() + while done not in data: + ev = self.reader.console.getpending() + data += ev.data + trace( + "bracketed pasting of {l} chars done in {s:.2f}s", + l=len(data), + s=time.time() - start, + ) + self.reader.insert(data.replace(done, "")) + self.reader.last_refresh_cache.invalidated = True diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py index 9a005281dab..9d2d43be514 100644 --- a/Lib/_pyrepl/completing_reader.py +++ b/Lib/_pyrepl/completing_reader.py @@ -91,7 +91,7 @@ def build_menu( # D E F B E # G C F # - # "fill" the table with empty words, so we always have the same amout + # "fill" the table with empty words, so we always have the same amount # of rows for each column missing = cols*rows - len(wordlist) wordlist = wordlist + ['']*missing @@ -293,3 +293,7 @@ def get_stem(self) -> str: def get_completions(self, stem: str) -> list[str]: return [] + + def get_line(self) -> str: + """Return the current line until the cursor position.""" + return ''.join(self.buffer[:self.pos]) diff --git a/Lib/_pyrepl/console.py b/Lib/_pyrepl/console.py index 0d78890b4f4..8956fb1242e 100644 --- a/Lib/_pyrepl/console.py +++ b/Lib/_pyrepl/console.py @@ -19,11 +19,12 @@ from __future__ import annotations -import _colorize # type: ignore[import-not-found] +import _colorize from abc import ABC, abstractmethod import ast import code +import linecache from dataclasses import dataclass, field import os.path import sys @@ -152,6 +153,8 @@ def repaint(self) -> None: ... class InteractiveColoredConsole(code.InteractiveConsole): + STATEMENT_FAILED = object() + def __init__( self, locals: dict[str, object] | None = None, @@ -159,7 +162,7 @@ def __init__( *, local_exit: bool = False, ) -> None: - super().__init__(locals=locals, filename=filename, local_exit=local_exit) # type: ignore[call-arg] + super().__init__(locals=locals, filename=filename, local_exit=local_exit) self.can_colorize = _colorize.can_colorize() def showsyntaxerror(self, filename=None, **kwargs): @@ -173,6 +176,16 @@ def _excepthook(self, typ, value, tb): limit=traceback.BUILTIN_EXCEPTION_LIMIT) self.write(''.join(lines)) + def runcode(self, code): + try: + exec(code, self.locals) + except SystemExit: + raise + except BaseException: + self.showtraceback() + return self.STATEMENT_FAILED + return None + def runsource(self, source, filename="", symbol="single"): try: tree = self.compile.compiler( @@ -193,6 +206,7 @@ def runsource(self, source, filename="", symbol="single"): item = wrapper([stmt]) try: code = self.compile.compiler(item, filename, the_symbol) + linecache._register_code(code, source, filename) except SyntaxError as e: if e.args[0] == "'await' outside function": python = os.path.basename(sys.executable) @@ -209,5 +223,7 @@ def runsource(self, source, filename="", symbol="single"): if code is None: return True - self.runcode(code) + result = self.runcode(code) + if result is self.STATEMENT_FAILED: + break return False diff --git a/Lib/_pyrepl/curses.py b/Lib/_pyrepl/curses.py deleted file mode 100644 index 3a624d9f683..00000000000 --- a/Lib/_pyrepl/curses.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright 2000-2010 Michael Hudson-Doyle -# Armin Rigo -# -# All Rights Reserved -# -# -# Permission to use, copy, modify, and distribute this software and -# its documentation for any purpose is hereby granted without fee, -# provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in -# supporting documentation. -# -# THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO -# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -# AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, -# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER -# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF -# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -try: - import _curses -except ImportError: - try: - import curses as _curses # type: ignore[no-redef] - except ImportError: - from . import _minimal_curses as _curses # type: ignore[no-redef] - -setupterm = _curses.setupterm -tigetstr = _curses.tigetstr -tparm = _curses.tparm -error = _curses.error diff --git a/Lib/_pyrepl/fancy_termios.py b/Lib/_pyrepl/fancy_termios.py index 0468b9a2670..8d5bd183f21 100644 --- a/Lib/_pyrepl/fancy_termios.py +++ b/Lib/_pyrepl/fancy_termios.py @@ -20,19 +20,25 @@ import termios +TYPE_CHECKING = False + +if TYPE_CHECKING: + from typing import cast +else: + cast = lambda typ, val: val + + class TermState: - def __init__(self, tuples): - ( - self.iflag, - self.oflag, - self.cflag, - self.lflag, - self.ispeed, - self.ospeed, - self.cc, - ) = tuples + def __init__(self, attrs: list[int | list[bytes]]) -> None: + self.iflag = cast(int, attrs[0]) + self.oflag = cast(int, attrs[1]) + self.cflag = cast(int, attrs[2]) + self.lflag = cast(int, attrs[3]) + self.ispeed = cast(int, attrs[4]) + self.ospeed = cast(int, attrs[5]) + self.cc = cast(list[bytes], attrs[6]) - def as_list(self): + def as_list(self) -> list[int | list[bytes]]: return [ self.iflag, self.oflag, @@ -45,32 +51,32 @@ def as_list(self): self.cc[:], ] - def copy(self): + def copy(self) -> "TermState": return self.__class__(self.as_list()) -def tcgetattr(fd): +def tcgetattr(fd: int) -> TermState: return TermState(termios.tcgetattr(fd)) -def tcsetattr(fd, when, attrs): +def tcsetattr(fd: int, when: int, attrs: TermState) -> None: termios.tcsetattr(fd, when, attrs.as_list()) class Term(TermState): TS__init__ = TermState.__init__ - def __init__(self, fd=0): + def __init__(self, fd: int = 0) -> None: self.TS__init__(termios.tcgetattr(fd)) self.fd = fd - self.stack = [] + self.stack: list[list[int | list[bytes]]] = [] - def save(self): + def save(self) -> None: self.stack.append(self.as_list()) - def set(self, when=termios.TCSANOW): + def set(self, when: int = termios.TCSANOW) -> None: termios.tcsetattr(self.fd, when, self.as_list()) - def restore(self): + def restore(self) -> None: self.TS__init__(self.stack.pop()) self.set() diff --git a/Lib/_pyrepl/keymap.py b/Lib/_pyrepl/keymap.py index 2fb03d19523..d11df4b5164 100644 --- a/Lib/_pyrepl/keymap.py +++ b/Lib/_pyrepl/keymap.py @@ -30,20 +30,20 @@ pyrepl uses its own keyspec format that is meant to be a strict superset of readline's KEYSEQ format. This means that if a spec is found that readline accepts that this doesn't, it should be logged as a bug. Note that this means -we're using the `\\C-o' style of readline's keyspec, not the `Control-o' sort. +we're using the '\\C-o' style of readline's keyspec, not the 'Control-o' sort. The extension to readline is that the sequence \\ denotes the sequence of characters produced by hitting KEY. Examples: -`a' - what you get when you hit the `a' key -`\\EOA' - Escape - O - A (up, on my terminal) -`\\' - the up arrow key -`\\' - ditto (keynames are case-insensitive) -`\\C-o', `\\c-o' - control-o -`\\M-.' - meta-period -`\\E.' - ditto (that's how meta works for pyrepl) -`\\', `\\', `\\t', `\\011', '\\x09', '\\X09', '\\C-i', '\\C-I' +'a' - what you get when you hit the 'a' key +'\\EOA' - Escape - O - A (up, on my terminal) +'\\' - the up arrow key +'\\' - ditto (keynames are case-insensitive) +'\\C-o', '\\c-o' - control-o +'\\M-.' - meta-period +'\\E.' - ditto (that's how meta works for pyrepl) +'\\', '\\', '\\t', '\\011', '\\x09', '\\X09', '\\C-i', '\\C-I' - all of these are the tab character. """ diff --git a/Lib/_pyrepl/main.py b/Lib/_pyrepl/main.py index a6f824dcc4a..447eb1e551e 100644 --- a/Lib/_pyrepl/main.py +++ b/Lib/_pyrepl/main.py @@ -1,6 +1,7 @@ import errno import os import sys +import types CAN_USE_PYREPL: bool @@ -29,12 +30,10 @@ def interactive_console(mainmodule=None, quiet=False, pythonstartup=False): print(FAIL_REASON, file=sys.stderr) return sys._baserepl() - if mainmodule: - namespace = mainmodule.__dict__ - else: - import __main__ - namespace = __main__.__dict__ - namespace.pop("__pyrepl_interactive_console", None) + if not mainmodule: + mainmodule = types.ModuleType("__main__") + + namespace = mainmodule.__dict__ # sys._baserepl() above does this internally, we do it here startup_path = os.getenv("PYTHONSTARTUP") diff --git a/Lib/_pyrepl/mypy.ini b/Lib/_pyrepl/mypy.ini index 395f5945ab7..9375a55b53c 100644 --- a/Lib/_pyrepl/mypy.ini +++ b/Lib/_pyrepl/mypy.ini @@ -4,8 +4,9 @@ [mypy] files = Lib/_pyrepl +mypy_path = $MYPY_CONFIG_FILE_DIR/../../Misc/mypy explicit_package_bases = True -python_version = 3.12 +python_version = 3.13 platform = linux pretty = True diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index dc26bfd3a34..f0116e742d2 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -22,15 +22,13 @@ from __future__ import annotations import sys +import _colorize from contextlib import contextmanager from dataclasses import dataclass, field, fields -import unicodedata -from _colorize import can_colorize, ANSIColors # type: ignore[import-not-found] - from . import commands, console, input -from .utils import ANSI_ESCAPE_SEQUENCE, wlen, str_width +from .utils import wlen, unbracket, disp_str, gen_colors, THEME from .trace import trace @@ -39,38 +37,7 @@ from .types import Callback, SimpleContextManager, KeySpec, CommandName -def disp_str(buffer: str) -> tuple[str, list[int]]: - """disp_str(buffer:string) -> (string, [int]) - - Return the string that should be the printed representation of - |buffer| and a list detailing where the characters of |buffer| - get used up. E.g.: - - >>> disp_str(chr(3)) - ('^C', [1, 0]) - - """ - b: list[int] = [] - s: list[str] = [] - for c in buffer: - if c == '\x1a': - s.append(c) - b.append(2) - elif ord(c) < 128: - s.append(c) - b.append(1) - elif unicodedata.category(c).startswith("C"): - c = r"\u%04x" % ord(c) - s.append(c) - b.extend([0] * (len(c) - 1)) - else: - s.append(c) - b.append(str_width(c)) - return "".join(s), b - - -# syntax classes: - +# syntax classes SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3) @@ -136,8 +103,7 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"\M-9", "digit-arg"), (r"\M-\n", "accept"), ("\\\\", "self-insert"), - (r"\x1b[200~", "enable_bracketed_paste"), - (r"\x1b[201~", "disable_bracketed_paste"), + (r"\x1b[200~", "perform-bracketed-paste"), (r"\x03", "ctrl-c"), ] + [(c, "self-insert") for c in map(chr, range(32, 127)) if c != "\\"] @@ -175,20 +141,21 @@ class Reader: Instance variables of note include: * buffer: - A *list* (*not* a string at the moment :-) containing all the - characters that have been entered. + A per-character list containing all the characters that have been + entered. Does not include color information. * console: Hopefully encapsulates the OS dependent stuff. * pos: - A 0-based index into `buffer' for where the insertion point + A 0-based index into 'buffer' for where the insertion point is. * screeninfo: - Ahem. This list contains some info needed to move the - insertion point around reasonably efficiently. + A list of screen position tuples. Each list element is a tuple + representing information on visible line length for a given line. + Allows for efficient skipping of color escape sequences. * cxy, lxy: the position of the insertion point in screen ... * syntax_table: - Dictionary mapping characters to `syntax class'; read the + Dictionary mapping characters to 'syntax class'; read the emacs docs to see what this means :-) * commands: Dictionary mapping command names to command classes. @@ -234,7 +201,6 @@ class Reader: dirty: bool = False finished: bool = False paste_mode: bool = False - in_bracketed_paste: bool = False commands: dict[str, type[Command]] = field(default_factory=make_default_commands) last_command: type[Command] | None = None syntax_table: dict[str, int] = field(default_factory=make_default_syntax_table) @@ -252,7 +218,6 @@ class Reader: ## cached metadata to speed up screen refreshes @dataclass class RefreshCache: - in_bracketed_paste: bool = False screen: list[str] = field(default_factory=list) screeninfo: list[tuple[int, list[int]]] = field(init=False) line_end_offsets: list[int] = field(default_factory=list) @@ -266,7 +231,6 @@ def update_cache(self, screen: list[str], screeninfo: list[tuple[int, list[int]]], ) -> None: - self.in_bracketed_paste = reader.in_bracketed_paste self.screen = screen.copy() self.screeninfo = screeninfo.copy() self.pos = reader.pos @@ -279,8 +243,7 @@ def valid(self, reader: Reader) -> bool: return False dimensions = reader.console.width, reader.console.height dimensions_changed = dimensions != self.dimensions - paste_changed = reader.in_bracketed_paste != self.in_bracketed_paste - return not (dimensions_changed or paste_changed) + return not dimensions_changed def get_cached_location(self, reader: Reader) -> tuple[int, int]: if self.invalidated: @@ -310,7 +273,7 @@ def __post_init__(self) -> None: self.screeninfo = [(0, [])] self.cxy = self.pos2xy() self.lxy = (self.pos, 0) - self.can_colorize = can_colorize() + self.can_colorize = _colorize.can_colorize() self.last_refresh_cache.screeninfo = self.screeninfo self.last_refresh_cache.pos = self.pos @@ -348,13 +311,17 @@ def calc_screen(self) -> list[str]: prompt_from_cache = (offset and self.buffer[offset - 1] != "\n") + if self.can_colorize: + colors = list(gen_colors(self.get_unicode())) + else: + colors = None + trace("colors = {colors}", colors=colors) lines = "".join(self.buffer[offset:]).split("\n") - cursor_found = False lines_beyond_cursor = 0 for ln, line in enumerate(lines, num_common_lines): - ll = len(line) - if 0 <= pos <= ll: + line_len = len(line) + if 0 <= pos <= line_len: self.lxy = pos, ln cursor_found = True elif cursor_found: @@ -368,34 +335,33 @@ def calc_screen(self) -> list[str]: prompt_from_cache = False prompt = "" else: - prompt = self.get_prompt(ln, ll >= pos >= 0) + prompt = self.get_prompt(ln, line_len >= pos >= 0) while "\n" in prompt: pre_prompt, _, prompt = prompt.partition("\n") last_refresh_line_end_offsets.append(offset) screen.append(pre_prompt) screeninfo.append((0, [])) - pos -= ll + 1 - prompt, lp = self.process_prompt(prompt) - l, l2 = disp_str(line) - wrapcount = (wlen(l) + lp) // self.console.width - if wrapcount == 0: - offset += ll + 1 # Takes all of the line plus the newline + pos -= line_len + 1 + prompt, prompt_len = self.process_prompt(prompt) + chars, char_widths = disp_str(line, colors, offset) + wrapcount = (sum(char_widths) + prompt_len) // self.console.width + if wrapcount == 0 or not char_widths: + offset += line_len + 1 # Takes all of the line plus the newline last_refresh_line_end_offsets.append(offset) - screen.append(prompt + l) - screeninfo.append((lp, l2)) + screen.append(prompt + "".join(chars)) + screeninfo.append((prompt_len, char_widths)) else: - i = 0 - while l: - prelen = lp if i == 0 else 0 + pre = prompt + prelen = prompt_len + for wrap in range(wrapcount + 1): index_to_wrap_before = 0 column = 0 - for character_width in l2: - if column + character_width >= self.console.width - prelen: + for char_width in char_widths: + if column + char_width + prelen >= self.console.width: break index_to_wrap_before += 1 - column += character_width - pre = prompt if i == 0 else "" - if len(l) > index_to_wrap_before: + column += char_width + if len(chars) > index_to_wrap_before: offset += index_to_wrap_before post = "\\" after = [1] @@ -404,11 +370,14 @@ def calc_screen(self) -> list[str]: post = "" after = [] last_refresh_line_end_offsets.append(offset) - screen.append(pre + l[:index_to_wrap_before] + post) - screeninfo.append((prelen, l2[:index_to_wrap_before] + after)) - l = l[index_to_wrap_before:] - l2 = l2[index_to_wrap_before:] - i += 1 + render = pre + "".join(chars[:index_to_wrap_before]) + post + render_widths = char_widths[:index_to_wrap_before] + after + screen.append(render) + screeninfo.append((prelen, render_widths)) + chars = chars[index_to_wrap_before:] + char_widths = char_widths[index_to_wrap_before:] + pre = "" + prelen = 0 self.screeninfo = screeninfo self.cxy = self.pos2xy() if self.msg: @@ -421,42 +390,15 @@ def calc_screen(self) -> list[str]: @staticmethod def process_prompt(prompt: str) -> tuple[str, int]: - """Process the prompt. - - This means calculate the length of the prompt. The character \x01 - and \x02 are used to bracket ANSI control sequences and need to be - excluded from the length calculation. So also a copy of the prompt - is returned with these control characters removed.""" + r"""Return a tuple with the prompt string and its visible length. - # The logic below also ignores the length of common escape - # sequences if they were not explicitly within \x01...\x02. - # They are CSI (or ANSI) sequences ( ESC [ ... LETTER ) - - # wlen from utils already excludes ANSI_ESCAPE_SEQUENCE chars, - # which breaks the logic below so we redefine it here. - def wlen(s: str) -> int: - return sum(str_width(i) for i in s) - - out_prompt = "" - l = wlen(prompt) - pos = 0 - while True: - s = prompt.find("\x01", pos) - if s == -1: - break - e = prompt.find("\x02", s) - if e == -1: - break - # Found start and end brackets, subtract from string length - l = l - (e - s + 1) - keep = prompt[pos:s] - l -= sum(map(wlen, ANSI_ESCAPE_SEQUENCE.findall(keep))) - out_prompt += keep + prompt[s + 1 : e] - pos = e + 1 - keep = prompt[pos:] - l -= sum(map(wlen, ANSI_ESCAPE_SEQUENCE.findall(keep))) - out_prompt += keep - return out_prompt, l + The prompt string has the zero-width brackets recognized by shells + (\x01 and \x02) removed. The length ignores anything between those + brackets as well as any ANSI escape sequences. + """ + out_prompt = unbracket(prompt, including_content=False) + visible_prompt = unbracket(prompt, including_content=True) + return out_prompt, wlen(visible_prompt) def bow(self, p: int | None = None) -> int: """Return the 0-based index of the word break preceding p most @@ -525,7 +467,7 @@ def max_row(self) -> int: def get_arg(self, default: int = 1) -> int: """Return any prefix argument that the user has supplied, - returning `default' if there is None. Defaults to 1. + returning 'default' if there is None. Defaults to 1. """ if self.arg is None: return default @@ -533,10 +475,10 @@ def get_arg(self, default: int = 1) -> int: def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: """Return what should be in the left-hand margin for line - `lineno'.""" + 'lineno'.""" if self.arg is not None and cursor_on_line: prompt = f"(arg: {self.arg}) " - elif self.paste_mode and not self.in_bracketed_paste: + elif self.paste_mode: prompt = "(paste) " elif "\n" in self.buffer: if lineno == 0: @@ -549,7 +491,8 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: prompt = self.ps1 if self.can_colorize: - prompt = f"{ANSIColors.BOLD_MAGENTA}{prompt}{ANSIColors.RESET}" + t = THEME() + prompt = f"{t.prompt}{prompt}{t.reset}" return prompt def push_input_trans(self, itrans: input.KeymapTranslator) -> None: @@ -564,9 +507,9 @@ def setpos_from_xy(self, x: int, y: int) -> None: pos = 0 i = 0 while i < y: - prompt_len, character_widths = self.screeninfo[i] - offset = len(character_widths) - character_widths.count(0) - in_wrapped_line = prompt_len + sum(character_widths) >= self.console.width + prompt_len, char_widths = self.screeninfo[i] + offset = len(char_widths) + in_wrapped_line = prompt_len + sum(char_widths) >= self.console.width if in_wrapped_line: pos += offset - 1 # -1 cause backslash is not in buffer else: @@ -577,6 +520,7 @@ def setpos_from_xy(self, x: int, y: int) -> None: cur_x = self.screeninfo[i][0] while cur_x < x: if self.screeninfo[i][1][j] == 0: + j += 1 # prevent potential future infinite loop continue cur_x += self.screeninfo[i][1][j] j += 1 @@ -586,29 +530,33 @@ def setpos_from_xy(self, x: int, y: int) -> None: def pos2xy(self) -> tuple[int, int]: """Return the x, y coordinates of position 'pos'.""" - # this *is* incomprehensible, yes. - p, y = 0, 0 - l2: list[int] = [] + + prompt_len, y = 0, 0 + char_widths: list[int] = [] pos = self.pos assert 0 <= pos <= len(self.buffer) + + # optimize for the common case: typing at the end of the buffer if pos == len(self.buffer) and len(self.screeninfo) > 0: y = len(self.screeninfo) - 1 - p, l2 = self.screeninfo[y] - return p + sum(l2) + l2.count(0), y + prompt_len, char_widths = self.screeninfo[y] + return prompt_len + sum(char_widths), y + + for prompt_len, char_widths in self.screeninfo: + offset = len(char_widths) + in_wrapped_line = prompt_len + sum(char_widths) >= self.console.width + if in_wrapped_line: + offset -= 1 # need to remove line-wrapping backslash - for p, l2 in self.screeninfo: - l = len(l2) - l2.count(0) - in_wrapped_line = p + sum(l2) >= self.console.width - offset = l - 1 if in_wrapped_line else l # need to remove backslash if offset >= pos: break - if p + sum(l2) >= self.console.width: - pos -= l - 1 # -1 cause backslash is not in buffer - else: - pos -= l + 1 # +1 cause newline is in buffer + if not in_wrapped_line: + offset += 1 # there's a newline in buffer + + pos -= offset y += 1 - return p + sum(l2[:pos]), y + return prompt_len + sum(char_widths[:pos]), y def insert(self, text: str | list[str]) -> None: """Insert 'text' at the insertion point.""" @@ -619,6 +567,7 @@ def insert(self, text: str | list[str]) -> None: def update_cursor(self) -> None: """Move the cursor to reflect changes in self.pos""" self.cxy = self.pos2xy() + trace("update_cursor({pos}) = {cxy}", pos=self.pos, cxy=self.cxy) self.console.move_cursor(*self.cxy) def after_command(self, cmd: Command) -> None: @@ -670,6 +619,16 @@ def suspend(self) -> SimpleContextManager: setattr(self, arg, prev_state[arg]) self.prepare() + @contextmanager + def suspend_colorization(self) -> SimpleContextManager: + try: + old_can_colorize = self.can_colorize + self.can_colorize = False + yield + finally: + self.can_colorize = old_can_colorize + + def finish(self) -> None: """Called when a command signals that we're finished.""" pass @@ -685,9 +644,7 @@ def update_screen(self) -> None: def refresh(self) -> None: """Recalculate and refresh the screen.""" - if self.in_bracketed_paste and self.buffer and not self.buffer[-1] == "\n": - return - + self.console.height, self.console.width = self.console.getheightwidth() # this call sets up self.cxy, so call it first. self.screen = self.calc_screen() self.console.refresh(self.screen, self.cxy) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 888185eb03b..23b8fa6b9c7 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -32,20 +32,22 @@ from dataclasses import dataclass, field import os -from site import gethistoryfile # type: ignore[attr-defined] +from site import gethistoryfile import sys from rlcompleter import Completer as RLCompleter from . import commands, historical_reader from .completing_reader import CompletingReader from .console import Console as ConsoleType +from ._module_completer import ModuleCompleter, make_default_module_completer Console: type[ConsoleType] _error: tuple[type[Exception], ...] | type[Exception] -try: - from .unix_console import UnixConsole as Console, _error -except ImportError: + +if os.name == "nt": from .windows_console import WindowsConsole as Console, _error +else: + from .unix_console import UnixConsole as Console, _error ENCODING = sys.getdefaultencoding() or "latin1" @@ -89,6 +91,7 @@ # "set_pre_input_hook", "set_startup_hook", "write_history_file", + "append_history_file", # ---- multiline extensions ---- "multiline_input", ] @@ -99,7 +102,7 @@ class ReadlineConfig: readline_completer: Completer | None = None completer_delims: frozenset[str] = frozenset(" \t\n`~!@#$%^&*()-=+[{]}\\|;:'\",<>/?") - + module_completer: ModuleCompleter = field(default_factory=make_default_module_completer) @dataclass(kw_only=True) class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader): @@ -132,6 +135,9 @@ def get_stem(self) -> str: return "".join(b[p + 1 : self.pos]) def get_completions(self, stem: str) -> list[str]: + module_completions = self.get_module_completions() + if module_completions is not None: + return module_completions if len(stem) == 0 and self.more_lines is not None: b = self.buffer p = self.pos @@ -161,6 +167,10 @@ def get_completions(self, stem: str) -> list[str]: result.sort() return result + def get_module_completions(self) -> list[str] | None: + line = self.get_line() + return self.config.module_completer.get_completions(line) + def get_trimmed_history(self, maxlength: int) -> list[str]: if maxlength >= 0: cut = len(self.history) - maxlength @@ -268,10 +278,6 @@ def do(self) -> None: r = self.reader # type: ignore[assignment] r.dirty = True # this is needed to hide the completion menu, if visible - if self.reader.in_bracketed_paste: - r.insert("\n") - return - # if there are already several lines and the cursor # is not on the last one, always insert a new \n. text = r.get_unicode() @@ -446,6 +452,7 @@ def read_history_file(self, filename: str = gethistoryfile()) -> None: del buffer[:] if line: history.append(line) + self.set_history_length(self.get_current_history_length()) def write_history_file(self, filename: str = gethistoryfile()) -> None: maxlength = self.saved_history_length @@ -457,6 +464,19 @@ def write_history_file(self, filename: str = gethistoryfile()) -> None: entry = entry.replace("\n", "\r\n") # multiline history support f.write(entry + "\n") + def append_history_file(self, filename: str = gethistoryfile()) -> None: + reader = self.get_reader() + saved_length = self.get_history_length() + length = self.get_current_history_length() - saved_length + history = reader.get_trimmed_history(length) + f = open(os.path.expanduser(filename), "a", + encoding="utf-8", newline="\n") + with f: + for entry in history: + entry = entry.replace("\n", "\r\n") # multiline history support + f.write(entry + "\n") + self.set_history_length(saved_length + length) + def clear_history(self) -> None: del self.get_reader().history[:] @@ -526,6 +546,7 @@ def insert_text(self, text: str) -> None: get_current_history_length = _wrapper.get_current_history_length read_history_file = _wrapper.read_history_file write_history_file = _wrapper.write_history_file +append_history_file = _wrapper.append_history_file clear_history = _wrapper.clear_history get_history_item = _wrapper.get_history_item remove_history_item = _wrapper.remove_history_item @@ -587,6 +608,7 @@ def _setup(namespace: Mapping[str, Any]) -> None: # set up namespace in rlcompleter, which requires it to be a bona fide dict if not isinstance(namespace, dict): namespace = dict(namespace) + _wrapper.config.module_completer = ModuleCompleter(namespace) _wrapper.config.readline_completer = RLCompleter(namespace).complete # this is not really what readline.c does. Better than nothing I guess diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 66e66eae7ea..6508f0233b9 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -26,18 +26,14 @@ from __future__ import annotations import _sitebuiltins -import linecache import functools import os import sys import code +import warnings +import errno -from .readline import _get_reader, multiline_input - -TYPE_CHECKING = False - -if TYPE_CHECKING: - from typing import Any +from .readline import _get_reader, multiline_input, append_history_file _error: tuple[type[Exception], ...] | type[Exception] @@ -115,6 +111,10 @@ def run_multiline_interactive_console( more_lines = functools.partial(_more_lines, console) input_n = 0 + _is_x_showrefcount_set = sys._xoptions.get("showrefcount") + _is_pydebug_build = hasattr(sys, "gettotalrefcount") + show_ref_count = _is_x_showrefcount_set and _is_pydebug_build + def maybe_run_command(statement: str) -> bool: statement = statement.strip() if statement in console.locals or statement not in REPL_COMMANDS: @@ -125,12 +125,12 @@ def maybe_run_command(statement: str) -> bool: command = REPL_COMMANDS[statement] if callable(command): # Make sure that history does not change because of commands - with reader.suspend_history(): + with reader.suspend_history(), reader.suspend_colorization(): command() return True return False - while 1: + while True: try: try: sys.stdout.flush() @@ -148,20 +148,34 @@ def maybe_run_command(statement: str) -> bool: continue input_name = f"" - linecache._register_code(input_name, statement, "") # type: ignore[attr-defined] more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg] assert not more + try: + append_history_file() + except (FileNotFoundError, PermissionError, OSError) as e: + warnings.warn(f"failed to open the history file for writing: {e}") + input_n += 1 except KeyboardInterrupt: r = _get_reader() + r.cmpltn_reset() if r.input_trans is r.isearch_trans: r.do_cmd(("isearch-end", [""])) r.pos = len(r.get_unicode()) r.dirty = True r.refresh() - r.in_bracketed_paste = False console.write("\nKeyboardInterrupt\n") console.resetbuffer() except MemoryError: console.write("\nMemoryError\n") console.resetbuffer() + except SystemExit: + raise + except: + console.showtraceback() + console.resetbuffer() + if show_ref_count: + console.write( + f"[{sys.gettotalrefcount()} refs," + f" {sys.getallocatedblocks()} blocks]\n" + ) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py new file mode 100644 index 00000000000..d02ef69cce0 --- /dev/null +++ b/Lib/_pyrepl/terminfo.py @@ -0,0 +1,488 @@ +"""Pure Python curses-like terminal capability queries.""" + +from dataclasses import dataclass, field +import errno +import os +from pathlib import Path +import re +import struct + + +# Terminfo constants +MAGIC16 = 0o432 # Magic number for 16-bit terminfo format +MAGIC32 = 0o1036 # Magic number for 32-bit terminfo format + +# Special values for absent/cancelled capabilities +ABSENT_BOOLEAN = -1 +ABSENT_NUMERIC = -1 +CANCELLED_NUMERIC = -2 +ABSENT_STRING = None +CANCELLED_STRING = None + + +# Standard string capability names from ncurses Caps file +# This matches the order used by ncurses when compiling terminfo +# fmt: off +_STRING_NAMES: tuple[str, ...] = ( + "cbt", "bel", "cr", "csr", "tbc", "clear", "el", "ed", "hpa", "cmdch", + "cup", "cud1", "home", "civis", "cub1", "mrcup", "cnorm", "cuf1", "ll", + "cuu1", "cvvis", "dch1", "dl1", "dsl", "hd", "smacs", "blink", "bold", + "smcup", "smdc", "dim", "smir", "invis", "prot", "rev", "smso", "smul", + "ech", "rmacs", "sgr0", "rmcup", "rmdc", "rmir", "rmso", "rmul", "flash", + "ff", "fsl", "is1", "is2", "is3", "if", "ich1", "il1", "ip", "kbs", "ktbc", + "kclr", "kctab", "kdch1", "kdl1", "kcud1", "krmir", "kel", "ked", "kf0", + "kf1", "kf10", "kf2", "kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", + "khome", "kich1", "kil1", "kcub1", "kll", "knp", "kpp", "kcuf1", "kind", + "kri", "khts", "kcuu1", "rmkx", "smkx", "lf0", "lf1", "lf10", "lf2", "lf3", + "lf4", "lf5", "lf6", "lf7", "lf8", "lf9", "rmm", "smm", "nel", "pad", "dch", + "dl", "cud", "ich", "indn", "il", "cub", "cuf", "rin", "cuu", "pfkey", + "pfloc", "pfx", "mc0", "mc4", "mc5", "rep", "rs1", "rs2", "rs3", "rf", "rc", + "vpa", "sc", "ind", "ri", "sgr", "hts", "wind", "ht", "tsl", "uc", "hu", + "iprog", "ka1", "ka3", "kb2", "kc1", "kc3", "mc5p", "rmp", "acsc", "pln", + "kcbt", "smxon", "rmxon", "smam", "rmam", "xonc", "xoffc", "enacs", "smln", + "rmln", "kbeg", "kcan", "kclo", "kcmd", "kcpy", "kcrt", "kend", "kent", + "kext", "kfnd", "khlp", "kmrk", "kmsg", "kmov", "knxt", "kopn", "kopt", + "kprv", "kprt", "krdo", "kref", "krfr", "krpl", "krst", "kres", "ksav", + "kspd", "kund", "kBEG", "kCAN", "kCMD", "kCPY", "kCRT", "kDC", "kDL", + "kslt", "kEND", "kEOL", "kEXT", "kFND", "kHLP", "kHOM", "kIC", "kLFT", + "kMSG", "kMOV", "kNXT", "kOPT", "kPRV", "kPRT", "kRDO", "kRPL", "kRIT", + "kRES", "kSAV", "kSPD", "kUND", "rfi", "kf11", "kf12", "kf13", "kf14", + "kf15", "kf16", "kf17", "kf18", "kf19", "kf20", "kf21", "kf22", "kf23", + "kf24", "kf25", "kf26", "kf27", "kf28", "kf29", "kf30", "kf31", "kf32", + "kf33", "kf34", "kf35", "kf36", "kf37", "kf38", "kf39", "kf40", "kf41", + "kf42", "kf43", "kf44", "kf45", "kf46", "kf47", "kf48", "kf49", "kf50", + "kf51", "kf52", "kf53", "kf54", "kf55", "kf56", "kf57", "kf58", "kf59", + "kf60", "kf61", "kf62", "kf63", "el1", "mgc", "smgl", "smgr", "fln", "sclk", + "dclk", "rmclk", "cwin", "wingo", "hup","dial", "qdial", "tone", "pulse", + "hook", "pause", "wait", "u0", "u1", "u2", "u3", "u4", "u5", "u6", "u7", + "u8", "u9", "op", "oc", "initc", "initp", "scp", "setf", "setb", "cpi", + "lpi", "chr", "cvr", "defc", "swidm", "sdrfq", "sitm", "slm", "smicm", + "snlq", "snrmq", "sshm", "ssubm", "ssupm", "sum", "rwidm", "ritm", "rlm", + "rmicm", "rshm", "rsubm", "rsupm", "rum", "mhpa", "mcud1", "mcub1", "mcuf1", + "mvpa", "mcuu1", "porder", "mcud", "mcub", "mcuf", "mcuu", "scs", "smgb", + "smgbp", "smglp", "smgrp", "smgt", "smgtp", "sbim", "scsd", "rbim", "rcsd", + "subcs", "supcs", "docr", "zerom", "csnm", "kmous", "minfo", "reqmp", + "getm", "setaf", "setab", "pfxl", "devt", "csin", "s0ds", "s1ds", "s2ds", + "s3ds", "smglr", "smgtb", "birep", "binel", "bicr", "colornm", "defbi", + "endbi", "setcolor", "slines", "dispc", "smpch", "rmpch", "smsc", "rmsc", + "pctrm", "scesc", "scesa", "ehhlm", "elhlm", "elohlm", "erhlm", "ethlm", + "evhlm", "sgr1", "slength", "OTi2", "OTrs", "OTnl", "OTbc", "OTko", "OTma", + "OTG2", "OTG3", "OTG1", "OTG4", "OTGR", "OTGL", "OTGU", "OTGD", "OTGH", + "OTGV", "OTGC","meml", "memu", "box1" +) +# fmt: on + + +def _get_terminfo_dirs() -> list[Path]: + """Get list of directories to search for terminfo files. + + Based on ncurses behavior in: + - ncurses/tinfo/db_iterator.c:_nc_next_db() + - ncurses/tinfo/read_entry.c:_nc_read_entry() + """ + dirs = [] + + terminfo = os.environ.get("TERMINFO") + if terminfo: + dirs.append(terminfo) + + try: + home = Path.home() + dirs.append(str(home / ".terminfo")) + except RuntimeError: + pass + + # Check TERMINFO_DIRS + terminfo_dirs = os.environ.get("TERMINFO_DIRS", "") + if terminfo_dirs: + for d in terminfo_dirs.split(":"): + if d: + dirs.append(d) + + dirs.extend( + [ + "/etc/terminfo", + "/lib/terminfo", + "/usr/lib/terminfo", + "/usr/share/terminfo", + "/usr/share/lib/terminfo", + "/usr/share/misc/terminfo", + "/usr/local/lib/terminfo", + "/usr/local/share/terminfo", + ] + ) + + return [Path(d) for d in dirs if Path(d).is_dir()] + + +def _validate_terminal_name_or_raise(terminal_name: str) -> None: + if not isinstance(terminal_name, str): + raise TypeError("`terminal_name` must be a string") + + if not terminal_name: + raise ValueError("`terminal_name` cannot be empty") + + if "\x00" in terminal_name: + raise ValueError("NUL character found in `terminal_name`") + + t = Path(terminal_name) + if len(t.parts) > 1: + raise ValueError("`terminal_name` cannot contain path separators") + + +def _read_terminfo_file(terminal_name: str) -> bytes: + """Find and read terminfo file for given terminal name. + + Terminfo files are stored in directories using the first character + of the terminal name as a subdirectory. + """ + _validate_terminal_name_or_raise(terminal_name) + first_char = terminal_name[0].lower() + filename = terminal_name + + for directory in _get_terminfo_dirs(): + path = directory / first_char / filename + if path.is_file(): + return path.read_bytes() + + # Try with hex encoding of first char (for special chars) + hex_dir = "%02x" % ord(first_char) + path = directory / hex_dir / filename + if path.is_file(): + return path.read_bytes() + + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filename) + + +# Hard-coded terminal capabilities for common terminals +# This is a minimal subset needed by PyREPL +_TERMINAL_CAPABILITIES = { + # ANSI/xterm-compatible terminals + "ansi": { + # Bell + "bel": b"\x07", + # Cursor movement + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x08", # Move cursor left 1 column + "cud1": b"\n", # Move cursor down 1 row + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row + "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + # Clear operations + "clear": b"\x1b[H\x1b[2J", # Clear screen and home cursor + "el": b"\x1b[K", # Clear to end of line + # Insert/delete + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"", # Insert 1 character + # Cursor visibility + "civis": b"\x1b[?25l", # Make cursor invisible + "cnorm": b"\x1b[?12l\x1b[?25h", # Make cursor normal (visible) + # Scrolling + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line + # Keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + # Padding (not used in modern terminals) + "pad": b"", + # Function keys and special keys + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1bOB", # Down arrow + "kend": b"\x1bOF", # End key + "kent": b"\x1bOM", # Enter key + "khome": b"\x1bOH", # Home key + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1bOD", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1bOC", # Right arrow + "kcuu1": b"\x1bOA", # Up arrow + # Function keys F1-F20 + "kf1": b"\x1bOP", + "kf2": b"\x1bOQ", + "kf3": b"\x1bOR", + "kf4": b"\x1bOS", + "kf5": b"\x1b[15~", + "kf6": b"\x1b[17~", + "kf7": b"\x1b[18~", + "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", + "kf10": b"\x1b[21~", + "kf11": b"\x1b[23~", + "kf12": b"\x1b[24~", + "kf13": b"\x1b[1;2P", + "kf14": b"\x1b[1;2Q", + "kf15": b"\x1b[1;2R", + "kf16": b"\x1b[1;2S", + "kf17": b"\x1b[15;2~", + "kf18": b"\x1b[17;2~", + "kf19": b"\x1b[18;2~", + "kf20": b"\x1b[19;2~", + }, + # Dumb terminal - minimal capabilities + "dumb": { + "bel": b"\x07", # Bell + "cud1": b"\n", # Move down 1 row (newline) + "ind": b"\n", # Scroll up one line (newline) + }, + # Linux console + "linux": { + # Bell + "bel": b"\x07", + # Cursor movement + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x08", # Move cursor left 1 column (backspace) + "cud1": b"\n", # Move cursor down 1 row (newline) + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row + "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + # Clear operations + "clear": b"\x1b[H\x1b[J", # Clear screen and home cursor (different from ansi!) + "el": b"\x1b[K", # Clear to end of line + # Insert/delete + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"\x1b[@", # Insert 1 character + # Cursor visibility + "civis": b"\x1b[?25l\x1b[?1c", # Make cursor invisible + "cnorm": b"\x1b[?25h\x1b[?0c", # Make cursor normal + # Scrolling + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line + # Keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + # Function keys and special keys + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1b[B", # Down arrow + "kend": b"\x1b[4~", # End key (different from ansi!) + "khome": b"\x1b[1~", # Home key (different from ansi!) + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1b[D", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1b[C", # Right arrow + "kcuu1": b"\x1b[A", # Up arrow + # Function keys + "kf1": b"\x1b[[A", + "kf2": b"\x1b[[B", + "kf3": b"\x1b[[C", + "kf4": b"\x1b[[D", + "kf5": b"\x1b[[E", + "kf6": b"\x1b[17~", + "kf7": b"\x1b[18~", + "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", + "kf10": b"\x1b[21~", + "kf11": b"\x1b[23~", + "kf12": b"\x1b[24~", + "kf13": b"\x1b[25~", + "kf14": b"\x1b[26~", + "kf15": b"\x1b[28~", + "kf16": b"\x1b[29~", + "kf17": b"\x1b[31~", + "kf18": b"\x1b[32~", + "kf19": b"\x1b[33~", + "kf20": b"\x1b[34~", + }, +} + +# Map common TERM values to capability sets +_TERM_ALIASES = { + "xterm": "ansi", + "xterm-color": "ansi", + "xterm-256color": "ansi", + "screen": "ansi", + "screen-256color": "ansi", + "tmux": "ansi", + "tmux-256color": "ansi", + "vt100": "ansi", + "vt220": "ansi", + "rxvt": "ansi", + "rxvt-unicode": "ansi", + "rxvt-unicode-256color": "ansi", + "unknown": "dumb", +} + + +@dataclass +class TermInfo: + terminal_name: str | bytes | None + fallback: bool = True + + _capabilities: dict[str, bytes] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Initialize terminal capabilities for the given terminal type. + + Based on ncurses implementation in: + - ncurses/tinfo/lib_setup.c:setupterm() and _nc_setupterm() + - ncurses/tinfo/lib_setup.c:TINFO_SETUP_TERM() + + This version first attempts to read terminfo database files like ncurses, + then, if `fallback` is True, falls back to hardcoded capabilities for + common terminal types. + """ + # If termstr is None or empty, try to get from environment + if not self.terminal_name: + self.terminal_name = os.environ.get("TERM") or "ANSI" + + if isinstance(self.terminal_name, bytes): + self.terminal_name = self.terminal_name.decode("ascii") + + try: + self._parse_terminfo_file(self.terminal_name) + except (OSError, ValueError): + if not self.fallback: + raise + + term_type = _TERM_ALIASES.get( + self.terminal_name, self.terminal_name + ) + if term_type not in _TERMINAL_CAPABILITIES: + term_type = "dumb" + self._capabilities = _TERMINAL_CAPABILITIES[term_type].copy() + + def _parse_terminfo_file(self, terminal_name: str) -> None: + """Parse a terminfo file. + + Populate the _capabilities dict for easy retrieval + + Based on ncurses implementation in: + - ncurses/tinfo/read_entry.c:_nc_read_termtype() + - ncurses/tinfo/read_entry.c:_nc_read_file_entry() + - ncurses/tinfo/lib_ti.c:tigetstr() + """ + data = _read_terminfo_file(terminal_name) + too_short = f"TermInfo file for {terminal_name!r} too short" + offset = 12 + if len(data) < offset: + raise ValueError(too_short) + + magic, name_size, bool_count, num_count, str_count, str_size = ( + struct.unpack(" len(data): + raise ValueError(too_short) + + # Read string offsets + end_offset = offset + 2 * str_count + if offset > len(data): + raise ValueError(too_short) + string_offset_data = data[offset:end_offset] + string_offsets = [ + off for [off] in struct.iter_unpack(" len(data): + raise ValueError(too_short) + string_table = data[offset : offset + str_size] + + # Extract strings from string table + capabilities = {} + for cap, off in zip(_STRING_NAMES, string_offsets): + if off < 0: + # CANCELLED_STRING; we do not store those + continue + elif off < len(string_table): + # Find null terminator + end = string_table.find(0, off) + if end >= 0: + capabilities[cap] = string_table[off:end] + # in other cases this is ABSENT_STRING; we don't store those. + + # Note: we don't support extended capabilities since PyREPL doesn't + # need them. + + self._capabilities = capabilities + + def get(self, cap: str) -> bytes | None: + """Get terminal capability string by name. + """ + if not isinstance(cap, str): + raise TypeError(f"`cap` must be a string, not {type(cap)}") + + return self._capabilities.get(cap) + + +def tparm(cap_bytes: bytes, *params: int) -> bytes: + """Parameterize a terminal capability string. + + Based on ncurses implementation in: + - ncurses/tinfo/lib_tparm.c:tparm() + - ncurses/tinfo/lib_tparm.c:tparam_internal() + + The ncurses version implements a full stack-based interpreter for + terminfo parameter strings. This pure Python version implements only + the subset of parameter substitution operations needed by PyREPL: + - %i (increment parameters for 1-based indexing) + - %p[1-9]%d (parameter substitution) + - %p[1-9]%{n}%+%d (parameter plus constant) + """ + if not isinstance(cap_bytes, bytes): + raise TypeError(f"`cap` must be bytes, not {type(cap_bytes)}") + + result = cap_bytes + + # %i - increment parameters (1-based instead of 0-based) + increment = b"%i" in result + if increment: + result = result.replace(b"%i", b"") + + # Replace %p1%d, %p2%d, etc. with actual parameter values + for i in range(len(params)): + pattern = b"%%p%d%%d" % (i + 1) + if pattern in result: + value = params[i] + if increment: + value += 1 + result = result.replace(pattern, str(value).encode("ascii")) + + # Handle %p1%{1}%+%d (parameter plus constant) + # Used in some cursor positioning sequences + pattern_re = re.compile(rb"%p(\d)%\{(\d+)\}%\+%d") + matches = list(pattern_re.finditer(result)) + for match in reversed(matches): # reversed to maintain positions + param_idx = int(match.group(1)) + constant = int(match.group(2)) + value = params[param_idx] + constant + result = ( + result[: match.start()] + + str(value).encode("ascii") + + result[match.end() :] + ) + + return result diff --git a/Lib/_pyrepl/trace.py b/Lib/_pyrepl/trace.py index a8eb2433cd3..943ee12f964 100644 --- a/Lib/_pyrepl/trace.py +++ b/Lib/_pyrepl/trace.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys # types if False: @@ -12,10 +13,22 @@ trace_file = open(trace_filename, "a") -def trace(line: str, *k: object, **kw: object) -> None: - if trace_file is None: - return - if k or kw: - line = line.format(*k, **kw) - trace_file.write(line + "\n") - trace_file.flush() + +if sys.platform == "emscripten": + from posix import _emscripten_log + + def trace(line: str, *k: object, **kw: object) -> None: + if "PYREPL_TRACE" not in os.environ: + return + if k or kw: + line = line.format(*k, **kw) + _emscripten_log(line) + +else: + def trace(line: str, *k: object, **kw: object) -> None: + if trace_file is None: + return + if k or kw: + line = line.format(*k, **kw) + trace_file.write(line + "\n") + trace_file.flush() diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index f9d48b828c7..c5b7ebc1a40 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -1,8 +1,10 @@ from collections.abc import Callable, Iterator -Callback = Callable[[], object] -SimpleContextManager = Iterator[None] -KeySpec = str # like r"\C-c" -CommandName = str # like "interrupt" -EventTuple = tuple[CommandName, str] -Completer = Callable[[str, int], str | None] +type Callback = Callable[[], object] +type SimpleContextManager = Iterator[None] +type KeySpec = str # like r"\C-c" +type CommandName = str # like "interrupt" +type EventTuple = tuple[CommandName, str] +type Completer = Callable[[str, int], str | None] +type CharBuffer = list[str] +type CharWidths = list[int] diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index e69c96b1159..639d16db3f8 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -29,31 +29,41 @@ import struct import termios import time +import types import platform from fcntl import ioctl -from . import curses +from . import terminfo from .console import Console, Event -from .fancy_termios import tcgetattr, tcsetattr +from .fancy_termios import tcgetattr, tcsetattr, TermState from .trace import trace from .unix_eventqueue import EventQueue from .utils import wlen +# declare posix optional to allow None assignment on other platforms +posix: types.ModuleType | None +try: + import posix +except ImportError: + posix = None TYPE_CHECKING = False # types if TYPE_CHECKING: - from typing import IO, Literal, overload + from typing import AbstractSet, IO, Literal, overload, cast else: overload = lambda func: None + cast = lambda typ, val: val class InvalidTerminal(RuntimeError): - pass + def __init__(self, message: str) -> None: + super().__init__(errno.EIO, message) -_error = (termios.error, curses.error, InvalidTerminal) +_error = (termios.error, InvalidTerminal) +_error_codes_to_ignore = frozenset([errno.EIO, errno.ENXIO, errno.EPERM]) SIGWINCH_EVENT = "repaint" @@ -110,7 +120,7 @@ def add_baudrate_if_supported(dictionary: dict[int, int], rate: int) -> None: try: poll: type[select.poll] = select.poll except AttributeError: - # this is exactly the minumum necessary to support what we + # this is exactly the minimum necessary to support what we # do with poll objects class MinimalPoll: def __init__(self): @@ -118,12 +128,13 @@ def __init__(self): def register(self, fd, flag): self.fd = fd + # note: The 'timeout' argument is received as *milliseconds* def poll(self, timeout: float | None = None) -> list[int]: if timeout is None: r, w, e = select.select([self.fd], [], []) else: - r, w, e = select.select([self.fd], [], [], timeout/1000) + r, w, e = select.select([self.fd], [], [], timeout / 1000) return r poll = MinimalPoll # type: ignore[assignment] @@ -150,19 +161,28 @@ def __init__( self.pollob = poll() self.pollob.register(self.input_fd, select.POLLIN) - self.input_buffer = b"" - self.input_buffer_pos = 0 - curses.setupterm(term or None, self.output_fd) + self.terminfo = terminfo.TermInfo(term or None) self.term = term + self.is_apple_terminal = ( + platform.system() == "Darwin" + and os.getenv("TERM_PROGRAM") == "Apple_Terminal" + ) + + try: + self.__input_fd_set(tcgetattr(self.input_fd), ignore=frozenset()) + except _error as e: + raise RuntimeError(f"termios failure ({e.args[1]})") @overload - def _my_getstr(cap: str, optional: Literal[False] = False) -> bytes: ... + def _my_getstr( + cap: str, optional: Literal[False] = False + ) -> bytes: ... @overload def _my_getstr(cap: str, optional: bool) -> bytes | None: ... def _my_getstr(cap: str, optional: bool = False) -> bytes | None: - r = curses.tigetstr(cap) + r = self.terminfo.get(cap) if not optional and r is None: raise InvalidTerminal( f"terminal doesn't have the required {cap} capability" @@ -196,26 +216,19 @@ def _my_getstr(cap: str, optional: bool = False) -> bytes | None: self.__setup_movement() - self.event_queue = EventQueue(self.input_fd, self.encoding) - self.cursor_visible = 1 - - def more_in_buffer(self) -> bool: - return bool( - self.input_buffer - and self.input_buffer_pos < len(self.input_buffer) + self.event_queue = EventQueue( + self.input_fd, self.encoding, self.terminfo ) + self.cursor_visible = 1 - def __read(self, n: int) -> bytes: - if not self.more_in_buffer(): - self.input_buffer = os.read(self.input_fd, 10000) + signal.signal(signal.SIGCONT, self._sigcont_handler) - ret = self.input_buffer[self.input_buffer_pos : self.input_buffer_pos + n] - self.input_buffer_pos += len(ret) - if self.input_buffer_pos >= len(self.input_buffer): - self.input_buffer = b"" - self.input_buffer_pos = 0 - return ret + def _sigcont_handler(self, signum, frame): + self.restore() + self.prepare() + def __read(self, n: int) -> bytes: + return os.read(self.input_fd, n) def change_encoding(self, encoding: str) -> None: """ @@ -238,8 +251,9 @@ def refresh(self, screen, c_xy): if not self.__gone_tall: while len(self.screen) < min(len(screen), self.height): self.__hide_cursor() - self.__move(0, len(self.screen) - 1) - self.__write("\n") + if self.screen: + self.__move(0, len(self.screen) - 1) + self.__write("\n") self.posxy = 0, len(self.screen) self.screen.append("") else: @@ -328,6 +342,8 @@ def prepare(self): """ Prepare the console for input/output operations. """ + self.__buffer = [] + self.__svtermstate = tcgetattr(self.input_fd) raw = self.__svtermstate.copy() raw.iflag &= ~(termios.INPCK | termios.ISTRIP | termios.IXON) @@ -339,17 +355,15 @@ def prepare(self): raw.lflag |= termios.ISIG raw.cc[termios.VMIN] = 1 raw.cc[termios.VTIME] = 0 - tcsetattr(self.input_fd, termios.TCSADRAIN, raw) + self.__input_fd_set(raw) # In macOS terminal we need to deactivate line wrap via ANSI escape code - if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": + if self.is_apple_terminal: os.write(self.output_fd, b"\033[?7l") self.screen = [] self.height, self.width = self.getheightwidth() - self.__buffer = [] - self.posxy = 0, 0 self.__gone_tall = 0 self.__move = self.__move_short @@ -371,13 +385,18 @@ def restore(self): self.__disable_bracketed_paste() self.__maybe_write_code(self._rmkx) self.flushoutput() - tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) + self.__input_fd_set(self.__svtermstate) - if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": + if self.is_apple_terminal: os.write(self.output_fd, b"\033[?7h") if hasattr(self, "old_sigwinch"): - signal.signal(signal.SIGWINCH, self.old_sigwinch) + try: + signal.signal(signal.SIGWINCH, self.old_sigwinch) + except ValueError as e: + import threading + if threading.current_thread() is threading.main_thread(): + raise e del self.old_sigwinch def push_char(self, char: int | bytes) -> None: @@ -410,6 +429,8 @@ def get_event(self, block: bool = True) -> Event | None: return self.event_queue.get() else: continue + elif err.errno == errno.EIO: + raise SystemExit(errno.EIO) else: raise else: @@ -422,7 +443,6 @@ def wait(self, timeout: float | None = None) -> bool: """ return ( not self.event_queue.empty() - or self.more_in_buffer() or bool(self.pollob.poll(timeout)) ) @@ -525,6 +545,7 @@ def getpending(self): e.raw += e.raw amount = struct.unpack("i", ioctl(self.input_fd, FIONREAD, b"\0\0\0\0"))[0] + trace("getpending({a})", a=amount) raw = self.__read(amount) data = str(raw, self.encoding, "replace") e.data += data @@ -566,11 +587,9 @@ def clear(self): @property def input_hook(self): - try: - import posix - except ImportError: - return None - if posix._is_inputhook_installed(): + # avoid inline imports here so the repl doesn't get flooded + # with import logging from -X importtime=2 + if posix is not None and posix._is_inputhook_installed(): return posix._inputhook def __enable_bracketed_paste(self) -> None: @@ -602,14 +621,14 @@ def __setup_movement(self): if self._dch1: self.dch1 = self._dch1 elif self._dch: - self.dch1 = curses.tparm(self._dch, 1) + self.dch1 = terminfo.tparm(self._dch, 1) else: self.dch1 = None if self._ich1: self.ich1 = self._ich1 elif self._ich: - self.ich1 = curses.tparm(self._ich, 1) + self.ich1 = terminfo.tparm(self._ich, 1) else: self.ich1 = None @@ -634,7 +653,7 @@ def __write_changed_line(self, y, oldline, newline, px_coord): # reuse the oldline as much as possible, but stop as soon as we # encounter an ESCAPE, because it might be the start of an escape - # sequene + # sequence while ( x_coord < minlen and oldline[x_pos] == newline[x_pos] @@ -706,7 +725,7 @@ def __write(self, text): self.__buffer.append((text, 0)) def __write_code(self, fmt, *args): - self.__buffer.append((curses.tparm(fmt, *args), 1)) + self.__buffer.append((terminfo.tparm(fmt, *args), 1)) def __maybe_write_code(self, fmt, *args): if fmt: @@ -757,7 +776,6 @@ def __move_tall(self, x, y): self.__write_code(self._cup, y - self.__offset, x) def __sigwinch(self, signum, frame): - self.height, self.width = self.getheightwidth() self.event_queue.insert(Event("resize", None)) def __hide_cursor(self): @@ -790,9 +808,9 @@ def __tputs(self, fmt, prog=delayprog): will never do anyone any good.""" # using .get() means that things will blow up # only if the bps is actually needed (which I'm - # betting is pretty unlkely) + # betting is pretty unlikely) bps = ratedict.get(self.__svtermstate.ospeed) - while 1: + while True: m = prog.search(fmt) if not m: os.write(self.output_fd, fmt) @@ -808,3 +826,17 @@ def __tputs(self, fmt, prog=delayprog): os.write(self.output_fd, self._pad * nchars) else: time.sleep(float(delay) / 1000.0) + + def __input_fd_set( + self, + state: TermState, + ignore: AbstractSet[int] = _error_codes_to_ignore, + ) -> bool: + try: + tcsetattr(self.input_fd, termios.TCSADRAIN, state) + except termios.error as te: + if te.args[0] not in ignore: + raise + return False + else: + return True diff --git a/Lib/_pyrepl/unix_eventqueue.py b/Lib/_pyrepl/unix_eventqueue.py index 70cfade26e2..2a9cca59e74 100644 --- a/Lib/_pyrepl/unix_eventqueue.py +++ b/Lib/_pyrepl/unix_eventqueue.py @@ -18,12 +18,9 @@ # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -from collections import deque - -from . import keymap -from .console import Event -from . import curses +from .terminfo import TermInfo from .trace import trace +from .base_eventqueue import BaseEventQueue from termios import tcgetattr, VERASE import os @@ -57,96 +54,24 @@ b'\033Oc': 'ctrl right', } -def get_terminal_keycodes() -> dict[bytes, str]: +def get_terminal_keycodes(ti: TermInfo) -> dict[bytes, str]: """ Generates a dictionary mapping terminal keycodes to human-readable names. """ keycodes = {} for key, terminal_code in TERMINAL_KEYNAMES.items(): - keycode = curses.tigetstr(terminal_code) + keycode = ti.get(terminal_code) trace('key {key} tiname {terminal_code} keycode {keycode!r}', **locals()) if keycode: keycodes[keycode] = key keycodes.update(CTRL_ARROW_KEYCODES) return keycodes -class EventQueue: - def __init__(self, fd: int, encoding: str) -> None: - self.keycodes = get_terminal_keycodes() + +class EventQueue(BaseEventQueue): + def __init__(self, fd: int, encoding: str, ti: TermInfo) -> None: + keycodes = get_terminal_keycodes(ti) if os.isatty(fd): backspace = tcgetattr(fd)[6][VERASE] - self.keycodes[backspace] = "backspace" - self.compiled_keymap = keymap.compile_keymap(self.keycodes) - self.keymap = self.compiled_keymap - trace("keymap {k!r}", k=self.keymap) - self.encoding = encoding - self.events: deque[Event] = deque() - self.buf = bytearray() - - def get(self) -> Event | None: - """ - Retrieves the next event from the queue. - """ - if self.events: - return self.events.popleft() - else: - return None - - def empty(self) -> bool: - """ - Checks if the queue is empty. - """ - return not self.events - - def flush_buf(self) -> bytearray: - """ - Flushes the buffer and returns its contents. - """ - old = self.buf - self.buf = bytearray() - return old - - def insert(self, event: Event) -> None: - """ - Inserts an event into the queue. - """ - trace('added event {event}', event=event) - self.events.append(event) - - def push(self, char: int | bytes) -> None: - """ - Processes a character by updating the buffer and handling special key mappings. - """ - ord_char = char if isinstance(char, int) else ord(char) - char = bytes(bytearray((ord_char,))) - self.buf.append(ord_char) - if char in self.keymap: - if self.keymap is self.compiled_keymap: - #sanity check, buffer is empty when a special key comes - assert len(self.buf) == 1 - k = self.keymap[char] - trace('found map {k!r}', k=k) - if isinstance(k, dict): - self.keymap = k - else: - self.insert(Event('key', k, self.flush_buf())) - self.keymap = self.compiled_keymap - - elif self.buf and self.buf[0] == 27: # escape - # escape sequence not recognized by our keymap: propagate it - # outside so that i can be recognized as an M-... key (see also - # the docstring in keymap.py - trace('unrecognized escape sequence, propagating...') - self.keymap = self.compiled_keymap - self.insert(Event('key', '\033', bytearray(b'\033'))) - for _c in self.flush_buf()[1:]: - self.push(_c) - - else: - try: - decoded = bytes(self.buf).decode(self.encoding) - except UnicodeError: - return - else: - self.insert(Event('key', decoded, self.flush_buf())) - self.keymap = self.compiled_keymap + keycodes[backspace] = "backspace" + BaseEventQueue.__init__(self, encoding, keycodes) diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index 4651717bd7e..06cddef851b 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -1,25 +1,391 @@ +from __future__ import annotations +import builtins +import functools +import keyword import re +import token as T +import tokenize import unicodedata -import functools +import _colorize + +from collections import deque +from io import StringIO +from tokenize import TokenInfo as TI +from typing import Iterable, Iterator, Match, NamedTuple, Self + +from .types import CharBuffer, CharWidths +from .trace import trace ANSI_ESCAPE_SEQUENCE = re.compile(r"\x1b\[[ -@]*[A-~]") +ZERO_WIDTH_BRACKET = re.compile(r"\x01.*?\x02") +ZERO_WIDTH_TRANS = str.maketrans({"\x01": "", "\x02": ""}) +IDENTIFIERS_AFTER = {"def", "class"} +KEYWORD_CONSTANTS = {"True", "False", "None"} +BUILTINS = {str(name) for name in dir(builtins) if not name.startswith('_')} + + +def THEME(**kwargs): + # Not cached: the user can modify the theme inside the interactive session. + return _colorize.get_theme(**kwargs).syntax + + +class Span(NamedTuple): + """Span indexing that's inclusive on both ends.""" + + start: int + end: int + + @classmethod + def from_re(cls, m: Match[str], group: int | str) -> Self: + re_span = m.span(group) + return cls(re_span[0], re_span[1] - 1) + + @classmethod + def from_token(cls, token: TI, line_len: list[int]) -> Self: + end_offset = -1 + if (token.type in {T.FSTRING_MIDDLE, T.TSTRING_MIDDLE} + and token.string.endswith(("{", "}"))): + # gh-134158: a visible trailing brace comes from a double brace in input + end_offset += 1 + + return cls( + line_len[token.start[0] - 1] + token.start[1], + line_len[token.end[0] - 1] + token.end[1] + end_offset, + ) + + +class ColorSpan(NamedTuple): + span: Span + tag: str @functools.cache def str_width(c: str) -> int: if ord(c) < 128: return 1 + # gh-139246 for zero-width joiner and combining characters + if unicodedata.combining(c): + return 0 + category = unicodedata.category(c) + if category == "Cf" and c != "\u00ad": + return 0 w = unicodedata.east_asian_width(c) - if w in ('N', 'Na', 'H', 'A'): + if w in ("N", "Na", "H", "A"): return 1 return 2 def wlen(s: str) -> int: - if len(s) == 1 and s != '\x1a': + if len(s) == 1 and s != "\x1a": return str_width(s) length = sum(str_width(i) for i in s) # remove lengths of any escape sequences sequence = ANSI_ESCAPE_SEQUENCE.findall(s) - ctrl_z_cnt = s.count('\x1a') + ctrl_z_cnt = s.count("\x1a") return length - sum(len(i) for i in sequence) + ctrl_z_cnt + + +def unbracket(s: str, including_content: bool = False) -> str: + r"""Return `s` with \001 and \002 characters removed. + + If `including_content` is True, content between \001 and \002 is also + stripped. + """ + if including_content: + return ZERO_WIDTH_BRACKET.sub("", s) + return s.translate(ZERO_WIDTH_TRANS) + + +def gen_colors(buffer: str) -> Iterator[ColorSpan]: + """Returns a list of index spans to color using the given color tag. + + The input `buffer` should be a valid start of a Python code block, i.e. + it cannot be a block starting in the middle of a multiline string. + """ + sio = StringIO(buffer) + line_lengths = [0] + [len(line) for line in sio.readlines()] + # make line_lengths cumulative + for i in range(1, len(line_lengths)): + line_lengths[i] += line_lengths[i-1] + + sio.seek(0) + gen = tokenize.generate_tokens(sio.readline) + last_emitted: ColorSpan | None = None + try: + for color in gen_colors_from_token_stream(gen, line_lengths): + yield color + last_emitted = color + except SyntaxError: + return + except tokenize.TokenError as te: + yield from recover_unterminated_string( + te, line_lengths, last_emitted, buffer + ) + + +def recover_unterminated_string( + exc: tokenize.TokenError, + line_lengths: list[int], + last_emitted: ColorSpan | None, + buffer: str, +) -> Iterator[ColorSpan]: + msg, loc = exc.args + if loc is None: + return + + line_no, column = loc + + if msg.startswith( + ( + "unterminated string literal", + "unterminated f-string literal", + "unterminated t-string literal", + "EOF in multi-line string", + "unterminated triple-quoted f-string literal", + "unterminated triple-quoted t-string literal", + ) + ): + start = line_lengths[line_no - 1] + column - 1 + end = line_lengths[-1] - 1 + + # in case FSTRING_START was already emitted + if last_emitted and start <= last_emitted.span.start: + trace("before last emitted = {s}", s=start) + start = last_emitted.span.end + 1 + + span = Span(start, end) + trace("yielding span {a} -> {b}", a=span.start, b=span.end) + yield ColorSpan(span, "string") + else: + trace( + "unhandled token error({buffer}) = {te}", + buffer=repr(buffer), + te=str(exc), + ) + + +def gen_colors_from_token_stream( + token_generator: Iterator[TI], + line_lengths: list[int], +) -> Iterator[ColorSpan]: + token_window = prev_next_window(token_generator) + + is_def_name = False + bracket_level = 0 + for prev_token, token, next_token in token_window: + assert token is not None + if token.start == token.end: + continue + + match token.type: + case ( + T.STRING + | T.FSTRING_START | T.FSTRING_MIDDLE | T.FSTRING_END + | T.TSTRING_START | T.TSTRING_MIDDLE | T.TSTRING_END + ): + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "string") + case T.COMMENT: + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "comment") + case T.NUMBER: + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "number") + case T.OP: + if token.string in "([{": + bracket_level += 1 + elif token.string in ")]}": + bracket_level -= 1 + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "op") + case T.NAME: + if is_def_name: + is_def_name = False + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "definition") + elif keyword.iskeyword(token.string): + span_cls = "keyword" + if token.string in KEYWORD_CONSTANTS: + span_cls = "keyword_constant" + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, span_cls) + if token.string in IDENTIFIERS_AFTER: + is_def_name = True + elif ( + keyword.issoftkeyword(token.string) + and bracket_level == 0 + and is_soft_keyword_used(prev_token, token, next_token) + ): + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "soft_keyword") + elif ( + token.string in BUILTINS + and not (prev_token and prev_token.exact_type == T.DOT) + ): + span = Span.from_token(token, line_lengths) + yield ColorSpan(span, "builtin") + + +keyword_first_sets_match = {"False", "None", "True", "await", "lambda", "not"} +keyword_first_sets_case = {"False", "None", "True"} + + +def is_soft_keyword_used(*tokens: TI | None) -> bool: + """Returns True if the current token is a keyword in this context. + + For the `*tokens` to match anything, they have to be a three-tuple of + (previous, current, next). + """ + trace("is_soft_keyword_used{t}", t=tokens) + match tokens: + case ( + None | TI(T.NEWLINE) | TI(T.INDENT) | TI(string=":"), + TI(string="match"), + TI(T.NUMBER | T.STRING | T.FSTRING_START | T.TSTRING_START) + | TI(T.OP, string="(" | "*" | "[" | "{" | "~" | "...") + ): + return True + case ( + None | TI(T.NEWLINE) | TI(T.INDENT) | TI(string=":"), + TI(string="match"), + TI(T.NAME, string=s) + ): + if keyword.iskeyword(s): + return s in keyword_first_sets_match + return True + case ( + None | TI(T.NEWLINE) | TI(T.INDENT) | TI(T.DEDENT) | TI(string=":"), + TI(string="case"), + TI(T.NUMBER | T.STRING | T.FSTRING_START | T.TSTRING_START) + | TI(T.OP, string="(" | "*" | "-" | "[" | "{") + ): + return True + case ( + None | TI(T.NEWLINE) | TI(T.INDENT) | TI(T.DEDENT) | TI(string=":"), + TI(string="case"), + TI(T.NAME, string=s) + ): + if keyword.iskeyword(s): + return s in keyword_first_sets_case + return True + case (TI(string="case"), TI(string="_"), TI(string=":")): + return True + case ( + None | TI(T.NEWLINE) | TI(T.INDENT) | TI(T.DEDENT) | TI(string=":"), + TI(string="type"), + TI(T.NAME, string=s) + ): + return not keyword.iskeyword(s) + case _: + return False + + +def disp_str( + buffer: str, + colors: list[ColorSpan] | None = None, + start_index: int = 0, + force_color: bool = False, +) -> tuple[CharBuffer, CharWidths]: + r"""Decompose the input buffer into a printable variant with applied colors. + + Returns a tuple of two lists: + - the first list is the input buffer, character by character, with color + escape codes added (while those codes contain multiple ASCII characters, + each code is considered atomic *and is attached for the corresponding + visible character*); + - the second list is the visible width of each character in the input + buffer. + + Note on colors: + - The `colors` list, if provided, is partially consumed within. We're using + a list and not a generator since we need to hold onto the current + unfinished span between calls to disp_str in case of multiline strings. + - The `colors` list is computed from the start of the input block. `buffer` + is only a subset of that input block, a single line within. This is why + we need `start_index` to inform us which position is the start of `buffer` + actually within user input. This allows us to match color spans correctly. + + Examples: + >>> utils.disp_str("a = 9") + (['a', ' ', '=', ' ', '9'], [1, 1, 1, 1, 1]) + + >>> line = "while 1:" + >>> colors = list(utils.gen_colors(line)) + >>> utils.disp_str(line, colors=colors) + (['\x1b[1;34mw', 'h', 'i', 'l', 'e\x1b[0m', ' ', '1', ':'], [1, 1, 1, 1, 1, 1, 1, 1]) + + """ + chars: CharBuffer = [] + char_widths: CharWidths = [] + + if not buffer: + return chars, char_widths + + while colors and colors[0].span.end < start_index: + # move past irrelevant spans + colors.pop(0) + + theme = THEME(force_color=force_color) + pre_color = "" + post_color = "" + if colors and colors[0].span.start < start_index: + # looks like we're continuing a previous color (e.g. a multiline str) + pre_color = theme[colors[0].tag] + + for i, c in enumerate(buffer, start_index): + if colors and colors[0].span.start == i: # new color starts now + pre_color = theme[colors[0].tag] + + if c == "\x1a": # CTRL-Z on Windows + chars.append(c) + char_widths.append(2) + elif ord(c) < 128: + chars.append(c) + char_widths.append(1) + elif unicodedata.category(c).startswith("C"): + c = r"\u%04x" % ord(c) + chars.append(c) + char_widths.append(len(c)) + else: + chars.append(c) + char_widths.append(str_width(c)) + + if colors and colors[0].span.end == i: # current color ends now + post_color = theme.reset + colors.pop(0) + + chars[-1] = pre_color + chars[-1] + post_color + pre_color = "" + post_color = "" + + if colors and colors[0].span.start < i and colors[0].span.end > i: + # even though the current color should be continued, reset it for now. + # the next call to `disp_str()` will revive it. + chars[-1] += theme.reset + + return chars, char_widths + + +def prev_next_window[T]( + iterable: Iterable[T] +) -> Iterator[tuple[T | None, ...]]: + """Generates three-tuples of (previous, current, next) items. + + On the first iteration previous is None. On the last iteration next + is None. In case of exception next is None and the exception is re-raised + on a subsequent next() call. + + Inspired by `sliding_window` from `itertools` recipes. + """ + + iterator = iter(iterable) + window = deque((None, next(iterator)), maxlen=3) + try: + for x in iterator: + window.append(x) + yield tuple(window) + except Exception: + raise + finally: + window.append(None) + yield tuple(window) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index fffadd5e2ec..46c6030748b 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -22,11 +22,9 @@ import io import os import sys -import time -import msvcrt -from collections import deque import ctypes +import types from ctypes.wintypes import ( _COORD, WORD, @@ -42,9 +40,10 @@ from .console import Event, Console from .trace import trace from .utils import wlen +from .windows_eventqueue import EventQueue try: - from ctypes import GetLastError, WinDLL, windll, WinError # type: ignore[attr-defined] + from ctypes import get_last_error, GetLastError, WinDLL, windll, WinError # type: ignore[attr-defined] except: # Keep MyPy happy off Windows from ctypes import CDLL as WinDLL, cdll as windll @@ -52,11 +51,20 @@ def GetLastError() -> int: return 42 + def get_last_error() -> int: + return 42 + class WinError(OSError): # type: ignore[no-redef] def __init__(self, err: int | None, descr: str | None = None) -> None: self.err = err self.descr = descr +# declare nt optional to allow None assignment on other platforms +nt: types.ModuleType | None +try: + import nt +except ImportError: + nt = None TYPE_CHECKING = False @@ -94,7 +102,9 @@ def __init__(self, err: int | None, descr: str | None = None) -> None: 0x83: "f20", # VK_F20 } -# Console escape codes: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences +# Virtual terminal output sequences +# Reference: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#output-sequences +# Check `windows_eventqueue.py` for input sequences ERASE_IN_LINE = "\x1b[K" MOVE_LEFT = "\x1b[{}D" MOVE_RIGHT = "\x1b[{}C" @@ -102,10 +112,25 @@ def __init__(self, err: int | None, descr: str | None = None) -> None: MOVE_DOWN = "\x1b[{}B" CLEAR = "\x1b[H\x1b[J" +# State of control keys: https://learn.microsoft.com/en-us/windows/console/key-event-record-str +ALT_ACTIVE = 0x01 | 0x02 +CTRL_ACTIVE = 0x04 | 0x08 + +WAIT_TIMEOUT = 0x102 +WAIT_FAILED = 0xFFFFFFFF + +# from winbase.h +INFINITE = 0xFFFFFFFF + class _error(Exception): pass +def _supports_vt(): + try: + return nt._supports_virtual_terminal() + except AttributeError: + return False class WindowsConsole(Console): def __init__( @@ -117,17 +142,29 @@ def __init__( ): super().__init__(f_in, f_out, term, encoding) + self.__vt_support = _supports_vt() + + if self.__vt_support: + trace('console supports virtual terminal') + + # Save original console modes so we can recover on cleanup. + original_input_mode = DWORD() + GetConsoleMode(InHandle, original_input_mode) + trace(f'saved original input mode 0x{original_input_mode.value:x}') + self.__original_input_mode = original_input_mode.value + SetConsoleMode( OutHandle, ENABLE_WRAP_AT_EOL_OUTPUT | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING, ) + self.screen: list[str] = [] self.width = 80 self.height = 25 self.__offset = 0 - self.event_queue: deque[Event] = deque() + self.event_queue = EventQueue(encoding) try: self.out = io._WindowsConsoleIO(self.output_fd, "w") # type: ignore[attr-defined] except ValueError: @@ -146,8 +183,9 @@ def refresh(self, screen: list[str], c_xy: tuple[int, int]) -> None: while len(self.screen) < min(len(screen), self.height): self._hide_cursor() - self._move_relative(0, len(self.screen) - 1) - self.__write("\n") + if self.screen: + self._move_relative(0, len(self.screen) - 1) + self.__write("\n") self.posxy = 0, len(self.screen) self.screen.append("") @@ -204,11 +242,9 @@ def refresh(self, screen: list[str], c_xy: tuple[int, int]) -> None: @property def input_hook(self): - try: - import nt - except ImportError: - return None - if nt._is_inputhook_installed(): + # avoid inline imports here so the repl doesn't get flooded + # with import logging from -X importtime=2 + if nt is not None and nt._is_inputhook_installed(): return nt._inputhook def __write_changed_line( @@ -232,7 +268,7 @@ def __write_changed_line( # reuse the oldline as much as possible, but stop as soon as we # encounter an ESCAPE, because it might be the start of an escape - # sequene + # sequence while ( x_coord < minlen and oldline[x_pos] == newline[x_pos] @@ -247,18 +283,13 @@ def __write_changed_line( self._erase_to_end() self.__write(newline[x_pos:]) - if wlen(newline) == self.width: - # If we wrapped we want to start at the next line - self._move_relative(0, y + 1) - self.posxy = 0, y + 1 - else: - self.posxy = wlen(newline), y + self.posxy = min(wlen(newline), self.width - 1), y - if "\x1b" in newline or y != self.posxy[1] or '\x1a' in newline: - # ANSI escape characters are present, so we can't assume - # anything about the position of the cursor. Moving the cursor - # to the left margin should work to get to a known position. - self.move_cursor(0, y) + if "\x1b" in newline or y != self.posxy[1] or '\x1a' in newline: + # ANSI escape characters are present, so we can't assume + # anything about the position of the cursor. Moving the cursor + # to the left margin should work to get to a known position. + self.move_cursor(0, y) def _scroll( self, top: int, bottom: int, left: int | None = None, right: int | None = None @@ -291,6 +322,12 @@ def _enable_blinking(self): def _disable_blinking(self): self.__write("\x1b[?12l") + def _enable_bracketed_paste(self) -> None: + self.__write("\x1b[?2004h") + + def _disable_bracketed_paste(self) -> None: + self.__write("\x1b[?2004l") + def __write(self, text: str) -> None: if "\x1a" in text: text = ''.join(["^Z" if x == '\x1a' else x for x in text]) @@ -320,8 +357,15 @@ def prepare(self) -> None: self.__gone_tall = 0 self.__offset = 0 + if self.__vt_support: + SetConsoleMode(InHandle, self.__original_input_mode | ENABLE_VIRTUAL_TERMINAL_INPUT) + self._enable_bracketed_paste() + def restore(self) -> None: - pass + if self.__vt_support: + # Recover to original mode before running REPL + self._disable_bracketed_paste() + SetConsoleMode(InHandle, self.__original_input_mode) def _move_relative(self, x: int, y: int) -> None: """Moves relative to the current posxy""" @@ -342,7 +386,7 @@ def move_cursor(self, x: int, y: int) -> None: raise ValueError(f"Bad cursor position {x}, {y}") if y < self.__offset or y >= self.__offset + self.height: - self.event_queue.insert(0, Event("scroll", "")) + self.event_queue.insert(Event("scroll", "")) else: self._move_relative(x, y) self.posxy = x, y @@ -371,14 +415,7 @@ def _getscrollbacksize(self) -> int: return info.srWindow.Bottom # type: ignore[no-any-return] - def _read_input(self, block: bool = True) -> INPUT_RECORD | None: - if not block: - events = DWORD() - if not GetNumberOfConsoleInputEvents(InHandle, events): - raise WinError(GetLastError()) - if not events.value: - return None - + def _read_input(self) -> INPUT_RECORD | None: rec = INPUT_RECORD() read = DWORD() if not ReadConsoleInput(InHandle, rec, 1, read): @@ -386,15 +423,26 @@ def _read_input(self, block: bool = True) -> INPUT_RECORD | None: return rec + def _read_input_bulk( + self, n: int + ) -> tuple[ctypes.Array[INPUT_RECORD], int]: + rec = (n * INPUT_RECORD)() + read = DWORD() + if not ReadConsoleInput(InHandle, rec, n, read): + raise WinError(GetLastError()) + + return rec, read.value + def get_event(self, block: bool = True) -> Event | None: """Return an Event instance. Returns None if |block| is false and there is no event pending, otherwise waits for the completion of an event.""" - if self.event_queue: - return self.event_queue.pop() - while True: - rec = self._read_input(block) + if not block and not self.wait(timeout=0): + return None + + while self.event_queue.empty(): + rec = self._read_input() if rec is None: return None @@ -407,31 +455,47 @@ def get_event(self, block: bool = True) -> Event | None: continue return None - key = rec.Event.KeyEvent.uChar.UnicodeChar + key_event = rec.Event.KeyEvent + raw_key = key = key_event.uChar.UnicodeChar - if rec.Event.KeyEvent.uChar.UnicodeChar == "\r": - # Make enter make unix-like - return Event(evt="key", data="\n", raw=b"\n") - elif rec.Event.KeyEvent.wVirtualKeyCode == 8: + if key == "\r": + # Make enter unix-like + return Event(evt="key", data="\n") + elif key_event.wVirtualKeyCode == 8: # Turn backspace directly into the command - return Event( - evt="key", - data="backspace", - raw=rec.Event.KeyEvent.uChar.UnicodeChar, - ) - elif rec.Event.KeyEvent.uChar.UnicodeChar == "\x00": + key = "backspace" + elif key == "\x00": # Handle special keys like arrow keys and translate them into the appropriate command - code = VK_MAP.get(rec.Event.KeyEvent.wVirtualKeyCode) - if code: - return Event( - evt="key", data=code, raw=rec.Event.KeyEvent.uChar.UnicodeChar - ) + key = VK_MAP.get(key_event.wVirtualKeyCode) + if key: + if key_event.dwControlKeyState & CTRL_ACTIVE: + key = f"ctrl {key}" + elif key_event.dwControlKeyState & ALT_ACTIVE: + # queue the key, return the meta command + self.event_queue.insert(Event(evt="key", data=key)) + return Event(evt="key", data="\033") # keymap.py uses this for meta + return Event(evt="key", data=key) if block: continue return None - - return Event(evt="key", data=key, raw=rec.Event.KeyEvent.uChar.UnicodeChar) + elif self.__vt_support: + # If virtual terminal is enabled, scanning VT sequences + for char in raw_key.encode(self.event_queue.encoding, "replace"): + self.event_queue.push(char) + continue + + if key_event.dwControlKeyState & ALT_ACTIVE: + # Do not swallow characters that have been entered via AltGr: + # Windows internally converts AltGr to CTRL+ALT, see + # https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-vkkeyscanw + if not key_event.dwControlKeyState & CTRL_ACTIVE: + # queue the key, return the meta command + self.event_queue.insert(Event(evt="key", data=key)) + return Event(evt="key", data="\033") # keymap.py uses this for meta + + return Event(evt="key", data=key) + return self.event_queue.get() def push_char(self, char: int | bytes) -> None: """ @@ -446,7 +510,7 @@ def clear(self) -> None: """Wipe the screen""" self.__write(CLEAR) self.posxy = 0, 0 - self.screen = [""] + self.screen = [] def finish(self) -> None: """Move the cursor to the end of the display and otherwise get @@ -472,18 +536,53 @@ def forgetinput(self) -> None: def getpending(self) -> Event: """Return the characters that have been typed but not yet processed.""" - return Event("key", "", b"") + e = Event("key", "", b"") + + while not self.event_queue.empty(): + e2 = self.event_queue.get() + if e2: + e.data += e2.data + + recs, rec_count = self._read_input_bulk(1024) + for i in range(rec_count): + rec = recs[i] + # In case of a legacy console, we do not only receive a keydown + # event, but also a keyup event - and for uppercase letters + # an additional SHIFT_PRESSED event. + if rec and rec.EventType == KEY_EVENT: + key_event = rec.Event.KeyEvent + if not key_event.bKeyDown: + continue + ch = key_event.uChar.UnicodeChar + if ch == "\x00": + # ignore SHIFT_PRESSED and special keys + continue + if ch == "\r": + ch += "\n" + e.data += ch + return e - def wait(self, timeout: float | None) -> bool: + def wait_for_event(self, timeout: float | None) -> bool: """Wait for an event.""" - # Poor man's Windows select loop - start_time = time.time() - while True: - if msvcrt.kbhit(): # type: ignore[attr-defined] - return True - if timeout and time.time() - start_time > timeout / 1000: - return False - time.sleep(0.01) + if timeout is None: + timeout = INFINITE + else: + timeout = int(timeout) + ret = WaitForSingleObject(InHandle, timeout) + if ret == WAIT_FAILED: + raise WinError(get_last_error()) + elif ret == WAIT_TIMEOUT: + return False + return True + + def wait(self, timeout: float | None) -> bool: + """ + Wait for events on the console. + """ + return ( + not self.event_queue.empty() + or self.wait_for_event(timeout) + ) def repaint(self) -> None: raise NotImplementedError("No repaint support") @@ -553,6 +652,13 @@ class INPUT_RECORD(Structure): MOUSE_EVENT = 0x02 WINDOW_BUFFER_SIZE_EVENT = 0x04 +ENABLE_PROCESSED_INPUT = 0x0001 +ENABLE_LINE_INPUT = 0x0002 +ENABLE_ECHO_INPUT = 0x0004 +ENABLE_MOUSE_INPUT = 0x0010 +ENABLE_INSERT_MODE = 0x0020 +ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + ENABLE_PROCESSED_OUTPUT = 0x01 ENABLE_WRAP_AT_EOL_OUTPUT = 0x02 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x04 @@ -584,6 +690,10 @@ class INPUT_RECORD(Structure): ] ScrollConsoleScreenBuffer.restype = BOOL + GetConsoleMode = _KERNEL32.GetConsoleMode + GetConsoleMode.argtypes = [HANDLE, POINTER(DWORD)] + GetConsoleMode.restype = BOOL + SetConsoleMode = _KERNEL32.SetConsoleMode SetConsoleMode.argtypes = [HANDLE, DWORD] SetConsoleMode.restype = BOOL @@ -592,14 +702,15 @@ class INPUT_RECORD(Structure): ReadConsoleInput.argtypes = [HANDLE, POINTER(INPUT_RECORD), DWORD, POINTER(DWORD)] ReadConsoleInput.restype = BOOL - GetNumberOfConsoleInputEvents = _KERNEL32.GetNumberOfConsoleInputEvents - GetNumberOfConsoleInputEvents.argtypes = [HANDLE, POINTER(DWORD)] - GetNumberOfConsoleInputEvents.restype = BOOL FlushConsoleInputBuffer = _KERNEL32.FlushConsoleInputBuffer FlushConsoleInputBuffer.argtypes = [HANDLE] FlushConsoleInputBuffer.restype = BOOL + WaitForSingleObject = _KERNEL32.WaitForSingleObject + WaitForSingleObject.argtypes = [HANDLE, DWORD] + WaitForSingleObject.restype = DWORD + OutHandle = GetStdHandle(STD_OUTPUT_HANDLE) InHandle = GetStdHandle(STD_INPUT_HANDLE) else: @@ -610,9 +721,10 @@ def _win_only(*args, **kwargs): GetStdHandle = _win_only GetConsoleScreenBufferInfo = _win_only ScrollConsoleScreenBuffer = _win_only + GetConsoleMode = _win_only SetConsoleMode = _win_only ReadConsoleInput = _win_only - GetNumberOfConsoleInputEvents = _win_only FlushConsoleInputBuffer = _win_only + WaitForSingleObject = _win_only OutHandle = 0 InHandle = 0 diff --git a/Lib/_pyrepl/windows_eventqueue.py b/Lib/_pyrepl/windows_eventqueue.py new file mode 100644 index 00000000000..d99722f9a16 --- /dev/null +++ b/Lib/_pyrepl/windows_eventqueue.py @@ -0,0 +1,42 @@ +""" +Windows event and VT sequence scanner +""" + +from .base_eventqueue import BaseEventQueue + + +# Reference: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#input-sequences +VT_MAP: dict[bytes, str] = { + b'\x1b[A': 'up', + b'\x1b[B': 'down', + b'\x1b[C': 'right', + b'\x1b[D': 'left', + b'\x1b[1;5D': 'ctrl left', + b'\x1b[1;5C': 'ctrl right', + + b'\x1b[H': 'home', + b'\x1b[F': 'end', + + b'\x7f': 'backspace', + b'\x1b[2~': 'insert', + b'\x1b[3~': 'delete', + b'\x1b[5~': 'page up', + b'\x1b[6~': 'page down', + + b'\x1bOP': 'f1', + b'\x1bOQ': 'f2', + b'\x1bOR': 'f3', + b'\x1bOS': 'f4', + b'\x1b[15~': 'f5', + b'\x1b[17~': 'f6', + b'\x1b[18~': 'f7', + b'\x1b[19~': 'f8', + b'\x1b[20~': 'f9', + b'\x1b[21~': 'f10', + b'\x1b[23~': 'f11', + b'\x1b[24~': 'f12', +} + +class EventQueue(BaseEventQueue): + def __init__(self, encoding: str) -> None: + BaseEventQueue.__init__(self, encoding, VT_MAP) diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index e07dd52a2a5..44e14771b11 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -86,22 +86,27 @@ def run(self): global return_code try: - banner = ( - f'asyncio REPL {sys.version} on {sys.platform}\n' - f'Use "await" directly instead of "asyncio.run()".\n' - f'Type "help", "copyright", "credits" or "license" ' - f'for more information.\n' - ) + if not sys.flags.quiet: + banner = ( + f'asyncio REPL {sys.version} on {sys.platform}\n' + f'Use "await" directly instead of "asyncio.run()".\n' + f'Type "help", "copyright", "credits" or "license" ' + f'for more information.\n' + ) - console.write(banner) + console.write(banner) - if startup_path := os.getenv("PYTHONSTARTUP"): + if not sys.flags.isolated and (startup_path := os.getenv("PYTHONSTARTUP")): sys.audit("cpython.run_startup", startup_path) - - import tokenize - with tokenize.open(startup_path) as f: - startup_code = compile(f.read(), startup_path, "exec") + try: + import tokenize + with tokenize.open(startup_path) as f: + startup_code = compile(f.read(), startup_path, "exec") exec(startup_code, console.locals) + except SystemExit: + raise + except BaseException: + console.showtraceback() ps1 = getattr(sys, "ps1", ">>> ") if CAN_USE_PYREPL: @@ -236,4 +241,5 @@ def interrupt(self) -> None: break console.write('exiting asyncio REPL...\n') + loop.close() sys.exit(return_code) diff --git a/Lib/asyncio/base_events.py b/Lib/asyncio/base_events.py index 8cbb71f7085..6d1f1f84323 100644 --- a/Lib/asyncio/base_events.py +++ b/Lib/asyncio/base_events.py @@ -483,10 +483,10 @@ def set_task_factory(self, factory): If factory is None the default task factory will be set. If factory is a callable, it should have a signature matching - '(loop, coro, **kwargs)', where 'loop' will be a reference to the active - event loop, 'coro' will be a coroutine object, and **kwargs will be - arbitrary keyword arguments that should be passed on to Task. - The callable must return a Task. + '(loop, coro, **kwargs)', where 'loop' will be a reference to the + active event loop, 'coro' will be a coroutine object, and **kwargs + will be arbitrary keyword arguments that should be passed on to + Task. The callable must return a Task. """ if factory is not None and not callable(factory): raise TypeError('task factory must be a callable or None') @@ -721,8 +721,8 @@ def run_until_complete(self, future): def stop(self): """Stop running the event loop. - Every callback already scheduled will still run. This simply informs - run_forever to stop looping after a complete iteration. + Every callback already scheduled will still run. This simply + informs run_forever to stop looping after a complete iteration. """ self._stopping = True @@ -963,7 +963,7 @@ async def _sock_sendfile_native(self, sock, file, offset, count): f"and file {file!r} combination") async def _sock_sendfile_fallback(self, sock, file, offset, count): - if offset: + if hasattr(file, 'seek'): file.seek(offset) blocksize = ( min(count, constants.SENDFILE_FALLBACK_READBUFFER_SIZE) @@ -1070,12 +1070,12 @@ async def create_connection( Create a streaming transport connection to a given internet host and port: socket family AF_INET or socket.AF_INET6 depending on host (or - family if specified), socket type SOCK_STREAM. protocol_factory must be - a callable returning a protocol instance. + family if specified), socket type SOCK_STREAM. protocol_factory must + be a callable returning a protocol instance. - This method is a coroutine which will try to establish the connection - in the background. When successful, the coroutine returns a - (transport, protocol) pair. + This method is a coroutine which will try to establish the + connection in the background. When successful, the coroutine + returns a (transport, protocol) pair. """ if server_hostname is not None and not ssl: raise ValueError('server_hostname is only meaningful with ssl') @@ -1278,7 +1278,6 @@ async def sendfile(self, transport, file, offset=0, count=None, raise RuntimeError( f"fallback is disabled and native sendfile is not " f"supported for transport {transport!r}") - return await self._sendfile_fallback(transport, file, offset, count) @@ -1287,7 +1286,7 @@ async def _sendfile_native(self, transp, file, offset, count): "sendfile syscall is not supported") async def _sendfile_fallback(self, transp, file, offset, count): - if offset: + if hasattr(file, 'seek'): file.seek(offset) blocksize = min(count, 16384) if count else 16384 buf = bytearray(blocksize) @@ -1345,6 +1344,17 @@ async def start_tls(self, transport, protocol, sslcontext, *, # have a chance to get called before "ssl_protocol.connection_made()". transport.pause_reading() + # gh-142352: move buffered StreamReader data to SSLProtocol + if server_side: + from .streams import StreamReaderProtocol + if isinstance(protocol, StreamReaderProtocol): + stream_reader = getattr(protocol, '_stream_reader', None) + if stream_reader is not None: + buffer = stream_reader._buffer + if buffer: + ssl_protocol._incoming.write(buffer) + buffer.clear() + transport.set_protocol(ssl_protocol) conmade_cb = self.call_soon(ssl_protocol.connection_made, transport) resume_cb = self.call_soon(transport.resume_reading) @@ -1532,11 +1542,11 @@ async def create_server( The host parameter can be a string, in that case the TCP server is bound to host and port. - The host parameter can also be a sequence of strings and in that case - the TCP server is bound to all hosts of the sequence. If a host - appears multiple times (possibly indirectly e.g. when hostnames - resolve to the same IP address), the server is only bound once to that - host. + The host parameter can also be a sequence of strings and in that + case the TCP server is bound to all hosts of the sequence. If + a host appears multiple times (possibly indirectly e.g. when + hostnames resolve to the same IP address), the server is only bound + once to that host. Return a Server object which can be used to stop the service. diff --git a/Lib/asyncio/base_subprocess.py b/Lib/asyncio/base_subprocess.py index 321a4e5d5d1..224b1883808 100644 --- a/Lib/asyncio/base_subprocess.py +++ b/Lib/asyncio/base_subprocess.py @@ -265,7 +265,7 @@ def _try_finish(self): # to avoid hanging forever in self._wait as otherwise _exit_waiters # would never be woken up, we wake them up here. for waiter in self._exit_waiters: - if not waiter.cancelled(): + if not waiter.done(): waiter.set_result(self._returncode) if all(p is not None and p.disconnected for p in self._pipes.values()): @@ -278,7 +278,7 @@ def _call_connection_lost(self, exc): finally: # wake up futures waiting for wait() for waiter in self._exit_waiters: - if not waiter.cancelled(): + if not waiter.done(): waiter.set_result(self._returncode) self._exit_waiters = None self._loop = None diff --git a/Lib/asyncio/coroutines.py b/Lib/asyncio/coroutines.py index a51319cb72a..6727065bbe3 100644 --- a/Lib/asyncio/coroutines.py +++ b/Lib/asyncio/coroutines.py @@ -18,8 +18,8 @@ def _is_debug_mode(): def iscoroutinefunction(func): - import warnings """Return True if func is a decorated coroutine function.""" + import warnings warnings._deprecated("asyncio.iscoroutinefunction", f"{warnings._DEPRECATED_MSG}; " "use inspect.iscoroutinefunction() instead", diff --git a/Lib/asyncio/events.py b/Lib/asyncio/events.py index a7fb55982ab..42d8408a38d 100644 --- a/Lib/asyncio/events.py +++ b/Lib/asyncio/events.py @@ -374,8 +374,8 @@ async def create_server( If host is an empty string or None all interfaces are assumed and a list of multiple sockets will be returned (most likely - one for IPv4 and another one for IPv6). The host parameter can also be - a sequence (e.g. list) of hosts to bind to. + one for IPv4 and another one for IPv6). The host parameter can also + be a sequence (e.g. list) of hosts to bind to. family can be set to either AF_INET or AF_INET6 to force the socket to use IPv4 or IPv6. If not set it will be determined @@ -415,8 +415,9 @@ async def create_server( start_serving set to True (default) causes the created server to start accepting connections immediately. When set to False, - the user should await Server.start_serving() or Server.serve_forever() - to make the server to start accepting connections. + the user should await Server.start_serving() or + Server.serve_forever() to make the server to start accepting + connections. """ raise NotImplementedError @@ -479,8 +480,9 @@ async def create_unix_server( start_serving set to True (default) causes the created server to start accepting connections immediately. When set to False, - the user should await Server.start_serving() or Server.serve_forever() - to make the server to start accepting connections. + the user should await Server.start_serving() or + Server.serve_forever() to make the server to start accepting + connections. """ raise NotImplementedError @@ -511,8 +513,8 @@ async def create_datagram_endpoint(self, protocol_factory, protocol_factory must be a callable returning a protocol instance. - socket family AF_INET, socket.AF_INET6 or socket.AF_UNIX depending on - host (or family if specified), socket type SOCK_DGRAM. + socket family AF_INET, socket.AF_INET6 or socket.AF_UNIX depending + on host (or family if specified), socket type SOCK_DGRAM. reuse_address tells the kernel to reuse a local socket in TIME_WAIT state, without waiting for its natural timeout to @@ -552,7 +554,8 @@ async def connect_read_pipe(self, protocol_factory, pipe): async def connect_write_pipe(self, protocol_factory, pipe): """Register write pipe in event loop. - protocol_factory should instantiate object with BaseProtocol interface. + protocol_factory should instantiate object with BaseProtocol + interface. Pipe is file-like object already switched to nonblocking. Return pair (transport, protocol), where transport support WriteTransport interface.""" diff --git a/Lib/asyncio/futures.py b/Lib/asyncio/futures.py index d1df6707302..e8a99556b18 100644 --- a/Lib/asyncio/futures.py +++ b/Lib/asyncio/futures.py @@ -392,7 +392,7 @@ def _set_state(future, other): def _call_check_cancel(destination): if destination.cancelled(): - if source_loop is None or source_loop is dest_loop: + if source_loop is None or source_loop is events._get_running_loop(): source.cancel() else: source_loop.call_soon_threadsafe(source.cancel) @@ -401,7 +401,7 @@ def _call_set_state(source): if (destination.cancelled() and dest_loop is not None and dest_loop.is_closed()): return - if dest_loop is None or dest_loop is source_loop: + if dest_loop is None or dest_loop is events._get_running_loop(): _set_state(destination, source) else: if dest_loop.is_closed(): diff --git a/Lib/asyncio/graph.py b/Lib/asyncio/graph.py index b5bfeb1630a..35f7fa62140 100644 --- a/Lib/asyncio/graph.py +++ b/Lib/asyncio/graph.py @@ -112,13 +112,13 @@ def capture_call_graph( optional keyword-only 'depth' argument can be used to skip the specified number of frames from top of the stack. - If the optional keyword-only 'limit' argument is provided, each call stack - in the resulting graph is truncated to include at most ``abs(limit)`` - entries. If 'limit' is positive, the entries left are the closest to - the invocation point. If 'limit' is negative, the topmost entries are - left. If 'limit' is omitted or None, all entries are present. - If 'limit' is 0, the call stack is not captured at all, only - "awaited by" information is present. + If the optional keyword-only 'limit' argument is provided, each call + stack in the resulting graph is truncated to include at most + ``abs(limit)`` entries. If 'limit' is positive, the entries left are + the closest to the invocation point. If 'limit' is negative, the + topmost entries are left. If 'limit' is omitted or None, all entries + are present. If 'limit' is 0, the call stack is not captured at all, + only "awaited by" information is present. """ loop = events._get_running_loop() diff --git a/Lib/asyncio/locks.py b/Lib/asyncio/locks.py index fa3a94764b5..1592d30ed45 100644 --- a/Lib/asyncio/locks.py +++ b/Lib/asyncio/locks.py @@ -158,10 +158,10 @@ def _wake_up_first(self): class Event(mixins._LoopBoundMixin): """Asynchronous equivalent to threading.Event. - Class implementing event objects. An event manages a flag that can be set - to true with the set() method and reset to false with the clear() method. - The wait() method blocks until the flag is true. The flag is initially - false. + Class implementing event objects. An event manages a flag that can be + set to true with the set() method and reset to false with the clear() + method. The wait() method blocks until the flag is true. The flag is + initially false. """ def __init__(self): @@ -353,9 +353,9 @@ class Semaphore(_ContextManagerMixin, mixins._LoopBoundMixin): """A Semaphore implementation. A semaphore manages an internal counter which is decremented by each - acquire() call and incremented by each release() call. The counter - can never go below zero; when acquire() finds that it is zero, it blocks, - waiting until some other thread calls release(). + acquire() call and incremented by each release() call. The counter + can never go below zero; when acquire() finds that it is zero, it + blocks, waiting until some other thread calls release(). Semaphores also support the context management protocol. @@ -511,8 +511,8 @@ async def __aexit__(self, *args): async def wait(self): """Wait for the barrier. - When the specified number of tasks have started waiting, they are all - simultaneously awoken. + When the specified number of tasks have started waiting, they are + all simultaneously awoken. Returns an unique and individual index number from 0 to 'parties-1'. """ async with self._cond: diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index f404273c3ae..6b94975d004 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -756,8 +756,7 @@ async def _sock_sendfile_native(self, sock, file, offset, count): offset += blocksize total_sent += blocksize finally: - if total_sent > 0: - file.seek(offset) + file.seek(offset) async def _sendfile_native(self, transp, file, offset, count): resume_reading = transp.is_reading() diff --git a/Lib/asyncio/queues.py b/Lib/asyncio/queues.py index 084fccaaff2..30004f2bc9b 100644 --- a/Lib/asyncio/queues.py +++ b/Lib/asyncio/queues.py @@ -33,11 +33,11 @@ class QueueShutDown(Exception): class Queue(mixins._LoopBoundMixin): """A queue, useful for coordinating producer and consumer coroutines. - If maxsize is less than or equal to zero, the queue size is infinite. If it - is an integer greater than 0, then "await put()" will block when the - queue reaches maxsize, until an item is removed by get(). + If maxsize is less than or equal to zero, the queue size is infinite. + If it is an integer greater than 0, then "await put()" will block when + the queue reaches maxsize, until an item is removed by get(). - Unlike the standard library Queue, you can reliably know this Queue's size + Unlike queue.Queue, you can reliably know this Queue's size with qsize(), since your single-threaded asyncio application won't be interrupted between calling qsize() and doing an operation on the Queue. """ @@ -174,8 +174,8 @@ async def get(self): If queue is empty, wait until an item is available. - Raises QueueShutDown if the queue has been shut down and is empty, or - if the queue has been shut down immediately. + Raises QueueShutDown if the queue has been shut down and is empty, + or if the queue has been shut down immediately. """ while self.empty(): if self._is_shutdown and self.empty(): @@ -203,10 +203,11 @@ async def get(self): def get_nowait(self): """Remove and return an item from the queue. - Return an item if one is immediately available, else raise QueueEmpty. + Return an item if one is immediately available, else raise + QueueEmpty. - Raises QueueShutDown if the queue has been shut down and is empty, or - if the queue has been shut down immediately. + Raises QueueShutDown if the queue has been shut down and is empty, + or if the queue has been shut down immediately. """ if self.empty(): if self._is_shutdown: @@ -223,12 +224,12 @@ def task_done(self): a subsequent call to task_done() tells the queue that the processing on the task is complete. - If a join() is currently blocking, it will resume when all items have - been processed (meaning that a task_done() call was received for every - item that had been put() into the queue). + If a join() is currently blocking, it will resume when all items + have been processed (meaning that a task_done() call was received + for every item that had been put() into the queue). - Raises ValueError if called more times than there were items placed in - the queue. + Raises ValueError if called more times than there were items placed + in the queue. """ if self._unfinished_tasks <= 0: raise ValueError('task_done() called too many times') @@ -239,10 +240,11 @@ def task_done(self): async def join(self): """Block until all items in the queue have been gotten and processed. - The count of unfinished tasks goes up whenever an item is added to the - queue. The count goes down whenever a consumer calls task_done() to - indicate that the item was retrieved and all work on it is complete. - When the count of unfinished tasks drops to zero, join() unblocks. + The count of unfinished tasks goes up whenever an item is added to + the queue. The count goes down whenever a consumer calls + task_done() to indicate that the item was retrieved and all work on + it is complete. When the count of unfinished tasks drops to zero, + join() unblocks. """ if self._unfinished_tasks > 0: await self._finished.wait() diff --git a/Lib/asyncio/runners.py b/Lib/asyncio/runners.py index ba37e003a65..774a317564d 100644 --- a/Lib/asyncio/runners.py +++ b/Lib/asyncio/runners.py @@ -35,7 +35,8 @@ class Runner: with asyncio.Runner(debug=True) as runner: runner.run(main()) - The run() method can be called multiple times within the runner's context. + The run() method can be called multiple times within the runner's + context. This can be useful for interactive console (e.g. IPython), unittest runners, console tools, -- everywhere when async code diff --git a/Lib/asyncio/selector_events.py b/Lib/asyncio/selector_events.py index ff7e16df3c6..7c2062e3dd5 100644 --- a/Lib/asyncio/selector_events.py +++ b/Lib/asyncio/selector_events.py @@ -530,11 +530,12 @@ def _sock_recvfrom_into(self, fut, sock, buf, bufsize): async def sock_sendall(self, sock, data): """Send data to the socket. - The socket must be connected to a remote socket. This method continues - to send data from data until either all data has been sent or an - error occurs. None is returned on success. On error, an exception is - raised, and there is no way to determine how much data, if any, was - successfully processed by the receiving end of the connection. + The socket must be connected to a remote socket. This method + continues to send data from data until either all data has been + sent or an error occurs. None is returned on success. On error, + an exception is raised, and there is no way to determine how much + data, if any, was successfully processed by the receiving end of + the connection. """ base_events._check_ssl_socket(sock) if self._debug and sock.gettimeout() != 0: @@ -583,11 +584,12 @@ def _sock_sendall(self, fut, sock, view, pos): async def sock_sendto(self, sock, data, address): """Send data to the socket. - The socket must be connected to a remote socket. This method continues - to send data from data until either all data has been sent or an - error occurs. None is returned on success. On error, an exception is - raised, and there is no way to determine how much data, if any, was - successfully processed by the receiving end of the connection. + The socket must be connected to a remote socket. This method + continues to send data from data until either all data has been + sent or an error occurs. None is returned on success. On error, + an exception is raised, and there is no way to determine how much + data, if any, was successfully processed by the receiving end of + the connection. """ base_events._check_ssl_socket(sock) if self._debug and sock.gettimeout() != 0: @@ -698,10 +700,11 @@ def _sock_connect_cb(self, fut, sock, address): async def sock_accept(self, sock): """Accept a connection. - The socket must be bound to an address and listening for connections. - The return value is a pair (conn, address) where conn is a new socket - object usable to send and receive data on the connection, and address - is the address bound to the socket on the other end of the connection. + The socket must be bound to an address and listening for + connections. The return value is a pair (conn, address) where + conn is a new socket object usable to send and receive data on the + connection, and address is the address bound to the socket on the + other end of the connection. """ base_events._check_ssl_socket(sock) if self._debug and sock.gettimeout() != 0: diff --git a/Lib/asyncio/streams.py b/Lib/asyncio/streams.py index 64aac4cc50d..dd8f6618623 100644 --- a/Lib/asyncio/streams.py +++ b/Lib/asyncio/streams.py @@ -541,17 +541,17 @@ async def _wait_for_data(self, func_name): self._waiter = None async def readline(self): - """Read chunk of data from the stream until newline (b'\n') is found. + r"""Read chunk of data from the stream until newline (b'\n') is found. - On success, return chunk that ends with newline. If only partial + On success, return chunk that ends with newline. If only partial line can be read due to EOF, return incomplete line without - terminating newline. When EOF was reached while no bytes read, empty - bytes object is returned. + terminating newline. When EOF was reached while no bytes read, + empty bytes object is returned. - If limit is reached, ValueError will be raised. In that case, if + If limit is reached, ValueError will be raised. In that case, if newline was found, complete line including newline will be removed - from internal buffer. Else, internal buffer will be cleared. Limit is - compared against part of the line without newline. + from internal buffer. Else, internal buffer will be cleared. + Limit is compared against part of the line without newline. If stream was paused, this function will automatically resume it if needed. diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py index fbd5c39a7c5..14d3c1ceb58 100644 --- a/Lib/asyncio/tasks.py +++ b/Lib/asyncio/tasks.py @@ -624,10 +624,11 @@ def as_completed(fs, *, timeout=None): Run the supplied awaitables concurrently. The returned object can be iterated to obtain the results of the awaitables as they finish. - The object returned can be iterated as an asynchronous iterator or a plain - iterator. When asynchronous iteration is used, the originally-supplied - awaitables are yielded if they are tasks or futures. This makes it easy to - correlate previously-scheduled tasks with their results: + The object returned can be iterated as an asynchronous iterator or + a plain iterator. When asynchronous iteration is used, the + originally-supplied awaitables are yielded if they are tasks or + futures. This makes it easy to correlate previously-scheduled tasks + with their results: ipv4_connect = create_task(open_connection("127.0.0.1", 80)) ipv6_connect = create_task(open_connection("::1", 80)) @@ -643,26 +644,27 @@ def as_completed(fs, *, timeout=None): else: print("IPv4 connection established.") - During asynchronous iteration, implicitly-created tasks will be yielded for - supplied awaitables that aren't tasks or futures. + During asynchronous iteration, implicitly-created tasks will be + yielded for supplied awaitables that aren't tasks or futures. - When used as a plain iterator, each iteration yields a new coroutine that - returns the result or raises the exception of the next completed awaitable. - This pattern is compatible with Python versions older than 3.13: + When used as a plain iterator, each iteration yields a new coroutine + that returns the result or raises the exception of the next completed + awaitable. This pattern is compatible with Python versions older than + 3.13: ipv4_connect = create_task(open_connection("127.0.0.1", 80)) ipv6_connect = create_task(open_connection("::1", 80)) tasks = [ipv4_connect, ipv6_connect] for next_connect in as_completed(tasks): - # next_connect is not one of the original task objects. It must be - # awaited to obtain the result value or raise the exception of the - # awaitable that finishes next. + # next_connect is not one of the original task objects. It must + # be awaited to obtain the result value or raise the exception + # of the awaitable that finishes next. reader, writer = await next_connect - A TimeoutError is raised if the timeout occurs before all awaitables are - done. This is raised by the async for loop during asynchronous iteration or - by the coroutines yielded during plain iteration. + A TimeoutError is raised if the timeout occurs before all awaitables + are done. This is raised by the async for loop during asynchronous + iteration or by the coroutines yielded during plain iteration. """ if inspect.isawaitable(fs): raise TypeError( @@ -1030,21 +1032,22 @@ def callback(): def create_eager_task_factory(custom_task_constructor): """Create a function suitable for use as a task factory on an event-loop. - Example usage: + Example usage: - loop.set_task_factory( - asyncio.create_eager_task_factory(my_task_constructor)) + loop.set_task_factory( + asyncio.create_eager_task_factory(my_task_constructor)) - Now, tasks created will be started immediately (rather than being first - scheduled to an event loop). The constructor argument can be any callable - that returns a Task-compatible object and has a signature compatible - with `Task.__init__`; it must have the `eager_start` keyword argument. + Now, tasks created will be started immediately (rather than being first + scheduled to an event loop). The constructor argument can be any + callable that returns a Task-compatible object and has a signature + compatible with `Task.__init__`; it must have the `eager_start` + keyword argument. - Most applications will use `Task` for `custom_task_constructor` and in - this case there's no need to call `create_eager_task_factory()` - directly. Instead the global `eager_task_factory` instance can be - used. E.g. `loop.set_task_factory(asyncio.eager_task_factory)`. - """ + Most applications will use `Task` for `custom_task_constructor` and in + this case there's no need to call `create_eager_task_factory()` + directly. Instead the global `eager_task_factory` instance can be + used. E.g. `loop.set_task_factory(asyncio.eager_task_factory)`. + """ def factory(loop, coro, *, eager_start=True, **kwargs): return custom_task_constructor( diff --git a/Lib/asyncio/threads.py b/Lib/asyncio/threads.py index db048a8231d..5001351b097 100644 --- a/Lib/asyncio/threads.py +++ b/Lib/asyncio/threads.py @@ -17,7 +17,8 @@ async def to_thread(func, /, *args, **kwargs): allowing context variables from the main thread to be accessed in the separate thread. - Return a coroutine that can be awaited to get the eventual result of *func*. + Return a coroutine that can be awaited to get the eventual result of + *func*. """ loop = events.get_running_loop() ctx = contextvars.copy_context() diff --git a/Lib/asyncio/timeouts.py b/Lib/asyncio/timeouts.py index 09342dc7c13..65ddc285abd 100644 --- a/Lib/asyncio/timeouts.py +++ b/Lib/asyncio/timeouts.py @@ -25,7 +25,8 @@ class _State(enum.Enum): class Timeout: """Asynchronous context manager for cancelling overdue coroutines. - Use `timeout()` or `timeout_at()` rather than instantiating this class directly. + Use `timeout()` or `timeout_at()` rather than instantiating this class + directly. """ def __init__(self, when: float | None) -> None: diff --git a/Lib/asyncio/unix_events.py b/Lib/asyncio/unix_events.py index 1c1458127db..bfb3da0debb 100644 --- a/Lib/asyncio/unix_events.py +++ b/Lib/asyncio/unix_events.py @@ -54,7 +54,8 @@ def waitstatus_to_exitcode(status): class _UnixSelectorEventLoop(selector_events.BaseSelectorEventLoop): """Unix event loop. - Adds signal handling and UNIX Domain Socket support to SelectorEventLoop. + Adds signal handling and UNIX Domain Socket support to + SelectorEventLoop. """ def __init__(self, selector=None): @@ -384,12 +385,12 @@ def _sock_sendfile_native_impl(self, fut, registered_fd, sock, fileno, # order to simplify the common case. self.remove_writer(registered_fd) if fut.cancelled(): - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) return if count: blocksize = count - total_sent if blocksize <= 0: - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) fut.set_result(total_sent) return @@ -423,20 +424,20 @@ def _sock_sendfile_native_impl(self, fut, registered_fd, sock, fileno, # plain send(). err = exceptions.SendfileNotAvailableError( "os.sendfile call failed") - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) fut.set_exception(err) else: - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) fut.set_exception(exc) except (SystemExit, KeyboardInterrupt): raise except BaseException as exc: - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) fut.set_exception(exc) else: if sent == 0: # EOF - self._sock_sendfile_update_filepos(fileno, offset, total_sent) + self._sock_sendfile_update_filepos(fileno, offset) fut.set_result(total_sent) else: offset += sent @@ -447,9 +448,9 @@ def _sock_sendfile_native_impl(self, fut, registered_fd, sock, fileno, fd, sock, fileno, offset, count, blocksize, total_sent) - def _sock_sendfile_update_filepos(self, fileno, offset, total_sent): - if total_sent > 0: - os.lseek(fileno, offset, os.SEEK_SET) + def _sock_sendfile_update_filepos(self, fileno, offset): + # After this helper runs, the source fd's lseek pointer is at offset." + os.lseek(fileno, offset, os.SEEK_SET) def _sock_add_cancellation_callback(self, fut, sock): def cb(fut): diff --git a/Lib/asyncio/windows_events.py b/Lib/asyncio/windows_events.py index 5f75b17d8ca..0bf7732136f 100644 --- a/Lib/asyncio/windows_events.py +++ b/Lib/asyncio/windows_events.py @@ -610,6 +610,9 @@ def sendfile(self, sock, file, offset, count): ov = _overlapped.Overlapped(NULL) offset_low = offset & 0xffff_ffff offset_high = (offset >> 32) & 0xffff_ffff + # TransmitFile ignores OVERLAPPED.Offset for handles not opened with + # FILE_FLAG_OVERLAPPED, so seek the CRT file pointer to match. + file.seek(offset) ov.TransmitFile(sock.fileno(), msvcrt.get_osfhandle(file.fileno()), offset_low, offset_high, diff --git a/Lib/asyncio/windows_utils.py b/Lib/asyncio/windows_utils.py index ef277fac3e2..d6393f0b1ff 100644 --- a/Lib/asyncio/windows_utils.py +++ b/Lib/asyncio/windows_utils.py @@ -10,7 +10,6 @@ import msvcrt import os import subprocess -import tempfile import warnings @@ -24,6 +23,7 @@ PIPE = subprocess.PIPE STDOUT = subprocess.STDOUT _mmap_counter = itertools.count() +_MAX_PIPE_ATTEMPTS = 20 # Replacement for os.pipe() using handles instead of fds @@ -31,10 +31,6 @@ def pipe(*, duplex=False, overlapped=(True, True), bufsize=BUFSIZE): """Like os.pipe() but with overlapped support and using handles not fds.""" - address = tempfile.mktemp( - prefix=r'\\.\pipe\python-pipe-{:d}-{:d}-'.format( - os.getpid(), next(_mmap_counter))) - if duplex: openmode = _winapi.PIPE_ACCESS_DUPLEX access = _winapi.GENERIC_READ | _winapi.GENERIC_WRITE @@ -56,9 +52,20 @@ def pipe(*, duplex=False, overlapped=(True, True), bufsize=BUFSIZE): h1 = h2 = None try: - h1 = _winapi.CreateNamedPipe( - address, openmode, _winapi.PIPE_WAIT, - 1, obsize, ibsize, _winapi.NMPWAIT_WAIT_FOREVER, _winapi.NULL) + for attempts in itertools.count(): + address = r'\\.\pipe\python-pipe-{:d}-{:d}-{}'.format( + os.getpid(), next(_mmap_counter), os.urandom(8).hex()) + try: + h1 = _winapi.CreateNamedPipe( + address, openmode, _winapi.PIPE_WAIT, + 1, obsize, ibsize, _winapi.NMPWAIT_WAIT_FOREVER, _winapi.NULL) + break + except OSError as e: + if attempts >= _MAX_PIPE_ATTEMPTS: + raise + if e.winerror not in (_winapi.ERROR_PIPE_BUSY, + _winapi.ERROR_ACCESS_DENIED): + raise h2 = _winapi.CreateFile( address, access, 0, _winapi.NULL, _winapi.OPEN_EXISTING, @@ -104,8 +111,9 @@ def fileno(self): def close(self, *, CloseHandle=_winapi.CloseHandle): if self._handle is not None: - CloseHandle(self._handle) + handle = self._handle self._handle = None + CloseHandle(handle) def __del__(self, _warn=warnings.warn): if self._handle is not None: diff --git a/Lib/base64.py b/Lib/base64.py index f95132a4274..775ceab0ec0 100644 --- a/Lib/base64.py +++ b/Lib/base64.py @@ -325,17 +325,20 @@ def a85encode(b, *, foldspaces=False, wrapcol=0, pad=False, adobe=False): foldspaces is an optional flag that uses the special short sequence 'y' instead of 4 consecutive spaces (ASCII 0x20) as supported by 'btoa'. This - feature is not supported by the "standard" Adobe encoding. + feature is not supported by the standard encoding used in PDF. - wrapcol controls whether the output should have newline (b'\\n') characters - added to it. If this is non-zero, each output line will be at most this - many characters long, excluding the trailing newline. + If wrapcol is non-zero, insert a newline (b'\\n') character after at most + every wrapcol characters. - pad controls whether the input is padded to a multiple of 4 before - encoding. Note that the btoa implementation always pads. + pad controls whether zero-padding applied to the end of the input + is fully retained in the output encoding, as done by btoa, + producing an exact multiple of 5 bytes of output. + + adobe controls whether the encoded byte sequence is framed with <~ + and ~>, as in a PostScript base-85 string literal. Note that + while ASCII85Decode streams in PDF documents must be terminated + with ~>, they must not use a leading <~. - adobe controls whether the encoded byte sequence is framed with <~ and ~>, - which is used by the Adobe implementation. """ global _a85chars, _a85chars2 # Delay the initialization of tables to not waste memory @@ -364,12 +367,14 @@ def a85encode(b, *, foldspaces=False, wrapcol=0, pad=False, adobe=False): def a85decode(b, *, foldspaces=False, adobe=False, ignorechars=b' \t\n\r\v'): """Decode the Ascii85 encoded bytes-like object or ASCII string b. - foldspaces is a flag that specifies whether the 'y' short sequence should be - accepted as shorthand for 4 consecutive spaces (ASCII 0x20). This feature is - not supported by the "standard" Adobe encoding. + foldspaces is a flag that specifies whether the 'y' short sequence + should be accepted as shorthand for 4 consecutive spaces (ASCII + 0x20). This feature is not supported by the standard Ascii85 + encoding used in PDF and PostScript. - adobe controls whether the input sequence is in Adobe Ascii85 format (i.e. - is framed with <~ and ~>). + adobe controls whether the <~ and ~> markers are present. While + the leading <~ is not required, the input must end with ~>, or a + ValueError is raised. ignorechars should be a byte string containing characters to ignore from the input. This should only contain whitespace characters, and by default @@ -442,8 +447,10 @@ def a85decode(b, *, foldspaces=False, adobe=False, ignorechars=b' \t\n\r\v'): def b85encode(b, pad=False): """Encode bytes-like object b in base85 format and return a bytes object. - If pad is true, the input is padded with b'\\0' so its length is a multiple of - 4 bytes before encoding. + The input is padded with b'\0' so its length is a multiple of 4 + bytes before encoding. If pad is true, all the resulting + characters are retained in the output, which will always be a + multiple of 5 bytes. """ global _b85chars, _b85chars2 # Delay the initialization of tables to not waste memory diff --git a/Lib/collections/__init__.py b/Lib/collections/__init__.py index 3d3bbd7a39a..803de0c6792 100644 --- a/Lib/collections/__init__.py +++ b/Lib/collections/__init__.py @@ -57,8 +57,7 @@ try: from _collections import defaultdict except ImportError: - # TODO: RUSTPYTHON - implement defaultdict in Rust - from ._defaultdict import defaultdict + pass heapq = None # Lazily imported diff --git a/Lib/collections/_defaultdict.py b/Lib/collections/_defaultdict.py deleted file mode 100644 index cb9d403c8ad..00000000000 --- a/Lib/collections/_defaultdict.py +++ /dev/null @@ -1,62 +0,0 @@ -from reprlib import recursive_repr as _recursive_repr - -class defaultdict(dict): - def __init__(self, *args, **kwargs): - if len(args) >= 1: - default_factory = args[0] - if default_factory is not None and not callable(default_factory): - raise TypeError("first argument must be callable or None") - args = args[1:] - else: - default_factory = None - super().__init__(*args, **kwargs) - self.default_factory = default_factory - - def __missing__(self, key): - if self.default_factory is not None: - val = self.default_factory() - else: - raise KeyError(key) - # CPython parity: a recursive __missing__ via factory() may have - # already populated key; preserve that value instead of overwriting. - if key in self: - return self[key] - self[key] = val - return val - - @_recursive_repr() - def __repr_factory(factory): - return repr(factory) - - def __repr__(self): - return f"{type(self).__name__}({defaultdict.__repr_factory(self.default_factory)}, {dict.__repr__(self)})" - - def copy(self): - return type(self)(self.default_factory, self) - - __copy__ = copy - - def __reduce__(self): - if self.default_factory is not None: - args = self.default_factory, - else: - args = () - return type(self), args, None, None, iter(self.items()) - - def __or__(self, other): - if not isinstance(other, dict): - return NotImplemented - - new = defaultdict(self.default_factory, self) - new.update(other) - return new - - def __ror__(self, other): - if not isinstance(other, dict): - return NotImplemented - - new = defaultdict(self.default_factory, other) - new.update(self) - return new - -defaultdict.__module__ = 'collections' diff --git a/Lib/compression/zstd/__init__.py b/Lib/compression/zstd/__init__.py index 84b25914b0a..5326cf9b19c 100644 --- a/Lib/compression/zstd/__init__.py +++ b/Lib/compression/zstd/__init__.py @@ -61,8 +61,9 @@ def __setattr__(self, name, _): def get_frame_info(frame_buffer): """Get Zstandard frame information from a frame header. - *frame_buffer* is a bytes-like object. It should start from the beginning - of a frame, and needs to include at least the frame header (6 to 18 bytes). + *frame_buffer* is a bytes-like object. It should start from the + beginning of a frame, and needs to include at least the frame header + (6 to 18 bytes). The returned FrameInfo object has two attributes. 'decompressed_size' is the size in bytes of the data in the frame when @@ -103,16 +104,17 @@ def finalize_dict(zstd_dict, /, samples, dict_size, level): finalize *zstd_dict* by adding headers and statistics according to the Zstandard dictionary format. - You may compose an effective dictionary content by hand, which is used as - basis dictionary, and use some samples to finalize a dictionary. The basis - dictionary may be a "raw content" dictionary. See *is_raw* in ZstdDict. + You may compose an effective dictionary content by hand, which is used + as basis dictionary, and use some samples to finalize a dictionary. The + basis dictionary may be a "raw content" dictionary. See *is_raw* in + ZstdDict. - *samples* is an iterable of samples, where a sample is a bytes-like object - representing a file. + *samples* is an iterable of samples, where a sample is a bytes-like + object representing a file. *dict_size* is the dictionary's maximum size, in bytes. *level* is the expected compression level. The statistics for each - compression level differ, so tuning the dictionary to the compression level - can provide improvements. + compression level differ, so tuning the dictionary to the compression + level can provide improvements. """ if not isinstance(zstd_dict, ZstdDict): @@ -140,8 +142,8 @@ def compress(data, level=None, options=None, zstd_dict=None): COMPRESSION_LEVEL_DEFAULT ('3'). *options* is a dict object that contains advanced compression parameters. See CompressionParameter for more on options. - *zstd_dict* is a ZstdDict object, a pre-trained Zstandard dictionary. See - the function train_dict for how to train a ZstdDict on sample data. + *zstd_dict* is a ZstdDict object, a pre-trained Zstandard dictionary. + See the function train_dict for how to train a ZstdDict on sample data. For incremental compression, use a ZstdCompressor instead. """ @@ -152,8 +154,8 @@ def compress(data, level=None, options=None, zstd_dict=None): def decompress(data, zstd_dict=None, options=None): """Decompress one or more frames of Zstandard compressed *data*. - *zstd_dict* is a ZstdDict object, a pre-trained Zstandard dictionary. See - the function train_dict for how to train a ZstdDict on sample data. + *zstd_dict* is a ZstdDict object, a pre-trained Zstandard dictionary. + See the function train_dict for how to train a ZstdDict on sample data. *options* is a dict object that contains advanced compression parameters. See DecompressionParameter for more on options. diff --git a/Lib/compression/zstd/_zstdfile.py b/Lib/compression/zstd/_zstdfile.py index d709f5efc65..8d3358152e9 100644 --- a/Lib/compression/zstd/_zstdfile.py +++ b/Lib/compression/zstd/_zstdfile.py @@ -36,9 +36,9 @@ def __init__(self, file, /, mode='r', *, *file* can be either an file-like object, or a file name to open. - *mode* can be 'r' for reading (default), 'w' for (over)writing, 'x' for - creating exclusively, or 'a' for appending. These can equivalently be - given as 'rb', 'wb', 'xb' and 'ab' respectively. + *mode* can be 'r' for reading (default), 'w' for (over)writing, 'x' + for creating exclusively, or 'a' for appending. These can + equivalently be given as 'rb', 'wb', 'xb' and 'ab' respectively. *level* is an optional int specifying the compression level to use, or COMPRESSION_LEVEL_DEFAULT if not given. @@ -296,26 +296,27 @@ def open(file, /, mode='rb', *, level=None, options=None, zstd_dict=None, encoding=None, errors=None, newline=None): """Open a Zstandard compressed file in binary or text mode. - file can be either a file name (given as a str, bytes, or PathLike object), - in which case the named file is opened, or it can be an existing file object - to read from or write to. + file can be either a file name (given as a str, bytes, or PathLike + object), in which case the named file is opened, or it can be + an existing file object to read from or write to. - The mode parameter can be 'r', 'rb' (default), 'w', 'wb', 'x', 'xb', 'a', - 'ab' for binary mode, or 'rt', 'wt', 'xt', 'at' for text mode. + The mode parameter can be 'r', 'rb' (default), 'w', 'wb', 'x', 'xb', + 'a', 'ab' for binary mode, or 'rt', 'wt', 'xt', 'at' for text mode. - The level, options, and zstd_dict parameters specify the settings the same - as ZstdFile. + The level, options, and zstd_dict parameters specify the settings the + same as ZstdFile. When using read mode (decompression), the options parameter is a dict - representing advanced decompression options. The level parameter is not - supported in this case. When using write mode (compression), only one of - level, an int representing the compression level, or options, a dict - representing advanced compression options, may be passed. In both modes, - zstd_dict is a ZstdDict instance containing a trained Zstandard dictionary. - - For binary mode, this function is equivalent to the ZstdFile constructor: - ZstdFile(filename, mode, ...). In this case, the encoding, errors and - newline parameters must not be provided. + representing advanced decompression options. The level parameter is not + supported in this case. When using write mode (compression), only one + of level, an int representing the compression level, or options, a dict + representing advanced compression options, may be passed. In both + modes, zstd_dict is a ZstdDict instance containing a trained Zstandard + dictionary. + + For binary mode, this function is equivalent to the ZstdFile + constructor: ZstdFile(filename, mode, ...). In this case, the encoding, + errors and newline parameters must not be provided. For text mode, an ZstdFile object is created, and wrapped in an io.TextIOWrapper instance with the specified encoding, error handling diff --git a/Lib/ctypes/__init__.py b/Lib/ctypes/__init__.py index 04ec0270148..7223fc49977 100644 --- a/Lib/ctypes/__init__.py +++ b/Lib/ctypes/__init__.py @@ -470,6 +470,8 @@ def _load_library(self, name, mode, handle, winmode): if name and name.endswith(")") and ".a(" in name: mode |= _os.RTLD_MEMBER | _os.RTLD_NOW self._name = name + if handle is not None: + return handle return _dlopen(name, mode) def __repr__(self): diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 378f12167c6..3b21658433b 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -85,15 +85,10 @@ def find_library(name): wintypes.DWORD, ) - _psapi = ctypes.WinDLL('psapi', use_last_error=True) - _enum_process_modules = _psapi["EnumProcessModules"] - _enum_process_modules.restype = wintypes.BOOL - _enum_process_modules.argtypes = ( - wintypes.HANDLE, - ctypes.POINTER(wintypes.HMODULE), - wintypes.DWORD, - wintypes.LPDWORD, - ) + # gh-145307: We defer loading psapi.dll until _get_module_handles is called. + # Loading additional DLLs at startup for functionality that may never be + # used is wasteful. + _enum_process_modules = None def _get_module_filename(module: wintypes.HMODULE): name = (wintypes.WCHAR * 32767)() # UNICODE_STRING_MAX_CHARS @@ -101,8 +96,19 @@ def _get_module_filename(module: wintypes.HMODULE): return name.value return None - def _get_module_handles(): + global _enum_process_modules + if _enum_process_modules is None: + _psapi = ctypes.WinDLL('psapi', use_last_error=True) + _enum_process_modules = _psapi["EnumProcessModules"] + _enum_process_modules.restype = wintypes.BOOL + _enum_process_modules.argtypes = ( + wintypes.HANDLE, + ctypes.POINTER(wintypes.HMODULE), + wintypes.DWORD, + wintypes.LPDWORD, + ) + process = _get_current_process() space_needed = wintypes.DWORD() n = 1024 diff --git a/Lib/email/__init__.py b/Lib/email/__init__.py index 9fa47783004..6d597006e5e 100644 --- a/Lib/email/__init__.py +++ b/Lib/email/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2007 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/_encoded_words.py b/Lib/email/_encoded_words.py index 6795a606de0..05a34a4c105 100644 --- a/Lib/email/_encoded_words.py +++ b/Lib/email/_encoded_words.py @@ -219,7 +219,7 @@ def encode(string, charset='utf-8', encoding=None, lang=''): """ if charset == 'unknown-8bit': - bstring = string.encode('ascii', 'surrogateescape') + bstring = string.encode('utf-8', 'surrogateescape') else: bstring = string.encode(charset) if encoding is None: diff --git a/Lib/email/_header_value_parser.py b/Lib/email/_header_value_parser.py index 91243378dc0..c02175e24e0 100644 --- a/Lib/email/_header_value_parser.py +++ b/Lib/email/_header_value_parser.py @@ -80,7 +80,8 @@ # Useful constants and functions # -WSP = set(' \t') +_WSP = ' \t' +WSP = set(_WSP) CFWS_LEADER = WSP | set('(') SPECIALS = set(r'()<>@,:;.\"[]') ATOM_ENDS = SPECIALS | WSP @@ -101,6 +102,12 @@ def make_quoted_pairs(value): return str(value).replace('\\', '\\\\').replace('"', '\\"') +def make_parenthesis_pairs(value): + """Escape parenthesis and backslash for use within a comment.""" + return str(value).replace('\\', '\\\\') \ + .replace('(', '\\(').replace(')', '\\)') + + def quote_string(value): escaped = make_quoted_pairs(value) return f'"{escaped}"' @@ -632,11 +639,11 @@ def local_part(self): for tok in self[0] + [DOT]: if tok.token_type == 'cfws': continue - if (last_is_tl and tok.token_type == 'dot' and + if (last_is_tl and tok.token_type == 'dot' and last and last[-1].token_type == 'cfws'): res[-1] = TokenList(last[:-1]) is_tl = isinstance(tok, TokenList) - if (is_tl and last.token_type == 'dot' and + if (is_tl and last.token_type == 'dot' and tok and tok[0].token_type == 'cfws'): res.append(TokenList(tok[1:])) else: @@ -874,6 +881,12 @@ class MessageID(MsgID): class InvalidMessageID(MessageID): token_type = 'invalid-message-id' +class MessageIDList(TokenList): + token_type = 'message-id-list' + + @property + def message_ids(self): + return [x for x in self if x.token_type=='msg-id'] class Header(TokenList): token_type = 'header' @@ -933,7 +946,7 @@ def value(self): return ' ' def startswith_fws(self): - return True + return self and self[0] in WSP class ValueTerminal(Terminal): @@ -1232,8 +1245,7 @@ def get_bare_quoted_string(value): bare_quoted_string = BareQuotedString() value = value[1:] if value and value[0] == '"': - token, value = get_qcontent(value) - bare_quoted_string.append(token) + return bare_quoted_string, value[1:] while value and value[0] != '"': if value[0] in WSP: token, value = get_fws(value) @@ -1451,6 +1463,16 @@ def get_phrase(value): else: try: token, value = get_word(value) + if (token[0].token_type == 'encoded-word' + and phrase + and phrase[-1].token_type == 'atom' + and len(phrase[-1]) > 1 + and phrase[-1][-2].token_type == 'encoded-word' + and phrase[-1][-1].token_type == 'cfws' + and not phrase[-1][-1].comments + ): + # linear ws between ews needs special handing... + phrase[-1][-1] = EWWhiteSpaceTerminal(phrase[-1], 'fws') except errors.HeaderParseError: if value[0] in CFWS_LEADER: token, value = get_cfws(value) @@ -2046,12 +2068,10 @@ def get_address_list(value): address_list.defects.append(errors.InvalidHeaderDefect( "invalid address in address-list")) if value and value[0] != ',': - # Crap after address; treat it as an invalid mailbox. - # The mailbox info will still be available. - mailbox = address_list[-1][0] - mailbox.token_type = 'invalid-mailbox' + # Crap after address: add it to the address list + # as an invalid mailbox token, value = get_invalid_mailbox(value, ',') - mailbox.extend(token) + address_list.append(Address([token])) address_list.defects.append(errors.InvalidHeaderDefect( "invalid address in address-list")) if value: # Must be a , at this point. @@ -2171,6 +2191,32 @@ def parse_message_id(value): return message_id +def parse_message_ids(value): + """in-reply-to = "In-Reply-To:" 1*msg-id CRLF + references = "References:" 1*msg-id CRLF + """ + message_id_list = MessageIDList() + while value: + if value[0] == ',': + # message id list separated with commas - this is invalid, + # but happens rather frequently in the wild + message_id_list.defects.append( + errors.InvalidHeaderDefect("comma in msg-id list")) + message_id_list.append( + WhiteSpaceTerminal(' ', 'invalid-comma-replacement')) + value = value[1:] + continue + try: + token, value = get_msg_id(value) + message_id_list.append(token) + except errors.HeaderParseError as ex: + token = get_unstructured(value) + message_id_list.append(InvalidMessageID(token)) + message_id_list.defects.append( + errors.InvalidHeaderDefect("Invalid msg-id: {!r}".format(ex))) + break + return message_id_list + # # XXX: As I begin to add additional header parsers, I'm realizing we probably # have two level of parser routines: the get_XXX methods that get a token in @@ -2788,8 +2834,12 @@ def _steal_trailing_WSP_if_exists(lines): if lines and lines[-1] and lines[-1][-1] in WSP: wsp = lines[-1][-1] lines[-1] = lines[-1][:-1] + # gh-142006: if the line is now empty, remove it entirely. + if not lines[-1]: + lines.pop() return wsp + def _refold_parse_tree(parse_tree, *, policy): """Return string of contents of parse_tree folded according to RFC rules. @@ -2798,11 +2848,9 @@ def _refold_parse_tree(parse_tree, *, policy): maxlen = policy.max_line_length or sys.maxsize encoding = 'utf-8' if policy.utf8 else 'us-ascii' lines = [''] # Folded lines to be output - leading_whitespace = '' # When we have whitespace between two encoded - # words, we may need to encode the whitespace - # at the beginning of the second word. - last_ew = None # Points to the last encoded character if there's an ew on - # the line + last_word_is_ew = False + last_ew = None # if there is an encoded word in the last line of lines, + # points to the encoded word's first character last_charset = None wrap_as_ew_blocked = 0 want_encoding = False # This is set to True if we need to encode this part @@ -2837,6 +2885,7 @@ def _refold_parse_tree(parse_tree, *, policy): if part.token_type == 'mime-parameters': # Mime parameter folding (using RFC2231) is extra special. _fold_mime_parameters(part, lines, maxlen, encoding) + last_word_is_ew = False continue if want_encoding and not wrap_as_ew_blocked: @@ -2853,6 +2902,7 @@ def _refold_parse_tree(parse_tree, *, policy): # XXX what if encoded_part has no leading FWS? lines.append(newline) lines[-1] += encoded_part + last_word_is_ew = False continue # Either this is not a major syntactic break, so we don't # want it on a line by itself even if it fits, or it @@ -2871,11 +2921,16 @@ def _refold_parse_tree(parse_tree, *, policy): (last_charset == 'unknown-8bit' or last_charset == 'utf-8' and charset != 'us-ascii')): last_ew = None - last_ew = _fold_as_ew(tstr, lines, maxlen, last_ew, - part.ew_combine_allowed, charset, leading_whitespace) - # This whitespace has been added to the lines in _fold_as_ew() - # so clear it now. - leading_whitespace = '' + last_ew = _fold_as_ew( + tstr, + lines, + maxlen, + last_ew, + part.ew_combine_allowed, + charset, + last_word_is_ew, + ) + last_word_is_ew = True last_charset = charset want_encoding = False continue @@ -2888,28 +2943,19 @@ def _refold_parse_tree(parse_tree, *, policy): if len(tstr) <= maxlen - len(lines[-1]): lines[-1] += tstr + last_word_is_ew = last_word_is_ew and not bool(tstr.strip(_WSP)) continue # This part is too long to fit. The RFC wants us to break at # "major syntactic breaks", so unless we don't consider this # to be one, check if it will fit on the next line by itself. - leading_whitespace = '' if (part.syntactic_break and len(tstr) + 1 <= maxlen): newline = _steal_trailing_WSP_if_exists(lines) if newline or part.startswith_fws(): - # We're going to fold the data onto a new line here. Due to - # the way encoded strings handle continuation lines, we need to - # be prepared to encode any whitespace if the next line turns - # out to start with an encoded word. lines.append(newline + tstr) - - whitespace_accumulator = [] - for char in lines[-1]: - if char not in WSP: - break - whitespace_accumulator.append(char) - leading_whitespace = ''.join(whitespace_accumulator) + last_word_is_ew = (last_word_is_ew + and not bool(lines[-1].strip(_WSP))) last_ew = None continue if not hasattr(part, 'encode'): @@ -2924,6 +2970,13 @@ def _refold_parse_tree(parse_tree, *, policy): [ValueTerminal(make_quoted_pairs(p), 'ptext') for p in newparts] + [ValueTerminal('"', 'ptext')]) + if part.token_type == 'comment': + newparts = ( + [ValueTerminal('(', 'ptext')] + + [ValueTerminal(make_parenthesis_pairs(p), 'ptext') + if p.token_type == 'ptext' else p + for p in newparts] + + [ValueTerminal(')', 'ptext')]) if not part.as_ew_allowed: wrap_as_ew_blocked += 1 newparts.append(end_ew_not_allowed) @@ -2942,10 +2995,11 @@ def _refold_parse_tree(parse_tree, *, policy): else: # We can't fold it onto the next line either... lines[-1] += tstr + last_word_is_ew = last_word_is_ew and not bool(tstr.strip(_WSP)) return policy.linesep.join(lines) + policy.linesep -def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, leading_whitespace): +def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, last_word_is_ew): """Fold string to_encode into lines as encoded word, combining if allowed. Return the new value for last_ew, or None if ew_combine_allowed is False. @@ -2960,6 +3014,16 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, to_encode = str( get_unstructured(lines[-1][last_ew:] + to_encode)) lines[-1] = lines[-1][:last_ew] + elif last_word_is_ew: + # If we are following up an encoded word with another encoded word, + # any white space between the two will be ignored when decoded. + # Therefore, we encode all to-be-displayed whitespace in the second + # encoded word. + len_without_wsp = len(lines[-1].rstrip(_WSP)) + leading_whitespace = lines[-1][len_without_wsp:] + lines[-1] = (lines[-1][:len_without_wsp] + + (' ' if leading_whitespace else '')) + to_encode = leading_whitespace + to_encode elif to_encode[0] in WSP: # We're joining this to non-encoded text, so don't encode # the leading blank. @@ -2988,20 +3052,13 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, while to_encode: remaining_space = maxlen - len(lines[-1]) - text_space = remaining_space - chrome_len - len(leading_whitespace) + text_space = remaining_space - chrome_len if text_space <= 0: - lines.append(' ') + newline = _steal_trailing_WSP_if_exists(lines) + lines.append(newline or ' ') + new_last_ew = len(lines[-1]) continue - # If we are at the start of a continuation line, prepend whitespace - # (we only want to do this when the line starts with an encoded word - # but if we're folding in this helper function, then we know that we - # are going to be writing out an encoded word.) - if len(lines) > 1 and len(lines[-1]) == 1 and leading_whitespace: - encoded_word = _ew.encode(leading_whitespace, charset=encode_as) - lines[-1] += encoded_word - leading_whitespace = '' - to_encode_word = to_encode[:text_space] encoded_word = _ew.encode(to_encode_word, charset=encode_as) excess = len(encoded_word) - remaining_space @@ -3013,7 +3070,6 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, excess = len(encoded_word) - remaining_space lines[-1] += encoded_word to_encode = to_encode[len(to_encode_word):] - leading_whitespace = '' if to_encode: lines.append(' ') diff --git a/Lib/email/_parseaddr.py b/Lib/email/_parseaddr.py index 565af0cf361..e311948cb09 100644 --- a/Lib/email/_parseaddr.py +++ b/Lib/email/_parseaddr.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2007 Python Software Foundation +# Copyright (C) 2002 Python Software Foundation # Contact: email-sig@python.org """Email address parsing code. @@ -59,7 +59,7 @@ def _parsedate_tz(data): The last (additional) element is the time zone offset in seconds, except if the timezone was specified as -0000. In that case the last element is - None. This indicates a UTC timestamp that explicitly declaims knowledge of + None. This indicates a UTC timestamp that explicitly disclaims knowledge of the source timezone, as opposed to a +0000 timestamp that indicates the source timezone really was UTC. @@ -225,7 +225,7 @@ class AddrlistClass: def __init__(self, field): """Initialize a new instance. - `field' is an unparsed address header field, containing + 'field' is an unparsed address header field, containing one or more addresses. """ self.specials = '()<>@,:;.\"[]' @@ -426,14 +426,14 @@ def getdomain(self): def getdelimited(self, beginchar, endchars, allowcomments=True): """Parse a header fragment delimited by special characters. - `beginchar' is the start character for the fragment. - If self is not looking at an instance of `beginchar' then + 'beginchar' is the start character for the fragment. + If self is not looking at an instance of 'beginchar' then getdelimited returns the empty string. - `endchars' is a sequence of allowable end-delimiting characters. + 'endchars' is a sequence of allowable end-delimiting characters. Parsing stops when one of these is encountered. - If `allowcomments' is non-zero, embedded RFC 2822 comments are allowed + If 'allowcomments' is non-zero, embedded RFC 2822 comments are allowed within the parsed fragment. """ if self.field[self.pos] != beginchar: @@ -477,7 +477,7 @@ def getatom(self, atomends=None): Optional atomends specifies a different set of end token delimiters (the default is to use self.atomends). This is used e.g. in - getphraselist() since phrase endings must not include the `.' (which + getphraselist() since phrase endings must not include the '.' (which is legal in phrases).""" atomlist = [''] if atomends is None: diff --git a/Lib/email/_policybase.py b/Lib/email/_policybase.py index 0d486c90a9c..e23843df448 100644 --- a/Lib/email/_policybase.py +++ b/Lib/email/_policybase.py @@ -4,6 +4,7 @@ """ import abc +import re from email import header from email import charset as _charset from email.utils import _has_surrogates @@ -14,6 +15,14 @@ 'compat32', ] +# validation regex from RFC 5322, equivalent to pattern re.compile("[!-9;-~]+$") +valid_header_name_re = re.compile("[\041-\071\073-\176]+$") + +def validate_header_name(name): + # Validate header name according to RFC 5322 + if not valid_header_name_re.match(name): + raise ValueError( + f"Header field name contains invalid characters: {name!r}") class _PolicyBase: @@ -150,7 +159,7 @@ class Policy(_PolicyBase, metaclass=abc.ABCMeta): wrapping is done. Default is 78. mangle_from_ -- a flag that, when True escapes From_ lines in the - body of the message by putting a `>' in front of + body of the message by putting a '>' in front of them. This is used when the message is being serialized by a generator. Default: False. @@ -314,6 +323,7 @@ def header_store_parse(self, name, value): """+ The name and value are returned unmodified. """ + validate_header_name(name) return (name, value) def header_fetch_parse(self, name, value): diff --git a/Lib/email/base64mime.py b/Lib/email/base64mime.py index 4cdf22666e3..a5a3f737a97 100644 --- a/Lib/email/base64mime.py +++ b/Lib/email/base64mime.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2007 Python Software Foundation +# Copyright (C) 2002 Python Software Foundation # Author: Ben Gertzfield # Contact: email-sig@python.org @@ -15,7 +15,7 @@ with Base64 encoding. RFC 2045 defines a method for including character set information in an -`encoded-word' in a header. This method is commonly used for 8-bit real names +'encoded-word' in a header. This method is commonly used for 8-bit real names in To:, From:, Cc:, etc. fields, as well as Subject: lines. This module does not do the line wrapping or end-of-line character conversion diff --git a/Lib/email/charset.py b/Lib/email/charset.py index 043801107b6..c4b246455f8 100644 --- a/Lib/email/charset.py +++ b/Lib/email/charset.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2007 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Ben Gertzfield, Barry Warsaw # Contact: email-sig@python.org @@ -93,8 +93,6 @@ # Map charsets to their Unicode codec strings. CODEC_MAP = { - 'gb2312': 'eucgb2312_cn', - 'big5': 'big5_tw', # Hack: We don't want *any* conversion for stuff marked us-ascii, as all # sorts of garbage might be sent to us in the guise of 7-bit us-ascii. # Let that stuff pass through without conversion to/from Unicode. @@ -175,7 +173,7 @@ class Charset: module expose the following information about a character set: input_charset: The initial character set specified. Common aliases - are converted to their `official' email names (e.g. latin_1 + are converted to their 'official' email names (e.g. latin_1 is converted to iso-8859-1). Defaults to 7-bit us-ascii. header_encoding: If the character set must be encoded before it can be @@ -245,7 +243,7 @@ def __eq__(self, other): def get_body_encoding(self): """Return the content-transfer-encoding used for body encoding. - This is either the string `quoted-printable' or `base64' depending on + This is either the string 'quoted-printable' or 'base64' depending on the encoding used, or it is a function in which case you should call the function with a single argument, the Message object being encoded. The function should then set the Content-Transfer-Encoding diff --git a/Lib/email/encoders.py b/Lib/email/encoders.py index 17bd1ab7b19..55741a22a07 100644 --- a/Lib/email/encoders.py +++ b/Lib/email/encoders.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/errors.py b/Lib/email/errors.py index 02aa5eced6a..6bc744bd59c 100644 --- a/Lib/email/errors.py +++ b/Lib/email/errors.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/feedparser.py b/Lib/email/feedparser.py index bc773f38030..ae8ef32792b 100644 --- a/Lib/email/feedparser.py +++ b/Lib/email/feedparser.py @@ -1,4 +1,4 @@ -# Copyright (C) 2004-2006 Python Software Foundation +# Copyright (C) 2004 Python Software Foundation # Authors: Baxter, Wouters and Warsaw # Contact: email-sig@python.org @@ -30,7 +30,7 @@ NLCRE = re.compile(r'\r\n|\r|\n') NLCRE_bol = re.compile(r'(\r\n|\r|\n)') -NLCRE_eol = re.compile(r'(\r\n|\r|\n)\Z') +NLCRE_eol = re.compile(r'(\r\n|\r|\n)\z') NLCRE_crack = re.compile(r'(\r\n|\r|\n)') # RFC 5322 section 3.6.8 Optional fields. ftext is %d33-57 / %d59-126, Any character # except controls, SP, and ":". @@ -504,10 +504,9 @@ def _parse_headers(self, lines): self._input.unreadline(line) return else: - # Weirdly placed unix-from line. Note this as a defect - # and ignore it. + # Weirdly placed unix-from line. defect = errors.MisplacedEnvelopeHeaderDefect(line) - self._cur.defects.append(defect) + self.policy.handle_defect(self._cur, defect) continue # Split the line on the colon separating field name from value. # There will always be a colon, because if there wasn't the part of @@ -519,7 +518,7 @@ def _parse_headers(self, lines): # message. Track the error but keep going. if i == 0: defect = errors.InvalidHeaderDefect("Missing header name.") - self._cur.defects.append(defect) + self.policy.handle_defect(self._cur, defect) continue assert i>0, "_parse_headers fed line with no : and no leading WS" diff --git a/Lib/email/generator.py b/Lib/email/generator.py index ce94f5c56fe..ba11d63fba6 100644 --- a/Lib/email/generator.py +++ b/Lib/email/generator.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org @@ -22,6 +22,7 @@ NLCRE = re.compile(r'\r\n|\r|\n') fcre = re.compile(r'^From ', re.MULTILINE) NEWLINE_WITHOUT_FWSP = re.compile(r'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]') +NEWLINE_WITHOUT_FWSP_BYTES = re.compile(br'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]') class Generator: @@ -43,7 +44,7 @@ def __init__(self, outfp, mangle_from_=None, maxheaderlen=None, *, Optional mangle_from_ is a flag that, when True (the default if policy is not set), escapes From_ lines in the body of the message by putting - a `>' in front of them. + a '>' in front of them. Optional maxheaderlen specifies the longest length for a non-continued header. When a header line is longer (in characters, with tabs @@ -76,7 +77,7 @@ def flatten(self, msg, unixfrom=False, linesep=None): unixfrom is a flag that forces the printing of a Unix From_ delimiter before the first object in the message tree. If the original message - has no From_ delimiter, a `standard' one is crafted. By default, this + has no From_ delimiter, a 'standard' one is crafted. By default, this is False to inhibit the printing of any From_ delimiter. Note that for subobjects, no From_ line is printed. @@ -227,7 +228,7 @@ def _write_headers(self, msg): folded = self.policy.fold(h, v) if self.policy.verify_generated_headers: linesep = self.policy.linesep - if not folded.endswith(self.policy.linesep): + if not folded.endswith(linesep): raise HeaderWriteError( f'folded header does not end with {linesep!r}: {folded!r}') if NEWLINE_WITHOUT_FWSP.search(folded.removesuffix(linesep)): @@ -391,7 +392,7 @@ def _make_boundary(cls, text=None): b = boundary counter = 0 while True: - cre = cls._compile_re('^--' + re.escape(b) + '(--)?$', re.MULTILINE) + cre = cls._compile_re('^--' + re.escape(b) + '(--)?\r?$', re.MULTILINE) if not cre.search(text): break b = boundary + '.' + str(counter) @@ -429,7 +430,16 @@ def _write_headers(self, msg): # This is almost the same as the string version, except for handling # strings with 8bit bytes. for h, v in msg.raw_items(): - self._fp.write(self.policy.fold_binary(h, v)) + folded = self.policy.fold_binary(h, v) + if self.policy.verify_generated_headers: + linesep = self.policy.linesep.encode() + if not folded.endswith(linesep): + raise HeaderWriteError( + f'folded header does not end with {linesep!r}: {folded!r}') + if NEWLINE_WITHOUT_FWSP_BYTES.search(folded.removesuffix(linesep)): + raise HeaderWriteError( + f'folded header contains newline: {folded!r}') + self._fp.write(folded) # A blank line always separates headers from body self.write(self._NL) @@ -467,7 +477,7 @@ def __init__(self, outfp, mangle_from_=None, maxheaderlen=None, fmt=None, *, argument is allowed. Walks through all subparts of a message. If the subpart is of main - type `text', then it prints the decoded payload of the subpart. + type 'text', then it prints the decoded payload of the subpart. Otherwise, fmt is a format string that is used instead of the message payload. fmt is expanded with the following keywords (in diff --git a/Lib/email/header.py b/Lib/email/header.py index a0aadb97ca6..220a84a7454 100644 --- a/Lib/email/header.py +++ b/Lib/email/header.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2007 Python Software Foundation +# Copyright (C) 2002 Python Software Foundation # Author: Ben Gertzfield, Barry Warsaw # Contact: email-sig@python.org @@ -201,7 +201,7 @@ def __init__(self, s=None, charset=None, The maximum line length can be specified explicitly via maxlinelen. For splitting the first line to a shorter value (to account for the field - header which isn't included in s, e.g. `Subject') pass in the name of + header which isn't included in s, e.g. 'Subject') pass in the name of the field in header_name. The default maxlinelen is 78 as recommended by RFC 2822. @@ -285,7 +285,7 @@ def append(self, s, charset=None, errors='strict'): output codec of the charset. If the string cannot be encoded to the output codec, a UnicodeError will be raised. - Optional `errors' is passed as the errors argument to the decode + Optional 'errors' is passed as the errors argument to the decode call if s is a byte string. """ if charset is None: @@ -335,7 +335,7 @@ def encode(self, splitchars=';, \t', maxlinelen=None, linesep='\n'): Optional splitchars is a string containing characters which should be given extra weight by the splitting algorithm during normal header - wrapping. This is in very rough support of RFC 2822's `higher level + wrapping. This is in very rough support of RFC 2822's 'higher level syntactic breaks': split points preceded by a splitchar are preferred during line splitting, with the characters preferred in the order in which they appear in the string. Space and tab may be included in the diff --git a/Lib/email/headerregistry.py b/Lib/email/headerregistry.py index 543141dc427..0e8698efc0b 100644 --- a/Lib/email/headerregistry.py +++ b/Lib/email/headerregistry.py @@ -534,6 +534,18 @@ def parse(cls, value, kwds): kwds['defects'].extend(parse_tree.all_defects) +class ReferencesHeader: + + max_count = 1 + value_parser = staticmethod(parser.parse_message_ids) + + @classmethod + def parse(cls, value, kwds): + kwds['parse_tree'] = parse_tree = cls.value_parser(value) + kwds['decoded'] = str(parse_tree) + kwds['defects'].extend(parse_tree.all_defects) + + # The header factory # _default_header_map = { @@ -557,6 +569,8 @@ def parse(cls, value, kwds): 'content-disposition': ContentDispositionHeader, 'content-transfer-encoding': ContentTransferEncodingHeader, 'message-id': MessageIDHeader, + 'in-reply-to': ReferencesHeader, + 'references': ReferencesHeader, } class HeaderRegistry: diff --git a/Lib/email/iterators.py b/Lib/email/iterators.py index 3410935e38f..08ede3ec679 100644 --- a/Lib/email/iterators.py +++ b/Lib/email/iterators.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org @@ -43,8 +43,8 @@ def body_line_iterator(msg, decode=False): def typed_subpart_iterator(msg, maintype='text', subtype=None): """Iterate over the subparts with a given MIME type. - Use `maintype' as the main MIME type to match against; this defaults to - "text". Optional `subtype' is the MIME subtype to match against; if + Use 'maintype' as the main MIME type to match against; this defaults to + "text". Optional 'subtype' is the MIME subtype to match against; if omitted, only the main type is matched. """ for subpart in msg.walk(): diff --git a/Lib/email/message.py b/Lib/email/message.py index 80f01d66a33..641fb2e944d 100644 --- a/Lib/email/message.py +++ b/Lib/email/message.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2007 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org @@ -21,7 +21,7 @@ SEMISPACE = '; ' -# Regular expression that matches `special' characters in parameters, the +# Regular expression that matches 'special' characters in parameters, the # existence of which force quoting of the parameter value. tspecials = re.compile(r'[ \(\)<>@,;:\\"/\[\]\?=]') @@ -147,7 +147,7 @@ class Message: multipart or a message/rfc822), then the payload is a list of Message objects, otherwise it is a string. - Message objects implement part of the `mapping' interface, which assumes + Message objects implement part of the 'mapping' interface, which assumes there is exactly one occurrence of the header per message. Some headers do in fact appear multiple times (e.g. Received) and for those headers, you must use the explicit API to set or get all the headers. Not all of @@ -609,7 +609,7 @@ def get_content_type(self): """Return the message's content type. The returned string is coerced to lower case of the form - `maintype/subtype'. If there was no Content-Type header in the + 'maintype/subtype'. If there was no Content-Type header in the message, the default type as given by get_default_type() will be returned. Since according to RFC 2045, messages always have a default type this will always return a value. @@ -632,7 +632,7 @@ def get_content_type(self): def get_content_maintype(self): """Return the message's main content type. - This is the `maintype' part of the string returned by + This is the 'maintype' part of the string returned by get_content_type(). """ ctype = self.get_content_type() @@ -641,14 +641,14 @@ def get_content_maintype(self): def get_content_subtype(self): """Returns the message's sub-content type. - This is the `subtype' part of the string returned by + This is the 'subtype' part of the string returned by get_content_type(). """ ctype = self.get_content_type() return ctype.split('/')[1] def get_default_type(self): - """Return the `default' content type. + """Return the 'default' content type. Most messages have a default content type of text/plain, except for messages that are subparts of multipart/digest containers. Such @@ -657,7 +657,7 @@ def get_default_type(self): return self._default_type def set_default_type(self, ctype): - """Set the `default' content type. + """Set the 'default' content type. ctype should be either "text/plain" or "message/rfc822", although this is not enforced. The default content type is not stored in the @@ -690,8 +690,8 @@ def get_params(self, failobj=None, header='content-type', unquote=True): """Return the message's Content-Type parameters, as a list. The elements of the returned list are 2-tuples of key/value pairs, as - split on the `=' sign. The left hand side of the `=' is the key, - while the right hand side is the value. If there is no `=' sign in + split on the '=' sign. The left hand side of the '=' is the key, + while the right hand side is the value. If there is no '=' sign in the parameter the value is the empty string. The value is as described in the get_param() method. @@ -851,9 +851,9 @@ def get_filename(self, failobj=None): """Return the filename associated with the payload if present. The filename is extracted from the Content-Disposition header's - `filename' parameter, and it is unquoted. If that header is missing - the `filename' parameter, this method falls back to looking for the - `name' parameter. + 'filename' parameter, and it is unquoted. If that header is missing + the 'filename' parameter, this method falls back to looking for the + 'name' parameter. """ missing = object() filename = self.get_param('filename', missing, 'content-disposition') @@ -866,7 +866,7 @@ def get_filename(self, failobj=None): def get_boundary(self, failobj=None): """Return the boundary associated with the payload if present. - The boundary is extracted from the Content-Type header's `boundary' + The boundary is extracted from the Content-Type header's 'boundary' parameter, and it is unquoted. """ missing = object() diff --git a/Lib/email/mime/application.py b/Lib/email/mime/application.py index f67cbad3f03..9a9d213d2a9 100644 --- a/Lib/email/mime/application.py +++ b/Lib/email/mime/application.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Keith Dart # Contact: email-sig@python.org diff --git a/Lib/email/mime/audio.py b/Lib/email/mime/audio.py index aa0c4905cbb..85f4a955238 100644 --- a/Lib/email/mime/audio.py +++ b/Lib/email/mime/audio.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2007 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Anthony Baxter # Contact: email-sig@python.org diff --git a/Lib/email/mime/base.py b/Lib/email/mime/base.py index f601f621cec..da4c6e591a5 100644 --- a/Lib/email/mime/base.py +++ b/Lib/email/mime/base.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/mime/image.py b/Lib/email/mime/image.py index 4b7f2f9cbad..dab96858481 100644 --- a/Lib/email/mime/image.py +++ b/Lib/email/mime/image.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/mime/message.py b/Lib/email/mime/message.py index 61836b5a786..13d9ff599f8 100644 --- a/Lib/email/mime/message.py +++ b/Lib/email/mime/message.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/mime/multipart.py b/Lib/email/mime/multipart.py index 94d81c771a4..1abb84d5fed 100644 --- a/Lib/email/mime/multipart.py +++ b/Lib/email/mime/multipart.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2006 Python Software Foundation +# Copyright (C) 2002 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org @@ -21,7 +21,7 @@ def __init__(self, _subtype='mixed', boundary=None, _subparts=None, Content-Type and MIME-Version headers. _subtype is the subtype of the multipart content type, defaulting to - `mixed'. + 'mixed'. boundary is the multipart boundary string. By default it is calculated as needed. diff --git a/Lib/email/mime/nonmultipart.py b/Lib/email/mime/nonmultipart.py index a41386eb148..5beab3a441e 100644 --- a/Lib/email/mime/nonmultipart.py +++ b/Lib/email/mime/nonmultipart.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2006 Python Software Foundation +# Copyright (C) 2002 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/mime/text.py b/Lib/email/mime/text.py index 7672b789138..aa4da7f8217 100644 --- a/Lib/email/mime/text.py +++ b/Lib/email/mime/text.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org diff --git a/Lib/email/parser.py b/Lib/email/parser.py index e3003118ce1..c6a51dd8e37 100644 --- a/Lib/email/parser.py +++ b/Lib/email/parser.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2007 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw, Thomas Wouters, Anthony Baxter # Contact: email-sig@python.org diff --git a/Lib/email/policy.py b/Lib/email/policy.py index 6e109b65011..4169150101a 100644 --- a/Lib/email/policy.py +++ b/Lib/email/policy.py @@ -4,7 +4,13 @@ import re import sys -from email._policybase import Policy, Compat32, compat32, _extend_docstrings +from email._policybase import ( + Compat32, + Policy, + _extend_docstrings, + compat32, + validate_header_name +) from email.utils import _has_surrogates from email.headerregistry import HeaderRegistry as HeaderRegistry from email.contentmanager import raw_data_manager @@ -138,6 +144,7 @@ def header_store_parse(self, name, value): CR or LF characters. """ + validate_header_name(name) if hasattr(value, 'name') and value.name.lower() == name.lower(): return (name, value) if isinstance(value, str) and len(value.splitlines())>1: diff --git a/Lib/email/quoprimime.py b/Lib/email/quoprimime.py index 27fcbb5a26e..bc53b376821 100644 --- a/Lib/email/quoprimime.py +++ b/Lib/email/quoprimime.py @@ -1,11 +1,11 @@ -# Copyright (C) 2001-2006 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Ben Gertzfield # Contact: email-sig@python.org """Quoted-printable content transfer encoding per RFCs 2045-2047. This module handles the content transfer encoding method defined in RFC 2045 -to encode US ASCII-like 8-bit data called `quoted-printable'. It is used to +to encode US ASCII-like 8-bit data called 'quoted-printable'. It is used to safely encode text that is in a character set similar to the 7-bit US ASCII character set, but that includes some 8-bit characters that are normally not allowed in email bodies or headers. @@ -17,7 +17,7 @@ with quoted-printable encoding. RFC 2045 defines a method for including character set information in an -`encoded-word' in a header. This method is commonly used for 8-bit real names +'encoded-word' in a header. This method is commonly used for 8-bit real names in To:/From:/Cc: etc. fields, as well as Subject: lines. This module does not do the line wrapping or end-of-line character @@ -127,7 +127,7 @@ def quote(c): def header_encode(header_bytes, charset='iso-8859-1'): """Encode a single header line with quoted-printable (like) encoding. - Defined in RFC 2045, this `Q' encoding is similar to quoted-printable, but + Defined in RFC 2045, this 'Q' encoding is similar to quoted-printable, but used specifically for email header fields to allow charsets with mostly 7 bit characters (and some 8 bit) to remain more or less readable in non-RFC 2045 aware mail clients. @@ -272,7 +272,7 @@ def decode(encoded, eol=NL): decoded += eol # Special case if original string did not end with eol if encoded[-1] not in '\r\n' and decoded.endswith(eol): - decoded = decoded[:-1] + decoded = decoded[:-len(eol)] return decoded @@ -290,7 +290,7 @@ def _unquote_match(match): # Header decoding is done a bit differently def header_decode(s): - """Decode a string encoded with RFC 2045 MIME header `Q' encoding. + """Decode a string encoded with RFC 2045 MIME header 'Q' encoding. This function does not parse a full MIME header value encoded with quoted-printable (like =?iso-8859-1?q?Hello_World?=) -- please use diff --git a/Lib/email/utils.py b/Lib/email/utils.py index e4d35f06abc..3de1f0d24a1 100644 --- a/Lib/email/utils.py +++ b/Lib/email/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 Python Software Foundation +# Copyright (C) 2001 Python Software Foundation # Author: Barry Warsaw # Contact: email-sig@python.org @@ -472,23 +472,15 @@ def collapse_rfc2231_value(value, errors='replace', # better than not having it. # -def localtime(dt=None, isdst=None): +def localtime(dt=None): """Return local time as an aware datetime object. If called without arguments, return current time. Otherwise *dt* argument should be a datetime instance, and it is converted to the local time zone according to the system time zone database. If *dt* is naive (that is, dt.tzinfo is None), it is assumed to be in local time. - The isdst parameter is ignored. """ - if isdst is not None: - import warnings - warnings._deprecated( - "The 'isdst' parameter to 'localtime'", - message='{name} is deprecated and slated for removal in Python {remove}', - remove=(3, 14), - ) if dt is None: dt = datetime.datetime.now() return dt.astimezone() diff --git a/Lib/encodings/aliases.py b/Lib/encodings/aliases.py index 4ecb6b6e297..8b175772c58 100644 --- a/Lib/encodings/aliases.py +++ b/Lib/encodings/aliases.py @@ -71,6 +71,10 @@ # cp1140 codec '1140' : 'cp1140', + 'cp01140' : 'cp1140', + 'csibm01140' : 'cp1140', + 'ebcdic_us_37_euro' : 'cp1140', + 'ibm01140' : 'cp1140', 'ibm1140' : 'cp1140', # cp1250 codec @@ -159,8 +163,12 @@ # cp858 codec '858' : 'cp858', + 'cp00858' : 'cp858', + 'csibm00858' : 'cp858', 'csibm858' : 'cp858', + 'ibm00858' : 'cp858', 'ibm858' : 'cp858', + 'pc_multilingual_850_euro' : 'cp858', # cp860 codec '860' : 'cp860', diff --git a/Lib/ensurepip/__init__.py b/Lib/ensurepip/__init__.py index 715389ea6c5..7d13414ff82 100644 --- a/Lib/ensurepip/__init__.py +++ b/Lib/ensurepip/__init__.py @@ -10,7 +10,7 @@ __all__ = ["version", "bootstrap"] -_PIP_VERSION = "26.0.1" +_PIP_VERSION = "26.1.2" # Directory of system wheel packages. Some Linux distribution packaging # policies recommend against bundling dependencies. For example, Fedora diff --git a/Lib/ensurepip/_bundled/pip-26.0.1-py3-none-any.whl b/Lib/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl similarity index 72% rename from Lib/ensurepip/_bundled/pip-26.0.1-py3-none-any.whl rename to Lib/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl index 580d09a9204..24b5cc90ace 100644 Binary files a/Lib/ensurepip/_bundled/pip-26.0.1-py3-none-any.whl and b/Lib/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl differ diff --git a/Lib/enum.py b/Lib/enum.py index b4551da1c17..87b2f2ab7eb 100644 --- a/Lib/enum.py +++ b/Lib/enum.py @@ -678,9 +678,9 @@ def __call__(cls, value, names=_not_given, *values, module=None, qualname=None, """ Either returns an existing member, or creates a new enum class. - This method is used both when an enum class is given a value to match - to an enumeration member (i.e. Color(3)) and for the functional API - (i.e. Color = Enum('Color', names='RED GREEN BLUE')). + This method is used both when an enum class is given a value to + match to an enumeration member (i.e. Color(3)) and for the + functional API (i.e. Color = Enum('Color', names='RED GREEN BLUE')). The value lookup branch is chosen if the enum is final. @@ -688,16 +688,17 @@ def __call__(cls, value, names=_not_given, *values, module=None, qualname=None, `value` will be the name of the new class. - `names` should be either a string of white-space/comma delimited names - (values will start at `start`), or an iterator/mapping of name, value pairs. + `names` should be either a string of white-space/comma delimited + names (values will start at `start`), or an iterator/mapping of + name, value pairs. `module` should be set to the module this class is being created in; - if it is not set, an attempt to find that module will be made, but if - it fails the class will not be picklable. + if it is not set, an attempt to find that module will be made, but + if it fails the class will not be picklable. - `qualname` should be set to the actual location this class can be found - at in its module; by default it is set to the global scope. If this is - not correct, unpickling will fail in some circumstances. + `qualname` should be set to the actual location this class can be + found at in its module; by default it is set to the global scope. + If this is not correct, unpickling will fail in some circumstances. `type`, if set, will be mixed in as the first base class. """ @@ -791,8 +792,8 @@ def __members__(cls): """ Returns a mapping of member name->value. - This mapping lists all enum members, including aliases. Note that this - is a read-only view of the internal mapping. + This mapping lists all enum members, including aliases. Note that + this is a read-only view of the internal mapping. """ return MappingProxyType(cls._member_map_) diff --git a/Lib/ftplib.py b/Lib/ftplib.py index 50771e8c17c..73882a38dce 100644 --- a/Lib/ftplib.py +++ b/Lib/ftplib.py @@ -883,7 +883,16 @@ def ftpcp(source, sourcename, target, targetname = '', type = 'I'): type = 'TYPE ' + type source.voidcmd(type) target.voidcmd(type) - sourcehost, sourceport = parse227(source.sendcmd('PASV')) + # Don't trust the IPv4 address the source server advertises in its PASV + # reply: a malicious source could otherwise point the target's data + # connection at an arbitrary host (SSRF). A caller that needs the old + # behavior can set trust_server_pasv_ipv4_address on the source FTP + # object. See FTP.makepasv(), which applies the same rule. + untrusted_host, sourceport = parse227(source.sendcmd('PASV')) + if source.trust_server_pasv_ipv4_address: + sourcehost = untrusted_host + else: + sourcehost = source.sock.getpeername()[0] target.sendport(sourcehost, sourceport) # RFC 959: the user must "listen" [...] BEFORE sending the # transfer request. diff --git a/Lib/functools.py b/Lib/functools.py index df4660eef3f..e8ccc2795d2 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -556,16 +556,16 @@ def lru_cache(maxsize=128, typed=False): If *maxsize* is set to None, the LRU features are disabled and the cache can grow without bound. - If *typed* is True, arguments of different types will be cached separately. - For example, f(decimal.Decimal("3.0")) and f(3.0) will be treated as - distinct calls with distinct results. Some types such as str and int may - be cached separately even when typed is false. + If *typed* is True, arguments of different types will be cached + separately. For example, f(decimal.Decimal("3.0")) and f(3.0) will be + treated as distinct calls with distinct results. Some types such as + str and int may be cached separately even when typed is false. Arguments to the cached function must be hashable. View the cache statistics named tuple (hits, misses, maxsize, currsize) - with f.cache_info(). Clear the cache and statistics with f.cache_clear(). - Access the underlying function with f.__wrapped__. + with f.cache_info(). Clear the cache and statistics with + f.cache_clear(). Access the underlying function with f.__wrapped__. See: https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU) diff --git a/Lib/glob.py b/Lib/glob.py index 7ce3998c27c..f8be46771b4 100644 --- a/Lib/glob.py +++ b/Lib/glob.py @@ -25,17 +25,17 @@ def glob(pathname, *, root_dir=None, dir_fd=None, recursive=False, The order of the returned list is undefined. Sort it if you need a particular order. - If `root_dir` is not None, it should be a path-like object specifying the - root directory for searching. It has the same effect as changing the - current directory before calling it (without actually - changing it). If pathname is relative, the result will contain - paths relative to `root_dir`. + If `root_dir` is not None, it should be a path-like object specifying + the root directory for searching. It has the same effect as changing + the current directory before calling it (without actually changing it). + If pathname is relative, the result will contain paths relative to + `root_dir`. If `dir_fd` is not None, it should be a file descriptor referring to a directory, and paths will then be relative to that directory. - If `include_hidden` is true, the patterns '*', '?', '**' will match hidden - directories. + If `include_hidden` is true, the patterns '*', '?', '**' will match + hidden directories. If `recursive` is true, the pattern '**' will match any files and zero or more directories and subdirectories. @@ -56,16 +56,16 @@ def iglob(pathname, *, root_dir=None, dir_fd=None, recursive=False, particular order. If `root_dir` is not None, it should be a path-like object specifying - the root directory for searching. It has the same effect as changing - the current directory before calling it (without actually - changing it). If pathname is relative, the result will contain - paths relative to `root_dir`. + the root directory for searching. It has the same effect as changing + the current directory before calling it (without actually changing it). + If pathname is relative, the result will contain paths relative to + `root_dir`. If `dir_fd` is not None, it should be a file descriptor referring to a directory, and paths will then be relative to that directory. - If `include_hidden` is true, the patterns '*', '?', '**' will match hidden - directories. + If `include_hidden` is true, the patterns '*', '?', '**' will match + hidden directories. If `recursive` is true, the pattern '**' will match any files and zero or more directories and subdirectories. @@ -294,15 +294,15 @@ def escape(pathname): def translate(pat, *, recursive=False, include_hidden=False, seps=None): """Translate a pathname with shell wildcards to a regular expression. - If `recursive` is true, the pattern segment '**' will match any number of - path segments. + If `recursive` is true, the pattern segment '**' will match any number + of path segments. If `include_hidden` is true, wildcards can match path segments beginning with a dot ('.'). If a sequence of separator characters is given to `seps`, they will be - used to split the pattern into segments and match path separators. If not - given, os.path.sep and os.path.altsep (where available) are used. + used to split the pattern into segments and match path separators. If + not given, os.path.sep and os.path.altsep (where available) are used. """ if not seps: if os.path.altsep: diff --git a/Lib/gzip.py b/Lib/gzip.py index c00f51858de..f3f9fa91c81 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -34,16 +34,16 @@ def open(filename, mode="rb", compresslevel=_COMPRESS_LEVEL_BEST, encoding=None, errors=None, newline=None): """Open a gzip-compressed file in binary or text mode. - The filename argument can be an actual filename (a str or bytes object), or - an existing file object to read from or write to. + The filename argument can be an actual filename (a str or bytes object), + or an existing file object to read from or write to. - The mode argument can be "r", "rb", "w", "wb", "x", "xb", "a" or "ab" for - binary mode, or "rt", "wt", "xt" or "at" for text mode. The default mode is - "rb", and the default compresslevel is 9. + The mode argument can be "r", "rb", "w", "wb", "x", "xb", "a" or "ab" + for binary mode, or "rt", "wt", "xt" or "at" for text mode. The default + mode is "rb", and the default compresslevel is 9. - For binary mode, this function is equivalent to the GzipFile constructor: - GzipFile(filename, mode, compresslevel). In this case, the encoding, errors - and newline arguments must not be provided. + For binary mode, this function is equivalent to the GzipFile + constructor: GzipFile(filename, mode, compresslevel). In this case, + the encoding, errors and newline arguments must not be provided. For text mode, a GzipFile object is created, and wrapped in an io.TextIOWrapper instance with the specified encoding, error handling @@ -148,8 +148,8 @@ class GzipFile(_streams.BaseStream): """The GzipFile class simulates most of the methods of a file object with the exception of the truncate() method. - This class only supports opening files in binary mode. If you need to open a - compressed file in text mode, use the gzip.open() function. + This class only supports opening files in binary mode. If you need to + open a compressed file in text mode, use the gzip.open() function. """ @@ -165,31 +165,33 @@ def __init__(self, filename=None, mode=None, non-trivial value. The new class instance is based on fileobj, which can be a regular - file, an io.BytesIO object, or any other object which simulates a file. - It defaults to None, in which case filename is opened to provide - a file object. + file, an io.BytesIO object, or any other object which simulates + a file. It defaults to None, in which case filename is opened to + provide a file object. When fileobj is not None, the filename argument is only used to be included in the gzip file header, which may include the original filename of the uncompressed file. It defaults to the filename of fileobj, if discernible; otherwise, it defaults to the empty string, - and in this case the original filename is not included in the header. - - The mode argument can be any of 'r', 'rb', 'a', 'ab', 'w', 'wb', 'x', or - 'xb' depending on whether the file will be read or written. The default - is the mode of fileobj if discernible; otherwise, the default is 'rb'. - A mode of 'r' is equivalent to one of 'rb', and similarly for 'w' and - 'wb', 'a' and 'ab', and 'x' and 'xb'. - - The compresslevel argument is an integer from 0 to 9 controlling the - level of compression; 1 is fastest and produces the least compression, - and 9 is slowest and produces the most compression. 0 is no compression - at all. The default is 9. - - The optional mtime argument is the timestamp requested by gzip. The time - is in Unix format, i.e., seconds since 00:00:00 UTC, January 1, 1970. - If mtime is omitted or None, the current time is used. Use mtime = 0 - to generate a compressed stream that does not depend on creation time. + and in this case the original filename is not included in the + header. + + The mode argument can be any of 'r', 'rb', 'a', 'ab', 'w', 'wb', + 'x', or 'xb' depending on whether the file will be read or written. + The default is the mode of fileobj if discernible; otherwise, the + default is 'rb'. A mode of 'r' is equivalent to one of 'rb', and + similarly for 'w' and 'wb', 'a' and 'ab', and 'x' and 'xb'. + + The compresslevel argument is an integer from 0 to 9 controlling + the level of compression; 1 is fastest and produces the least + compression, and 9 is slowest and produces the most compression. + 0 is no compression at all. The default is 9. + + The optional mtime argument is the timestamp requested by gzip. + The time is in Unix format, i.e., seconds since 00:00:00 UTC, + January 1, 1970. If mtime is omitted or None, the current time + is used. Use mtime = 0 to generate a compressed stream that does + not depend on creation time. """ @@ -575,10 +577,10 @@ def read(self, size=-1): # Read a chunk of data from the file if self._decompressor.needs_input: buf = self._fp.read(READ_BUFFER_SIZE) - uncompress = self._decompressor.decompress(buf, size) else: - uncompress = self._decompressor.decompress(b"", size) + buf = b"" + uncompress = self._decompressor.decompress(buf, size) if self._decompressor.unused_data != b"": # Prepend the already read bytes to the fileobj so they can # be seen by _read_eof() and _read_gzip_header() diff --git a/Lib/http/client.py b/Lib/http/client.py index 77f8d26291d..6fb7d254ea9 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -972,13 +972,22 @@ def _wrap_ipv6(self, ip): return ip def _tunnel(self): + if _contains_disallowed_url_pchar_re.search(self._tunnel_host): + raise ValueError('Tunnel host can\'t contain control characters %r' + % (self._tunnel_host,)) connect = b"CONNECT %s:%d %s\r\n" % ( self._wrap_ipv6(self._tunnel_host.encode("idna")), self._tunnel_port, self._http_vsn_str.encode("ascii")) headers = [connect] for header, value in self._tunnel_headers.items(): - headers.append(f"{header}: {value}\r\n".encode("latin-1")) + header_bytes = header.encode("latin-1") + value_bytes = value.encode("latin-1") + if not _is_legal_header_name(header_bytes): + raise ValueError('Invalid header name %r' % (header_bytes,)) + if _is_illegal_header_value(value_bytes): + raise ValueError('Invalid header value %r' % (value_bytes,)) + headers.append(b"%s: %s\r\n" % (header_bytes, value_bytes)) headers.append(b"\r\n") # Making a single send() call instead of one per line encourages # the host OS to use a more optimal packet size instead of diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py index d5b8ba939be..abebb4b69fd 100644 --- a/Lib/http/cookies.py +++ b/Lib/http/cookies.py @@ -391,17 +391,21 @@ def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self.OutputString()) def js_output(self, attrs=None): + import urllib.parse # Print javascript output_string = self.OutputString(attrs) if _has_control_character(output_string): raise CookieError("Control characters are not allowed in cookies") + # Base64-encode value to avoid template + # injection in cookie values. + output_encoded = urllib.parse.quote(output_string, safe='', encoding='utf-8') return """ - """ % (output_string.replace('"', r'\"')) + """ % (output_encoded,) def OutputString(self, attrs=None): # Build up our result diff --git a/Lib/idlelib/CREDITS.txt b/Lib/idlelib/CREDITS.txt new file mode 100644 index 00000000000..1b853e8cc1c --- /dev/null +++ b/Lib/idlelib/CREDITS.txt @@ -0,0 +1,48 @@ +Guido van Rossum, as well as being the creator of the Python language, is the +original creator of IDLE. Other contributors prior to Version 0.8 include +Mark Hammond, Jeremy Hylton, Tim Peters, and Moshe Zadka. + +Until Python 2.3, IDLE's development was carried out in the SF IDLEfork project. The +objective was to develop a version of IDLE which had an execution environment +which could be initialized prior to each run of user code. +IDLefork was merged into the Python code base in 2003. + +The IDLEfork project was initiated by David Scherer, with some help from Peter +Schneider-Kamp and Nicholas Riley. David wrote the first version of the RPC +code and designed a fast turn-around environment for VPython. Guido developed +the RPC code and Remote Debugger currently integrated in IDLE. Bruce Sherwood +contributed considerable time testing and suggesting improvements. + +Besides David and Guido, the main developers who were active on IDLEfork +are Stephen M. Gava, who implemented the configuration GUI, the new +configuration system, and the About dialog, and Kurt B. Kaiser, who completed +the integration of the RPC and remote debugger, implemented the threaded +subprocess, and made a number of usability enhancements. + +Other contributors include Raymond Hettinger, Tony Lownds (Mac integration), +Neal Norwitz (code check and clean-up), Ronald Oussoren (Mac integration), +Noam Raphael (Code Context, Call Tips, many other patches), and Chui Tey (RPC +integration, debugger integration and persistent breakpoints). + +Scott David Daniels, Tal Einat, Hernan Foffani, Christos Georgiou, +Jim Jewett, Martin v. Löwis, Jason Orendorff, Guilherme Polo, Josh Robb, +Nigel Rowe, Bruce Sherwood, Jeff Shute, and Weeble have submitted useful +patches. Thanks, guys! + +Major contributors since 2005: + +- 2005: Tal Einat +- 2010: Terry Jan Reedy (current maintainer) +- 2013: Roger Serwy +- 2014: Saimadhav Heblikar +- 2015: Mark Roseman +- 2017: Louie Lu, Cheryl Sabella, and Serhiy Storchaka +- 2025: Stan Ulbrych + +For additional details refer to NEWS.txt and Changelog. + +If we missed you, feel free to submit a PR with a summary of +contributions (for instance, at least 5 merged PRs). + + + diff --git a/Lib/idlelib/ChangeLog b/Lib/idlelib/ChangeLog new file mode 100644 index 00000000000..c8960cfa535 --- /dev/null +++ b/Lib/idlelib/ChangeLog @@ -0,0 +1,1591 @@ +Please refer to the IDLEfork and IDLE CVS repositories for +change details subsequent to the 0.8.1 release. + + +IDLEfork ChangeLog +================== + +2001-07-20 11:35 elguavas + + * README.txt, NEWS.txt: bring up to date for 0.8.1 release + +2001-07-19 16:40 elguavas + + * IDLEFORK.html: replaced by IDLEFORK-index.html + +2001-07-19 16:39 elguavas + + * IDLEFORK-index.html: updated placeholder idlefork homepage + +2001-07-19 14:49 elguavas + + * ChangeLog, EditorWindow.py, INSTALLATION, NEWS.txt, README.txt, + TODO.txt, idlever.py: + minor tidy-ups ready for 0.8.1 alpha tarball release + +2001-07-17 15:12 kbk + + * INSTALLATION, setup.py: INSTALLATION: Remove the coexist.patch + instructions + + **************** setup.py: + + Remove the idles script, add some words on IDLE Fork to the + long_description, and clean up some line spacing. + +2001-07-17 15:01 kbk + + * coexist.patch: Put this in the attic, at least for now... + +2001-07-17 14:59 kbk + + * PyShell.py, idle, idles: Implement idle command interface as + suggested by GvR [idle-dev] 16 July **************** PyShell: Added + functionality: + + usage: idle.py [-c command] [-d] [-i] [-r script] [-s] [-t title] + [arg] ... + + idle file(s) (without options) edit the file(s) + + -c cmd run the command in a shell -d enable the + debugger -i open an interactive shell -i file(s) open a + shell and also an editor window for each file -r script run a file + as a script in a shell -s run $IDLESTARTUP or + $PYTHONSTARTUP before anything else -t title set title of shell + window + + Remaining arguments are applied to the command (-c) or script (-r). + + ****************** idles: Removed the idles script, not needed + + ****************** idle: Removed the IdleConf references, not + required anymore + +2001-07-16 17:08 kbk + + * INSTALLATION, coexist.patch: Added installation instructions. + + Added a patch which modifies idlefork so that it can co-exist with + "official" IDLE in the site-packages directory. This patch is not + necessary if only idlefork IDLE is installed. See INSTALLATION for + further details. + +2001-07-16 15:50 kbk + + * idles: Add a script "idles" which opens a Python Shell window. + + The default behaviour of idlefork idle is to open an editor window + instead of a shell. Complex expressions may be run in a fresh + environment by selecting "run". There are times, however, when a + shell is desired. Though one can be started by "idle -t 'foo'", + this script is more convenient. In addition, a shell and an editor + window can be started in parallel by "idles -e foo.py". + +2001-07-16 15:25 kbk + + * PyShell.py: Call out IDLE Fork in startup message. + +2001-07-16 14:00 kbk + + * PyShell.py, setup.py: Add a script "idles" which opens a Python + Shell window. + + The default behaviour of idlefork idle is to open an editor window + instead of a shell. Complex expressions may be run in a fresh + environment by selecting "run". There are times, however, when a + shell is desired. Though one can be started by "idle -t 'foo'", + this script is more convenient. In addition, a shell and an editor + window can be started in parallel by "idles -e foo.py". + +2001-07-15 03:06 kbk + + * pyclbr.py, tabnanny.py: tabnanny and pyclbr are now found in /Lib + +2001-07-15 02:29 kbk + + * BrowserControl.py: Remove, was retained for 1.5.2 support + +2001-07-14 15:48 kbk + + * setup.py: Installing Idle to site-packages via Distutils does not + copy the Idle help.txt file. + + Ref SF Python Patch 422471 + +2001-07-14 15:26 kbk + + * keydefs.py: py-cvs-2001_07_13 (Rev 1.3) merge + + "Make copy, cut and paste events case insensitive. Reported by + Patrick K. O'Brien on idle-dev. (Should other bindings follow + suit?)" --GvR + +2001-07-14 15:21 kbk + + * idle.py: py-cvs-2001_07_13 (Rev 1.4) merge + + "Move the action of loading the configuration to the IdleConf + module rather than the idle.py script. This has advantages and + disadvantages; the biggest advantage being that we can more easily + have an alternative main program." --GvR + +2001-07-14 15:18 kbk + + * extend.txt: py-cvs-2001_07_13 (Rev 1.4) merge + + "Quick update to the extension mechanism (extend.py is gone, long + live config.txt)" --GvR + +2001-07-14 15:15 kbk + + * StackViewer.py: py-cvs-2001_07_13 (Rev 1.16) merge + + "Refactored, with some future plans in mind. This now uses the new + gotofileline() method defined in FileList.py" --GvR + +2001-07-14 15:10 kbk + + * PyShell.py: py-cvs-2001_07_13 (Rev 1.34) merge + + "Amazing. A very subtle change in policy in descr-branch actually + found a bug here. Here's the deal: Class PyShell derives from + class OutputWindow. Method PyShell.close() wants to invoke its + parent method, but because PyShell long ago was inherited from + class PyShellEditorWindow, it invokes + PyShelEditorWindow.close(self). Now, class PyShellEditorWindow + itself derives from class OutputWindow, and inherits the close() + method from there without overriding it. Under the old rules, + PyShellEditorWindow.close would return an unbound method restricted + to the class that defined the implementation of close(), which was + OutputWindow.close. Under the new rules, the unbound method is + restricted to the class whose method was requested, that is + PyShellEditorWindow, and this was correctly trapped as an error." + --GvR + +2001-07-14 14:59 kbk + + * PyParse.py: py-cvs-2001_07_13 (Rel 1.9) merge + + "Taught IDLE's autoident parser that "yield" is a keyword that + begins a stmt. Along w/ the preceding change to keyword.py, making + all this work w/ a future-stmt just looks harder and harder." + --tim_one + + (From Rel 1.8: "Hack to make this still work with Python 1.5.2. + ;-( " --fdrake) + +2001-07-14 14:51 kbk + + * IdleConf.py: py-cvs-2001_07_13 (Rel 1.7) merge + + "Move the action of loading the configuration to the IdleConf + module rather than the idle.py script. This has advantages and + disadvantages; the biggest advantage being that we can more easily + have an alternative main program." --GvR + +2001-07-14 14:45 kbk + + * FileList.py: py-cvs-2000_07_13 (Rev 1.9) merge + + "Delete goodname() method, which is unused. Add gotofileline(), a + convenience method which I intend to use in a variant. Rename + test() to _test()." --GvR + + This was an interesting merge. The join completely missed removing + goodname(), which was adjacent, but outside of, a small conflict. + I only caught it by comparing the 1.1.3.2/1.1.3.3 diff. CVS ain't + infallible. + +2001-07-14 13:58 kbk + + * EditorWindow.py: py-cvs-2000_07_13 (Rev 1.38) merge "Remove + legacy support for the BrowserControl module; the webbrowser module + has been included since Python 2.0, and that is the preferred + interface." --fdrake + +2001-07-14 13:32 kbk + + * EditorWindow.py, FileList.py, IdleConf.py, PyParse.py, + PyShell.py, StackViewer.py, extend.txt, idle.py, keydefs.py: Import + the 2001 July 13 23:59 GMT version of Python CVS IDLE on the + existing 1.1.3 vendor branch named py-cvs-vendor-branch. Release + tag is py-cvs-2001_07_13. + +2001-07-14 12:02 kbk + + * Icons/python.gif: py-cvs-rel2_1 (Rev 1.2) merge Copied py-cvs rev + 1.2 changed file to idlefork MAIN + +2001-07-14 11:58 kbk + + * Icons/minusnode.gif: py-cvs-rel2_1 (Rev 1.2) merge Copied py-cvs + 1.2 changed file to idlefork MAIN + +2001-07-14 11:23 kbk + + * ScrolledList.py: py-cvs-rel2_1 (rev 1.5) merge - whitespace + normalization + +2001-07-14 11:20 kbk + + * Separator.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-14 11:16 kbk + + * StackViewer.py: py-cvs-rel2_1 (Rev 1.15) merge - whitespace + normalization + +2001-07-14 11:14 kbk + + * ToolTip.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 10:13 kbk + + * PyShell.py: cvs-py-rel2_1 (Rev 1.29 - 1.33) merge + + Merged the following py-cvs revs without conflict: 1.29 Reduce + copyright text output at startup 1.30 Delay setting sys.args until + Tkinter is fully initialized 1.31 Whitespace normalization 1.32 + Turn syntax warning into error when interactive 1.33 Fix warning + initialization bug + + Note that module is extensively modified wrt py-cvs + +2001-07-14 06:33 kbk + + * PyParse.py: py-cvs-rel2_1 (Rev 1.6 - 1.8) merge Fix autoindent + bug and deflect Unicode from text.get() + +2001-07-14 06:00 kbk + + * Percolator.py: py-cvs-rel2_1 (Rev 1.3) "move "from Tkinter import + *" to module level" --jhylton + +2001-07-14 05:57 kbk + + * PathBrowser.py: py-cvs-rel2_1 (Rev 1.6) merge - whitespace + normalization + +2001-07-14 05:49 kbk + + * ParenMatch.py: cvs-py-rel2_1 (Rev 1.5) merge - whitespace + normalization + +2001-07-14 03:57 kbk + + * ObjectBrowser.py: py-cvs-rel2_1 (Rev 1.3) merge "Make the test + program work outside IDLE." -- GvR + +2001-07-14 03:52 kbk + + * MultiStatusBar.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 03:44 kbk + + * MultiScrolledLists.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 03:40 kbk + + * IdleHistory.py: py-cvs-rel2_1 (Rev 1.4) merge - whitespace + normalization + +2001-07-14 03:38 kbk + + * IdleConf.py: py-cvs-rel2_1 (Rev 1.6) merge - whitespace + normalization + +2001-07-13 14:18 kbk + + * IOBinding.py: py-cvs-rel2_1 (Rev 1.4) merge - move "import *" to + module level + +2001-07-13 14:12 kbk + + * FormatParagraph.py: py-cvs-rel2_1 (Rev 1.9) merge - whitespace + normalization + +2001-07-13 14:07 kbk + + * FileList.py: py-cvs-rel2_1 (Rev 1.8) merge - whitespace + normalization + +2001-07-13 13:35 kbk + + * EditorWindow.py: py-cvs-rel2_1 (Rev 1.33 - 1.37) merge + + VP IDLE version depended on VP's ExecBinding.py and spawn.py to get + the path to the Windows Doc directory (relative to python.exe). + Removed this conflicting code in favor of py-cvs updates which on + Windows use a hard coded path relative to the location of this + module. py-cvs updates include support for webbrowser.py. Module + still has BrowserControl.py for 1.5.2 support. + + At this point, the differences wrt py-cvs relate to menu + functionality. + +2001-07-13 11:30 kbk + + * ConfigParser.py: py-cvs-rel2_1 merge - Remove, lives in /Lib + +2001-07-13 10:10 kbk + + * Delegator.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-13 10:07 kbk + + * Debugger.py: py-cvs-rel2_1 (Rev 1.15) merge - whitespace + normalization + +2001-07-13 10:04 kbk + + * ColorDelegator.py: py-cvs-rel2_1 (Rev 1.11 and 1.12) merge + Colorize "as" after "import" / use DEBUG instead of __debug__ + +2001-07-13 09:54 kbk + + * ClassBrowser.py: py-cvs-rel2_1 (Rev 1.12) merge - whitespace + normalization + +2001-07-13 09:41 kbk + + * BrowserControl.py: py-cvs-rel2_1 (Rev 1.1) merge - New File - + Force HEAD to trunk with -f Note: browser.py was renamed + BrowserControl.py 10 May 2000. It provides a collection of classes + and convenience functions to control external browsers "for 1.5.2 + support". It was removed from py-cvs 18 April 2001. + +2001-07-13 09:10 kbk + + * CallTips.py: py-cvs-rel2_1 (Rev 1.8) merge - whitespace + normalization + +2001-07-13 08:26 kbk + + * CallTipWindow.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-13 08:13 kbk + + * AutoExpand.py: py-cvs-rel1_2 (Rev 1.4) merge, "Add Alt-slash to + Unix keydefs (I somehow need it on RH 6.2). Get rid of assignment + to unused self.text.wordlist." --GvR + +2001-07-12 16:54 elguavas + + * ReplaceDialog.py: py-cvs merge, python 1.5.2 compatibility + +2001-07-12 16:46 elguavas + + * ScriptBinding.py: py-cvs merge, better error dialog + +2001-07-12 16:38 elguavas + + * TODO.txt: py-cvs merge, additions + +2001-07-12 15:35 elguavas + + * WindowList.py: py-cvs merge, correct indentation + +2001-07-12 15:24 elguavas + + * config.txt: py-cvs merge, correct typo + +2001-07-12 15:21 elguavas + + * help.txt: py-cvs merge, update colour changing info + +2001-07-12 14:51 elguavas + + * idle.py: py-cvs merge, idle_dir loading changed + +2001-07-12 14:44 elguavas + + * idlever.py: py-cvs merge, version update + +2001-07-11 12:53 kbk + + * BrowserControl.py: Initial revision + +2001-07-11 12:53 kbk + + * AutoExpand.py, BrowserControl.py, CallTipWindow.py, CallTips.py, + ClassBrowser.py, ColorDelegator.py, Debugger.py, Delegator.py, + EditorWindow.py, FileList.py, FormatParagraph.py, IOBinding.py, + IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, ObjectBrowser.py, OutputWindow.py, + ParenMatch.py, PathBrowser.py, Percolator.py, PyParse.py, + PyShell.py, RemoteInterp.py, ReplaceDialog.py, ScriptBinding.py, + ScrolledList.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, WindowList.py, config.txt, help.txt, idle, idle.bat, + idle.py, idlever.py, setup.py, Icons/minusnode.gif, + Icons/python.gif: Import the release 2.1 version of Python CVS IDLE + on the existing 1.1.3 vendor branch named py-cvs-vendor-branch, + with release tag py-cvs-rel2_1. + +2001-07-11 12:34 kbk + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, ChangeLog, ClassBrowser.py, ColorDelegator.py, + Debugger.py, Delegator.py, EditorWindow.py, FileList.py, + FormatParagraph.py, FrameViewer.py, GrepDialog.py, IOBinding.py, + IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, RemoteInterp.py, + ReplaceDialog.py, ScriptBinding.py, ScrolledList.py, + SearchBinding.py, SearchDialog.py, SearchDialogBase.py, + SearchEngine.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, TreeWidget.py, UndoDelegator.py, WidgetRedirector.py, + WindowList.py, ZoomHeight.py, __init__.py, config-unix.txt, + config-win.txt, config.txt, eventparse.py, extend.txt, help.txt, + idle.bat, idle.py, idle.pyw, idlever.py, keydefs.py, pyclbr.py, + tabnanny.py, testcode.py, Icons/folder.gif, Icons/minusnode.gif, + Icons/openfolder.gif, Icons/plusnode.gif, Icons/python.gif, + Icons/tk.gif: Import the 9 March 2000 version of Python CVS IDLE as + 1.1.3 vendor branch named py-cvs-vendor-branch. + +2001-07-04 13:43 kbk + + * Icons/: folder.gif, minusnode.gif, openfolder.gif, plusnode.gif, + python.gif, tk.gif: Null commit with -f option to force an uprev + and put HEADs firmly on the trunk. + +2001-07-04 13:15 kbk + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, ChangeLog, ClassBrowser.py, ColorDelegator.py, + ConfigParser.py, Debugger.py, Delegator.py, EditorWindow.py, + ExecBinding.py, FileList.py, FormatParagraph.py, FrameViewer.py, + GrepDialog.py, IDLEFORK.html, IOBinding.py, IdleConf.py, + IdleHistory.py, MultiScrolledLists.py, MultiStatusBar.py, NEWS.txt, + ObjectBrowser.py, OldStackViewer.py, OutputWindow.py, + ParenMatch.py, PathBrowser.py, Percolator.py, PyParse.py, + PyShell.py, README.txt, Remote.py, RemoteInterp.py, + ReplaceDialog.py, ScriptBinding.py, ScrolledList.py, + SearchBinding.py, SearchDialog.py, SearchDialogBase.py, + SearchEngine.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, TreeWidget.py, UndoDelegator.py, WidgetRedirector.py, + WindowList.py, ZoomHeight.py, __init__.py, config-unix.txt, + config-win.txt, config.txt, eventparse.py, extend.txt, help.txt, + idle, idle.bat, idle.py, idle.pyw, idlever.py, keydefs.py, + loader.py, protocol.py, pyclbr.py, setup.py, spawn.py, tabnanny.py, + testcode.py: Null commit with -f option to force an uprev and put + HEADs firmly on the trunk. + +2001-06-27 10:24 elguavas + + * IDLEFORK.html: updated contact details + +2001-06-25 17:23 elguavas + + * idle, RemoteInterp.py, setup.py: Initial revision + +2001-06-25 17:23 elguavas + + * idle, RemoteInterp.py, setup.py: import current python cvs idle + as a vendor branch + +2001-06-24 15:10 elguavas + + * IDLEFORK.html: tiny change to test new syncmail setup + +2001-06-24 14:41 elguavas + + * IDLEFORK.html: change to new developer contact, also a test + commit for new syncmail setup + +2001-06-23 18:15 elguavas + + * IDLEFORK.html: tiny test update for revitalised idle-fork + +2000-09-24 17:29 nriley + + * protocol.py: Fixes for Python 1.6 compatibility - socket bind and + connect get a tuple instead two arguments. + +2000-09-24 17:28 nriley + + * spawn.py: Change for Python 1.6 compatibility - UNIX's 'os' + module defines 'spawnv' now, so we check for 'fork' first. + +2000-08-15 22:51 nowonder + + * IDLEFORK.html: + corrected email address + +2000-08-15 22:47 nowonder + + * IDLEFORK.html: + added .html file for http://idlefork.sourceforge.net + +2000-08-15 11:13 dscherer + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, __init__.py, ChangeLog, ClassBrowser.py, + ColorDelegator.py, ConfigParser.py, Debugger.py, Delegator.py, + FileList.py, FormatParagraph.py, FrameViewer.py, GrepDialog.py, + IOBinding.py, IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, ReplaceDialog.py, + ScriptBinding.py, ScrolledList.py, SearchBinding.py, + SearchDialog.py, SearchDialogBase.py, SearchEngine.py, + Separator.py, StackViewer.py, TODO.txt, ToolTip.py, TreeWidget.py, + UndoDelegator.py, WidgetRedirector.py, WindowList.py, help.txt, + ZoomHeight.py, config-unix.txt, config-win.txt, config.txt, + eventparse.py, extend.txt, idle.bat, idle.py, idle.pyw, idlever.py, + keydefs.py, loader.py, pyclbr.py, tabnanny.py, testcode.py, + EditorWindow.py, ExecBinding.py, Remote.py, protocol.py, spawn.py, + Icons/folder.gif, Icons/minusnode.gif, Icons/openfolder.gif, + Icons/plusnode.gif, Icons/python.gif, Icons/tk.gif: Initial + revision + +2000-08-15 11:13 dscherer + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, __init__.py, ChangeLog, ClassBrowser.py, + ColorDelegator.py, ConfigParser.py, Debugger.py, Delegator.py, + FileList.py, FormatParagraph.py, FrameViewer.py, GrepDialog.py, + IOBinding.py, IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, ReplaceDialog.py, + ScriptBinding.py, ScrolledList.py, SearchBinding.py, + SearchDialog.py, SearchDialogBase.py, SearchEngine.py, + Separator.py, StackViewer.py, TODO.txt, ToolTip.py, TreeWidget.py, + UndoDelegator.py, WidgetRedirector.py, WindowList.py, help.txt, + ZoomHeight.py, config-unix.txt, config-win.txt, config.txt, + eventparse.py, extend.txt, idle.bat, idle.py, idle.pyw, idlever.py, + keydefs.py, loader.py, pyclbr.py, tabnanny.py, testcode.py, + EditorWindow.py, ExecBinding.py, Remote.py, protocol.py, spawn.py, + Icons/folder.gif, Icons/minusnode.gif, Icons/openfolder.gif, + Icons/plusnode.gif, Icons/python.gif, Icons/tk.gif: Modified IDLE + from VPython 0.2 + + +original IDLE ChangeLog: +======================== + +Tue Feb 15 18:08:19 2000 Guido van Rossum + + * NEWS.txt: Notice status bar and stack viewer. + + * EditorWindow.py: Support for Moshe's status bar. + + * MultiStatusBar.py: Status bar code -- by Moshe Zadka. + + * OldStackViewer.py: + Adding the old stack viewer implementation back, for the debugger. + + * StackViewer.py: New stack viewer, uses a tree widget. + (XXX: the debugger doesn't yet use this.) + + * WindowList.py: + Correct a typo and remove an unqualified except that was hiding the error. + + * ClassBrowser.py: Add an XXX comment about the ClassBrowser AIP. + + * ChangeLog: Updated change log. + + * NEWS.txt: News update. Probably incomplete; what else is new? + + * README.txt: + Updated for pending IDLE 0.5 release (still very rough -- just getting + it out in a more convenient format than CVS). + + * TODO.txt: Tiny addition. + +Thu Sep 9 14:16:02 1999 Guido van Rossum + + * TODO.txt: A few new TODO entries. + +Thu Aug 26 23:06:22 1999 Guido van Rossum + + * Bindings.py: Add Python Documentation entry to Help menu. + + * EditorWindow.py: + Find the help.txt file relative to __file__ or ".", not in sys.path. + (Suggested by Moshe Zadka, but implemented differently.) + + Add <> event which, on Unix, brings up Netscape pointing + to http://www.python.doc/current/ (a local copy would be nice but its + location can't be predicted). Windows solution TBD. + +Wed Aug 11 14:55:43 1999 Guido van Rossum + + * TreeWidget.py: + Moshe noticed an inconsistency in his comment, so I'm rephrasing it to + be clearer. + + * TreeWidget.py: + Patch inspired by Moshe Zadka to search for the Icons directory in the + same directory as __file__, rather than searching for it along sys.path. + This works better when idle is a package. + +Thu Jul 15 13:11:02 1999 Guido van Rossum + + * TODO.txt: New wishes. + +Sat Jul 10 13:17:35 1999 Guido van Rossum + + * IdlePrefs.py: + Make the color for stderr red (i.e. the standard warning/danger/stop + color) rather than green. Suggested by Sam Schulenburg. + +Fri Jun 25 17:26:34 1999 Guido van Rossum + + * PyShell.py: Close debugger when closing. This may break a cycle. + + * Debugger.py: Break cycle on close. + + * ClassBrowser.py: Destroy the tree when closing. + + * TreeWidget.py: Add destroy() method to recursively destroy a tree. + + * PyShell.py: Extend _close() to break cycles. + Break some other cycles too (and destroy the root when done). + + * EditorWindow.py: + Add _close() method that does the actual cleanup (close() asks the + user what they want first if there's unsaved stuff, and may cancel). + It closes more than before. + + Add unload_extensions() method to unload all extensions; called from + _close(). It calls an extension's close() method if it has one. + + * Percolator.py: Add close() method that breaks cycles. + + * WidgetRedirector.py: Add unregister() method. + Unregister everything at closing. + Don't call close() in __del__, rely on explicit call to close(). + + * IOBinding.py, FormatParagraph.py, CallTips.py: + Add close() method that breaks a cycle. + +Fri Jun 11 15:03:00 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, FormatParagraph.py: + Tim Peters smart.patch: + + EditorWindow.py: + + + Added get_tabwidth & set_tabwidth "virtual text" methods, that get/set the + widget's view of what a tab means. + + + Moved TK_TABWIDTH_DEFAULT here from AutoIndent. + + + Renamed Mark's get_selection_index to get_selection_indices (sorry, Mark, + but the name was plain wrong ). + + FormatParagraph.py: renamed use of get_selection_index. + + AutoIndent.py: + + + Moved TK_TABWIDTH_DEFAULT to EditorWindow. + + + Rewrote set_indentation_params to use new VTW get/set_tabwidth methods. + + + Changed smart_backspace_event to delete whitespace back to closest + preceding virtual tab stop or real character (note that this may require + inserting characters if backspacing over a tab!). + + + Nuked almost references to the selection tag, in favor of using + get_selection_indices. The sole exception is in set_region, for which no + "set_selection" abstraction has yet been agreed upon. + + + Had too much fun using the spiffy new features of the format-paragraph + cmd. + +Thu Jun 10 17:48:02 1999 Guido van Rossum + + * FormatParagraph.py: + Code by Mark Hammond to format paragraphs embedded in comments. + Read the comments (which I reformatted using the new feature :-) + for some limitations. + + * EditorWindow.py: + Added abstraction get_selection_index() (Mark Hammond). Also + reformatted some comment blocks to show off a cool feature I'm about + to check in next. + + * ClassBrowser.py: + Adapt to the new pyclbr's support of listing top-level functions. If + this functionality is not present (e.g. when used with a vintage + Python 1.5.2 installation) top-level functions are not listed. + + (Hmm... Any distribution of IDLE 0.5 should probably include a copy + of the new pyclbr.py!) + + * AutoIndent.py: + Fix off-by-one error in Tim's recent change to comment_region(): the + list of lines returned by get_region() contains an empty line at the + end representing the start of the next line, and this shouldn't be + commented out! + + * CallTips.py: + Mark Hammond writes: Here is another change that allows it to work for + class creation - tries to locate an __init__ function. Also updated + the test code to reflect your new "***" change. + + * CallTipWindow.py: + Mark Hammond writes: Tim's suggestion of copying the font for the + CallTipWindow from the text control makes sense, and actually makes + the control look better IMO. + +Wed Jun 9 20:34:57 1999 Guido van Rossum + + * CallTips.py: + Append "..." if the appropriate flag (for varargs) in co_flags is set. + Ditto "***" for kwargs. + +Tue Jun 8 13:06:07 1999 Guido van Rossum + + * ReplaceDialog.py: + Hmm... Tim didn't turn "replace all" into a single undo block. + I think I like it better if it os, so here. + + * ReplaceDialog.py: Tim Peters: made replacement atomic for undo/redo. + + * AutoIndent.py: Tim Peters: + + + Set usetabs=1. Editing pyclbr.py was driving me nuts <0.6 wink>. + usetabs=1 is the Emacs pymode default too, and thanks to indentwidth != + tabwidth magical usetabs disabling, new files are still created with tabs + turned off. The only implication is that if you open a file whose first + indent is a single tab, IDLE will now magically use tabs for that file (and + set indentwidth to 8). Note that the whole scheme doesn't work right for + PythonWin, though, since Windows users typically set tabwidth to 4; Mark + probably has to hide the IDLE algorithm from them (which he already knows). + + + Changed comment_region_event to stick "##" in front of every line. The + "holes" previously left on blank lines were visually confusing (made it + needlessly hard to figure out what to uncomment later). + +Mon Jun 7 15:38:40 1999 Guido van Rossum + + * TreeWidget.py, ObjectBrowser.py: + Remove unnecessary reference to pyclbr from test() code. + + * PyParse.py: Tim Peters: + + Smarter logic for finding a parse synch point. + + Does a half to a fifth the work in normal cases; don't notice the speedup, + but makes more breathing room for other extensions. + + Speeds terrible cases by at least a factor of 10. "Terrible" == e.g. you put + """ at the start of Tkinter.py, undo it, zoom to the bottom, and start + typing in code. Used to take about 8 seconds for ENTER to respond, now some + large fraction of a second. The new code gets indented correctly, despite + that it all remains "string colored" until the colorizer catches up (after + which, ENTER appears instantaneous again). + +Fri Jun 4 19:21:19 1999 Guido van Rossum + + * extend.py: Might as well enable CallTips by default. + If there are too many complaints I'll remove it again or fix it. + +Thu Jun 3 14:32:16 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, PyParse.py: + New offerings by Tim Peters; he writes: + + IDLE is now the first Python editor in the Universe not confused by my + doctest.py . + + As threatened, this defines IDLE's is_char_in_string function as a + method of EditorWindow. You just need to define one similarly in + whatever it is you pass as editwin to AutoIndent; looking at the + EditorWindow.py part of the patch should make this clear. + + * GrepDialog.py: Enclose pattern in quotes in status message. + + * CallTips.py: + Mark Hammond fixed some comments and improved the way the tip text is + constructed. + +Wed Jun 2 18:18:57 1999 Guido van Rossum + + * CallTips.py: + My fix to Mark's code: restore the universal check on . + Always cancel on or . + + * CallTips.py: + A version that Mark Hammond posted to the newsgroup. Has some newer + stuff for getting the tip. Had to fix the Key-( and Key-) events + for Unix. Will have to re-apply my patch for catching KeyRelease and + ButtonRelease events. + + * CallTipWindow.py, CallTips.py: + Call tips by Mark Hammond (plus tiny fix by me.) + + * IdleHistory.py: + Changes by Mark Hammond: (1) support optional output_sep argument to + the constructor so he can eliminate the sys.ps2 that PythonWin leaves + in the source; (2) remove duplicate history items. + + * AutoIndent.py: + Changes by Mark Hammond to allow using IDLE extensions in PythonWin as + well: make three dialog routines instance variables. + + * EditorWindow.py: + Change by Mark Hammond to allow using IDLE extensions in PythonWin as + well: make three dialog routines instance variables. + +Tue Jun 1 20:06:44 1999 Guido van Rossum + + * AutoIndent.py: Hah! A fix of my own to Tim's code! + Unix bindings for <> and <> were + missing, and somehow that meant the events were never generated, + even though they were in the menu. The new Unix bindings are now + the same as the Windows bindings (M-t and M-u). + + * AutoIndent.py, PyParse.py, PyShell.py: Tim Peters again: + + The new version (attached) is fast enough all the time in every real module + I have . You can make it slow by, e.g., creating an open list with + 5,000 90-character identifiers (+ trailing comma) each on its own line, then + adding an item to the end -- but that still consumes less than a second on + my P5-166. Response time in real code appears instantaneous. + + Fixed some bugs. + + New feature: when hitting ENTER and the cursor is beyond the line's leading + indentation, whitespace is removed on both sides of the cursor; before + whitespace was removed only on the left; e.g., assuming the cursor is + between the comma and the space: + + def something(arg1, arg2): + ^ cursor to the left of here, and hit ENTER + arg2): # new line used to end up here + arg2): # but now lines up the way you expect + + New hack: AutoIndent has grown a context_use_ps1 Boolean config option, + defaulting to 0 (false) and set to 1 (only) by PyShell. Reason: handling + the fancy stuff requires looking backward for a parsing synch point; ps1 + lines are the only sensible thing to look for in a shell window, but are a + bad thing to look for in a file window (ps1 lines show up in my module + docstrings often). PythonWin's shell should set this true too. + + Persistent problem: strings containing def/class can still screw things up + completely. No improvement. Simplest workaround is on the user's head, and + consists of inserting e.g. + + def _(): pass + + (or any other def/class) after the end of the multiline string that's + screwing them up. This is especially irksome because IDLE's syntax coloring + is *not* confused, so when this happens the colors don't match the + indentation behavior they see. + + * AutoIndent.py: Tim Peters again: + + [Tim, after adding some bracket smarts to AutoIndent.py] + > ... + > What it can't possibly do without reparsing large gobs of text is + > suggest a reasonable indent level after you've *closed* a bracket + > left open on some previous line. + > ... + + The attached can, and actually fast enough to use -- most of the time. The + code is tricky beyond belief to achieve that, but it works so far; e.g., + + return len(string.expandtabs(str[self.stmt_start : + ^ indents to caret + i], + ^ indents to caret + self.tabwidth)) + 1 + ^ indents to caret + + It's about as smart as pymode now, wrt both bracket and backslash + continuation rules. It does require reparsing large gobs of text, and if it + happens to find something that looks like a "def" or "class" or sys.ps1 + buried in a multiline string, but didn't suck up enough preceding text to + see the start of the string, it's completely hosed. I can't repair that -- + it's just too slow to reparse from the start of the file all the time. + + AutoIndent has grown a new num_context_lines tuple attribute that controls + how far to look back, and-- like other params --this could/should be made + user-overridable at startup and per-file on the fly. + + * PyParse.py: New file by Tim Peters: + + One new file in the attached, PyParse.py. The LineStudier (whatever it was + called ) class was removed from AutoIndent; PyParse subsumes its + functionality. + + * AutoIndent.py: Tim Peters keeps revising this module (more to come): + + Removed "New tabwidth" menu binding. + + Added "a tab means how many spaces?" dialog to block tabify and untabify. I + think prompting for this is good now: they're usually at-most-once-per-file + commands, and IDLE can't let them change tabwidth from the Tk default + anymore, so IDLE can no longer presume to have any idea what a tab means. + + Irony: for the purpose of keeping comments aligned via tabs, Tk's + non-default approach is much nicer than the Emacs/Notepad/Codewright/vi/etc + approach. + + * EditorWindow.py: + 1. Catch NameError on import (could be raised by case mismatch on Windows). + 2. No longer need to reset pyclbr cache and show watch cursor when calling + ClassBrowser -- the ClassBrowser takes care of pyclbr and the TreeWidget + takes care of the watch cursor. + 3. Reset the focus to the current window after error message about class + browser on buffer without filename. + + * Icons/minusnode.gif, Icons/plusnode.gif: Missed a few. + + * ClassBrowser.py, PathBrowser.py: Rewritten based on TreeWidget.py + + * ObjectBrowser.py: Object browser, based on TreeWidget.py. + + * TreeWidget.py: Tree widget done right. + + * ToolTip.py: As yet unused code for tool tips. + + * ScriptBinding.py: + Ensure sys.argv[0] is the script name on Run Script. + + * ZoomHeight.py: Move zoom height functionality to separate function. + + * Icons/folder.gif, Icons/openfolder.gif, Icons/python.gif, Icons/tk.gif: + A few icons used by ../TreeWidget.py and its callers. + + * AutoIndent.py: New version by Tim Peters improves block opening test. + +Fri May 21 04:46:17 1999 Guido van Rossum + + * Attic/History.py, PyShell.py: Rename History to IdleHistory. + Add isatty() to pseudo files. + + * StackViewer.py: Make initial stack viewer wider + + * TODO.txt: New wishes + + * AutoIndent.py, EditorWindow.py, PyShell.py: + Much improved autoindent and handling of tabs, + by Tim Peters. + +Mon May 3 15:49:52 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, FormatParagraph.py, UndoDelegator.py: + Tim Peters writes: + + I'm still unsure, but couldn't stand the virtual event trickery so tried a + different sin (adding undo_block_start/stop methods to the Text instance in + EditorWindow.py). Like it or not, it's efficient and works . Better + idea? + + Give the attached a whirl. Even if you hate the implementation, I think + you'll like the results. Think I caught all the "block edit" cmds, + including Format Paragraph, plus subtler ones involving smart indents and + backspacing. + + * WidgetRedirector.py: Tim Peters writes: + + [W]hile trying to dope out how redirection works, stumbled into two + possible glitches. In the first, it doesn't appear to make sense to try to + rename a command that's already been destroyed; in the second, the name + "previous" doesn't really bring to mind "ignore the previous value" . + +Fri Apr 30 19:39:25 1999 Guido van Rossum + + * __init__.py: Support for using idle as a package. + + * PathBrowser.py: + Avoid listing files more than once (e.g. foomodule.so has two hits: + once for foo + module.so, once for foomodule + .so). + +Mon Apr 26 22:20:38 1999 Guido van Rossum + + * ChangeLog, ColorDelegator.py, PyShell.py: Tim Peters strikes again: + + Ho ho ho -- that's trickier than it sounded! The colorizer is working with + "line.col" strings instead of Text marks, and the absolute coordinates of + the point of interest can change across the self.update call (voice of + baffled experience, when two quick backspaces no longer fooled it, but a + backspace followed by a quick ENTER did ). + + Anyway, the attached appears to do the trick. CPU usage goes way up when + typing quickly into a long triple-quoted string, but the latency is fine for + me (a relatively fast typist on a relatively slow machine). Most of the + changes here are left over from reducing the # of vrbl names to help me + reason about the logic better; I hope the code is a *little* easier to + +Fri Apr 23 14:01:25 1999 Guido van Rossum + + * EditorWindow.py: + Provide full arguments to __import__ so it works in packagized IDLE. + +Thu Apr 22 23:20:17 1999 Guido van Rossum + + * help.txt: + Bunch of updates necessary due to recent changes; added docs for File + menu, command line and color preferences. + + * Bindings.py: Remove obsolete 'script' menu. + + * TODO.txt: Several wishes fulfilled. + + * OutputWindow.py: + Moved classes OnDemandOutputWindow and PseudoFile here, + from ScriptBinding.py where they are no longer needed. + + * ScriptBinding.py: + Mostly rewritten. Instead of the old Run module and Debug module, + there are two new commands: + + Import module (F5) imports or reloads the module and also adds its + name to the __main__ namespace. This gets executed in the PyShell + window under control of its debug settings. + + Run script (Control-F5) is similar but executes the contents of the + file directly in the __main__ namespace. + + * PyShell.py: Nits: document use of $IDLESTARTUP; display idle version + + * idlever.py: New version to celebrate new command line + + * OutputWindow.py: Added flush(), for completeness. + + * PyShell.py: + A lot of changes to make the command line more useful. You can now do: + idle.py -e file ... -- to edit files + idle.py script arg ... -- to run a script + idle.py -c cmd arg ... -- to run a command + Other options, see also the usage message (also new!) for more details: + -d -- enable debugger + -s -- run $IDLESTARTUP or $PYTHONSTARTUP + -t title -- set Python Shell window's title + sys.argv is set accordingly, unless -e is used. + sys.path is absolutized, and all relevant paths are inserted into it. + + Other changes: + - the environment in which commands are executed is now the + __main__ module + - explicitly save sys.stdout etc., don't restore from sys.__stdout__ + - new interpreter methods execsource(), execfile(), stuffsource() + - a few small nits + + * TODO.txt: + Some more TODO items. Made up my mind about command line args, + Run/Import, __main__. + + * ColorDelegator.py: + Super-elegant patch by Tim Peters that speeds up colorization + dramatically (up to 15 times he claims). Works by reading more than + one line at a time, up to 100-line chunks (starting with one line and + then doubling up to the limit). On a typical machine (e.g. Tim's + P5-166) this doesn't reduce interactive responsiveness in a noticeable + way. + +Wed Apr 21 15:49:34 1999 Guido van Rossum + + * ColorDelegator.py: + Patch by Tim Peters to speed up colorizing of big multiline strings. + +Tue Apr 20 17:32:52 1999 Guido van Rossum + + * extend.txt: + For an event 'foo-bar', the corresponding method must be called + foo_bar_event(). Therefore, fix the references to zoom_height() in + the example. + + * IdlePrefs.py: Restored the original IDLE color scheme. + + * PyShell.py, IdlePrefs.py, ColorDelegator.py, EditorWindow.py: + Color preferences code by Loren Luke (massaged by me somewhat) + + * SearchEngine.py: + Patch by Mark Favas: it fixes the search engine behaviour where an + unsuccessful search wraps around and re-searches that part of the file + between the start of the search and the end of the file - only really + an issue for very large files, but... (also removes a redundant + m.span() call). + +Mon Apr 19 16:26:02 1999 Guido van Rossum + + * TODO.txt: A few wishes are now fulfilled. + + * AutoIndent.py: Tim Peters implements some of my wishes: + + o Makes the tab key intelligently insert spaces when appropriate + (see Help list banter twixt David Ascher and me; idea stolen from + every other editor on earth ). + + o newline_and_indent_event trims trailing whitespace on the old + line (pymode and Codewright). + + o newline_and_indent_event no longer fooled by trailing whitespace or + comment after ":" (pymode, PTUI). + + o newline_and_indent_event now reduces the new line's indentation after + return, break, continue, raise and pass stmts (pymode). + + The last two are easy to fool in the presence of strings & + continuations, but pymode requires Emacs's high-powered C parsing + functions to avoid that in finite time. + +====================================================================== + Python release 1.5.2c1, IDLE version 0.4 +====================================================================== + +Wed Apr 7 18:41:59 1999 Guido van Rossum + + * README.txt, NEWS.txt: New version. + + * idlever.py: Version bump awaiting impending new release. + (Not much has changed :-( ) + +Mon Mar 29 14:52:28 1999 Guido van Rossum + + * ScriptBinding.py, PyShell.py: + At Tim Peters' recommendation, add a dummy flush() method to + PseudoFile. + +Thu Mar 11 23:21:23 1999 Guido van Rossum + + * PathBrowser.py: Don't crash when sys.path contains an empty string. + + * Attic/Outline.py: This file was never supposed to be part of IDLE. + + * PathBrowser.py: + - Don't crash in the case where a superclass is a string instead of a + pyclbr.Class object; this can happen when the superclass is + unrecognizable (to pyclbr), e.g. when module renaming is used. + + - Show a watch cursor when calling pyclbr (since it may take a while + recursively parsing imported modules!). + +Wed Mar 10 05:18:02 1999 Guido van Rossum + + * EditorWindow.py, Bindings.py: Add PathBrowser to File module + + * PathBrowser.py: "Path browser" - 4 scrolled lists displaying: + directories on sys.path + modules in selected directory + classes in selected module + methods of selected class + + Single clicking in a directory, module or class item updates the next + column with info about the selected item. Double clicking in a + module, class or method item opens the file (and selects the clicked + item if it is a class or method). + + I guess eventually I should be using a tree widget for this, but the + ones I've seen don't work well enough, so for now I use the old + Smalltalk or NeXT style multi-column hierarchical browser. + + * MultiScrolledLists.py: + New utility: multiple scrolled lists in parallel + + * ScrolledList.py: - White background. + - Display "(None)" (or text of your choosing) when empty. + - Don't set the focus. + +====================================================================== + Python release 1.5.2b2, IDLE version 0.3 +====================================================================== + +Wed Feb 17 22:47:41 1999 Guido van Rossum + + * NEWS.txt: News in 0.3. + + * README.txt, idlever.py: Bump version to 0.3. + + * EditorWindow.py: + After all, we don't need to call the callbacks ourselves! + + * WindowList.py: + When deleting, call the callbacks *after* deleting the window from our list! + + * EditorWindow.py: + Fix up the Windows menu via the new callback mechanism instead of + depending on menu post commands (which don't work when the menu is + torn off). + + * WindowList.py: + Support callbacks to patch up Windows menus everywhere. + + * ChangeLog: Oh, why not. Checking in the Emacs-generated change log. + +Tue Feb 16 22:34:17 1999 Guido van Rossum + + * ScriptBinding.py: + Only pop up the stack viewer when requested in the Debug menu. + +Mon Feb 8 22:27:49 1999 Guido van Rossum + + * WindowList.py: Don't crash if a window no longer exists. + + * TODO.txt: Restructured a bit. + +Mon Feb 1 23:06:17 1999 Guido van Rossum + + * PyShell.py: Add current dir or paths of file args to sys.path. + + * Debugger.py: Add canonic() function -- for brand new bdb.py feature. + + * StackViewer.py: Protect against accessing an empty stack. + +Fri Jan 29 20:44:45 1999 Guido van Rossum + + * ZoomHeight.py: + Use only the height to decide whether to zoom in or out. + +Thu Jan 28 22:24:30 1999 Guido van Rossum + + * EditorWindow.py, FileList.py: + Make sure the Tcl variables are shared between windows. + + * PyShell.py, EditorWindow.py, Bindings.py: + Move menu/key binding code from Bindings.py to EditorWindow.py, + with changed APIs -- it makes much more sense there. + Also add a new feature: if the first character of a menu label is + a '!', it gets a checkbox. Checkboxes are bound to Boolean Tcl variables + that can be accessed through the new getvar/setvar/getrawvar API; + the variable is named after the event to which the menu is bound. + + * Debugger.py: Add Quit button to the debugger window. + + * SearchDialog.py: + When find_again() finds exactly the current selection, it's a failure. + + * idle.py, Attic/idle: Rename idle -> idle.py + +Mon Jan 18 15:18:57 1999 Guido van Rossum + + * EditorWindow.py, WindowList.py: Only deiconify when iconic. + + * TODO.txt: Misc + +Tue Jan 12 22:14:34 1999 Guido van Rossum + + * testcode.py, Attic/test.py: + Renamed test.py to testcode.py so one can import Python's + test package from inside IDLE. (Suggested by Jack Jansen.) + + * EditorWindow.py, ColorDelegator.py: + Hack to close a window that is colorizing. + + * Separator.py: Vladimir Marangozov's patch: + The separator dances too much and seems to jump by arbitrary amounts + in arbitrary directions when I try to move it for resizing the frames. + This patch makes it more quiet. + +Mon Jan 11 14:52:40 1999 Guido van Rossum + + * TODO.txt: Some requests have been fulfilled. + + * EditorWindow.py: + Set the cursor to a watch when opening the class browser (which may + take quite a while, browsing multiple files). + + Newer, better center() -- but assumes no wrapping. + + * SearchBinding.py: + Got rid of debug print statement in goto_line_event(). + + * ScriptBinding.py: + I think I like it better if it prints the traceback even when it displays + the stack viewer. + + * Debugger.py: Bind ESC to close-window. + + * ClassBrowser.py: Use a HSeparator between the classes and the items. + Make the list of classes wider by default (40 chars). + Bind ESC to close-window. + + * Separator.py: + Separator classes (draggable divider between two panes). + +Sat Jan 9 22:01:33 1999 Guido van Rossum + + * WindowList.py: + Don't traceback when wakeup() is called when the window has been destroyed. + This can happen when a torn-of Windows menu references closed windows. + And Tim Peters claims that the Windows menu is his favorite to tear off... + + * EditorWindow.py: Allow tearing off of the Windows menu. + + * StackViewer.py: Close on ESC. + + * help.txt: Updated a bunch of things (it was mostly still 0.1!) + + * extend.py: Added ScriptBinding to standard bindings. + + * ScriptBinding.py: + This now actually works. See doc string. It can run a module (i.e. + import or reload) or debug it (same with debugger control). Output + goes to a fresh output window, only created when needed. + +====================================================================== + Python release 1.5.2b1, IDLE version 0.2 +====================================================================== + +Fri Jan 8 17:26:02 1999 Guido van Rossum + + * README.txt, NEWS.txt: What's new in this release. + + * Bindings.py, PyShell.py: + Paul Prescod's patches to allow the stack viewer to pop up when a + traceback is printed. + +Thu Jan 7 00:12:15 1999 Guido van Rossum + + * FormatParagraph.py: + Change paragraph width limit to 70 (like Emacs M-Q). + + * README.txt: + Separating TODO from README. Slight reformulation of features. No + exact release date. + + * TODO.txt: Separating TODO from README. + +Mon Jan 4 21:19:09 1999 Guido van Rossum + + * FormatParagraph.py: + Hm. There was a boundary condition error at the end of the file too. + + * SearchBinding.py: Hm. Add Unix binding for replace, too. + + * keydefs.py: Ran eventparse.py again. + + * FormatParagraph.py: Added Unix Meta-q key binding; + fix find_paragraph when at start of file. + + * AutoExpand.py: Added Meta-/ binding for Unix as alt for Alt-/. + + * SearchBinding.py: + Add unix binding for grep (otherwise the menu entry doesn't work!) + + * ZoomHeight.py: Adjusted Unix height to work with fvwm96. :=( + + * GrepDialog.py: Need to import sys! + + * help.txt, extend.txt, README.txt: Formatted some paragraphs + + * extend.py, FormatParagraph.py: + Add new extension to reformat a (text) paragraph. + + * ZoomHeight.py: Typo in Win specific height setting. + +Sun Jan 3 00:47:35 1999 Guido van Rossum + + * AutoIndent.py: Added something like Tim Peters' backspace patch. + + * ZoomHeight.py: Adapted to Unix (i.e., more hardcoded constants). + +Sat Jan 2 21:28:54 1999 Guido van Rossum + + * keydefs.py, idlever.py, idle.pyw, idle.bat, help.txt, extend.txt, extend.py, eventparse.py, ZoomHeight.py, WindowList.py, UndoDelegator.py, StackViewer.py, SearchEngine.py, SearchDialogBase.py, SearchDialog.py, ScrolledList.py, SearchBinding.py, ScriptBinding.py, ReplaceDialog.py, Attic/README, README.txt, PyShell.py, Attic/PopupMenu.py, OutputWindow.py, IOBinding.py, Attic/HelpWindow.py, History.py, GrepDialog.py, FileList.py, FrameViewer.py, EditorWindow.py, Debugger.py, Delegator.py, ColorDelegator.py, Bindings.py, ClassBrowser.py, AutoExpand.py, AutoIndent.py: + Checking in IDLE 0.2. + + Much has changed -- too much, in fact, to write down. + The big news is that there's a standard way to write IDLE extensions; + see extend.txt. Some sample extensions have been provided, and + some existing code has been converted to extensions. Probably the + biggest new user feature is a new search dialog with more options, + search and replace, and even search in files (grep). + + This is exactly as downloaded from my laptop after returning + from the holidays -- it hasn't even been tested on Unix yet. + +Fri Dec 18 15:52:54 1998 Guido van Rossum + + * FileList.py, ClassBrowser.py: + Fix the class browser to work even when the file is not on sys.path. + +Tue Dec 8 20:39:36 1998 Guido van Rossum + + * Attic/turtle.py: Moved to Python 1.5.2/Lib + +Fri Nov 27 03:19:20 1998 Guido van Rossum + + * help.txt: Typo + + * EditorWindow.py, FileList.py: Support underlining of menu labels + + * Bindings.py: + New approach, separate tables for menus (platform-independent) and key + definitions (platform-specific), and generating accelerator strings + automatically from the key definitions. + +Mon Nov 16 18:37:42 1998 Guido van Rossum + + * Attic/README: Clarify portability and main program. + + * Attic/README: Added intro for 0.1 release and append Grail notes. + +Mon Oct 26 18:49:00 1998 Guido van Rossum + + * Attic/turtle.py: root is now a global called _root + +Sat Oct 24 16:38:38 1998 Guido van Rossum + + * Attic/turtle.py: Raise the root window on reset(). + Different action on WM_DELETE_WINDOW is more likely to do the right thing, + allowing us to destroy old windows. + + * Attic/turtle.py: + Split the goto() function in two: _goto() is the internal one, + using Canvas coordinates, and goto() uses turtle coordinates + and accepts variable argument lists. + + * Attic/turtle.py: Cope with destruction of the window + + * Attic/turtle.py: Turtle graphics + + * Debugger.py: Use of Breakpoint class should be bdb.Breakpoint. + +Mon Oct 19 03:33:40 1998 Guido van Rossum + + * SearchBinding.py: + Speed up the search a bit -- don't drag a mark around... + + * PyShell.py: + Change our special entries from to . + Patch linecache.checkcache() to keep our special entries alive. + Add popup menu to all editor windows to set a breakpoint. + + * Debugger.py: + Use and pass through the 'force' flag to set_dict() where appropriate. + Default source and globals checkboxes to false. + Don't interact in user_return(). + Add primitive set_breakpoint() method. + + * ColorDelegator.py: + Raise priority of 'sel' tag so its foreground (on Windows) will take + priority over text colorization (which on Windows is almost the + same color as the selection background). + + Define a tag and color for breakpoints ("BREAK"). + + * Attic/PopupMenu.py: Disable "Open stack viewer" and "help" commands. + + * StackViewer.py: + Add optional 'force' argument (default 0) to load_dict(). + If set, redo the display even if it's the same dict. + +Fri Oct 16 21:10:12 1998 Guido van Rossum + + * StackViewer.py: Do nothing when loading the same dict as before. + + * PyShell.py: Details for debugger interface. + + * Debugger.py: + Restructured and more consistent. Save checkboxes across instantiations. + + * EditorWindow.py, Attic/README, Bindings.py: + Get rid of conflicting ^X binding. Use ^W. + + * Debugger.py, StackViewer.py: + Debugger can now show local and global variables. + + * Debugger.py: Oops + + * Debugger.py, PyShell.py: Better debugger support (show stack etc). + + * Attic/PopupMenu.py: Follow renames in StackViewer module + + * StackViewer.py: + Rename classes to StackViewer (the widget) and StackBrowser (the toplevel). + + * ScrolledList.py: Add close() method + + * EditorWindow.py: Clarify 'Open Module' dialog text + + * StackViewer.py: Restructured into a browser and a widget. + +Thu Oct 15 23:27:08 1998 Guido van Rossum + + * ClassBrowser.py, ScrolledList.py: + Generalized the scrolled list which is the base for the class and + method browser into a separate class in its own module. + + * Attic/test.py: Cosmetic change + + * Debugger.py: Don't show function name if there is none + +Wed Oct 14 03:43:05 1998 Guido van Rossum + + * Debugger.py, PyShell.py: Polish the Debugger GUI a bit. + Closing it now also does the right thing. + +Tue Oct 13 23:51:13 1998 Guido van Rossum + + * Debugger.py, PyShell.py, Bindings.py: + Ad primitive debugger interface (so far it will step and show you the + source, but it doesn't yet show the stack). + + * Attic/README: Misc + + * StackViewer.py: Whoops -- referenced self.top before it was set. + + * help.txt: Added history and completion commands. + + * help.txt: Updated + + * FileList.py: Add class browser functionality. + + * StackViewer.py: + Add a close() method and bind to WM_DELETE_WINDOW protocol + + * PyShell.py: Clear the linecache before printing a traceback + + * Bindings.py: Added class browser binding. + + * ClassBrowser.py: Much improved, much left to do. + + * PyShell.py: Make the return key do what I mean more often. + + * ClassBrowser.py: + Adding the beginnings of a Class browser. Incomplete, yet. + + * EditorWindow.py, Bindings.py: + Add new command, "Open module". You select or type a module name, + and it opens the source. + +Mon Oct 12 23:59:27 1998 Guido van Rossum + + * PyShell.py: Subsume functionality from Popup menu in Debug menu. + Other stuff so the PyShell window can be resurrected from the Windows menu. + + * FileList.py: Get rid of PopUp menu. + Create a simple Windows menu. (Imperfect when Untitled windows exist.) + Add wakeup() method: deiconify, raise, focus. + + * EditorWindow.py: Generalize menu creation. + + * Bindings.py: Add Debug and Help menu items. + + * EditorWindow.py: Added a menu bar to every window. + + * Bindings.py: Add menu configuration to the event configuration. + + * Attic/PopupMenu.py: Pass a root to the help window. + + * SearchBinding.py: + Add parent argument to 'go to line number' dialog box. + +Sat Oct 10 19:15:32 1998 Guido van Rossum + + * StackViewer.py: + Add a label at the top showing (very basic) help for the stack viewer. + Add a label at the bottom showing the exception info. + + * Attic/test.py, Attic/idle: Add Unix main script and test program. + + * idle.pyw, help.txt, WidgetRedirector.py, UndoDelegator.py, StackViewer.py, SearchBinding.py, Attic/README, PyShell.py, Attic/PopupMenu.py, Percolator.py, Outline.py, IOBinding.py, History.py, Attic/HelpWindow.py, FrameViewer.py, FileList.py, EditorWindow.py, Delegator.py, ColorDelegator.py, Bindings.py, AutoIndent.py, AutoExpand.py: + Initial checking of Tk-based Python IDE. + Features: text editor with syntax coloring and undo; + subclassed into interactive Python shell which adds history. + diff --git a/Lib/idlelib/HISTORY.txt b/Lib/idlelib/HISTORY.txt new file mode 100644 index 00000000000..8bd4e890260 --- /dev/null +++ b/Lib/idlelib/HISTORY.txt @@ -0,0 +1,296 @@ +IDLE History from Python 1.5.2 to 2.1 +===================================== + +This file contains the release messages for previous IDLE releases. +As you read on you go back to the dark ages of IDLE's history. + + +What's New in IDLEfork 0.8.1? +============================= + +*Release date: 22-Jul-2001* + +- New tarball released as a result of the 'revitalisation' of the IDLEfork + project. + +- This release requires python 2.1 or better. Compatibility with earlier + versions of python (especially ancient ones like 1.5x) is no longer a + priority in IDLEfork development. + +- This release is based on a merging of the earlier IDLE fork work with current + cvs IDLE (post IDLE version 0.8), with some minor additional coding by Kurt + B. Kaiser and Stephen M. Gava. + +- This release is basically functional but also contains some known breakages, + particularly with running things from the shell window. Also the debugger is + not working, but I believe this was the case with the previous IDLE fork + release (0.7.1) as well. + +- This release is being made now to mark the point at which IDLEfork is + launching into a new stage of development. + +- IDLEfork CVS will now be branched to enable further development and + exploration of the two "execution in a remote process" patches submitted by + David Scherer (David's is currently in IDLEfork) and GvR, while stabilisation + and development of less heavyweight improvements (like user customisation) + can continue on the trunk. + + +What's New in IDLEfork 0.7.1? +============================== + +*Release date: 15-Aug-2000* + +- First project tarball released. + +- This was the first release of IDLE fork, which at this stage was a + combination of IDLE 0.5 and the VPython idle fork, with additional changes + coded by David Scherer, Peter Schneider-Kamp and Nicholas Riley. + + + +IDLEfork 0.7.1 - 29 May 2000 +----------------------------- + + David Scherer + +- This is a modification of the CVS version of IDLE 0.5, updated as of + 2000-03-09. It is alpha software and might be unstable. If it breaks, you + get to keep both pieces. + +- If you have problems or suggestions, you should either contact me or post to + the idle-dev mailing list (making it clear that you are using this modified + version of IDLE). + +- Changes: + + - The ExecBinding module, a replacement for ScriptBinding, executes programs + in a separate process, piping standard I/O through an RPC mechanism to an + OnDemandOutputWindow in IDLE. It supports executing unnamed programs + (through a temporary file). It does not yet support debugging. + + - When running programs with ExecBinding, tracebacks will be clipped to + exclude system modules. If, however, a system module calls back into the + user program, that part of the traceback will be shown. + + - The OnDemandOutputWindow class has been improved. In particular, it now + supports a readline() function used to implement user input, and a + scroll_clear() operation which is used to hide the output of a previous run + by scrolling it out of the window. + + - Startup behavior has been changed. By default IDLE starts up with just a + blank editor window, rather than an interactive window. Opening a file in + such a blank window replaces the (nonexistent) contents of that window + instead of creating another window. Because of the need to have a + well-known port for the ExecBinding protocol, only one copy of IDLE can be + running. Additional invocations use the RPC mechanism to report their + command line arguments to the copy already running. + + - The menus have been reorganized. In particular, the excessively large + 'edit' menu has been split up into 'edit', 'format', and 'run'. + + - 'Python Documentation' now works on Windows, if the win32api module is + present. + + - A few key bindings have been changed: F1 now loads Python Documentation + instead of the IDLE help; shift-TAB is now a synonym for unindent. + +- New modules: + + ExecBinding.py Executes program through loader + loader.py Bootstraps user program + protocol.py RPC protocol + Remote.py User-process interpreter + spawn.py OS-specific code to start programs + +- Files modified: + + autoindent.py ( bindings tweaked ) + bindings.py ( menus reorganized ) + config.txt ( execbinding enabled ) + editorwindow.py ( new menus, fixed 'Python Documentation' ) + filelist.py ( hook for "open in same window" ) + formatparagraph.py ( bindings tweaked ) + idle.bat ( removed absolute pathname ) + idle.pyw ( weird bug due to import with same name? ) + iobinding.py ( open in same window, EOL convention ) + keydefs.py ( bindings tweaked ) + outputwindow.py ( readline, scroll_clear, etc ) + pyshell.py ( changed startup behavior ) + readme.txt ( ) + + + +IDLE 0.5 - February 2000 - Release Notes +---------------------------------------- + +This is an early release of IDLE, my own attempt at a Tkinter-based +IDE for Python. + +(For a more detailed change log, see the file ChangeLog.) + +FEATURES + +IDLE has the following features: + +- coded in 100% pure Python, using the Tkinter GUI toolkit (i.e. Tcl/Tk) + +- cross-platform: works on Windows and Unix (on the Mac, there are +currently problems with Tcl/Tk) + +- multi-window text editor with multiple undo, Python colorizing +and many other features, e.g. smart indent and call tips + +- Python shell window (a.k.a. interactive interpreter) + +- debugger (not complete, but you can set breakpoints, view and step) + +USAGE + +The main program is in the file "idle.py"; on Unix, you should be able +to run it by typing "./idle.py" to your shell. On Windows, you can +run it by double-clicking it; you can use idle.pyw to avoid popping up +a DOS console. If you want to pass command line arguments on Windows, +use the batch file idle.bat. + +Command line arguments: files passed on the command line are executed, +not opened for editing, unless you give the -e command line option. +Try "./idle.py -h" to see other command line options. + +IDLE requires Python 1.5.2, so it is currently only usable with a +Python 1.5.2 distribution. (An older version of IDLE is distributed +with Python 1.5.2; you can drop this version on top of it.) + +COPYRIGHT + +IDLE is covered by the standard Python copyright notice +(https://www.python.org/doc/copyright). + + +New in IDLE 0.5 (2/15/2000) +--------------------------- + +Tons of stuff, much of it contributed by Tim Peters and Mark Hammond: + +- Status bar, displaying current line/column (Moshe Zadka). + +- Better stack viewer, using tree widget. (XXX Only used by Stack +Viewer menu, not by the debugger.) + +- Format paragraph now recognizes Python block comments and reformats +them correctly (MH) + +- New version of pyclbr.py parses top-level functions and understands +much more of Python's syntax; this is reflected in the class and path +browsers (TP) + +- Much better auto-indent; knows how to indent the insides of +multi-line statements (TP) + +- Call tip window pops up when you type the name of a known function +followed by an open parenthesis. Hit ESC or click elsewhere in the +window to close the tip window (MH) + +- Comment out region now inserts ## to make it stand out more (TP) + +- New path and class browsers based on a tree widget that looks +familiar to Windows users + +- Reworked script running commands to be more intuitive: I/O now +always goes to the *Python Shell* window, and raw_input() works +correctly. You use F5 to import/reload a module: this adds the module +name to the __main__ namespace. You use Control-F5 to run a script: +this runs the script *in* the __main__ namespace. The latter also +sets sys.argv[] to the script name + + +New in IDLE 0.4 (4/7/99) +------------------------ + +Most important change: a new menu entry "File -> Path browser", shows +a 4-column hierarchical browser which lets you browse sys.path, +directories, modules, and classes. Yes, it's a superset of the Class +browser menu entry. There's also a new internal module, +MultiScrolledLists.py, which provides the framework for this dialog. + + +New in IDLE 0.3 (2/17/99) +------------------------- + +Most important changes: + +- Enabled support for running a module, with or without the debugger. +Output goes to a new window. Pressing F5 in a module is effectively a +reload of that module; Control-F5 loads it under the debugger. + +- Re-enable tearing off the Windows menu, and make a torn-off Windows +menu update itself whenever a window is opened or closed. + +- Menu items can now be have a checkbox (when the menu label starts +with "!"); use this for the Debugger and "Auto-open stack viewer" +(was: JIT stack viewer) menu items. + +- Added a Quit button to the Debugger API. + +- The current directory is explicitly inserted into sys.path. + +- Fix the debugger (when using Python 1.5.2b2) to use canonical +filenames for breakpoints, so these actually work. (There's still a +lot of work to be done to the management of breakpoints in the +debugger though.) + +- Closing a window that is still colorizing now actually works. + +- Allow dragging of the separator between the two list boxes in the +class browser. + +- Bind ESC to "close window" of the debugger, stack viewer and class +browser. It removes the selection highlighting in regular text +windows. (These are standard Windows conventions.) + + +New in IDLE 0.2 (1/8/99) +------------------------ + +Lots of changes; here are the highlights: + +General: + +- You can now write and configure your own IDLE extension modules; see +extend.txt. + + +File menu: + +The command to open the Python shell window is now in the File menu. + + +Edit menu: + +New Find dialog with more options; replace dialog; find in files dialog. + +Commands to tabify or untabify a region. + +Command to format a paragraph. + + +Debug menu: + +JIT (Just-In-Time) stack viewer toggle -- if set, the stack viewer +automatically pops up when you get a traceback. + +Windows menu: + +Zoom height -- make the window full height. + + +Help menu: + +The help text now show up in a regular window so you can search and +even edit it if you like. + + + +IDLE 0.1 was distributed with the Python 1.5.2b1 release on 12/22/98. + +====================================================================== diff --git a/Lib/idlelib/Icons/README.txt b/Lib/idlelib/Icons/README.txt new file mode 100644 index 00000000000..e245bc0b26e --- /dev/null +++ b/Lib/idlelib/Icons/README.txt @@ -0,0 +1,51 @@ +IDLE-PYTHON LOGOS + +These are sent to tk on Windows, *NIX, and non-Aqua macOS +in pyshell following "# set application icon". + + +2006?: Andrew Clover made variously sized python icons for win23. +https://www.doxdesk.com/software/py/pyicons.html + +2006: 16, 32, and 48 bit .png versions were copied to CPython +as Python application icons, maybe in PC/icons/py.ico. +https://github.com/python/cpython/issues/43372 + +2014: They were copied (perhaps a bit revised) to idlelib/Icons. +https://github.com/python/cpython/issues/64605 +.gif versions were also added. + +2020: Add Clover's 256-bit image. +https://github.com/python/cpython/issues/82620 +Other fixups were done. + +The idle.ico file used for Windows was created with ImageMagick: + $ convert idle_16.png idle_32.png idle_48.png idle_256.png idle.ico +** This needs redoing whenever files are changed. +?? Do Start, Desktop, and Taskbar use idlelib/Icons files? + +Issue added Windows Store PC/icons/idlex44.png and .../idlex150.png. +https://github.com/python/cpython/pull/22817 +?? Should these be updated with major changes? + +2022: Optimize .png images in CPython repository with external program. +https://github.com/python/cpython/pull/21348 +idle.ico (and idlex##) were not updated. + +The idlexx.gif files are only needed for *nix running tcl/tk 8.5. +As of 2022, this was known true for 1 'major' Linux distribution. +(Same would be true for any non-Aqua macOS with 8.5, but now none?) +Can be deleted when we require 8.6 or it is known always used. + +Future: Derivatives of Python logo should be submitted for approval. +PSF Trademark Working Group / Committee psf-trademarks@python.org +https://www.python.org/community/logos/ # Original files +https://www.python.org/psf/trademarks-faq/ +https://www.python.org/psf/trademarks/ # Usage. + + +OTHER GIFS: These are used by browsers using idlelib.tree. +At least some will not be used when tree is replaced by ttk.Treeview. + + +Edited 2024 August 26 by TJR. diff --git a/Lib/idlelib/Icons/folder.gif b/Lib/idlelib/Icons/folder.gif new file mode 100644 index 00000000000..effe8dc8a00 Binary files /dev/null and b/Lib/idlelib/Icons/folder.gif differ diff --git a/Lib/idlelib/Icons/idle.ico b/Lib/idlelib/Icons/idle.ico new file mode 100644 index 00000000000..2aa9a8300d9 Binary files /dev/null and b/Lib/idlelib/Icons/idle.ico differ diff --git a/Lib/idlelib/Icons/idle_16.gif b/Lib/idlelib/Icons/idle_16.gif new file mode 100644 index 00000000000..bcedcd64003 Binary files /dev/null and b/Lib/idlelib/Icons/idle_16.gif differ diff --git a/Lib/idlelib/Icons/idle_16.png b/Lib/idlelib/Icons/idle_16.png new file mode 100644 index 00000000000..77d8f711804 Binary files /dev/null and b/Lib/idlelib/Icons/idle_16.png differ diff --git a/Lib/idlelib/Icons/idle_256.png b/Lib/idlelib/Icons/idle_256.png new file mode 100644 index 00000000000..84ffdfddc6f Binary files /dev/null and b/Lib/idlelib/Icons/idle_256.png differ diff --git a/Lib/idlelib/Icons/idle_32.gif b/Lib/idlelib/Icons/idle_32.gif new file mode 100644 index 00000000000..a1b12a0531d Binary files /dev/null and b/Lib/idlelib/Icons/idle_32.gif differ diff --git a/Lib/idlelib/Icons/idle_32.png b/Lib/idlelib/Icons/idle_32.png new file mode 100644 index 00000000000..2aaa55805ed Binary files /dev/null and b/Lib/idlelib/Icons/idle_32.png differ diff --git a/Lib/idlelib/Icons/idle_48.gif b/Lib/idlelib/Icons/idle_48.gif new file mode 100644 index 00000000000..fc5304f31ee Binary files /dev/null and b/Lib/idlelib/Icons/idle_48.gif differ diff --git a/Lib/idlelib/Icons/idle_48.png b/Lib/idlelib/Icons/idle_48.png new file mode 100644 index 00000000000..705eec42e8a Binary files /dev/null and b/Lib/idlelib/Icons/idle_48.png differ diff --git a/Lib/idlelib/Icons/minusnode.gif b/Lib/idlelib/Icons/minusnode.gif new file mode 100644 index 00000000000..173e9709591 Binary files /dev/null and b/Lib/idlelib/Icons/minusnode.gif differ diff --git a/Lib/idlelib/Icons/openfolder.gif b/Lib/idlelib/Icons/openfolder.gif new file mode 100644 index 00000000000..24aea1bebe8 Binary files /dev/null and b/Lib/idlelib/Icons/openfolder.gif differ diff --git a/Lib/idlelib/Icons/plusnode.gif b/Lib/idlelib/Icons/plusnode.gif new file mode 100644 index 00000000000..abedde047f2 Binary files /dev/null and b/Lib/idlelib/Icons/plusnode.gif differ diff --git a/Lib/idlelib/Icons/python.gif b/Lib/idlelib/Icons/python.gif new file mode 100644 index 00000000000..01b13e35834 Binary files /dev/null and b/Lib/idlelib/Icons/python.gif differ diff --git a/Lib/idlelib/Icons/tk.gif b/Lib/idlelib/Icons/tk.gif new file mode 100644 index 00000000000..03bf3832abb Binary files /dev/null and b/Lib/idlelib/Icons/tk.gif differ diff --git a/Lib/idlelib/NEWS2x.txt b/Lib/idlelib/NEWS2x.txt new file mode 100644 index 00000000000..3721193007e --- /dev/null +++ b/Lib/idlelib/NEWS2x.txt @@ -0,0 +1,660 @@ +What's New in IDLE 2.7? (Merged into 3.1 before 2.7 release.) +======================= +*Release date: 03-Jul-2010* + +- idle.py modified and simplified to better support developing experimental + versions of IDLE which are not installed in the standard location. + +- OutputWindow/PyShell right click menu "Go to file/line" wasn't working with + file paths containing spaces. Bug 5559. + +- Windows: Version string for the .chm help file changed, file not being + accessed Patch 5783 Guilherme Polo + +- Allow multiple IDLE GUI/subprocess pairs to exist simultaneously. Thanks to + David Scherer for suggesting the use of an ephemeral port for the GUI. + Patch 1529142 Weeble. + +- Remove port spec from run.py and fix bug where subprocess fails to + extract port from command line when warnings are present. + +- Tk 8.5 Text widget requires 'wordprocessor' tabstyle attr to handle + mixed space/tab properly. Issue 5129, patch by Guilherme Polo. + +- Issue #3549: On MacOS the preferences menu was not present + +- IDLE would print a "Unhandled server exception!" message when internal + debugging is enabled. + +- Issue #4455: IDLE failed to display the windows list when two windows have + the same title. + +- Issue #4383: When IDLE cannot make the connection to its subprocess, it would + fail to properly display the error message. + +- help() was not paging to the shell. Issue1650. + +- CodeContext was not importing. + +- Corrected two 3.0 compatibility errors reported by Mark Summerfield: + http://mail.python.org/pipermail/python-3000/2007-December/011491.html + +- Shell was not colorizing due to bug introduced at r57998, Bug 1586. + +- Issue #1585: IDLE uses non-existent xrange() function. + +- Windows EOL sequence not converted correctly, encoding error. + Caused file save to fail. Bug 1130. + +- IDLE converted to Python 3000 syntax. + +- Strings became Unicode. + +- CallTips module now uses the inspect module to produce the argspec. + +- IDLE modules now use absolute import instead of implied relative import. + +- atexit call replaces sys.exitfunc. The functionality of delete-exitfunc flag + in config-main.cfg remains unchanged: if set, registered exit functions will + be cleared before IDLE exits. + + +What's New in IDLE 2.6 +====================== +*Release date: 01-Oct-2008*, merged into 3.0 releases detailed above (3.0rc2) + +- Issue #2665: On Windows, an IDLE installation upgraded from an old version + would not start if a custom theme was defined. + +- Home / Control-A toggles between left margin and end of leading white + space. issue1196903, patch by Jeff Shute. + +- Improved AutoCompleteWindow logic. issue2062, patch by Tal Einat. + +- Autocompletion of filenames now support alternate separators, e.g. the + '/' char on Windows. issue2061 Patch by Tal Einat. + +- Configured selection highlighting colors were ignored; updating highlighting + in the config dialog would cause non-Python files to be colored as if they + were Python source; improve use of ColorDelagator. Patch 1334. Tal Einat. + +- ScriptBinding event handlers weren't returning 'break'. Patch 2050, Tal Einat + +- There was an error on exit if no sys.exitfunc was defined. Issue 1647. + +- Could not open files in .idlerc directory if latter was hidden on Windows. + Issue 1743, Issue 1862. + +- Configure Dialog: improved layout for keybinding. Patch 1457 Tal Einat. + +- tabpage.py updated: tabbedPages.py now supports multiple dynamic rows + of tabs. Patch 1612746 Tal Einat. + +- Add confirmation dialog before printing. Patch 1717170 Tal Einat. + +- Show paste position if > 80 col. Patch 1659326 Tal Einat. + +- Update cursor color without restarting. Patch 1725576 Tal Einat. + +- Allow keyboard interrupt only when user code is executing in subprocess. + Patch 1225 Tal Einat (reworked from IDLE-Spoon). + +- configDialog cleanup. Patch 1730217 Tal Einat. + +- textView cleanup. Patch 1718043 Tal Einat. + +- Clean up EditorWindow close. + +- Patch 1693258: Fix for duplicate "preferences" menu-OS X. Backport of r56204. + +- OSX: Avoid crash for those versions of Tcl/Tk which don't have a console + +- Bug in idlelib.MultiCall: Options dialog was crashing IDLE if there was an + option in config-extensions w/o a value. Patch #1672481, Tal Einat + +- Corrected some bugs in AutoComplete. Also, Page Up/Down in ACW implemented; + mouse and cursor selection in ACWindow implemented; double Tab inserts + current selection and closes ACW (similar to double-click and Return); scroll + wheel now works in ACW. Added AutoComplete instructions to IDLE Help. + +- AutoCompleteWindow moved below input line, will move above if there + isn't enough space. Patch 1621265 Tal Einat + +- Calltips now 'handle' tuples in the argument list (display '' :) + Suggested solution by Christos Georgiou, Bug 791968. + +- Add 'raw' support to configHandler. Patch 1650174 Tal Einat. + +- Avoid hang when encountering a duplicate in a completion list. Bug 1571112. + +- Patch #1362975: Rework CodeContext indentation algorithm to + avoid hard-coding pixel widths. + +- Bug #813342: Start the IDLE subprocess with -Qnew if the parent + is started with that option. + +- Honor the "Cancel" action in the save dialog (Debian bug #299092) + +- Some syntax errors were being caught by tokenize during the tabnanny + check, resulting in obscure error messages. Do the syntax check + first. Bug 1562716, 1562719 + +- IDLE's version number takes a big jump to match the version number of + the Python release of which it's a part. + + +What's New in IDLE 1.2? +======================= +*Release date: 19-SEP-2006* + +- File menu hotkeys: there were three 'p' assignments. Reassign the + 'Save Copy As' and 'Print' hotkeys to 'y' and 't'. Change the + Shell hotkey from 's' to 'l'. + +- IDLE honors new quit() and exit() commands from site.py Quitter() object. + Patch 1540892, Jim Jewett + +- The 'with' statement is now a Code Context block opener. + Patch 1540851, Jim Jewett + +- Retrieval of previous shell command was not always preserving indentation + (since 1.2a1) Patch 1528468 Tal Einat. + +- Changing tokenize (39046) to detect dedent broke tabnanny check (since 1.2a1) + +- ToggleTab dialog was setting indent to 8 even if cancelled (since 1.2a1). + +- When used w/o subprocess, all exceptions were preceded by an error + message claiming they were IDLE internal errors (since 1.2a1). + +- Bug #1525817: Don't truncate short lines in IDLE's tool tips. + +- Bug #1517990: IDLE keybindings on MacOS X now work correctly + +- Bug #1517996: IDLE now longer shows the default Tk menu when a + path browser, class browser or debugger is the frontmost window on MacOS X + +- EditorWindow.test() was failing. Bug 1417598 + +- EditorWindow failed when used stand-alone if sys.ps1 not set. + Bug 1010370 Dave Florek + +- Tooltips failed on new-syle class __init__ args. Bug 1027566 Loren Guthrie + +- Avoid occasional failure to detect closing paren properly. + Patch 1407280 Tal Einat + +- Rebinding Tab key was inserting 'tab' instead of 'Tab'. Bug 1179168. + +- Colorizer now handles # correctly, also unicode strings and + 'as' keyword in comment directly following import command. Closes 1325071. + Patch 1479219 Tal Einat + +- Patch #1162825: Support non-ASCII characters in IDLE window titles. + +- Source file f.flush() after writing; trying to avoid lossage if user + kills GUI. + +- Options / Keys / Advanced dialog made functional. Also, allow binding + of 'movement' keys. + +- 'syntax' patch adds improved calltips and a new class attribute listbox. + MultiCall module allows binding multiple actions to an event. + Patch 906702 Noam Raphael + +- Better indentation after first line of string continuation. + IDLEfork Patch 681992, Noam Raphael + +- Fixed CodeContext alignment problem, following suggestion from Tal Einat. + +- Increased performance in CodeContext extension Patch 936169 Noam Raphael + +- Mac line endings were incorrect when pasting code from some browsers + when using X11 and the Fink distribution. Python Bug 1263656. + +- when cursor is on a previous command retrieves that command. Instead + of replacing the input line, the previous command is now appended to the + input line. Indentation is preserved, and undo is enabled. + Patch 1196917 Jeff Shute + +- Clarify "tab/space" Error Dialog and "Tab Width" Dialog associated with + the Untabify command. + +- Corrected "tab/space" Error Dialog to show correct menu for Untabify. + Patch 1196980 Jeff Shute + +- New files are colorized by default, and colorizing is removed when + saving as non-Python files. Patch 1196895 Jeff Shute + Closes Python Bugs 775012 and 800432, partial fix IDLEfork 763524 + +- Improve subprocess link error notification. + +- run.py: use Queue's blocking feature instead of sleeping in the main + loop. Patch # 1190163 Michiel de Hoon + +- Add config-main option to make the 'history' feature non-cyclic. + Default remains cyclic. Python Patch 914546 Noam Raphael. + +- Removed ability to configure tabs indent from Options dialog. This 'feature' + has never worked and no one has complained. It is still possible to set a + default tabs (v. spaces) indent 'manually' via config-main.def (or to turn on + tabs for the current EditorWindow via the Format menu) but IDLE will + encourage indentation via spaces. + +- Enable setting the indentation width using the Options dialog. + Bug # 783877 + +- Add keybindings for del-word-left and del-word-right. + +- Discourage using an indent width other than 8 when using tabs to indent + Python code. + +- Restore use of EditorWindow.set_indentation_params(), was dead code since + Autoindent was merged into EditorWindow. This allows IDLE to conform to the + indentation width of a loaded file. (But it still will not switch to tabs + even if the file uses tabs.) Any change in indent width is local to that + window. + +- Add Tabnanny check before Run/F5, not just when Checking module. + +- If an extension can't be loaded, print warning and skip it instead of + erroring out. + +- Improve error handling when .idlerc can't be created (warn and exit). + +- The GUI was hanging if the shell window was closed while a raw_input() + was pending. Restored the quit() of the readline() mainloop(). + http://mail.python.org/pipermail/idle-dev/2004-December/002307.html + +- The remote procedure call module rpc.py can now access data attributes of + remote registered objects. Changes to these attributes are local, however. + + +What's New in IDLE 1.1? +======================= +*Release date: 30-NOV-2004* + +- On OpenBSD, terminating IDLE with ctrl-c from the command line caused a + stuck subprocess MainThread because only the SocketThread was exiting. + +- Saving a Keyset w/o making changes (by using the "Save as New Custom Key Set" + button) caused IDLE to fail on restart (no new keyset was created in + config-keys.cfg). Also true for Theme/highlights. Python Bug 1064535. + +- A change to the linecache.py API caused IDLE to exit when an exception was + raised while running without the subprocess (-n switch). Python Bug 1063840. + +- When paragraph reformat width was made configurable, a bug was + introduced that caused reformatting of comment blocks to ignore how + far the block was indented, effectively adding the indentation width + to the reformat width. This has been repaired, and the reformat + width is again a bound on the total width of reformatted lines. + +- Improve keyboard focus binding, especially in Windows menu. Improve + window raising, especially in the Windows menu and in the debugger. + IDLEfork 763524. + +- If user passes a non-existent filename on the commandline, just + open a new file, don't raise a dialog. IDLEfork 854928. + +- EditorWindow.py was not finding the .chm help file on Windows. Typo + at Rev 1.54. Python Bug 990954 + +- checking sys.platform for substring 'win' was breaking IDLE docs on Mac + (darwin). Also, Mac Safari browser requires full file:// URIs. SF 900580. + +- Redirect the warning stream to the shell during the ScriptBinding check of + user code and format the warning similarly to an exception for both that + check and for runtime warnings raised in the subprocess. + +- CodeContext hint pane visibility state is now persistent across sessions. + The pane no longer appears in the shell window. Added capability to limit + extensions to shell window or editor windows. Noam Raphael addition + to Patch 936169. + +- Paragraph reformat width is now a configurable parameter in the + Options GUI. + +- New Extension: CodeContext. Provides block structuring hints for code + which has scrolled above an edit window. Patch 936169 Noam Raphael. + +- If nulls somehow got into the strings in recent-files.lst + EditorWindow.update_recent_files_list() was failing. Python Bug 931336. + +- If the normal background is changed via Configure/Highlighting, it will + update immediately, thanks to the previously mentioned patch by Nigel Rowe. + +- Add a highlight theme for builtin keywords. Python Patch 805830 Nigel Rowe + This also fixed IDLEfork bug [ 693418 ] Normal text background color not + refreshed and Python bug [897872 ] Unknown color name on HP-UX + +- rpc.py:SocketIO - Large modules were generating large pickles when downloaded + to the execution server. The return of the OK response from the subprocess + initialization was interfering and causing the sending socket to be not + ready. Add an IO ready test to fix this. Moved the polling IO ready test + into pollpacket(). + +- Fix typo in rpc.py, s/b "pickle.PicklingError" not "pickle.UnpicklingError". + +- Added a Tk error dialog to run.py inform the user if the subprocess can't + connect to the user GUI process. Added a timeout to the GUI's listening + socket. Added Tk error dialogs to PyShell.py to announce a failure to bind + the port or connect to the subprocess. Clean up error handling during + connection initiation phase. This is an update of Python Patch 778323. + +- Print correct exception even if source file changed since shell was + restarted. IDLEfork Patch 869012 Noam Raphael + +- Keybindings with the Shift modifier now work correctly. So do bindings which + use the Space key. Limit unmodified user keybindings to the function keys. + Python Bug 775353, IDLEfork Bugs 755647, 761557 + +- After an exception, run.py was not setting the exception vector. Noam + Raphael suggested correcting this so pdb's postmortem pm() would work. + IDLEfork Patch 844675 + +- IDLE now does not fail to save the file anymore if the Tk buffer is not a + Unicode string, yet eol_convention is. Python Bugs 774680, 788378 + +- IDLE didn't start correctly when Python was installed in "Program Files" on + W2K and XP. Python Bugs 780451, 784183 + +- config-main.def documentation incorrectly referred to idle- instead of + config- filenames. SF 782759 Also added note about .idlerc location. + + +What's New in IDLE 1.0? +======================= +*Release date: 29-Jul-2003* + +- Added a banner to the shell discussing warnings possibly raised by personal + firewall software. Added same comment to README.txt. + +- Calltip error when docstring was None Python Bug 775541 + +- Updated extend.txt, help.txt, and config-extensions.def to correctly + reflect the current status of the configuration system. Python Bug 768469 + +- Fixed: Call Tip Trimming May Loop Forever. Python Patch 769142 (Daniels) + +- Replaced apply(f, args, kwds) with f(*args, **kwargs) to improve performance + Python Patch 768187 + +- Break or continue statements outside a loop were causing IDLE crash + Python Bug 767794 + +- Convert Unicode strings from readline to IOBinding.encoding. Also set + sys.std{in|out|err}.encoding, for both the local and the subprocess case. + SF IDLEfork patch 682347. + +- Extend AboutDialog.ViewFile() to support file encodings. Make the CREDITS + file Latin-1. + +- Updated the About dialog to reflect re-integration into Python. Provide + buttons to display Python's NEWS, License, and Credits, plus additional + buttons for IDLE's README and NEWS. + +- TextViewer() now has a third parameter which allows inserting text into the + viewer instead of reading from a file. + +- (Created the .../Lib/idlelib directory in the Python CVS, which is a clone of + IDLEfork modified to install in the Python environment. The code in the + interrupt module has been moved to thread.interrupt_main(). ) + +- Printing the Shell window was failing if it was not saved first SF 748975 + +- When using the Search in Files dialog, if the user had a selection + highlighted in his Editor window, insert it into the dialog search field. + +- The Python Shell entry was disappearing from the Windows menu. + +- Update the Windows file list when a file name change occurs + +- Change to File / Open Module: always pop up the dialog, using the current + selection as the default value. This is easier to use habitually. + +- Avoided a problem with starting the subprocess when 'localhost' doesn't + resolve to the user's loopback interface. SF 747772 + +- Fixed an issue with highlighted errors never de-colorizing. SF 747677. Also + improved notification of Tabnanny Token Error. + +- File / New will by default save in the directory of the Edit window from + which it was initiated. SF 748973 Guido van Rossum patch. + + +What's New in IDLEfork 0.9b1? +============================= +*Release date: 02-Jun-2003* + +- The current working directory of the execution environment (and shell + following completion of execution) is now that of the module being run. + +- Added the delete-exitfunc option to config-main.def. (This option is not + included in the Options dialog.) Setting this to True (the default) will + cause IDLE to not run sys.exitfunc/atexit when the subprocess exits. + +- IDLE now preserves the line ending codes when editing a file produced on + a different platform. SF 661759, SF 538584 + +- Reduced default editor font size to 10 point and increased window height + to provide a better initial impression on Windows. + +- Options / Fonts/Tabs / Set Base Editor Font: List box was not highlighting + the default font when first installed on Windows. SF 661676 + +- Added Autosave feature: when user runs code from edit window, if the file + has been modified IDLE will silently save it if Autosave is enabled. The + option is set in the Options dialog, and the default is to prompt the + user to save the file. SF 661318 Bruce Sherwood patch. + +- Improved the RESTART annotation in the shell window when the user restarts + the shell while it is generating output. Also improved annotation when user + repeatedly hammers the Ctrl-F6 restart. + +- Allow IDLE to run when not installed and cwd is not the IDLE directory + SF Patch 686254 "Run IDLEfork from any directory without set-up" - Raphael + +- When a module is run from an EditorWindow: if its directory is not in + sys.path, prepend it. This allows the module to import other modules in + the same directory. Do the same for a script run from the command line. + +- Correctly restart the subprocess if it is running user code and the user + attempts to run some other module or restarts the shell. Do the same if + the link is broken and it is possible to restart the subprocess and re- + connect to the GUI. SF RFE 661321. + +- Improved exception reporting when running commands or scripts from the + command line. + +- Added a -n command line switch to start IDLE without the subprocess. + Removed the Shell menu when running in that mode. Updated help messages. + +- Added a comment to the shell startup header to indicate when IDLE is not + using the subprocess. + +- Restore the ability to run without the subprocess. This can be important for + some platforms or configurations. (Running without the subprocess allows the + debugger to trace through parts of IDLE itself, which may or may not be + desirable, depending on your point of view. In addition, the traditional + reload/import tricks must be use if user source code is changed.) This is + helpful for developing IDLE using IDLE, because one instance can be used to + edit the code and a separate instance run to test changes. (Multiple + concurrent IDLE instances with subprocesses is a future feature) + +- Improve the error message a user gets when saving a file with non-ASCII + characters and no source encoding is specified. Done by adding a dialog + 'EncodingMessage', which contains the line to add in a fixed-font entry + widget, and which has a button to add that line to the file automatically. + Also, add a configuration option 'EditorWindow/encoding', which has three + possible values: none, utf-8, and locale. None is the default: IDLE will show + this dialog when non-ASCII characters are encountered. utf-8 means that files + with non-ASCII characters are saved as utf-8-with-bom. locale means that + files are saved in the locale's encoding; the dialog is only displayed if the + source contains characters outside the locale's charset. SF 710733 - Loewis + +- Improved I/O response by tweaking the wait parameter in various + calls to signal.signal(). + +- Implemented a threaded subprocess which allows interrupting a pass + loop in user code using the 'interrupt' extension. User code runs + in MainThread, while the RPCServer is handled by SockThread. This is + necessary because Windows doesn't support signals. + +- Implemented the 'interrupt' extension module, which allows a subthread + to raise a KeyboardInterrupt in the main thread. + +- Attempting to save the shell raised an error related to saving + breakpoints, which are not implemented in the shell + +- Provide a correct message when 'exit' or 'quit' are entered at the + IDLE command prompt SF 695861 + +- Eliminate extra blank line in shell output caused by not flushing + stdout when user code ends with an unterminated print. SF 695861 + +- Moved responsibility for exception formatting (i.e. pruning IDLE internal + calls) out of rpc.py into the client and server. + +- Exit IDLE cleanly even when doing subprocess I/O + +- Handle subprocess interrupt with an RPC message. + +- Restart the subprocess if it terminates itself. (VPython programs do that) + +- Support subclassing of exceptions, including in the shell, by moving the + exception formatting to the subprocess. + + +What's New in IDLEfork 0.9 Alpha 2? +=================================== +*Release date: 27-Jan-2003* + +- Updated INSTALL.txt to claify use of the python2 rpm. + +- Improved formatting in IDLE Help. + +- Run menu: Replace "Run Script" with "Run Module". + +- Code encountering an unhandled exception under the debugger now shows + the correct traceback, with IDLE internal levels pruned out. + +- If an exception occurs entirely in IDLE, don't prune the IDLE internal + modules from the traceback displayed. + +- Class Browser and Path Browser now use Alt-Key-2 for vertical zoom. + +- IDLE icons will now install correctly even when setup.py is run from the + build directory + +- Class Browser now compatible with Python2.3 version of pyclbr.py + +- Left cursor move in presence of selected text now moves from left end + of the selection. + +- Add Meta keybindings to "IDLE Classic Windows" to handle reversed + Alt/Meta on some Linux distros. + +- Change default: IDLE now starts with Python Shell. + +- Removed the File Path from the Additional Help Sources scrolled list. + +- Add capability to access Additional Help Sources on the web if the + Help File Path begins with //http or www. (Otherwise local path is + validated, as before.) + +- Additional Help Sources were not being posted on the Help menu in the + order entered. Implement sorting the list by [HelpFiles] 'option' + number. + +- Add Browse button to New Help Source dialog. Arrange to start in + Python/Doc if platform is Windows, otherwise start in current directory. + +- Put the Additional Help Sources directly on the Help menu instead of in + an Extra Help cascade menu. Rearrange the Help menu so the Additional + Help Sources come last. Update help.txt appropriately. + +- Fix Tk root pop-ups in configSectionNameDialog.py and configDialog.py + +- Uniform capitalization in General tab of ConfigDialog, update the doc string. + +- Fix bug in ConfigDialog where SaveAllChangedConfig() was unexpectedly + deleting Additional Help Sources from the user's config file. + +- Make configHelpSourceEdit OK button the default and bind + +- Fix Tk root pop-ups in configHelpSourceEdit: error dialogs not attached + to parents. + +- Use os.startfile() to open both Additional Help and Python Help on the + Windows platform. The application associated with the file type will act as + the viewer. Windows help files (.chm) are now supported via the + Settings/General/Additional Help facility. + +- If Python Help files are installed locally on Linux, use them instead of + accessing python.org. + +- Make the methods for finding the Python help docs more robust, and make + them work in the installed configuration, also. + +- On the Save Before Run dialog, make the OK button the default. One + less mouse action! + +- Add a method: EditorWindow.get_geometry() for future use in implementing + window location persistence. + +- Removed the "Help/Advice" menu entry. Thanks, David! We'll remember! + +- Change the "Classic Windows" theme's paste key to be . + +- Rearrange the Shell menu to put Stack Viewer entries adjacent. + +- Add the ability to restart the subprocess interpreter from the shell window; + add an associated menu entry "Shell/Restart" with binding Control-F6. Update + IDLE help. + +- Upon a restart, annotate the shell window with a "restart boundary". Add a + shell window menu "Shell/View Restart" with binding F6 to jump to the most + recent restart boundary. + +- Add Shell menu to Python Shell; change "Settings" to "Options". + +- Remove incorrect comment in setup.py: IDLEfork is now installed as a package. + +- Add INSTALL.txt, HISTORY.txt, NEWS.txt to installed configuration. + +- In installer text, fix reference to Visual Python, should be VPython. + Properly credit David Scherer. + +- Modified idle, idle.py, idle.pyw to improve exception handling. + + +What's New in IDLEfork 0.9 Alpha 1? +=================================== +*Release date: 31-Dec-2002* + +- First release of major new functionality. For further details refer to + Idle-dev and/or the Sourceforge CVS. + +- Adapted to the Mac platform. + +- Overhauled the IDLE startup options and revised the idle -h help message, + which provides details of command line usage. + +- Multiple bug fixes and usability enhancements. + +- Introduced the new RPC implementation, which includes a debugger. The output + of user code is to the shell, and the shell may be used to inspect the + environment after the run has finished. (In version 0.8.1 the shell + environment was separate from the environment of the user code.) + +- Introduced the configuration GUI and a new About dialog. + +- Removed David Scherer's Remote Procedure Call code and replaced with Guido + van Rossum's. GvR code has support for the IDLE debugger and uses the shell + to inspect the environment of code Run from an Edit window. Files removed: + ExecBinding.py, loader.py, protocol.py, Remote.py, spawn.py + +-------------------------------------------------------------------- +Refer to HISTORY.txt for additional information on earlier releases. +-------------------------------------------------------------------- diff --git a/Lib/idlelib/News3.txt b/Lib/idlelib/News3.txt new file mode 100644 index 00000000000..da3a1458111 --- /dev/null +++ b/Lib/idlelib/News3.txt @@ -0,0 +1,1415 @@ +What's New in IDLE 3.14.z +(since 3.14.0) +Released after 2025-10-07 +========================= + + +gh-143774: Better explain the operation of Format / Format Paragraph. +Patch by Terry J. Reedy. + +gh-139742: Colorize t-string prefixes for template strings in IDLE, +as done for f-string prefixes. Patch by Anuradha Agrawal. + +What's New in IDLE 3.14.0 +(since 3.13.0) +Released on 2025-10-07 +========================= + +gh-129873: Simplify displaying the IDLE doc by only copying the text +section of idle.html to idlelib/help.html. Patch by Stan Ulbrych. + +gh-112936: IDLE - Include Shell menu in single-process mode, +though with Restart Shell and View Last Restart disabled. +Patch by Zhikang Yan. + +gh-112938: IDLE - Fix uninteruptable hang when Shell gets +rapid continuous output. + +gh-127060: Set TERM environment variable to 'dumb' to not add ANSI escape +sequences for text color in tracebacks. IDLE does not understand them. +Patch by Victor Stinner. + +gh-122392: Increase currently inadequate vertical spacing for the IDLE +browsers (path, module, and stack) on high-resolution monitors. + + +What's New in IDLE 3.13.0 +(since 3.12.0) +Released on 2024-10-07 +========================= + +gh-120104: Fix padding in config and search dialog windows in IDLE. + +gh-120083: Add explicit black IDLE Hovertip foreground color needed for +recent macOS. Fixes Sonoma showing unreadable white on pale yellow. +Patch by John Riggles. + +gh-122482: Change About IDLE to direct users to discuss.python.org +instead of the now unused idle-dev email and mailing list. + +gh-78889: Stop Shell freezes by blocking user access to non-method +sys.stdout.shell attributes, which are all private. + +gh-78955: Use user-selected color theme for Help => IDLE Doc. + +gh-96905: In idlelib code, stop redefining built-ins 'dict' and 'object'. + +gh-72284: Improve the lists of features, editor key bindings, +and shell key bingings in the IDLE doc. + +gh-113903: Fix rare failure of test.test_idle, in test_configdialog. + +gh-113729: Fix the "Help -> IDLE Doc" menu bug in 3.11.7 and 3.12.1. + +gh-57795: Enter selected text into the Find box when opening +a Replace dialog. Patch by Roger Serwy and Zackery Spytz. + +gh-113269: Fix test_editor hang on macOS Catalina. +Patch by Terry Reedy. + +gh-112939: Fix processing unsaved files when quitting IDLE on macOS. +Patch by Ronald Oussoren and Christopher Chavez. + +gh-79871: Add docstrings to debugger.py. Fix two bugs in +test_debugger and expand coverage by 47%. Patch by Anthony Shaw. + + +What's New in IDLE 3.12.0 +(since 3.11.0) +Released on 2023-10-02 +========================= + +gh-104719: Remove IDLE's modification of tokenize.tabsize and test +other uses of tokenize data and methods. + +gh-104499: Fix completions for Tk Aqua 8.7 (currently blank). + +gh-104486: Make About print both tcl and tk versions if different, +as is expected someday. + +gh-88496 Fix IDLE test hang on macOS. + +gh-103314 Support sys.last_exc after exceptions in Shell. +Patch by Irit Katriel. + + +What's New in IDLE 3.11.0 +(since 3.10.0) +Released on 2022-10-24 +========================= + +gh-97527: Fix a bug in the previous bugfix that caused IDLE to not +start when run with 3.10.8, 3.12.0a1, and at least Microsoft Python +3.10.2288.0 installed without the Lib/test package. 3.11.0 was never +affected. + +gh-65802: Document handling of extensions in Save As dialogs. + +gh-95191: Include prompts when saving Shell (interactive input/output). + +gh-95511: Fix the Shell context menu copy-with-prompts bug of copying +an extra line when one selects whole lines. + +gh-95471: Tweak Edit menu. Move 'Select All' above 'Cut' as it is used +with 'Cut' and 'Copy' but not 'Paste'. Add a separator between 'Replace' +and 'Go to Line' to help IDLE issue triagers. + +gh-95411: Enable using IDLE's module browser with .pyw files. + +gh-89610: Add .pyi as a recognized extension for IDLE on macOS. This allows +opening stub files by double clicking on them in the Finder. + +bpo-28950: Apply IDLE syntax highlighting to `.pyi` files. Add util.py +for common components. Patch by Alex Waygood and Terry Jan Reedy. + +bpo-46630: Make query dialogs on Windows start with a cursor in the +entry box. + +bpo-46591: Make the IDLE doc URL on the About IDLE dialog clickable. + +bpo-45296: Clarify close, quit, and exit in IDLE. In the File menu, +'Close' and 'Exit' are now 'Close Window' (the current one) and 'Exit' +is now 'Exit IDLE' (by closing all windows). In Shell, 'quit()' and +'exit()' mean 'close Shell'. If there are no other windows, +this also exits IDLE. + +bpo-45495: Add context keywords 'case' and 'match' to completions list. + +bpo-45296: On Windows, change exit/quit message to suggest Ctrl-D, which +works, instead of , which does not work in IDLE. + + +What's New in IDLE 3.10.0 +(since 3.9.0) +Released on 2021-10-04 +========================= + +bpo-45193: Make completion boxes appear on Ubuntu again. + +bpo-40128: Mostly fix completions on macOS when not using tcl/tk 8.6.11 +(as with 3.9). + +bpo-33962: Move the indent space setting from the Font tab to the new Windows +tab. Patch by Mark Roseman and Terry Jan Reedy. + +bpo-40468: Split the settings dialog General tab into Windows and Shell/Ed +tabs. Move help sources, which extend the Help menu, to the Extensions tab. +Make space for new options and shorten the dialog. The latter makes the +dialog better fit small screens. + +bpo-44010: Highlight the new match statement's soft keywords: match, case, +and _. This highlighting is not perfect and will be incorrect in some rare +cases, especially for some _s in case patterns. + +bpo-44026: Include interpreter's typo fix suggestions in message line +for NameErrors and AttributeErrors. Patch by E. Paine. + +bpo-41611: Avoid occasional uncaught exceptions and freezing when using +completions on macOS. + +bpo-37903: Add mouse actions to the shell sidebar. Left click and +optional drag selects one or more lines of text, as with the +editor line number sidebar. Right click after selecting text lines +displays a context menu with 'copy with prompts'. This zips together +prompts from the sidebar with lines from the selected text. This option +also appears on the context menu for the text. + +bpo-43981: Fix reference leaks in test_sidebar and test_squeezer. +Patches by Terry Jan Reedy and Pablo Galindo + +bpo-37892: Change Shell input indents from tabs to spaces. Shell input +now 'looks right'. Making this feasible motivated the shell sidebar. + +bpo-37903: Move the Shell input prompt to a side bar. + +bpo-43655: Make window managers on macOS and X Window recognize +IDLE dialog windows as dialogs. + +bpo-42225: Document that IDLE can fail on Unix either from misconfigured IP +masquerade rules or failure displaying complex colored (non-ascii) characters. + +bpo-43283: Document why printing to IDLE's Shell is often slower than +printing to a system terminal and that it can be made faster by +pre-formatting a single string before printing. + +bpo-23544: Disable Debug=>Stack Viewer when user code is running or +Debugger is active, to prevent hang or crash. Patch by Zackery Spytz. + +bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal, +2-process mode. User hooks were previously ignored. +Patch by Ken Hilton. + +bpo-33065: Fix problem debugging user classes with __repr__ method. + +bpo-32631: Finish zzdummy example extension module: make menu entries +work; add docstrings and tests with 100% coverage. + +bpo-42508: Keep IDLE running on macOS. Remove obsolete workaround +that prevented running files with shortcuts when using new universal2 +installers built on macOS 11. + +bpo-42426: Fix reporting offset of the RE error in searchengine. + +bpo-42416: Display docstrings in IDLE calltips in more cases, +by using inspect.getdoc. + +bpo-33987: Mostly finish using ttk widgets, mainly for editor, +settings, and searches. Some patches by Mark Roseman. + +bpo-40511: Stop unnecessary "flashing" when typing opening and closing +parentheses inside the parentheses of a function call. + +bpo-38439: Add a 256x256 pixel IDLE icon to the Windows .ico file. Created by +Andrew Clover. Remove the low-color gif variations from the .ico file. + +bpo-41775: Make 'IDLE Shell' the shell title. + +bpo-35764: Rewrite the Calltips doc section. + +bpo-40181: In calltips, stop reminding that '/' marks the end of +positional-only arguments. + + +What's New in IDLE 3.9.0 (since 3.8.0) +Released on 2020-10-05? +====================================== + +bpo-41468: Improve IDLE run crash error message (which users should +never see). + +bpo-41373: Save files loaded with no line ending, as when blank, or +different line endings, by setting its line ending to the system +default. Fix regression in 3.8.4 and 3.9.0b4. + +bpo-41300: Save files with non-ascii chars. Fix regression in +3.9.0b4 and 3.8.4. + +bpo-37765: Add keywords to module name completion list. Rewrite +Completions section of IDLE doc. + +bpo-41152: The encoding of ``stdin``, ``stdout`` and ``stderr`` in IDLE +is now always UTF-8. + +bpo-41144: Make Open Module open a special module such as os.path. + +bpo-40723: Make test_idle pass when run after import. +Patch by Florian Dahlitz. + +bpo-38689: IDLE will no longer freeze when inspect.signature fails +when fetching a calltip. + +bpo-27115: For 'Go to Line', use a Query entry box subclass with +IDLE standard behavior and improved error checking. + +bpo-39885: When a context menu is invoked by right-clicking outside +of a selection, clear the selection and move the cursor. Cut and +Copy require that the click be within the selection. + +bpo-39852: Edit "Go to line" now clears any selection, preventing +accidental deletion. It also updates Ln and Col on the status bar. + +bpo-39781: Selecting code context lines no longer causes a jump. + +bpo-39663: Add tests for pyparse find_good_parse_start(). + +bpo-39600: Remove duplicate font names from configuration list. + +bpo-38792: Close a shell calltip if a :exc:`KeyboardInterrupt` +or shell restart occurs. Patch by Zackery Spytz. + +bpo-30780: Add remaining configdialog tests for buttons and +highlights and keys tabs. + +bpo-39388: Settings dialog Cancel button cancels pending changes. + +bpo-39050: Settings dialog Help button again displays help text. + +bpo-32989: Add tests for editor newline_and_indent_event method. +Remove unneeded arguments and dead code from pyparse +find_good_parse_start method. + +bpo-38943: Fix autocomplete windows not always appearing on some +systems. Patch by Johnny Najera. + +bpo-38944: Escape key now closes IDLE completion windows. Patch by +Johnny Najera. + +bpo-38862: 'Strip Trailing Whitespace' on the Format menu removes extra +newlines at the end of non-shell files. + +bpo-38636: Fix IDLE Format menu tab toggle and file indent width. These +functions (default shortcuts Alt-T and Alt-U) were mistakenly disabled +in 3.7.5 and 3.8.0. + +bpo-4630: Add an option to toggle IDLE's cursor blink for shell, +editor, and output windows. See Settings, General, Window Preferences, +Cursor Blink. Patch by Zackery Spytz. + +bpo-26353: Stop adding newline when saving an IDLE shell window. + +bpo-38598: Do not try to compile IDLE shell or output windows. + + +What's New in IDLE 3.8.0 (since 3.7.0) +Released on 2019-10-14 +====================================== + +bpo-36698: IDLE no longer fails when writing non-encodable characters +to stderr. It now escapes them with a backslash, like the regular +Python interpreter. Add an errors field to the standard streams. + +bpo-13153: Improve tkinter's handing of non-BMP (astral) unicode +characters, such as 'rocket \U0001f680'. Whether a proper glyph or +replacement char is displayed depends on the OS and font. For IDLE, +astral chars in code interfere with editing. + +bpo-35379: When exiting IDLE, catch any AttributeError. One happens +when EditorWindow.close is called twice. Printing a traceback, when +IDLE is run from a terminal, is useless and annoying. + +bpo-38183: To avoid test issues, test_idle ignores the user config +directory. It no longer tries to create or access .idlerc or any files +within. Users must run IDLE to discover problems with saving settings. + +bpo-38077: IDLE no longer adds 'argv' to the user namespace when +initializing it. This bug only affected 3.7.4 and 3.8.0b2 to 3.8.0b4. + +bpo-38401: Shell restart lines now fill the window width, always start +with '=', and avoid wrapping unnecessarily. The line will still wrap +if the included file name is long relative to the width. + +bpo-37092: Add mousewheel scrolling for IDLE module, path, and stack +browsers. Patch by George Zhang. + +bpo-35771: To avoid occasional spurious test_idle failures on slower +machines, increase the ``hover_delay`` in test_tooltip. + +bpo-37824: Properly handle user input warnings in IDLE shell. +Cease turning SyntaxWarnings into SyntaxErrors. + +bpo-37929: IDLE Settings dialog now closes properly when there is no +shell window. + +bpo-37849: Fix completions list appearing too high or low when shown +above the current line. + +bpo-36419: Refactor autocompete and improve testing. + +bpo-37748: Reorder the Run menu. Put the most common choice, +Run Module, at the top. + +bpo-37692: Improve highlight config sample with example shell +interaction and better labels for shell elements. + +bpo-37628: Settings dialog no longer expands with font size. +The font and highlight sample boxes gain scrollbars instead. + +bpo-17535: Add optional line numbers for IDLE editor windows. + +bpo-37627: Initialize the Customize Run dialog with the command line +arguments most recently entered before. The user can optionally edit +before submitting them. + +bpo-33610: Code context always shows the correct context when toggled on. + +bpo-36390: Gather Format menu functions into format.py. Combine +paragraph.py, rstrip.py, and format methods from editor.py. + +bpo-37530: Optimize code context to reduce unneeded background activity. +Font and highlight changes now occur along with text changes instead +of after a random delay. + +bpo-27452: Cleanup config.py by inlining RemoveFile and simplifying +the handling of __file__ in CreateConfigHandlers/ + +bpo-26806: To compensate for stack frames added by IDLE and avoid +possible problems with low recursion limits, add 30 to limits in the +user code execution process. Subtract 30 when reporting recursion +limits to make this addition mostly transparent. + +bpo-37325: Fix tab focus traversal order for help source and custom +run dialogs. + +bpo-37321: Both subprocess connection error messages now refer to +the 'Startup failure' section of the IDLE doc. + +bpo-37177: Properly attach search dialogs to their main window so +that they behave like other dialogs and do not get hidden behind +their main window. + +bpo-37039: Adjust "Zoom Height" to individual screens by momentarily +maximizing the window on first use with a particular screen. Changing +screen settings may invalidate the saved height. While a window is +maximized, "Zoom Height" has no effect. + +bpo-35763: Make calltip reminder about '/' meaning positional-only less +obtrusive by only adding it when there is room on the first line. + +bpo-5680: Add 'Run Customized' to the Run menu to run a module with +customized settings. Any command line arguments entered are added +to sys.argv. One can suppress the normal Shell main module restart. + +bpo-35610: Replace now redundant editor.context_use_ps1 with +.prompt_last_line. This finishes change started in bpo-31858. + +bpo-32411: Stop sorting dict created with desired line order. + +bpo-37038: Make idlelib.run runnable; add test clause. + +bpo-36958: Print any argument other than None or int passed to +SystemExit or sys.exit(). + +bpo-36807: When saving a file, call file.flush() and os.fsync() +so bits are flushed to e.g. a USB drive. + +bpo-36429: Fix starting IDLE with pyshell. +Add idlelib.pyshell alias at top; remove pyshell alias at bottom. +Remove obsolete __name__=='__main__' command. + +bpo-30348: Increase test coverage of idlelib.autocomplete by 30%. +Patch by Louie Lu. + +bpo-23205: Add tests and refactor grep's findfiles. + +bpo-36405: Use dict unpacking in idlelib. + +bpo-36396: Remove fgBg param of idlelib.config.GetHighlight(). +This param was only used twice and changed the return type. + +bpo-23216: IDLE: Add docstrings to search modules. + +bpo-36176: Fix IDLE autocomplete & calltip popup colors. +Prevent conflicts with Linux dark themes +(and slightly darken calltip background). + +bpo-36152: Remove colorizer.ColorDelegator.close_when_done and the +corresponding argument of .close(). In IDLE, both have always been +None or False since 2007. + +bpo-36096: Make colorizer state variables instance-only. + +bpo-32129: Avoid blurry IDLE application icon on macOS with Tk 8.6. +Patch by Kevin Walzer. + +bpo-24310: Document settings dialog font tab sample. + +bpo-35689: Add docstrings and tests for colorizer. + +bpo-35833: Revise IDLE doc for control codes sent to Shell. +Add a code example block. + +bpo-35770: IDLE macosx deletes Options => Configure IDLE. +It previously deleted Window => Zoom Height by mistake. +(Zoom Height is now on the Options menu). On Mac, the settings +dialog is accessed via Preferences on the IDLE menu. + +bpo-35769: Change new file name from 'Untitled' to 'untitled'. + +bpo-35660: Fix imports in window module. + +bpo-35641: Properly format calltip for function without docstring. + +bpo-33987: Use ttk Frame for ttk widgets. + +bpo-34055: Fix erroneous 'smart' indents and newlines in IDLE Shell. + +bpo-28097: Add Previous/Next History entries to Shell menu. + +bpo-35591: Find Selection now works when selection not found. + +bpo-35598: Update config_key: use PEP 8 names and ttk widgets, +make some objects global, and add tests. + +bpo-35196: Speed up squeezer line counting. + +bpo-35208: Squeezer now counts wrapped lines before newlines. + +bpo-35555: Gray out Code Context menu entry when it's not applicable. + +bpo-22703: Improve the Code Context and Zoom Height menu labels. +The Code Context menu label now toggles between Show/Hide Code Context. +The Zoom Height menu now toggles between Zoom/Restore Height. +Zoom Height has moved from the Window menu to the Options menu. + +bpo-35521: Document the editor code context feature. +Add some internal references within the IDLE doc. + +bpo-34864: When starting IDLE on MacOS, warn if the system setting +"Prefer tabs when opening documents" is "Always". As previous +documented for this issue, running IDLE with this setting causes +problems. If the setting is changed while IDLE is running, +there will be no warning until IDLE is restarted. + +bpo-35213: Where appropriate, use 'macOS' in idlelib. + +bpo-34864: Document two IDLE on MacOS issues. The System Preferences +Dock "prefer tabs always" setting disables some IDLE features. +Menus are a bit different than as described for Windows and Linux. + +bpo-35202: Remove unused imports in idlelib. + +bpo-33000: Document that IDLE's shell has no line limit. +A program that runs indefinitely can overfill memory. + +bpo-23220: Explain how IDLE's Shell displays output. +Add new subsection "User output in Shell". + +bpo-35099: Improve the doc about IDLE running user code. +"IDLE -- console differences" is renamed "Running user code". +It mostly covers the implications of using custom sys.stdxxx objects. + +bpo-35097: Add IDLE doc subsection explaining editor windows. +Topics include opening, title and status bars, .py* extension, and running. + +Issue 35093: Document the IDLE document viewer in the IDLE doc. +Add a paragraph in "Help and preferences", "Help sources" subsection. + +bpo-1529353: Explain Shell text squeezing in the IDLE doc. + +bpo-35088: Update idlelib.help.copy_string docstring. +We now use git and backporting instead of hg and forward merging. + +bpo-35087: Update idlelib help files for the current doc build. +The main change is the elimination of chapter-section numbers. + +bpo-1529353: Output over N lines (50 by default) is squeezed down to a button. +N can be changed in the PyShell section of the General page of the +Settings dialog. Fewer, but possibly extra long, lines can be squeezed by +right clicking on the output. Squeezed output can be expanded in place +by double-clicking the button or into the clipboard or a separate window +by right-clicking the button. + +bpo-34548: Use configured color theme for read-only text views. + +bpo-33839: Refactor ToolTip and CallTip classes; add documentation +and tests. + +bpo-34047: Fix mouse wheel scrolling direction on macOS. + +bpo-34275: Make calltips always visible on Mac. +Patch by Kevin Walzer. + +bpo-34120: Fix freezing after closing some dialogs on Mac. +This is one of multiple regressions from using newer tcl/tk. + +bpo-33975: Avoid small type when running htests. +Since part of the purpose of human-viewed tests is to determine that +widgets look right, it is important that they look the same for +testing as when running IDLE. + +bpo-33905: Add test for idlelib.stackview.StackBrowser. + +bpo-33924: Change mainmenu.menudefs key 'windows' to 'window'. +Every other menudef key is the lowercase version of the +corresponding main menu entry (in this case, 'Window'). + +bpo-33906: Rename idlelib.windows as window +Match Window on the main menu and remove last plural module name. +Change imports, test, and attribute references to match new name. + +bpo-33917: Fix and document idlelib/idle_test/template.py. +The revised file compiles, runs, and tests OK. idle_test/README.txt +explains how to use it to create new IDLE test files. + +bpo-33904: In rstrip module, rename class RstripExtension as Rstrip. + +bpo-33907: For consistency and clarity, rename calltip objects. +Module calltips and its class CallTips are now calltip and Calltip. +In module calltip_w, class CallTip is now CalltipWindow. + +bpo-33855: Minimally test all IDLE modules. +Standardize the test file format. Add missing test files that import +the tested module and perform at least one test. Check and record the +coverage of each test. + +bpo-33856: Add 'help' to Shell's initial welcome message. + + +What's New in IDLE 3.7.0 (since 3.6.0) +Released on 2018-06-27 +====================================== + +bpo-33656: On Windows, add API call saying that tk scales for DPI. +On Windows 8.1+ or 10, with DPI compatibility properties of the Python +binary unchanged, and a monitor resolution greater than 96 DPI, this +should make text and lines sharper and some colors brighter. +On other systems, it should have no effect. If you have a custom theme, +you may want to adjust a color or two. If perchance it make text worse +on your monitor, you can disable the ctypes.OleDLL call near the top of +pyshell.py and report the problem on python-list or idle-dev@python.org. + +bpo-33768: Clicking on a context line moves that line to the top +of the editor window. + +bpo-33763: Replace the code context label widget with a text widget. + +bpo-33664: Scroll IDLE editor text by lines. +(Previously, the mouse wheel and scrollbar slider moved text by a fixed +number of pixels, resulting in partial lines at the top of the editor +box.) This change also applies to the shell and grep output windows, +but currently not to read-only text views. + +bpo-33679: Enable theme-specific color configuration for Code Context. +(Previously, there was one code context foreground and background font +color setting, default or custom, on the extensions tab, that applied +to all themes.) For built-in themes, the foreground is the same as +normal text and the background is a contrasting gray. Context colors for +custom themes are set on the Highlights tab along with other colors. +When one starts IDLE from a console and loads a custom theme without +definitions for 'context', one will see a warning message on the +console. + +bpo-33642: Display up to maxlines non-blank lines for Code Context. +If there is no current context, show a single blank line. (Previously, +the Code Context had numlines lines, usually with some blank.) The use +of a new option, 'maxlines' (default 15), avoids possible interference +with user settings of the old option, 'numlines' (default 3). + +bpo-33628: Cleanup codecontext.py and its test. + +bpo-32831: Add docstrings and tests for codecontext.py. +Coverage is 100%. Patch by Cheryl Sabella. + +bpo-33564: Code context now recognizes async as a block opener. + +bpo-21474: Update word/identifier definition from ascii to unicode. +In text and entry boxes, this affects selection by double-click, +movement left/right by control-left/right, and deletion left/right +by control-BACKSPACE/DEL. + +bpo-33204: Consistently color invalid string prefixes. +A 'u' string prefix cannot be paired with either 'r' or 'f'. +IDLE now consistently colors as much of the prefix, starting at the +right, as is valid. Revise and extend colorizer test. + +bpo-32984: Set __file__ while running a startup file. +Like Python, IDLE optionally runs 1 startup file in the Shell window +before presenting the first interactive input prompt. For IDLE, +option -s runs a file named in environmental variable IDLESTARTUP or +PYTHONSTARTUP; -r file runs file. Python sets __file__ to the startup +file name before running the file and unsets it before the first +prompt. IDLE now does the same when run normally, without the -n +option. + +bpo-32940: Replace StringTranslatePseudoMapping with faster code. + +bpo-32916: Change 'str' to 'code' in idlelib.pyparse and users. + +bpo-32905: Remove unused code in pyparse module. + +bpo-32874: IDLE - add pyparse tests with 97% coverage. + +bpo-32837: IDLE - require encoding argument for textview.view_file. +Using the system and place-dependent default encoding for open() +is a bad idea for IDLE's system and location-independent files. + +bpo-32826: Add "encoding=utf-8" to open() in IDLE's test_help_about. +GUI test test_file_buttons() only looks at initial ascii-only lines, +but failed on systems where open() defaults to 'ascii' because +readline() internally reads and decodes far enough ahead to encounter +a non-ascii character in CREDITS.txt. + +bpo-32765: Update configdialog General tab create page docstring. +Add new widgets to the widget list. + +bpo-32207: Improve tk event exception tracebacks in IDLE. +When tk event handling is driven by IDLE's run loop, a confusing +and distracting queue.EMPTY traceback context is no longer added +to tk event exception tracebacks. The traceback is now the same +as when event handling is driven by user code. Patch based on +a suggestion by Serhiy Storchaka. + +bpo-32164: Delete unused file idlelib/tabbedpages.py. +Use of TabbedPageSet in configdialog was replaced by ttk.Notebook. + +bpo-32100: Fix old and new bugs in pathbrowser; improve tests. +Patch mostly by Cheryl Sabella. + +bpo-31860: The font sample in the settings dialog is now editable. +Edits persist while IDLE remains open. +Patch by Serhiy Storchake and Terry Jan Reedy. + +bpo-31858: Restrict shell prompt manipulation to the shell. +Editor and output windows only see an empty last prompt line. This +simplifies the code and fixes a minor bug when newline is inserted. +Sys.ps1, if present, is read on Shell start-up, but is not set or changed. +Patch by Terry Jan Reedy. + +bpo-28603: Fix a TypeError that caused a shell restart when printing +a traceback that includes an exception that is unhashable. +Patch by Zane Bitter. + +bpo-13802: Use non-Latin characters in the Font settings sample. +Even if one selects a font that defines a limited subset of the unicode +Basic Multilingual Plane, tcl/tk will use other fonts that define a +character. The expanded example give users of non-Latin characters +a better idea of what they might see in the shell and editors. + +To make room for the expanded sample, frames on the Font tab are +re-arranged. The Font/Tabs help explains a bit about the additions. +Patch by Terry Jan Reedy + +bpo-31460: Simplify the API of IDLE's Module Browser. +Passing a widget instead of an flist with a root widget opens the +option of creating a browser frame that is only part of a window. +Passing a full file name instead of pieces assumed to come from a +.py file opens the possibility of browsing python files that do not +end in .py. + +bpo-31649: Make _htest and _utest parameters keyword-only. +These are used to adjust code for human and unit tests. + +bpo-31459: Rename module browser from Class Browser to Module Browser. +The original module-level class and method browser became a module +browser, with the addition of module-level functions, years ago. +Nested classes and functions were added yesterday. For back- +compatibility, the virtual event <>, which +appears on the Keys tab of the Settings dialog, is not changed. +Patch by Cheryl Sabella. + +bpo-1612262: Module browser now shows nested classes and functions. +Original patches for code and tests by Guilherme Polo and +Cheryl Sabella, respectively. Revisions by Terry Jan Reedy. + +bpo-31500: Tk's default fonts now are scaled on HiDPI displays. +This affects all dialogs. Patch by Serhiy Storchaka. + +bpo-31493: Fix code context update and font update timers. +Canceling timers prevents a warning message when test_idle completes. + +bpo-31488: Update non-key options in former extension classes. +When applying configdialog changes, call .reload for each feature class. +Change ParenMatch so updated options affect existing instances attached +to existing editor windows. + +bpo-31477: Improve rstrip entry in IDLE doc. +Strip Trailing Whitespace strips more than blank spaces. +Multiline string literals are not skipped. + +bpo-31480: fix tests to pass with zzdummy extension disabled. (#3590) +To see the example in action, enable it on options extensions tab. + +bpo-31421: Document how IDLE runs tkinter programs. +IDLE calls tcl/tk update in the background in order to make live +interaction and experimentation with tkinter applications much easier. + +bpo-31414: Fix tk entry box tests by deleting first. +Adding to an int entry is not the same as deleting and inserting +because int('') will fail. Patch by Terry Jan Reedy. + +bpo-27099: Convert IDLE's built-in 'extensions' to regular features. + About 10 IDLE features were implemented as supposedly optional +extensions. Their different behavior could be confusing or worse for +users and not good for maintenance. Hence the conversion. + The main difference for users is that user configurable key bindings +for builtin features are now handled uniformly. Now, editing a binding +in a keyset only affects its value in the keyset. All bindings are +defined together in the system-specific default keysets in config- +extensions.def. All custom keysets are saved as a whole in config- +extension.cfg. All take effect as soon as one clicks Apply or Ok. + The affected events are '<>', +'<>', '<>', '<>', +'<>', '<>', '<>', and +'<>'. Any (global) customizations made before 3.6.3 will +not affect their keyset-specific customization after 3.6.3. and vice +versa. + Initial patch by Charles Wohlganger, revised by Terry Jan Reedy. + +bpo-31051: Rearrange configdialog General tab. +Sort non-Help options into Window (Shell+Editor) and Editor (only). +Leave room for the addition of new options. +Patch by Terry Jan Reedy. + +bpo-30617: Add docstrings and tests for outwin subclass of editor. +Move some data and functions from the class to module level. +Patch by Cheryl Sabella. + +bpo-31287: Do not modify tkinter.messagebox in test_configdialog. +Instead, mask it with an instance mock that can be deleted. +Patch by Terry Jan Reedy. + +bpo-30781: Use ttk widgets in ConfigDialog pages. +These should especially look better on MacOSX. +Patches by Terry Jan Reedy and Cheryl Sabella. + +bpo-31206: Factor HighPage(Frame) class from ConfigDialog. +Patch by Cheryl Sabella. + +bp0-31001: Add tests for configdialog highlight tab. +Patch by Cheryl Sabella. + +bpo-31205: Factor KeysPage(Frame) class from ConfigDialog. +The slightly modified tests continue to pass. +Patch by Cheryl Sabella. + +bpo-31002: Add tests for configdialog keys tab. +Patch by Cheryl Sabella. + +bpo-19903: Change calltipes to use inspect.signature. +Idlelib.calltips.get_argspec now uses inspect.signature instead of +inspect.getfullargspec, like help() does. This improves the signature +in the call tip in a few different cases, including builtins converted +to provide a signature. A message is added if the object is not +callable, has an invalid signature, or if it has positional-only +parameters. Patch by Louie Lu. + +bop-31083: Add an outline of a TabPage class in configdialog. +Add template as comment. Update existing classes to match outline. +Initial patch by Cheryl Sabella. + +bpo-31050: Factor GenPage(Frame) class from ConfigDialog. +The slightly modified tests for the General tab continue to pass. +Patch by Cheryl Sabella. + +bpo-31004: Factor FontPage(Frame) class from ConfigDialog. +The slightly modified tests continue to pass. The General test +broken by the switch to ttk.Notebook is fixed. +Patch mostly by Cheryl Sabella. + +bpo-30781: IDLE - Use ttk Notebook in ConfigDialog. +This improves navigation by tabbing. +Patch by Terry Jan Reedy. + +bpo-31060: IDLE - Finish rearranging methods of ConfigDialog. +Grouping methods pertaining to each tab and the buttons will aid +writing tests and improving the tabs and will enable splitting the +groups into classes. +Patch by Terry Jan Reedy. + +bpo-30853: IDLE -- Factor a VarTrace class out of ConfigDialog. +Instance tracers manages pairs consisting of a tk variable and a +callback function. When tracing is turned on, setting the variable +calls the function. Test coverage for the new class is 100%. +Patch by Terry Jan Reedy. + +bpo-31003: IDLE: Add more tests for General tab. +Patch by Terry Jan Reedy. + +bpo-30993: IDLE - Improve configdialog font page and tests. +*In configdialog: Document causal pathways in create_font_tab +docstring. Simplify some attribute names. Move set_samples calls to +var_changed_font (idea from Cheryl Sabella). Move related functions to +positions after the create widgets function. +* In test_configdialog: Fix test_font_set so not order dependent. Fix +renamed test_indent_scale so it tests the widget. Adjust tests for +movement of set_samples call. Add tests for load functions. Put all +font tests in one class and tab indent tests in another. Except for +two lines, these tests completely cover the related functions. +Patch by Terry Jan Reedy. + +bpo-30981: IDLE -- Add more configdialog font page tests. + +bpo-28523: IDLE: replace 'colour' with 'color' in configdialog. + +bpo-30917: Add tests for idlelib.config.IdleConf. +Increase coverage from 46% to 96%. +Patch by Louie Lu. + +bpo-30913: Document ConfigDialog tk Vars, methods, and widgets in docstrings +This will facilitate improving the dialog and splitting up the class. +Original patch by Cheryl Sabella. + +bpo-30899: Add tests for ConfigParser subclasses in config. +Coverage is 100% for those classes and ConfigChanges. +Patch by Louie Lu. + +bpo-30881: Add docstrings to browser.py. +Patch by Cheryl Sabella. + +bpo-30851: Remove unused tk variables in configdialog. +One is a duplicate, one is set but cannot be altered by users. +Patch by Cheryl Sabella. + +bpo-30870: Select font option with Up and Down keys, as well as with mouse. +Added test increases configdialog coverage to 60% +Patches mostly by Louie Lu. + +bpo-8231: Call config.IdleConf.GetUserCfgDir only once per process. + +bpo-30779: Factor ConfigChanges class from configdialog, put in config; test. +* In config, put dump test code in a function; run it and unittest in + 'if __name__ == '__main__'. +* Add class config.ConfigChanges based on changes_class_v4.py on bpo issue. +* Add class test_config.ChangesTest, partly using configdialog_tests_v1.py. +* Revise configdialog to use ConfigChanges; see tracker msg297804. +* Revise test_configdialog to match configdialog changes. +* Remove configdialog functions unused or moved to ConfigChanges. +Cheryl Sabella contributed parts of the patch. + +bpo-30777: Configdialog - add docstrings and improve comments. +Patch by Cheryl Sabella. + +bpo-30495: Improve textview with docstrings, PEP8 names, and more tests. +Split TextViewer class into ViewWindow, ViewFrame, and TextFrame classes +so that instances of the latter two can be placed with other widgets +within a multiframe window. +Patches by Cheryl Sabella and Terry Jan Reedy. + +bpo-30723: Make several improvements to parenmatch. +* Add 'parens' style to highlight both opener and closer. +* Make 'default' style, which is not default, a synonym for 'opener'. +* Make time-delay work the same with all styles. +* Add help for config dialog extensions tab, including parenmatch. +* Add new tests. +Original patch by Charles Wohlganger. Revisions by Terry Jan Reedy + +bpo-30674: Grep -- Add docstrings. Patch by Cheryl Sabella. + +bpo-21519: IDLE's basic custom key entry dialog now detects +duplicates properly. Original patch by Saimadhav Heblikar. + +bpo-29910: IDLE no longer deletes a character after commenting out a +region by a key shortcut. Add "return 'break'" for this and other +potential conflicts between IDLE and default key bindings. +Patch by Serhiy Storchaka. + +bpo-30728: Modernize idlelib.configdialog: +* replace import * with specific imports; +* lowercase method and attribute lines. +Patch by Cheryl Sabella. + +bpo-6739: Verify user-entered key sequences by trying to bind them +with to a tk widget. Add tests for all 3 validation functions. +Original patch by G Polo. Tests added by Cheryl Sabella. +Code revised and more tests added by Terry Jan Reedy + +bpo-24813: Add icon to help_about and make other changes. + +bpo-15786: Fix several problems with IDLE's autocompletion box. +The following should now work: clicking on selection box items; +using the scrollbar; selecting an item by hitting Return. +Hangs on MacOSX should no longer happen. Patch by Louie Lu. + +bpo-25514: Add doc subsubsection about IDLE failure to start. +Popup no-connection message directs users to this section. + +bpo-30642: Fix reference leaks in IDLE tests. +Patches by Louie Lu and Terry Jan Reedy. + +bpo-30495: Add docstrings for textview.py and use PEP8 names. +Patches by Cheryl Sabella and Terry Jan Reedy. + +bpo-30290: Help-about: use pep8 names and add tests. +Increase coverage to 100%. +Patches by Louie Lu, Cheryl Sabella, and Terry Jan Reedy. + +bpo-30303: Add _utest option to textview; add new tests. +Increase coverage to 100%. +Patches by Louie Lu and Terry Jan Reedy. + +Issue #29071: IDLE colors f-string prefixes but not invalid ur prefixes. + +Issue #28572: Add 10% to coverage of IDLE's test_configdialog. +Update and augment description of the configuration system. + + +What's New in IDLE 3.6.0 (since 3.5.0) +Released on 2016-12-23 +====================================== + +- Issue #15308: Add 'interrupt execution' (^C) to Shell menu. + Patch by Roger Serwy, updated by Bayard Randel. + +- Issue #27922: Stop IDLE tests from 'flashing' gui widgets on the screen. + +- Issue #27891: Consistently group and sort imports within idlelib modules. + +- Issue #17642: add larger font sizes for classroom projection. + +- Add version to title of IDLE help window. + +- Issue #25564: In section on IDLE -- console differences, mention that + using exec means that __builtins__ is defined for each statement. + +- Issue #27821: Fix 3.6.0a3 regression that prevented custom key sets + from being selected when no custom theme was defined. + +- Issue #27714: text_textview and test_autocomplete now pass when re-run + in the same process. This occurs when test_idle fails when run with the + -w option but without -jn. Fix warning from test_config. + +- Issue #27621: Put query response validation error messages in the query + box itself instead of in a separate messagebox. Redo tests to match. + Add Mac OSX refinements. Original patch by Mark Roseman. + +- Issue #27620: Escape key now closes Query box as cancelled. + +- Issue #27609: IDLE: tab after initial whitespace should tab, not + autocomplete. This fixes problem with writing docstrings at least + twice indented. + +- Issue #27609: Explicitly return None when there are also non-None + returns. In a few cases, reverse a condition and eliminate a return. + +- Issue #25507: IDLE no longer runs buggy code because of its tkinter imports. + Users must include the same imports required to run directly in Python. + +- Issue #27173: Add 'IDLE Modern Unix' to the built-in key sets. + Make the default key set depend on the platform. + Add tests for the changes to the config module. + +- Issue #27452: add line counter and crc to IDLE configHandler test dump. + +- Issue #27477: IDLE search dialogs now use ttk widgets. + +- Issue #27173: Add 'IDLE Modern Unix' to the built-in key sets. + Make the default key set depend on the platform. + Add tests for the changes to the config module. + +- Issue #27452: make command line "idle-test> python test_help.py" work. + __file__ is relative when python is started in the file's directory. + +- Issue #27452: add line counter and crc to IDLE configHandler test dump. + +- Issue #27380: IDLE: add query.py with base Query dialog and ttk widgets. + Module had subclasses SectionName, ModuleName, and HelpSource, which are + used to get information from users by configdialog and file =>Load Module. + Each subclass has itw own validity checks. Using ModuleName allows users + to edit bad module names instead of starting over. + Add tests and delete the two files combined into the new one. + +- Issue #27372: Test_idle no longer changes the locale. + +- Issue #27365: Allow non-ascii chars in IDLE NEWS.txt, for contributor names. + +- Issue #27245: IDLE: Cleanly delete custom themes and key bindings. + Previously, when IDLE was started from a console or by import, a cascade + of warnings was emitted. Patch by Serhiy Storchaka. + +- Issue #24137: Run IDLE, test_idle, and htest with tkinter default root disabled. + Fix code and tests that fail with this restriction. + Fix htests to not create a second and redundant root and mainloop. + +- Issue #27310: Fix IDLE.app failure to launch on OS X due to vestigial import. + +- Issue #5124: Paste with text selected now replaces the selection on X11. + This matches how paste works on Windows, Mac, most modern Linux apps, + and ttk widgets. Original patch by Serhiy Storchaka. + +- Issue #24750: Switch all scrollbars in IDLE to ttk versions. + Where needed, minimal tests are added to cover changes. + +- Issue #24759: IDLE requires tk 8.5 and availability ttk widgets. + Delete now unneeded tk version tests and code for older versions. + Add test for IDLE syntax colorizer. + +- Issue #27239: idlelib.macosx.isXyzTk functions initialize as needed. + +- Issue #27262: move Aqua unbinding code, which enable context menus, to macosx. + +- Issue #24759: Make clear in idlelib.idle_test.__init__ that the directory + is a private implementation of test.test_idle and tool for maintainers. + +- Issue #27196: Stop 'ThemeChanged' warnings when running IDLE tests. + These persisted after other warnings were suppressed in #20567. + Apply Serhiy Storchaka's update_idletasks solution to four test files. + Record this additional advice in idle_test/README.txt + +- Issue #20567: Revise idle_test/README.txt with advice about avoiding + tk warning messages from tests. Apply advice to several IDLE tests. + +- Issue # 24225: Update idlelib/README.txt with new file names + and event handlers. + +- Issue #27156: Remove obsolete code not used by IDLE. Replacements: + 1. help.txt, replaced by help.html, is out-of-date and should not be used. + Its dedicated viewer has be replaced by the html viewer in help.py. + 2. 'import idlever; I = idlever.IDLE_VERSION' is the same as + 'import sys; I = version[:version.index(' ')]' + 3. After 'ob = stackviewer.VariablesTreeItem(*args)', + 'ob.keys()' == 'list(ob.object.keys). + 4. In macosc, runningAsOSXAPP == isAquaTk; idCarbonAquaTk == isCarbonTk + +- Issue #27117: Make colorizer htest and turtledemo work with dark themes. + Move code for configuring text widget colors to a new function. + +- Issue #24225: Rename many idlelib/*.py and idle_test/test_*.py files. + Edit files to replace old names with new names when the old name + referred to the module rather than the class it contained. + See the issue and IDLE section in What's New in 3.6 for more. + +- Issue #26673: When tk reports font size as 0, change to size 10. + Such fonts on Linux prevented the configuration dialog from opening. + +- Issue #21939: Add test for IDLE's percolator. + Original patch by Saimadhav Heblikar. + +- Issue #21676: Add test for IDLE's replace dialog. + Original patch by Saimadhav Heblikar. + +- Issue #18410: Add test for IDLE's search dialog. + Original patch by Westley Martínez. + +- Issue #21703: Add test for undo delegator. Patch mostly by + Saimadhav Heblikar . + +- Issue #27044: Add ConfigDialog.remove_var_callbacks to stop memory leaks. + +- Issue #23977: Add more asserts to test_delegator. + +- Issue #20640: Add tests for idlelib.configHelpSourceEdit. + Patch by Saimadhav Heblikar. + +- In the 'IDLE-console differences' section of the IDLE doc, clarify + how running with IDLE affects sys.modules and the standard streams. + +- Issue #25507: fix incorrect change in IOBinding that prevented printing. + Augment IOBinding htest to include all major IOBinding functions. + +- Issue #25905: Revert unwanted conversion of ' to ’ RIGHT SINGLE QUOTATION + MARK in README.txt and open this and NEWS.txt with 'ascii'. + Re-encode CREDITS.txt to utf-8 and open it with 'utf-8'. + +- Issue 15348: Stop the debugger engine (normally in a user process) + before closing the debugger window (running in the IDLE process). + This prevents the RuntimeErrors that were being caught and ignored. + +- Issue #24455: Prevent IDLE from hanging when a) closing the shell while the + debugger is active (15347); b) closing the debugger with the [X] button + (15348); and c) activating the debugger when already active (24455). + The patch by Mark Roseman does this by making two changes. + 1. Suspend and resume the gui.interaction method with the tcl vwait + mechanism intended for this purpose (instead of root.mainloop & .quit). + 2. In gui.run, allow any existing interaction to terminate first. + +- Change 'The program' to 'Your program' in an IDLE 'kill program?' message + to make it clearer that the program referred to is the currently running + user program, not IDLE itself. + +- Issue #24750: Improve the appearance of the IDLE editor window status bar. + Patch by Mark Roseman. + +- Issue #25313: Change the handling of new built-in text color themes to better + address the compatibility problem introduced by the addition of IDLE Dark. + Consistently use the revised idleConf.CurrentTheme everywhere in idlelib. + +- Issue #24782: Extension configuration is now a tab in the IDLE Preferences + dialog rather than a separate dialog. The former tabs are now a sorted + list. Patch by Mark Roseman. + +- Issue #22726: Re-activate the config dialog help button with some content + about the other buttons and the new IDLE Dark theme. + +- Issue #24820: IDLE now has an 'IDLE Dark' built-in text color theme. + It is more or less IDLE Classic inverted, with a cobalt blue background. + Strings, comments, keywords, ... are still green, red, orange, ... . + To use it with IDLEs released before November 2015, hit the + 'Save as New Custom Theme' button and enter a new name, + such as 'Custom Dark'. The custom theme will work with any IDLE + release, and can be modified. + +- Issue #25224: README.txt is now an idlelib index for IDLE developers and + curious users. The previous user content is now in the IDLE doc chapter. + 'IDLE' now means 'Integrated Development and Learning Environment'. + +- Issue #24820: Users can now set breakpoint colors in + Settings -> Custom Highlighting. Original patch by Mark Roseman. + +- Issue #24972: Inactive selection background now matches active selection + background, as configured by users, on all systems. Found items are now + always highlighted on Windows. Initial patch by Mark Roseman. + +- Issue #24570: Idle: make calltip and completion boxes appear on Macs + affected by a tk regression. Initial patch by Mark Roseman. + +- Issue #24988: Idle ScrolledList context menus (used in debugger) + now work on Mac Aqua. Patch by Mark Roseman. + +- Issue #24801: Make right-click for context menu work on Mac Aqua. + Patch by Mark Roseman. + +- Issue #25173: Associate tkinter messageboxes with a specific widget. + For Mac OSX, make them a 'sheet'. Patch by Mark Roseman. + +- Issue #25198: Enhance the initial html viewer now used for Idle Help. + * Properly indent fixed-pitch text (patch by Mark Roseman). + * Give code snippet a very Sphinx-like light blueish-gray background. + * Re-use initial width and height set by users for shell and editor. + * When the Table of Contents (TOC) menu is used, put the section header + at the top of the screen. + +- Issue #25225: Condense and rewrite Idle doc section on text colors. + +- Issue #21995: Explain some differences between IDLE and console Python. + +- Issue #22820: Explain need for *print* when running file from Idle editor. + +- Issue #25224: Doc: augment Idle feature list and no-subprocess section. + +- Issue #25219: Update doc for Idle command line options. + Some were missing and notes were not correct. + +- Issue #24861: Most of idlelib is private and subject to change. + Use idleib.idle.* to start Idle. See idlelib.__init__.__doc__. + +- Issue #25199: Idle: add synchronization comments for future maintainers. + +- Issue #16893: Replace help.txt with help.html for Idle doc display. + The new idlelib/help.html is rstripped Doc/build/html/library/idle.html. + It looks better than help.txt and will better document Idle as released. + The tkinter html viewer that works for this file was written by Mark Roseman. + The now unused EditorWindow.HelpDialog class and helt.txt file are deprecated. + +- Issue #24199: Deprecate unused idlelib.idlever with possible removal in 3.6. + +- Issue #24790: Remove extraneous code (which also create 2 & 3 conflicts). + + +What's New in IDLE 3.5.0? +========================= +*Release date: 2015-09-13* + +- Issue #23672: Allow Idle to edit and run files with astral chars in name. + Patch by Mohd Sanad Zaki Rizvi. + +- Issue 24745: Idle editor default font. Switch from Courier to + platform-sensitive TkFixedFont. This should not affect current customized + font selections. If there is a problem, edit $HOME/.idlerc/config-main.cfg + and remove 'fontxxx' entries from [Editor Window]. Patch by Mark Roseman. + +- Issue #21192: Idle editor. When a file is run, put its name in the restart bar. + Do not print false prompts. Original patch by Adnan Umer. + +- Issue #13884: Idle menus. Remove tearoff lines. Patch by Roger Serwy. + +- Issue #23184: remove unused names and imports in idlelib. + Initial patch by Al Sweigart. + +- Issue #20577: Configuration of the max line length for the FormatParagraph + extension has been moved from the General tab of the Idle preferences dialog + to the FormatParagraph tab of the Config Extensions dialog. + Patch by Tal Einat. + +- Issue #16893: Update Idle doc chapter to match current Idle and add new + information. + +- Issue #3068: Add Idle extension configuration dialog to Options menu. + Changes are written to HOME/.idlerc/config-extensions.cfg. + Original patch by Tal Einat. + +- Issue #16233: A module browser (File : Class Browser, Alt+C) requires an + editor window with a filename. When Class Browser is requested otherwise, + from a shell, output window, or 'Untitled' editor, Idle no longer displays + an error box. It now pops up an Open Module box (Alt+M). If a valid name + is entered and a module is opened, a corresponding browser is also opened. + +- Issue #4832: Save As to type Python files automatically adds .py to the + name you enter (even if your system does not display it). Some systems + automatically add .txt when type is Text files. + +- Issue #21986: Code objects are not normally pickled by the pickle module. + To match this, they are no longer pickled when running under Idle. + +- Issue #23180: Rename IDLE "Windows" menu item to "Window". + Patch by Al Sweigart. + +- Issue #17390: Adjust Editor window title; remove 'Python', + move version to end. + +- Issue #14105: Idle debugger breakpoints no longer disappear + when inserting or deleting lines. + +- Issue #17172: Turtledemo can now be run from Idle. + Currently, the entry is on the Help menu, but it may move to Run. + Patch by Ramchandra Apt and Lita Cho. + +- Issue #21765: Add support for non-ascii identifiers to HyperParser. + +- Issue #21940: Add unittest for WidgetRedirector. Initial patch by Saimadhav + Heblikar. + +- Issue #18592: Add unittest for SearchDialogBase. Patch by Phil Webster. + +- Issue #21694: Add unittest for ParenMatch. Patch by Saimadhav Heblikar. + +- Issue #21686: add unittest for HyperParser. Original patch by Saimadhav + Heblikar. + +- Issue #12387: Add missing upper(lower)case versions of default Windows key + bindings for Idle so Caps Lock does not disable them. Patch by Roger Serwy. + +- Issue #21695: Closing a Find-in-files output window while the search is + still in progress no longer closes Idle. + +- Issue #18910: Add unittest for textView. Patch by Phil Webster. + +- Issue #18292: Add unittest for AutoExpand. Patch by Saihadhav Heblikar. + +- Issue #18409: Add unittest for AutoComplete. Patch by Phil Webster. + +- Issue #21477: htest.py - Improve framework, complete set of tests. + Patches by Saimadhav Heblikar + +- Issue #18104: Add idlelib/idle_test/htest.py with a few sample tests to begin + consolidating and improving human-validated tests of Idle. Change other files + as needed to work with htest. Running the module as __main__ runs all tests. + +- Issue #21139: Change default paragraph width to 72, the PEP 8 recommendation. + +- Issue #21284: Paragraph reformat test passes after user changes reformat width. + +- Issue #17654: Ensure IDLE menus are customized properly on OS X for + non-framework builds and for all variants of Tk. + + +What's New in IDLE 3.4.0? +========================= +*Release date: 2014-03-16* + +- Issue #17390: Display Python version on Idle title bar. + Initial patch by Edmond Burnett. + +- Issue #5066: Update IDLE docs. Patch by Todd Rovito. + +- Issue #17625: Close the replace dialog after it is used. + +- Issue #16226: Fix IDLE Path Browser crash. + (Patch by Roger Serwy) + +- Issue #15853: Prevent IDLE crash on OS X when opening Preferences menu + with certain versions of Tk 8.5. Initial patch by Kevin Walzer. + + +What's New in IDLE 3.3.0? +========================= +*Release date: 2012-09-29* + +- Issue #17625: Close the replace dialog after it is used. + +- Issue #7163: Propagate return value of sys.stdout.write. + +- Issue #15318: Prevent writing to sys.stdin. + +- Issue #4832: Modify IDLE to save files with .py extension by + default on Windows and OS X (Tk 8.5) as it already does with X11 Tk. + +- Issue #13532, #15319: Check that arguments to sys.stdout.write are strings. + +- Issue # 12510: Attempt to get certain tool tips no longer crashes IDLE. + Erroneous tool tips have been corrected. Default added for callables. + +- Issue #10365: File open dialog now works instead of crashing even when + parent window is closed while dialog is open. + +- Issue 14876: use user-selected font for highlight configuration. + +- Issue #14937: Perform auto-completion of filenames in strings even for + non-ASCII filenames. Likewise for identifiers. + +- Issue #8515: Set __file__ when run file in IDLE. + Initial patch by Bruce Frederiksen. + +- IDLE can be launched as `python -m idlelib` + +- Issue #14409: IDLE now properly executes commands in the Shell window + when it cannot read the normal config files on startup and + has to use the built-in default key bindings. + There was previously a bug in one of the defaults. + +- Issue #3573: IDLE hangs when passing invalid command line args + (directory(ies) instead of file(s)). + +- Issue #14018: Update checks for unstable system Tcl/Tk versions on OS X + to include versions shipped with OS X 10.7 and 10.8 in addition to 10.6. + + +What's New in IDLE 3.2.1? +========================= +*Release date: 15-May-11* + +- Issue #6378: Further adjust idle.bat to start associated Python + +- Issue #11896: Save on Close failed despite selecting "Yes" in dialog. + +- Issue #1028: Ctrl-space binding to show completions was causing IDLE to exit. + Tk < 8.5 was sending invalid Unicode null; replaced with valid null. + +- Issue #4676: toggle failing on Tk 8.5, causing IDLE exits and strange selection + behavior. Improve selection extension behaviour. + +- Issue #3851: toggle non-functional when NumLock set on Windows. + + +What's New in IDLE 3.1b1? +========================= +*Release date: 06-May-09* + +- Issue #5707: Use of 'filter' in keybindingDialog.py was causing custom key assignment to + fail. Patch by Amaury Forgeot d'Arc. + +- Issue #4815: Offer conversion to UTF-8 if source files have + no encoding declaration and are not encoded in UTF-8. + +- Issue #4008: Fix problems with non-ASCII source files. + +- Issue #4323: Always encode source as UTF-8 without asking + the user (unless a different encoding is declared); remove + user configuration of source encoding; all according to + PEP 3120. + +- Issue #2665: On Windows, an IDLE installation upgraded from an old version + would not start if a custom theme was defined. + +------------------------------------------------------------------------ +Refer to NEWS2x.txt and HISTORY.txt for information on earlier releases. +------------------------------------------------------------------------ diff --git a/Lib/idlelib/README.txt b/Lib/idlelib/README.txt new file mode 100644 index 00000000000..76aec58912f --- /dev/null +++ b/Lib/idlelib/README.txt @@ -0,0 +1,290 @@ +README.txt: an index to idlelib files and the IDLE menu. + +IDLE is Python's Integrated Development and Learning +Environment. The user documentation is part of the Library Reference and +is available in IDLE by selecting Help => IDLE Help. This README documents +idlelib for IDLE developers and curious users. + +IDLELIB FILES lists files alphabetically by category, +with a short description of each. + +IDLE MENU show the menu tree, annotated with the module +or module object that implements the corresponding function. + +This file is descriptive, not prescriptive, and may have errors +and omissions and lag behind changes in idlelib. + + +IDLELIB FILES +============= + +Implementation files not in IDLE MENU are marked (nim). + +Startup +------- +__init__.py # import, does nothing +__main__.py # -m, starts IDLE +idle.bat +idle.py +idle.pyw + +Implementation +-------------- +autocomplete.py # Complete attribute names or filenames. +autocomplete_w.py # Display completions. +autoexpand.py # Expand word with previous word in file. +browser.py # Create module browser window. +calltip.py # Create calltip text. +calltip_w.py # Display calltip. +codecontext.py # Show compound statement headers otherwise not visible. +colorizer.py # Colorize text (nim). +config.py # Load, fetch, and save configuration (nim). +configdialog.py # Display user configuration dialogs. +config_key.py # Change keybindings. +debugger.py # Debug code run from shell or editor; show window. +debugger_r.py # Debug code run in remote process. +debugobj.py # Define class used in stackviewer. +debugobj_r.py # Communicate objects between processes with rpc (nim). +delegator.py # Define base class for delegators (nim). +dynoption.py # Define mutable OptionMenu widget (nim) +editor.py # Define most of editor and utility functions. +filelist.py # Open files and manage list of open windows (nim). +format.py # Define format menu options. +grep.py # Find all occurrences of pattern in multiple files. +help.py # Display IDLE's html doc. +help_about.py # Display About IDLE dialog. +history.py # Get previous or next user input in shell (nim) +hyperparser.py # Parse code around a given index. +iomenu.py # Open, read, and write files +macosx.py # Help IDLE run on Macs (nim). +mainmenu.py # Define most of IDLE menu. +multicall.py # Wrap tk widget to allow multiple calls per event (nim). +outwin.py # Create window for grep output. +parenmatch.py # Match fenceposts: (), [], and {}. +pathbrowser.py # Create path browser window. +percolator.py # Manage delegator stack (nim). +pyparse.py # Give information on code indentation +pyshell.py # Start IDLE, manage shell, complete editor window +query.py # Query user for information +redirector.py # Intercept widget subcommands (for percolator) (nim). +replace.py # Search and replace pattern in text. +rpc.py # Communicate between idle and user processes (nim). +run.py # Manage user code execution subprocess. +runscript.py # Check and run user code. +scrolledlist.py # Define scrolledlist widget for IDLE (nim). +search.py # Search for pattern in text. +searchbase.py # Define base for search, replace, and grep dialogs. +searchengine.py # Define engine for all 3 search dialogs. +sidebar.py # Define line number and shell prompt sidebars. +squeezer.py # Squeeze long shell output (nim). +stackviewer.py # View stack after exception. +statusbar.py # Define status bar for windows (nim). +tabbedpages.py # Define tabbed pages widget (nim). +textview.py # Define read-only text widget (nim). +tooltip.py # Define popups for calltips, squeezer (nim). +tree.py # Define tree widget, used in browsers (nim). +undo.py # Manage undo stack. +util.py # Define common objects imported elsewhere (nim). +windows.py # Manage window list and define listed top level. +zoomheight.py # Zoom window to full height of screen. +zzdummy.py # Example extension. + +Configuration +------------- +config-extensions.def # Defaults for extensions +config-highlight.def # Defaults for colorizing +config-keys.def # Defaults for key bindings +config-main.def # Defaults for font and general tabs + +Text +---- +CREDITS.txt # not maintained, displayed by About IDLE +HISTORY.txt # NEWS up to July 2001 +NEWS.txt # commits, displayed by About IDLE +NEWS2.txt # commits to Python2 +README.txt # this file, displayed by About IDLE +TODO.txt # needs review +extend.txt # about writing extensions +help.html # copy of idle.html in docs, displayed by IDLE Help + +Subdirectories +-------------- +Icons # small image files +idle_test # files for human test and automated unit tests + + +IDLE MENUS +========== + +Top level items and most submenu items are defined in mainmenu. +Extensions add submenu items when active. The names given are +found, quoted, in one of these modules, paired with a '<>'. +Each pseudoevent is bound to an event handler. Some event handlers +call another function that does the actual work. The annotations below +are intended to at least give the module where the actual work is done. +'eEW' = editor.EditorWindow + +File + New File # eEW.new_callback + Open... # iomenu.open + Open Module # eEw.open_module + Recent Files + Class Browser # eEW.open_class_browser, browser.ClassBrowser + Path Browser # eEW.open_path_browser, pathbrowser + --- + Save # iomenu.save + Save As... # iomenu.save_as + Save Copy As... # iomenu.save_a_copy + --- + Print Window # iomenu.print_window + --- + Close # eEW.close_event + Exit # flist.close_all_callback (bound in eEW) + +Edit + Undo # undodelegator + Redo # undodelegator + --- # eEW.right_menu_event + Cut # eEW.cut + Copy # eEW.copy + Paste # eEW.past + Select All # eEW.select_all (+ see eEW.remove_selection) + --- # Next 5 items use searchengine; dialogs use searchbase + Find # eEW.find_event, search.SearchDialog.find + Find Again # eEW.find_again_event, sSD.find_again + Find Selection # eEW.find_selection_event, sSD.find_selection + Find in Files... # eEW.find_in_files_event, grep + Replace... # eEW.replace_event, replace.ReplaceDialog.replace + Go to Line # eEW.goto_line_event + Show Completions # autocomplete extension and autocompleteWidow (&HP) + Expand Word # autoexpand extension + Show call tip # Calltips extension and CalltipWindow (& Hyperparser) + Show surrounding parens # parenmatch (& Hyperparser) + +Format (Editor only) [fFR = format.FormatRegion] + Format Paragraph # format.FormatParagraph.format_paragraph_event + Indent Region # fFR.indent_region_event + Dedent Region # fFR.dedent_region_event + Comment Out Reg. # fFR.comment_region_event + Uncomment Region # fFR.uncomment_region_event + Tabify Region # fFR.tabify_region_event + Untabify Region # fFR.untabify_region_event + Toggle Tabs # format.Indents.toggle_tabs_event + New Indent Width # format.Indents.change_indentwidth_event + Strip tailing whitespace # format.rstrip + Zin # zzdummy + Zout # zzdummy + +Run (Editor only) + Run Module # runscript.ScriptBinding.run_module_event + Run... Customized # runscript.ScriptBinding.run_custom_event + Check Module # runscript.ScriptBinding.check_module_event + Python Shell # pyshell.Pyshell, pyshell.ModifiedInterpreter + +Shell # pyshell + View Last Restart # pyshell.PyShell.view_restart_mark + Restart Shell # pyshell.PyShell.restart_shell + Previous History # history.History.history_prev + Next History # history.History.history_next + Interrupt Execution # pyshell.PyShell.cancel_callback + +Debug (Shell only) + Go to File/Line # outwin.OutputWindow.goto_file_line + debugger # debugger, debugger_r, PyShell.toggle_debugger + Stack Viewer # stackviewer, PyShell.open_stack_viewer + Auto-open Stack Viewer # stackviewer + +Options + Configure IDLE # eEW.config_dialog, config, configdialog (cd) + (Parts of the dialog) + Buttons # cd.ConfigDialog + Font tab # cd.FontPage, config-main.def + Highlight tab # cd.HighPage, query, config-highlight.def + Keys tab # cd.KeysPage, query, config_key, config_keys.def + Windows tab # cd.WinPage, config_main.def + Shell/Ed tab # cd.ShedPage, config-main.def + Extensions tab # config-extensions.def, corresponding .py files + --- + ... Code Context # codecontext + ... Line Numbers # sidebar + Zoomheight # zoomheight + +Window + # windows + +Help + About IDLE # eEW.about_dialog, help_about.AboutDialog + --- + IDLE Help # eEW.help_dialog, help.show_idlehelp + Python Docs # eEW.python_docs + Turtle Demo # eEW.open_turtle_demo + --- + + + (right click) + Defined in editor, PyShell.pyshell + Cut + Copy + Paste + --- + Go to file/line (shell and output only) + Set Breakpoint (editor only) + Clear Breakpoint (editor only) + Defined in debugger + Go to source line + Show stack frame + + +Center Insert # eEW.center_insert_event + + +OTHER TOPICS +============ + +Generally use PEP 8. + +import statements +----------------- +Put imports at the top, unless there is a good reason otherwise. +PEP 8 says to group stdlib, 3rd-party dependencies, and package imports. +For idlelib, the groups are general stdlib, tkinter, and idlelib. +Sort modules within each group, except that tkinter.ttk follows tkinter. +Sort 'from idlelib import mod1' and 'from idlelib.mod2 import object' +together by module, ignoring within module objects. +Put 'import __main__' after other idlelib imports. + +Imports only needed for testing are put not at the top but in an +htest function def or "if __name__ == '__main__'" clause. + +Within module imports like "from idlelib.mod import class" may cause +circular imports to deadlock. Even without this, circular imports may +require at least one of the imports to be delayed until a function call. + +What's New entries +------------------ + +Repository directory Doc/whatsnew/ has a file 3.n.rst for each 3.n +Python version. For the first entry in each file, add subsection +'IDLE and idlelib', in alphabetical position, to the 'Improved Modules' +section. For the rest of cpython, entries to 3.(n+1).rst begin with +the release of 3.n.0b1. For IDLE, entries for features backported from +'main' to '3.n' during its beta period do not got in 3.(n+1).rst. The +latter usually gets its first entry during the 3.n.0 candidate period +or after the 3.n.0 release. + +When, as per PEP 434, feature changes are backported, entries are placed +in the 3.n.rst file *in the main branch* for each Python version n that +gets the backport. (Note: the format of entries have varied between +versions.) Add a line "New in 3.n maintenance releases." before the +first back-ported feature after 3.n.0 is released. Since each older +version file gets a different number of backports, it is easiest to +make a separate PR for each file and label it with the backports +needed. + +Github repository and issues +---------------------------- + +The CPython repository is https://github.com/python/cpython. The +IDLE Issues listing is https://github.com/orgs/python/projects/31. +The main classification is by Topic, based on the IDLE menu. View the +topics list by clicking the [<]] button in the upper right. diff --git a/Lib/idlelib/TODO.txt b/Lib/idlelib/TODO.txt new file mode 100644 index 00000000000..41b86b0c6d5 --- /dev/null +++ b/Lib/idlelib/TODO.txt @@ -0,0 +1,210 @@ +Original IDLE todo, much of it now outdated: +============================================ +TO DO: + +- improve debugger: + - manage breakpoints globally, allow bp deletion, tbreak, cbreak etc. + - real object browser + - help on how to use it (a simple help button will do wonders) + - performance? (updates of large sets of locals are slow) + - better integration of "debug module" + - debugger should be global resource (attached to flist, not to shell) + - fix the stupid bug where you need to step twice + - display class name in stack viewer entries for methods + - suppress tracing through IDLE internals (e.g. print) DONE + - add a button to suppress through a specific module or class or method + - more object inspection to stack viewer, e.g. to view all array items +- insert the initial current directory into sys.path DONE +- default directory attribute for each window instead of only for windows + that have an associated filename +- command expansion from keywords, module contents, other buffers, etc. +- "Recent documents" menu item DONE +- Filter region command +- Optional horizontal scroll bar +- more Emacsisms: + - ^K should cut to buffer + - M-[, M-] to move by paragraphs + - incremental search? +- search should indicate wrap-around in some way +- restructure state sensitive code to avoid testing flags all the time +- persistent user state (e.g. window and cursor positions, bindings) +- make backups when saving +- check file mtimes at various points +- Pluggable interface with RCS/CVS/Perforce/Clearcase +- better help? +- don't open second class browser on same module (nor second path browser) +- unify class and path browsers +- Need to define a standard way whereby one can determine one is running + inside IDLE (needed for Tk mainloop, also handy for $PYTHONSTARTUP) +- Add more utility methods for use by extensions (a la get_selection) +- Way to run command in totally separate interpreter (fork+os.system?) DONE +- Way to find definition of fully-qualified name: + In other words, select "UserDict.UserDict", hit some magic key and + it loads up UserDict.py and finds the first def or class for UserDict. +- need a way to force colorization on/off +- need a way to force auto-indent on/off + +Details: + +- ^O (on Unix -- open-line) should honor autoindent +- after paste, show end of pasted text +- on Windows, should turn short filename to long filename (not only in argv!) + (shouldn't this be done -- or undone -- by ntpath.normpath?) +- new autoindent after colon even indents when the colon is in a comment! +- sometimes forward slashes in pathname remain +- sometimes star in window name remains in Windows menu +- With unix bindings, ESC by itself is ignored +- Sometimes for no apparent reason a selection from the cursor to the + end of the command buffer appears, which is hard to get rid of + because it stays when you are typing! +- The Line/Col in the status bar can be wrong initially in PyShell DONE + +Structural problems: + +- too much knowledge in FileList about EditorWindow (for example) +- should add some primitives for accessing the selection etc. + to repeat cumbersome code over and over + +====================================================================== + +Jeff Bauer suggests: + +- Open Module doesn't appear to handle hierarchical packages. +- Class browser should also allow hierarchical packages. +- Open and Open Module could benefit from a history, DONE + either command line style, or Microsoft recent-file + style. +- Add a Smalltalk-style inspector (i.e. Tkinspect) + +The last suggestion is already a reality, but not yet +integrated into IDLE. I use a module called inspector.py, +that used to be available from python.org(?) It no longer +appears to be in the contributed section, and the source +has no author attribution. + +In any case, the code is useful for visually navigating +an object's attributes, including its container hierarchy. + + >>> from inspector import Tkinspect + >>> Tkinspect(None, myObject) + +Tkinspect could probably be extended and refined to +integrate better into IDLE. + +====================================================================== + +Comparison to PTUI +------------------ + ++ PTUI's help is better (HTML!) + ++ PTUI can attach a shell to any module + ++ PTUI has some more I/O commands: + open multiple + append + examine (what's that?) + +====================================================================== + +Notes after trying to run Grail +------------------------------- + +- Grail does stuff to sys.path based on sys.argv[0]; you must set +sys.argv[0] to something decent first (it is normally set to the path of +the idle script). + +- Grail must be exec'ed in __main__ because that's imported by some +other parts of Grail. + +- Grail uses a module called History and so does idle :-( + +====================================================================== + +Robin Friedrich's items: + +Things I'd like to see: + - I'd like support for shift-click extending the selection. There's a + bug now that it doesn't work the first time you try it. + - Printing is needed. How hard can that be on Windows? FIRST CUT DONE + - The python-mode trick of autoindenting a line with is neat and + very handy. + - (someday) a spellchecker for docstrings and comments. + - a pagedown/up command key which moves to next class/def statement (top + level) + - split window capability + - DnD text relocation/copying + +Things I don't want to see. + - line numbers... will probably slow things down way too much. + - Please use another icon for the tree browser leaf. The small snake + isn't cutting it. + +---------------------------------------------------------------------- + +- Customizable views (multi-window or multi-pane). (Markus Gritsch) + +- Being able to double click (maybe double right click) on a callable +object in the editor which shows the source of the object, if +possible. (Gerrit Holl) + +- Hooks into the guts, like in Emacs. (Mike Romberg) + +- Sharing the editor with a remote tutor. (Martijn Faassen) + +- Multiple views on the same file. (Tony J Ibbs) + +- Store breakpoints in a global (per-project) database (GvR); Dirk +Heise adds: save some space-trimmed context and search around when +reopening a file that might have been edited by someone else. + +- Capture menu events in extensions without changing the IDLE source. +(Matthias Barmeier) + +- Use overlapping panels (a "notebook" in MFC terms I think) for info +that doesn't need to be accessible simultaneously (e.g. HTML source +and output). Use multi-pane windows for info that does need to be +shown together (e.g. class browser and source). (Albert Brandl) + +- A project should invisibly track all symbols, for instant search, +replace and cross-ref. Projects should be allowed to span multiple +directories, hosts, etc. Project management files are placed in a +directory you specify. A global mapping between project names and +project directories should exist [not so sure --GvR]. (Tim Peters) + +- Merge attr-tips and auto-expand. (Mark Hammond, Tim Peters) + +- Python Shell should behave more like a "shell window" as users know +it -- i.e. you can only edit the current command, and the cursor can't +escape from the command area. (Albert Brandl) + +- Set X11 class to "idle/Idle", set icon and title to something +beginning with "idle" -- for window managers. (Randall Hopper) + +- Config files editable through a preferences dialog. (me) DONE + +- Config files still editable outside the preferences dialog. +(Randall Hopper) DONE + +- When you're editing a command in PyShell, and there are only blank +lines below the cursor, hitting Return should ignore or delete those +blank lines rather than deciding you're not on the last line. (me) + +- Run command (F5 c.s.) should be more like Pythonwin's Run -- a +dialog with options to give command line arguments, run the debugger, +etc. (me) + +- Shouldn't be able to delete part of the prompt (or any text before +it) in the PyShell. (Martijn Faassen) DONE + +- Emacs style auto-fill (also smart about comments and strings). +(Jeremy Hylton) + +- Output of Run Script should go to a separate output window, not to +the shell window. Output of separate runs should all go to the same +window but clearly delimited. (David Scherer) REJECT FIRST, LATTER DONE + +- GUI form designer to kick VB's butt. (Robert Geiger) THAT'S NOT IDLE + +- Printing! Possibly via generation of PDF files which the user must +then send to the printer separately. (Dinu Gherman) FIRST CUT diff --git a/Lib/idlelib/__init__.py b/Lib/idlelib/__init__.py new file mode 100644 index 00000000000..791ddeab797 --- /dev/null +++ b/Lib/idlelib/__init__.py @@ -0,0 +1,10 @@ +"""The idlelib package implements the Idle application. + +Idle includes an interactive shell and editor. +Starting with Python 3.6, IDLE requires tcl/tk 8.5 or later. +Use the files named idle.* to start Idle. + +The other files are private implementations. Their details are subject to +change. See PEP 434 for more. Import them at your own risk. +""" +testing = False # Set True by test.test_idle. diff --git a/Lib/idlelib/__main__.py b/Lib/idlelib/__main__.py new file mode 100644 index 00000000000..ec3915b265f --- /dev/null +++ b/Lib/idlelib/__main__.py @@ -0,0 +1,7 @@ +""" +IDLE main entry point + +Run IDLE as python -m idlelib +""" +import idlelib.pyshell +idlelib.pyshell.main() diff --git a/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py new file mode 100644 index 00000000000..032d3122531 --- /dev/null +++ b/Lib/idlelib/autocomplete.py @@ -0,0 +1,228 @@ +"""Complete either attribute names or file names. + +Either on demand or after a user-selected delay after a key character, +pop up a list of candidates. +""" +import __main__ +import keyword +import os +import string +import sys + +# Modified keyword list is used in fetch_completions. +completion_kwds = [s for s in keyword.kwlist + if s not in {'True', 'False', 'None'}] # In builtins. +completion_kwds.extend(('match', 'case')) # Context keywords. +completion_kwds.sort() + +# Two types of completions; defined here for autocomplete_w import below. +ATTRS, FILES = 0, 1 +from idlelib import autocomplete_w +from idlelib.config import idleConf +from idlelib.hyperparser import HyperParser + +# Tuples passed to open_completions. +# EvalFunc, Complete, WantWin, Mode +FORCE = True, False, True, None # Control-Space. +TAB = False, True, True, None # Tab. +TRY_A = False, False, False, ATTRS # '.' for attributes. +TRY_F = False, False, False, FILES # '/' in quotes for file name. + +# This string includes all chars that may be in an identifier. +# TODO Update this here and elsewhere. +ID_CHARS = string.ascii_letters + string.digits + "_" + +SEPS = f"{os.sep}{os.altsep if os.altsep else ''}" +TRIGGERS = f".{SEPS}" + +class AutoComplete: + + def __init__(self, editwin=None, tags=None): + self.editwin = editwin + if editwin is not None: # not in subprocess or no-gui test + self.text = editwin.text + self.tags = tags + self.autocompletewindow = None + # id of delayed call, and the index of the text insert when + # the delayed call was issued. If _delayed_completion_id is + # None, there is no delayed call. + self._delayed_completion_id = None + self._delayed_completion_index = None + + @classmethod + def reload(cls): + cls.popupwait = idleConf.GetOption( + "extensions", "AutoComplete", "popupwait", type="int", default=0) + + def _make_autocomplete_window(self): # Makes mocking easier. + return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags) + + def _remove_autocomplete_window(self, event=None): + if self.autocompletewindow: + self.autocompletewindow.hide_window() + self.autocompletewindow = None + + def force_open_completions_event(self, event): + "(^space) Open completion list, even if a function call is needed." + self.open_completions(FORCE) + return "break" + + def autocomplete_event(self, event): + "(tab) Complete word or open list if multiple options." + if hasattr(event, "mc_state") and event.mc_state or\ + not self.text.get("insert linestart", "insert").strip(): + # A modifier was pressed along with the tab or + # there is only previous whitespace on this line, so tab. + return None + if self.autocompletewindow and self.autocompletewindow.is_active(): + self.autocompletewindow.complete() + return "break" + else: + opened = self.open_completions(TAB) + return "break" if opened else None + + def try_open_completions_event(self, event=None): + "(./) Open completion list after pause with no movement." + lastchar = self.text.get("insert-1c") + if lastchar in TRIGGERS: + args = TRY_A if lastchar == "." else TRY_F + self._delayed_completion_index = self.text.index("insert") + if self._delayed_completion_id is not None: + self.text.after_cancel(self._delayed_completion_id) + self._delayed_completion_id = self.text.after( + self.popupwait, self._delayed_open_completions, args) + + def _delayed_open_completions(self, args): + "Call open_completions if index unchanged." + self._delayed_completion_id = None + if self.text.index("insert") == self._delayed_completion_index: + self.open_completions(args) + + def open_completions(self, args): + """Find the completions and create the AutoCompleteWindow. + Return True if successful (no syntax error or so found). + If complete is True, then if there's nothing to complete and no + start of completion, won't open completions and return False. + If mode is given, will open a completion list only in this mode. + """ + evalfuncs, complete, wantwin, mode = args + # Cancel another delayed call, if it exists. + if self._delayed_completion_id is not None: + self.text.after_cancel(self._delayed_completion_id) + self._delayed_completion_id = None + + hp = HyperParser(self.editwin, "insert") + curline = self.text.get("insert linestart", "insert") + i = j = len(curline) + if hp.is_in_string() and (not mode or mode==FILES): + # Find the beginning of the string. + # fetch_completions will look at the file system to determine + # whether the string value constitutes an actual file name + # XXX could consider raw strings here and unescape the string + # value if it's not raw. + self._remove_autocomplete_window() + mode = FILES + # Find last separator or string start + while i and curline[i-1] not in "'\"" + SEPS: + i -= 1 + comp_start = curline[i:j] + j = i + # Find string start + while i and curline[i-1] not in "'\"": + i -= 1 + comp_what = curline[i:j] + elif hp.is_in_code() and (not mode or mode==ATTRS): + self._remove_autocomplete_window() + mode = ATTRS + while i and (curline[i-1] in ID_CHARS or ord(curline[i-1]) > 127): + i -= 1 + comp_start = curline[i:j] + if i and curline[i-1] == '.': # Need object with attributes. + hp.set_index("insert-%dc" % (len(curline)-(i-1))) + comp_what = hp.get_expression() + if (not comp_what or + (not evalfuncs and comp_what.find('(') != -1)): + return None + else: + comp_what = "" + else: + return None + + if complete and not comp_what and not comp_start: + return None + comp_lists = self.fetch_completions(comp_what, mode) + if not comp_lists[0]: + return None + self.autocompletewindow = self._make_autocomplete_window() + return not self.autocompletewindow.show_window( + comp_lists, "insert-%dc" % len(comp_start), + complete, mode, wantwin) + + def fetch_completions(self, what, mode): + """Return a pair of lists of completions for something. The first list + is a sublist of the second. Both are sorted. + + If there is a Python subprocess, get the comp. list there. Otherwise, + either fetch_completions() is running in the subprocess itself or it + was called in an IDLE EditorWindow before any script had been run. + + The subprocess environment is that of the most recently run script. If + two unrelated modules are being edited some calltips in the current + module may be inoperative if the module was not the last to run. + """ + try: + rpcclt = self.editwin.flist.pyshell.interp.rpcclt + except: + rpcclt = None + if rpcclt: + return rpcclt.remotecall("exec", "get_the_completion_list", + (what, mode), {}) + else: + if mode == ATTRS: + if what == "": # Main module names. + namespace = {**__main__.__builtins__.__dict__, + **__main__.__dict__} + bigl = eval("dir()", namespace) + bigl.extend(completion_kwds) + bigl.sort() + if "__all__" in bigl: + smalll = sorted(eval("__all__", namespace)) + else: + smalll = [s for s in bigl if s[:1] != '_'] + else: + try: + entity = self.get_entity(what) + bigl = dir(entity) + bigl.sort() + if "__all__" in bigl: + smalll = sorted(entity.__all__) + else: + smalll = [s for s in bigl if s[:1] != '_'] + except: + return [], [] + + elif mode == FILES: + if what == "": + what = "." + try: + expandedpath = os.path.expanduser(what) + bigl = os.listdir(expandedpath) + bigl.sort() + smalll = [s for s in bigl if s[:1] != '.'] + except OSError: + return [], [] + + if not smalll: + smalll = bigl + return smalll, bigl + + def get_entity(self, name): + "Lookup name in a namespace spanning sys.modules and __main.dict__." + return eval(name, {**sys.modules, **__main__.__dict__}) + + +AutoComplete.reload() + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autocomplete', verbosity=2) diff --git a/Lib/idlelib/autocomplete_w.py b/Lib/idlelib/autocomplete_w.py new file mode 100644 index 00000000000..24320b5a3bf --- /dev/null +++ b/Lib/idlelib/autocomplete_w.py @@ -0,0 +1,498 @@ +""" +An auto-completion window for IDLE, used by the autocomplete extension +""" +import platform + +from tkinter import * +from tkinter.ttk import Scrollbar + +from idlelib.autocomplete import FILES, ATTRS +from idlelib.multicall import MC_SHIFT + +HIDE_VIRTUAL_EVENT_NAME = "<>" +HIDE_FOCUS_OUT_SEQUENCE = "" +HIDE_SEQUENCES = (HIDE_FOCUS_OUT_SEQUENCE, "") +KEYPRESS_VIRTUAL_EVENT_NAME = "<>" +# We need to bind event beyond so that the function will be called +# before the default specific IDLE function +KEYPRESS_SEQUENCES = ("", "", "", "", + "", "", "", "", + "", "", "") +KEYRELEASE_VIRTUAL_EVENT_NAME = "<>" +KEYRELEASE_SEQUENCE = "" +LISTUPDATE_SEQUENCE = "" +WINCONFIG_SEQUENCE = "" +DOUBLECLICK_SEQUENCE = "" + +class AutoCompleteWindow: + + def __init__(self, widget, tags): + # The widget (Text) on which we place the AutoCompleteWindow + self.widget = widget + # Tags to mark inserted text with + self.tags = tags + # The widgets we create + self.autocompletewindow = self.listbox = self.scrollbar = None + # The default foreground and background of a selection. Saved because + # they are changed to the regular colors of list items when the + # completion start is not a prefix of the selected completion + self.origselforeground = self.origselbackground = None + # The list of completions + self.completions = None + # A list with more completions, or None + self.morecompletions = None + # The completion mode, either autocomplete.ATTRS or .FILES. + self.mode = None + # The current completion start, on the text box (a string) + self.start = None + # The index of the start of the completion + self.startindex = None + # The last typed start, used so that when the selection changes, + # the new start will be as close as possible to the last typed one. + self.lasttypedstart = None + # Do we have an indication that the user wants the completion window + # (for example, he clicked the list) + self.userwantswindow = None + # event ids + self.hideid = self.keypressid = self.listupdateid = \ + self.winconfigid = self.keyreleaseid = self.doubleclickid = None + # Flag set if last keypress was a tab + self.lastkey_was_tab = False + # Flag set to avoid recursive callback invocations. + self.is_configuring = False + + def _change_start(self, newstart): + min_len = min(len(self.start), len(newstart)) + i = 0 + while i < min_len and self.start[i] == newstart[i]: + i += 1 + if i < len(self.start): + self.widget.delete("%s+%dc" % (self.startindex, i), + "%s+%dc" % (self.startindex, len(self.start))) + if i < len(newstart): + self.widget.insert("%s+%dc" % (self.startindex, i), + newstart[i:], + self.tags) + self.start = newstart + + def _binary_search(self, s): + """Find the first index in self.completions where completions[i] is + greater or equal to s, or the last index if there is no such. + """ + i = 0; j = len(self.completions) + while j > i: + m = (i + j) // 2 + if self.completions[m] >= s: + j = m + else: + i = m + 1 + return min(i, len(self.completions)-1) + + def _complete_string(self, s): + """Assuming that s is the prefix of a string in self.completions, + return the longest string which is a prefix of all the strings which + s is a prefix of them. If s is not a prefix of a string, return s. + """ + first = self._binary_search(s) + if self.completions[first][:len(s)] != s: + # There is not even one completion which s is a prefix of. + return s + # Find the end of the range of completions where s is a prefix of. + i = first + 1 + j = len(self.completions) + while j > i: + m = (i + j) // 2 + if self.completions[m][:len(s)] != s: + j = m + else: + i = m + 1 + last = i-1 + + if first == last: # only one possible completion + return self.completions[first] + + # We should return the maximum prefix of first and last + first_comp = self.completions[first] + last_comp = self.completions[last] + min_len = min(len(first_comp), len(last_comp)) + i = len(s) + while i < min_len and first_comp[i] == last_comp[i]: + i += 1 + return first_comp[:i] + + def _selection_changed(self): + """Call when the selection of the Listbox has changed. + + Updates the Listbox display and calls _change_start. + """ + cursel = int(self.listbox.curselection()[0]) + + self.listbox.see(cursel) + + lts = self.lasttypedstart + selstart = self.completions[cursel] + if self._binary_search(lts) == cursel: + newstart = lts + else: + min_len = min(len(lts), len(selstart)) + i = 0 + while i < min_len and lts[i] == selstart[i]: + i += 1 + newstart = selstart[:i] + self._change_start(newstart) + + if self.completions[cursel][:len(self.start)] == self.start: + # start is a prefix of the selected completion + self.listbox.configure(selectbackground=self.origselbackground, + selectforeground=self.origselforeground) + else: + self.listbox.configure(selectbackground=self.listbox.cget("bg"), + selectforeground=self.listbox.cget("fg")) + # If there are more completions, show them, and call me again. + if self.morecompletions: + self.completions = self.morecompletions + self.morecompletions = None + self.listbox.delete(0, END) + for item in self.completions: + self.listbox.insert(END, item) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + + def show_window(self, comp_lists, index, complete, mode, userWantsWin): + """Show the autocomplete list, bind events. + + If complete is True, complete the text, and if there is exactly + one matching completion, don't open a list. + """ + # Handle the start we already have + self.completions, self.morecompletions = comp_lists + self.mode = mode + self.startindex = self.widget.index(index) + self.start = self.widget.get(self.startindex, "insert") + if complete: + completed = self._complete_string(self.start) + start = self.start + self._change_start(completed) + i = self._binary_search(completed) + if self.completions[i] == completed and \ + (i == len(self.completions)-1 or + self.completions[i+1][:len(completed)] != completed): + # There is exactly one matching completion + return completed == start + self.userwantswindow = userWantsWin + self.lasttypedstart = self.start + + self.autocompletewindow = acw = Toplevel(self.widget) + acw.withdraw() + acw.wm_overrideredirect(1) + try: + # Prevent grabbing focus on macOS. + acw.tk.call("::tk::unsupported::MacWindowStyle", "style", acw._w, + "help", "noActivates") + except TclError: + pass + self.scrollbar = scrollbar = Scrollbar(acw, orient=VERTICAL) + self.listbox = listbox = Listbox(acw, yscrollcommand=scrollbar.set, + exportselection=False) + for item in self.completions: + listbox.insert(END, item) + self.origselforeground = listbox.cget("selectforeground") + self.origselbackground = listbox.cget("selectbackground") + scrollbar.config(command=listbox.yview) + scrollbar.pack(side=RIGHT, fill=Y) + listbox.pack(side=LEFT, fill=BOTH, expand=True) + #acw.update_idletasks() # Need for tk8.6.8 on macOS: #40128. + acw.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + # Initialize the listbox selection + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + + # bind events + self.hideaid = acw.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) + self.hidewid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) + acw.event_add(HIDE_VIRTUAL_EVENT_NAME, HIDE_FOCUS_OUT_SEQUENCE) + for seq in HIDE_SEQUENCES: + self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq) + + self.keypressid = self.widget.bind(KEYPRESS_VIRTUAL_EVENT_NAME, + self.keypress_event) + for seq in KEYPRESS_SEQUENCES: + self.widget.event_add(KEYPRESS_VIRTUAL_EVENT_NAME, seq) + self.keyreleaseid = self.widget.bind(KEYRELEASE_VIRTUAL_EVENT_NAME, + self.keyrelease_event) + self.widget.event_add(KEYRELEASE_VIRTUAL_EVENT_NAME,KEYRELEASE_SEQUENCE) + self.listupdateid = listbox.bind(LISTUPDATE_SEQUENCE, + self.listselect_event) + self.is_configuring = False + self.winconfigid = acw.bind(WINCONFIG_SEQUENCE, self.winconfig_event) + self.doubleclickid = listbox.bind(DOUBLECLICK_SEQUENCE, + self.doubleclick_event) + return None + + def winconfig_event(self, event): + if self.is_configuring: + # Avoid running on recursive callback invocations. + return + + self.is_configuring = True + if not self.is_active(): + return + + # Since the event may occur after the completion window is gone, + # catch potential TclError exceptions when accessing acw. See: bpo-41611. + try: + # Position the completion list window + text = self.widget + text.see(self.startindex) + x, y, cx, cy = text.bbox(self.startindex) + acw = self.autocompletewindow + if platform.system().startswith('Windows'): + # On Windows an update() call is needed for the completion + # list window to be created, so that we can fetch its width + # and height. However, this is not needed on other platforms + # (tested on Ubuntu and macOS) but at one point began + # causing freezes on macOS. See issues 37849 and 41611. + acw.update() + acw_width, acw_height = acw.winfo_width(), acw.winfo_height() + text_width, text_height = text.winfo_width(), text.winfo_height() + new_x = text.winfo_rootx() + min(x, max(0, text_width - acw_width)) + new_y = text.winfo_rooty() + y + if (text_height - (y + cy) >= acw_height # enough height below + or y < acw_height): # not enough height above + # place acw below current line + new_y += cy + else: + # place acw above current line + new_y -= acw_height + acw.wm_geometry("+%d+%d" % (new_x, new_y)) + acw.deiconify() + acw.update_idletasks() + except TclError: + pass + + if platform.system().startswith('Windows'): + # See issue 15786. When on Windows platform, Tk will misbehave + # to call winconfig_event multiple times, we need to prevent this, + # otherwise mouse button double click will not be able to used. + try: + acw.unbind(WINCONFIG_SEQUENCE, self.winconfigid) + except TclError: + pass + self.winconfigid = None + + self.is_configuring = False + + def _hide_event_check(self): + if not self.autocompletewindow: + return + + try: + if not self.autocompletewindow.focus_get(): + self.hide_window() + except KeyError: + # See issue 734176, when user click on menu, acw.focus_get() + # will get KeyError. + self.hide_window() + + def hide_event(self, event): + # Hide autocomplete list if it exists and does not have focus or + # mouse click on widget / text area. + if self.is_active(): + if event.type == EventType.FocusOut: + # On Windows platform, it will need to delay the check for + # acw.focus_get() when click on acw, otherwise it will return + # None and close the window + self.widget.after(1, self._hide_event_check) + elif event.type == EventType.ButtonPress: + # ButtonPress event only bind to self.widget + self.hide_window() + + def listselect_event(self, event): + if self.is_active(): + self.userwantswindow = True + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + + def doubleclick_event(self, event): + # Put the selected completion in the text, and close the list + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + self.hide_window() + + def keypress_event(self, event): + if not self.is_active(): + return None + keysym = event.keysym + if hasattr(event, "mc_state"): + state = event.mc_state + else: + state = 0 + if keysym != "Tab": + self.lastkey_was_tab = False + if (len(keysym) == 1 or keysym in ("underscore", "BackSpace") + or (self.mode == FILES and keysym in + ("period", "minus"))) \ + and not (state & ~MC_SHIFT): + # Normal editing of text + if len(keysym) == 1: + self._change_start(self.start + keysym) + elif keysym == "underscore": + self._change_start(self.start + '_') + elif keysym == "period": + self._change_start(self.start + '.') + elif keysym == "minus": + self._change_start(self.start + '-') + else: + # keysym == "BackSpace" + if len(self.start) == 0: + self.hide_window() + return None + self._change_start(self.start[:-1]) + self.lasttypedstart = self.start + self.listbox.select_clear(0, int(self.listbox.curselection()[0])) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + return "break" + + elif keysym == "Return": + self.complete() + self.hide_window() + return 'break' + + elif (self.mode == ATTRS and keysym in + ("period", "space", "parenleft", "parenright", "bracketleft", + "bracketright")) or \ + (self.mode == FILES and keysym in + ("slash", "backslash", "quotedbl", "apostrophe")) \ + and not (state & ~MC_SHIFT): + # If start is a prefix of the selection, but is not '' when + # completing file names, put the whole + # selected completion. Anyway, close the list. + cursel = int(self.listbox.curselection()[0]) + if self.completions[cursel][:len(self.start)] == self.start \ + and (self.mode == ATTRS or self.start): + self._change_start(self.completions[cursel]) + self.hide_window() + return None + + elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \ + not state: + # Move the selection in the listbox + self.userwantswindow = True + cursel = int(self.listbox.curselection()[0]) + if keysym == "Home": + newsel = 0 + elif keysym == "End": + newsel = len(self.completions)-1 + elif keysym in ("Prior", "Next"): + jump = self.listbox.nearest(self.listbox.winfo_height()) - \ + self.listbox.nearest(0) + if keysym == "Prior": + newsel = max(0, cursel-jump) + else: + assert keysym == "Next" + newsel = min(len(self.completions)-1, cursel+jump) + elif keysym == "Up": + newsel = max(0, cursel-1) + else: + assert keysym == "Down" + newsel = min(len(self.completions)-1, cursel+1) + self.listbox.select_clear(cursel) + self.listbox.select_set(newsel) + self._selection_changed() + self._change_start(self.completions[newsel]) + return "break" + + elif (keysym == "Tab" and not state): + if self.lastkey_was_tab: + # two tabs in a row; insert current selection and close acw + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + self.hide_window() + return "break" + else: + # first tab; let AutoComplete handle the completion + self.userwantswindow = True + self.lastkey_was_tab = True + return None + + elif any(s in keysym for s in ("Shift", "Control", "Alt", + "Meta", "Command", "Option")): + # A modifier key, so ignore + return None + + elif event.char and event.char >= ' ': + # Regular character with a non-length-1 keycode + self._change_start(self.start + event.char) + self.lasttypedstart = self.start + self.listbox.select_clear(0, int(self.listbox.curselection()[0])) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + return "break" + + else: + # Unknown event, close the window and let it through. + self.hide_window() + return None + + def keyrelease_event(self, event): + if not self.is_active(): + return + if self.widget.index("insert") != \ + self.widget.index("%s+%dc" % (self.startindex, len(self.start))): + # If we didn't catch an event which moved the insert, close window + self.hide_window() + + def is_active(self): + return self.autocompletewindow is not None + + def complete(self): + self._change_start(self._complete_string(self.start)) + # The selection doesn't change. + + def hide_window(self): + if not self.is_active(): + return + + # unbind events + self.autocompletewindow.event_delete(HIDE_VIRTUAL_EVENT_NAME, + HIDE_FOCUS_OUT_SEQUENCE) + for seq in HIDE_SEQUENCES: + self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq) + + self.autocompletewindow.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideaid) + self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hidewid) + self.hideaid = None + self.hidewid = None + for seq in KEYPRESS_SEQUENCES: + self.widget.event_delete(KEYPRESS_VIRTUAL_EVENT_NAME, seq) + self.widget.unbind(KEYPRESS_VIRTUAL_EVENT_NAME, self.keypressid) + self.keypressid = None + self.widget.event_delete(KEYRELEASE_VIRTUAL_EVENT_NAME, + KEYRELEASE_SEQUENCE) + self.widget.unbind(KEYRELEASE_VIRTUAL_EVENT_NAME, self.keyreleaseid) + self.keyreleaseid = None + self.listbox.unbind(LISTUPDATE_SEQUENCE, self.listupdateid) + self.listupdateid = None + if self.winconfigid: + self.autocompletewindow.unbind(WINCONFIG_SEQUENCE, self.winconfigid) + self.winconfigid = None + + # Re-focusOn frame.text (See issue #15786) + self.widget.focus_set() + + # destroy widgets + self.scrollbar.destroy() + self.scrollbar = None + self.listbox.destroy() + self.listbox = None + self.autocompletewindow.destroy() + self.autocompletewindow = None + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autocomplete_w', verbosity=2, exit=False) + +# TODO: autocomplete/w htest here diff --git a/Lib/idlelib/autoexpand.py b/Lib/idlelib/autoexpand.py new file mode 100644 index 00000000000..92f5c84eb6f --- /dev/null +++ b/Lib/idlelib/autoexpand.py @@ -0,0 +1,96 @@ +'''Complete the current word before the cursor with words in the editor. + +Each menu selection or shortcut key selection replaces the word with a +different word with the same prefix. The search for matches begins +before the target and moves toward the top of the editor. It then starts +after the cursor and moves down. It then returns to the original word and +the cycle starts again. + +Changing the current text line or leaving the cursor in a different +place before requesting the next selection causes AutoExpand to reset +its state. + +There is only one instance of Autoexpand. +''' +import re +import string + + +class AutoExpand: + wordchars = string.ascii_letters + string.digits + "_" + + def __init__(self, editwin): + self.text = editwin.text + self.bell = self.text.bell + self.state = None + + def expand_word_event(self, event): + "Replace the current word with the next expansion." + curinsert = self.text.index("insert") + curline = self.text.get("insert linestart", "insert lineend") + if not self.state: + words = self.getwords() + index = 0 + else: + words, index, insert, line = self.state + if insert != curinsert or line != curline: + words = self.getwords() + index = 0 + if not words: + self.bell() + return "break" + word = self.getprevword() + self.text.delete("insert - %d chars" % len(word), "insert") + newword = words[index] + index = (index + 1) % len(words) + if index == 0: + self.bell() # Warn we cycled around + self.text.insert("insert", newword) + curinsert = self.text.index("insert") + curline = self.text.get("insert linestart", "insert lineend") + self.state = words, index, curinsert, curline + return "break" + + def getwords(self): + "Return a list of words that match the prefix before the cursor." + word = self.getprevword() + if not word: + return [] + before = self.text.get("1.0", "insert wordstart") + wbefore = re.findall(r"\b" + word + r"\w+\b", before) + del before + after = self.text.get("insert wordend", "end") + wafter = re.findall(r"\b" + word + r"\w+\b", after) + del after + if not wbefore and not wafter: + return [] + words = [] + dict = {} + # search backwards through words before + wbefore.reverse() + for w in wbefore: + if dict.get(w): + continue + words.append(w) + dict[w] = w + # search onwards through words after + for w in wafter: + if dict.get(w): + continue + words.append(w) + dict[w] = w + words.append(word) + return words + + def getprevword(self): + "Return the word prefix before the cursor." + line = self.text.get("insert linestart", "insert") + i = len(line) + while i > 0 and line[i-1] in self.wordchars: + i = i-1 + return line[i:] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autoexpand', verbosity=2) diff --git a/Lib/idlelib/browser.py b/Lib/idlelib/browser.py new file mode 100644 index 00000000000..8b9060e5707 --- /dev/null +++ b/Lib/idlelib/browser.py @@ -0,0 +1,260 @@ +"""Module browser. + +XXX TO DO: + +- reparse when source changed (maybe just a button would be OK?) + (or recheck on window popup) +- add popup menu with more options (e.g. doc strings, base classes, imports) +- add base classes to class browser tree +""" + +import os +import pyclbr +import sys + +from idlelib.config import idleConf +from idlelib import pyshell +from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas +from idlelib.util import py_extensions +from idlelib.window import ListedToplevel + + +file_open = None # Method...Item and Class...Item use this. +# Normally pyshell.flist.open, but there is no pyshell.flist for htest. + +# The browser depends on pyclbr and importlib which do not support .pyi files. +browseable_extension_blocklist = ('.pyi',) + + +def is_browseable_extension(path): + _, ext = os.path.splitext(path) + ext = os.path.normcase(ext) + return ext in py_extensions and ext not in browseable_extension_blocklist + + +def transform_children(child_dict, modname=None): + """Transform a child dictionary to an ordered sequence of objects. + + The dictionary maps names to pyclbr information objects. + Filter out imported objects. + Augment class names with bases. + The insertion order of the dictionary is assumed to have been in line + number order, so sorting is not necessary. + + The current tree only calls this once per child_dict as it saves + TreeItems once created. A future tree and tests might violate this, + so a check prevents multiple in-place augmentations. + """ + obs = [] # Use list since values should already be sorted. + for key, obj in child_dict.items(): + if modname is None or obj.module == modname: + if hasattr(obj, 'super') and obj.super and obj.name == key: + # If obj.name != key, it has already been suffixed. + supers = [] + for sup in obj.super: + if isinstance(sup, str): + sname = sup + else: + sname = sup.name + if sup.module != obj.module: + sname = f'{sup.module}.{sname}' + supers.append(sname) + obj.name += '({})'.format(', '.join(supers)) + obs.append(obj) + return obs + + +class ModuleBrowser: + """Browse module classes and functions in IDLE. + """ + # This class is also the base class for pathbrowser.PathBrowser. + # Init and close are inherited, other methods are overridden. + # PathBrowser.__init__ does not call __init__ below. + + def __init__(self, master, path, *, _htest=False, _utest=False): + """Create a window for browsing a module's structure. + + Args: + master: parent for widgets. + path: full path of file to browse. + _htest - bool; change box location when running htest. + -utest - bool; suppress contents when running unittest. + + Global variables: + file_open: Function used for opening a file. + + Instance variables: + name: Module name. + file: Full path and module with supported extension. + Used in creating ModuleBrowserTreeItem as the rootnode for + the tree and subsequently in the children. + """ + self.master = master + self.path = path + self._htest = _htest + self._utest = _utest + self.init() + + def close(self, event=None): + "Dismiss the window and the tree nodes." + self.top.destroy() + self.node.destroy() + + def init(self): + "Create browser tkinter widgets, including the tree." + global file_open + root = self.master + flist = (pyshell.flist if not (self._htest or self._utest) + else pyshell.PyShellFileList(root)) + file_open = flist.open + pyclbr._modules.clear() + + # create top + self.top = top = ListedToplevel(root) + top.protocol("WM_DELETE_WINDOW", self.close) + top.bind("", self.close) + if self._htest: # place dialog below parent if running htest + top.geometry("+%d+%d" % + (root.winfo_rootx(), root.winfo_rooty() + 200)) + self.settitle() + top.focus_set() + + # create scrolled canvas + theme = idleConf.CurrentTheme() + background = idleConf.GetHighlight(theme, 'normal')['background'] + sc = ScrolledCanvas(top, bg=background, highlightthickness=0, + takefocus=1) + sc.frame.pack(expand=1, fill="both") + item = self.rootnode() + self.node = node = TreeNode(sc.canvas, None, item) + if not self._utest: + node.update() + node.expand() + + def settitle(self): + "Set the window title." + self.top.wm_title("Module Browser - " + os.path.basename(self.path)) + self.top.wm_iconname("Module Browser") + + def rootnode(self): + "Return a ModuleBrowserTreeItem as the root of the tree." + return ModuleBrowserTreeItem(self.path) + + +class ModuleBrowserTreeItem(TreeItem): + """Browser tree for Python module. + + Uses TreeItem as the basis for the structure of the tree. + Used by both browsers. + """ + + def __init__(self, file): + """Create a TreeItem for the file. + + Args: + file: Full path and module name. + """ + self.file = file + + def GetText(self): + "Return the module name as the text string to display." + return os.path.basename(self.file) + + def GetIconName(self): + "Return the name of the icon to display." + return "python" + + def GetSubList(self): + "Return ChildBrowserTreeItems for children." + return [ChildBrowserTreeItem(obj) for obj in self.listchildren()] + + def OnDoubleClick(self): + "Open a module in an editor window when double clicked." + if not is_browseable_extension(self.file): + return + if not os.path.exists(self.file): + return + file_open(self.file) + + def IsExpandable(self): + "Return True if Python file." + return is_browseable_extension(self.file) + + def listchildren(self): + "Return sequenced classes and functions in the module." + if not is_browseable_extension(self.file): + return [] + dir, base = os.path.split(self.file) + name, _ = os.path.splitext(base) + try: + tree = pyclbr.readmodule_ex(name, [dir] + sys.path) + except ImportError: + return [] + return transform_children(tree, name) + + +class ChildBrowserTreeItem(TreeItem): + """Browser tree for child nodes within the module. + + Uses TreeItem as the basis for the structure of the tree. + """ + + def __init__(self, obj): + "Create a TreeItem for a pyclbr class/function object." + self.obj = obj + self.name = obj.name + self.isfunction = isinstance(obj, pyclbr.Function) + + def GetText(self): + "Return the name of the function/class to display." + name = self.name + if self.isfunction: + return "def " + name + "(...)" + else: + return "class " + name + + def GetIconName(self): + "Return the name of the icon to display." + if self.isfunction: + return "python" + else: + return "folder" + + def IsExpandable(self): + "Return True if self.obj has nested objects." + return self.obj.children != {} + + def GetSubList(self): + "Return ChildBrowserTreeItems for children." + return [ChildBrowserTreeItem(obj) + for obj in transform_children(self.obj.children)] + + def OnDoubleClick(self): + "Open module with file_open and position to lineno." + try: + edit = file_open(self.obj.file) + edit.gotoline(self.obj.lineno) + except (OSError, AttributeError): + pass + + +def _module_browser(parent): # htest # + if len(sys.argv) > 1: # If pass file on command line. + file = sys.argv[1] + else: + file = __file__ + # Add nested objects for htest. + class Nested_in_func(TreeNode): + def nested_in_class(): pass + def closure(): + class Nested_in_closure: pass + ModuleBrowser(parent, file, _htest=True) + + +if __name__ == "__main__": + if len(sys.argv) == 1: # If pass file on command line, unittest fails. + from unittest import main + main('idlelib.idle_test.test_browser', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_module_browser) diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py new file mode 100644 index 00000000000..40bc5a0ad79 --- /dev/null +++ b/Lib/idlelib/calltip.py @@ -0,0 +1,205 @@ +"""Pop up a reminder of how to call a function. + +Call Tips are floating windows which display function, class, and method +parameter and docstring information when you type an opening parenthesis, and +which disappear when you type a closing parenthesis. +""" +import __main__ +import inspect +import re +import sys +import textwrap +import types + +from idlelib import calltip_w +from idlelib.hyperparser import HyperParser + + +class Calltip: + + def __init__(self, editwin=None): + if editwin is None: # subprocess and test + self.editwin = None + else: + self.editwin = editwin + self.text = editwin.text + self.active_calltip = None + self._calltip_window = self._make_tk_calltip_window + + def close(self): + self._calltip_window = None + + def _make_tk_calltip_window(self): + # See __init__ for usage + return calltip_w.CalltipWindow(self.text) + + def remove_calltip_window(self, event=None): + if self.active_calltip: + self.active_calltip.hidetip() + self.active_calltip = None + + def force_open_calltip_event(self, event): + "The user selected the menu entry or hotkey, open the tip." + self.open_calltip(True) + return "break" + + def try_open_calltip_event(self, event): + """Happens when it would be nice to open a calltip, but not really + necessary, for example after an opening bracket, so function calls + won't be made. + """ + self.open_calltip(False) + + def refresh_calltip_event(self, event): + if self.active_calltip and self.active_calltip.tipwindow: + self.open_calltip(False) + + def open_calltip(self, evalfuncs): + """Maybe close an existing calltip and maybe open a new calltip. + + Called from (force_open|try_open|refresh)_calltip_event functions. + """ + hp = HyperParser(self.editwin, "insert") + sur_paren = hp.get_surrounding_brackets('(') + + # If not inside parentheses, no calltip. + if not sur_paren: + self.remove_calltip_window() + return + + # If a calltip is shown for the current parentheses, do + # nothing. + if self.active_calltip: + opener_line, opener_col = map(int, sur_paren[0].split('.')) + if ( + (opener_line, opener_col) == + (self.active_calltip.parenline, self.active_calltip.parencol) + ): + return + + hp.set_index(sur_paren[0]) + try: + expression = hp.get_expression() + except ValueError: + expression = None + if not expression: + # No expression before the opening parenthesis, e.g. + # because it's in a string or the opener for a tuple: + # Do nothing. + return + + # At this point, the current index is after an opening + # parenthesis, in a section of code, preceded by a valid + # expression. If there is a calltip shown, it's not for the + # same index and should be closed. + self.remove_calltip_window() + + # Simple, fast heuristic: If the preceding expression includes + # an opening parenthesis, it likely includes a function call. + if not evalfuncs and (expression.find('(') != -1): + return + + argspec = self.fetch_tip(expression) + if not argspec: + return + self.active_calltip = self._calltip_window() + self.active_calltip.showtip(argspec, sur_paren[0], sur_paren[1]) + + def fetch_tip(self, expression): + """Return the argument list and docstring of a function or class. + + If there is a Python subprocess, get the calltip there. Otherwise, + either this fetch_tip() is running in the subprocess or it was + called in an IDLE running without the subprocess. + + The subprocess environment is that of the most recently run script. If + two unrelated modules are being edited some calltips in the current + module may be inoperative if the module was not the last to run. + + To find methods, fetch_tip must be fed a fully qualified name. + + """ + try: + rpcclt = self.editwin.flist.pyshell.interp.rpcclt + except AttributeError: + rpcclt = None + if rpcclt: + return rpcclt.remotecall("exec", "get_the_calltip", + (expression,), {}) + else: + return get_argspec(get_entity(expression)) + + +def get_entity(expression): + """Return the object corresponding to expression evaluated + in a namespace spanning sys.modules and __main.dict__. + """ + if expression: + namespace = {**sys.modules, **__main__.__dict__} + try: + return eval(expression, namespace) # Only protect user code. + except BaseException: + # An uncaught exception closes idle, and eval can raise any + # exception, especially if user classes are involved. + return None + +# The following are used in get_argspec and some in tests +_MAX_COLS = 85 +_MAX_LINES = 5 # enough for bytes +_INDENT = ' '*4 # for wrapped signatures +_first_param = re.compile(r'(?<=\()\w*\,?\s*') +_default_callable_argspec = "See source or doc" +_invalid_method = "invalid method signature" + +def get_argspec(ob): + '''Return a string describing the signature of a callable object, or ''. + + For Python-coded functions and methods, the first line is introspected. + Delete 'self' parameter for classes (.__init__) and bound methods. + The next lines are the first lines of the doc string up to the first + empty line or _MAX_LINES. For builtins, this typically includes + the arguments in addition to the return value. + ''' + # Determine function object fob to inspect. + try: + ob_call = ob.__call__ + except BaseException: # Buggy user object could raise anything. + return '' # No popup for non-callables. + # For Get_argspecTest.test_buggy_getattr_class, CallA() & CallB(). + fob = ob_call if isinstance(ob_call, types.MethodType) else ob + + # Initialize argspec and wrap it to get lines. + try: + argspec = str(inspect.signature(fob)) + except Exception as err: + msg = str(err) + if msg.startswith(_invalid_method): + return _invalid_method + else: + argspec = '' + + if isinstance(fob, type) and argspec == '()': + # If fob has no argument, use default callable argspec. + argspec = _default_callable_argspec + + lines = (textwrap.wrap(argspec, _MAX_COLS, subsequent_indent=_INDENT) + if len(argspec) > _MAX_COLS else [argspec] if argspec else []) + + # Augment lines from docstring, if any, and join to get argspec. + doc = inspect.getdoc(ob) + if doc: + for line in doc.split('\n', _MAX_LINES)[:_MAX_LINES]: + line = line.strip() + if not line: + break + if len(line) > _MAX_COLS: + line = line[: _MAX_COLS - 3] + '...' + lines.append(line) + argspec = '\n'.join(lines) + + return argspec or _default_callable_argspec + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_calltip', verbosity=2) diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py new file mode 100644 index 00000000000..9386376058c --- /dev/null +++ b/Lib/idlelib/calltip_w.py @@ -0,0 +1,202 @@ +"""A call-tip window class for Tkinter/IDLE. + +After tooltip.py, which uses ideas gleaned from PySol. +Used by calltip.py. +""" +from tkinter import Label, LEFT, SOLID, TclError + +from idlelib.tooltip import TooltipBase + +HIDE_EVENT = "<>" +HIDE_SEQUENCES = ("", "") +CHECKHIDE_EVENT = "<>" +CHECKHIDE_SEQUENCES = ("", "") +CHECKHIDE_TIME = 100 # milliseconds + +MARK_RIGHT = "calltipwindowregion_right" + + +class CalltipWindow(TooltipBase): + """A call-tip widget for tkinter text widgets.""" + + def __init__(self, text_widget): + """Create a call-tip; shown by showtip(). + + text_widget: a Text widget with code for which call-tips are desired + """ + # Note: The Text widget will be accessible as self.anchor_widget + super().__init__(text_widget) + + self.label = self.text = None + self.parenline = self.parencol = self.lastline = None + self.hideid = self.checkhideid = None + self.checkhide_after_id = None + + def get_position(self): + """Choose the position of the call-tip.""" + curline = int(self.anchor_widget.index("insert").split('.')[0]) + if curline == self.parenline: + anchor_index = (self.parenline, self.parencol) + else: + anchor_index = (curline, 0) + box = self.anchor_widget.bbox("%d.%d" % anchor_index) + if not box: + box = list(self.anchor_widget.bbox("insert")) + # align to left of window + box[0] = 0 + box[2] = 0 + return box[0] + 2, box[1] + box[3] + + def position_window(self): + "Reposition the window if needed." + curline = int(self.anchor_widget.index("insert").split('.')[0]) + if curline == self.lastline: + return + self.lastline = curline + self.anchor_widget.see("insert") + super().position_window() + + def showtip(self, text, parenleft, parenright): + """Show the call-tip, bind events which will close it and reposition it. + + text: the text to display in the call-tip + parenleft: index of the opening parenthesis in the text widget + parenright: index of the closing parenthesis in the text widget, + or the end of the line if there is no closing parenthesis + """ + # Only called in calltip.Calltip, where lines are truncated + self.text = text + if self.tipwindow or not self.text: + return + + self.anchor_widget.mark_set(MARK_RIGHT, parenright) + self.parenline, self.parencol = map( + int, self.anchor_widget.index(parenleft).split(".")) + + super().showtip() + + self._bind_events() + + def showcontents(self): + """Create the call-tip widget.""" + self.label = Label(self.tipwindow, text=self.text, justify=LEFT, + background="#ffffd0", foreground="black", + relief=SOLID, borderwidth=1, + font=self.anchor_widget['font']) + self.label.pack() + + def checkhide_event(self, event=None): + """Handle CHECK_HIDE_EVENT: call hidetip or reschedule.""" + if not self.tipwindow: + # If the event was triggered by the same event that unbound + # this function, the function will be called nevertheless, + # so do nothing in this case. + return None + + # Hide the call-tip if the insertion cursor moves outside of the + # parenthesis. + curline, curcol = map(int, self.anchor_widget.index("insert").split('.')) + if curline < self.parenline or \ + (curline == self.parenline and curcol <= self.parencol) or \ + self.anchor_widget.compare("insert", ">", MARK_RIGHT): + self.hidetip() + return "break" + + # Not hiding the call-tip. + + self.position_window() + # Re-schedule this function to be called again in a short while. + if self.checkhide_after_id is not None: + self.anchor_widget.after_cancel(self.checkhide_after_id) + self.checkhide_after_id = \ + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + return None + + def hide_event(self, event): + """Handle HIDE_EVENT by calling hidetip.""" + if not self.tipwindow: + # See the explanation in checkhide_event. + return None + self.hidetip() + return "break" + + def hidetip(self): + """Hide the call-tip.""" + if not self.tipwindow: + return + + try: + self.label.destroy() + except TclError: + pass + self.label = None + + self.parenline = self.parencol = self.lastline = None + try: + self.anchor_widget.mark_unset(MARK_RIGHT) + except TclError: + pass + + try: + self._unbind_events() + except (TclError, ValueError): + # ValueError may be raised by MultiCall + pass + + super().hidetip() + + def _bind_events(self): + """Bind event handlers.""" + self.checkhideid = self.anchor_widget.bind(CHECKHIDE_EVENT, + self.checkhide_event) + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_add(CHECKHIDE_EVENT, seq) + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + self.hideid = self.anchor_widget.bind(HIDE_EVENT, + self.hide_event) + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_add(HIDE_EVENT, seq) + + def _unbind_events(self): + """Unbind event handlers.""" + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_delete(CHECKHIDE_EVENT, seq) + self.anchor_widget.unbind(CHECKHIDE_EVENT, self.checkhideid) + self.checkhideid = None + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_delete(HIDE_EVENT, seq) + self.anchor_widget.unbind(HIDE_EVENT, self.hideid) + self.hideid = None + + +def _calltip_window(parent): # htest # + from tkinter import Toplevel, Text, LEFT, BOTH + + top = Toplevel(parent) + top.title("Test call-tips") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("250x100+%d+%d" % (x + 175, y + 150)) + text = Text(top) + text.pack(side=LEFT, fill=BOTH, expand=1) + text.insert("insert", "string.split") + top.update() + + calltip = CalltipWindow(text) + def calltip_show(event): + calltip.showtip("(s='Hello world')", "insert", "end") + def calltip_hide(event): + calltip.hidetip() + text.event_add("<>", "(") + text.event_add("<>", ")") + text.bind("<>", calltip_show) + text.bind("<>", calltip_hide) + + text.focus_set() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_calltip_w', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_calltip_window) diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py new file mode 100644 index 00000000000..f2f44f5f8d4 --- /dev/null +++ b/Lib/idlelib/codecontext.py @@ -0,0 +1,270 @@ +"""codecontext - display the block context above the edit window + +Once code has scrolled off the top of a window, it can be difficult to +determine which block you are in. This extension implements a pane at the top +of each IDLE edit window which provides block structure hints. These hints are +the lines which contain the block opening keywords, e.g. 'if', for the +enclosing block. The number of hint lines is determined by the maxlines +variable in the codecontext section of config-extensions.def. Lines which do +not open blocks are not shown in the context hints pane. + +For EditorWindows, <> is bound to CodeContext(self). +toggle_code_context_event. +""" +import re +from sys import maxsize as INFINITY + +from tkinter import Frame, Text, TclError +from tkinter.constants import NSEW, SUNKEN + +from idlelib.config import idleConf + +BLOCKOPENERS = {'class', 'def', 'if', 'elif', 'else', 'while', 'for', + 'try', 'except', 'finally', 'with', 'async'} + + +def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")): + "Extract the beginning whitespace and first word from codeline." + return c.match(codeline).groups() + + +def get_line_info(codeline): + """Return tuple of (line indent value, codeline, block start keyword). + + The indentation of empty lines (or comment lines) is INFINITY. + If the line does not start a block, the keyword value is False. + """ + spaces, firstword = get_spaces_firstword(codeline) + indent = len(spaces) + if len(codeline) == indent or codeline[indent] == '#': + indent = INFINITY + opener = firstword in BLOCKOPENERS and firstword + return indent, codeline, opener + + +class CodeContext: + "Display block context above the edit window." + UPDATEINTERVAL = 100 # millisec + + def __init__(self, editwin): + """Initialize settings for context block. + + editwin is the Editor window for the context block. + self.text is the editor window text widget. + + self.context displays the code context text above the editor text. + Initially None, it is toggled via <>. + self.topvisible is the number of the top text line displayed. + self.info is a list of (line number, indent level, line text, + block keyword) tuples for the block structure above topvisible. + self.info[0] is initialized with a 'dummy' line which + starts the toplevel 'block' of the module. + + self.t1 and self.t2 are two timer events on the editor text widget to + monitor for changes to the context text or editor font. + """ + self.editwin = editwin + self.text = editwin.text + self._reset() + + def _reset(self): + self.context = None + self.cell00 = None + self.t1 = None + self.topvisible = 1 + self.info = [(0, -1, "", False)] + + @classmethod + def reload(cls): + "Load class variables from config." + cls.context_depth = idleConf.GetOption("extensions", "CodeContext", + "maxlines", type="int", + default=15) + + def __del__(self): + "Cancel scheduled events." + if self.t1 is not None: + try: + self.text.after_cancel(self.t1) + except TclError: # pragma: no cover + pass + self.t1 = None + + def toggle_code_context_event(self, event=None): + """Toggle code context display. + + If self.context doesn't exist, create it to match the size of the editor + window text (toggle on). If it does exist, destroy it (toggle off). + Return 'break' to complete the processing of the binding. + """ + if self.context is None: + # Calculate the border width and horizontal padding required to + # align the context with the text in the main Text widget. + # + # All values are passed through getint(), since some + # values may be pixel objects, which can't simply be added to ints. + widgets = self.editwin.text, self.editwin.text_frame + # Calculate the required horizontal padding and border width. + padx = 0 + border = 0 + for widget in widgets: + info = (widget.grid_info() + if widget is self.editwin.text + else widget.pack_info()) + padx += widget.tk.getint(info['padx']) + padx += widget.tk.getint(widget.cget('padx')) + border += widget.tk.getint(widget.cget('border')) + context = self.context = Text( + self.editwin.text_frame, + height=1, + width=1, # Don't request more than we get. + highlightthickness=0, + padx=padx, border=border, relief=SUNKEN, state='disabled') + self.update_font() + self.update_highlight_colors() + context.bind('', self.jumptoline) + # Get the current context and initiate the recurring update event. + self.timer_event() + # Grid the context widget above the text widget. + context.grid(row=0, column=1, sticky=NSEW) + + line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'linenumber') + self.cell00 = Frame(self.editwin.text_frame, + bg=line_number_colors['background']) + self.cell00.grid(row=0, column=0, sticky=NSEW) + menu_status = 'Hide' + else: + self.context.destroy() + self.context = None + self.cell00.destroy() + self.cell00 = None + self.text.after_cancel(self.t1) + self._reset() + menu_status = 'Show' + self.editwin.update_menu_label(menu='options', index='*ode*ontext', + label=f'{menu_status} Code Context') + return "break" + + def get_context(self, new_topvisible, stopline=1, stopindent=0): + """Return a list of block line tuples and the 'last' indent. + + The tuple fields are (linenum, indent, text, opener). + The list represents header lines from new_topvisible back to + stopline with successively shorter indents > stopindent. + The list is returned ordered by line number. + Last indent returned is the smallest indent observed. + """ + assert stopline > 0 + lines = [] + # The indentation level we are currently in. + lastindent = INFINITY + # For a line to be interesting, it must begin with a block opening + # keyword, and have less indentation than lastindent. + for linenum in range(new_topvisible, stopline-1, -1): + codeline = self.text.get(f'{linenum}.0', f'{linenum}.end') + indent, text, opener = get_line_info(codeline) + if indent < lastindent: + lastindent = indent + if opener in ("else", "elif"): + # Also show the if statement. + lastindent += 1 + if opener and linenum < new_topvisible and indent >= stopindent: + lines.append((linenum, indent, text, opener)) + if lastindent <= stopindent: + break + lines.reverse() + return lines, lastindent + + def update_code_context(self): + """Update context information and lines visible in the context pane. + + No update is done if the text hasn't been scrolled. If the text + was scrolled, the lines that should be shown in the context will + be retrieved and the context area will be updated with the code, + up to the number of maxlines. + """ + new_topvisible = self.editwin.getlineno("@0,0") + if self.topvisible == new_topvisible: # Haven't scrolled. + return + if self.topvisible < new_topvisible: # Scroll down. + lines, lastindent = self.get_context(new_topvisible, + self.topvisible) + # Retain only context info applicable to the region + # between topvisible and new_topvisible. + while self.info[-1][1] >= lastindent: + del self.info[-1] + else: # self.topvisible > new_topvisible: # Scroll up. + stopindent = self.info[-1][1] + 1 + # Retain only context info associated + # with lines above new_topvisible. + while self.info[-1][0] >= new_topvisible: + stopindent = self.info[-1][1] + del self.info[-1] + lines, lastindent = self.get_context(new_topvisible, + self.info[-1][0]+1, + stopindent) + self.info.extend(lines) + self.topvisible = new_topvisible + # Last context_depth context lines. + context_strings = [x[2] for x in self.info[-self.context_depth:]] + showfirst = 0 if context_strings[0] else 1 + # Update widget. + self.context['height'] = len(context_strings) - showfirst + self.context['state'] = 'normal' + self.context.delete('1.0', 'end') + self.context.insert('end', '\n'.join(context_strings[showfirst:])) + self.context['state'] = 'disabled' + + def jumptoline(self, event=None): + """ Show clicked context line at top of editor. + + If a selection was made, don't jump; allow copying. + If no visible context, show the top line of the file. + """ + try: + self.context.index("sel.first") + except TclError: + lines = len(self.info) + if lines == 1: # No context lines are showing. + newtop = 1 + else: + # Line number clicked. + contextline = int(float(self.context.index('insert'))) + # Lines not displayed due to maxlines. + offset = max(1, lines - self.context_depth) - 1 + newtop = self.info[offset + contextline][0] + self.text.yview(f'{newtop}.0') + self.update_code_context() + + def timer_event(self): + "Event on editor text widget triggered every UPDATEINTERVAL ms." + if self.context is not None: + self.update_code_context() + self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event) + + def update_font(self): + if self.context is not None: + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + self.context['font'] = font + + def update_highlight_colors(self): + if self.context is not None: + colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') + self.context['background'] = colors['background'] + self.context['foreground'] = colors['foreground'] + + if self.cell00 is not None: + line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'linenumber') + self.cell00.config(bg=line_number_colors['background']) + + +CodeContext.reload() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) + + # Add htest. diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py new file mode 100644 index 00000000000..bffa2ddd3cd --- /dev/null +++ b/Lib/idlelib/colorizer.py @@ -0,0 +1,384 @@ +import builtins +import keyword +import re +import time + +from idlelib.config import idleConf +from idlelib.delegator import Delegator + +DEBUG = False + + +def any(name, alternates): + "Return a named group pattern matching list of alternates." + return "(?P<%s>" % name + "|".join(alternates) + ")" + + +def make_pat(): + kw = r"\b" + any("KEYWORD", keyword.kwlist) + r"\b" + match_softkw = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pmatch)\b" + + r"(?![ \t]*(?:" + "|".join([ # not followed by ... + r"[:,;=^&|@~)\]}]", # a character which means it can't be a + # pattern-matching statement + r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword + ]) + + r"))" + ) + case_default = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pcase)" + + r"[ \t]+(?P_\b)" + ) + case_softkw_and_pattern = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pcase)\b" + + r"(?![ \t]*(?:" + "|".join([ # not followed by ... + r"_\b", # a lone underscore + r"[:,;=^&|@~)\]}]", # a character which means it can't be a + # pattern-matching case + r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword + ]) + + r"))" + ) + builtinlist = [str(name) for name in dir(builtins) + if not name.startswith('_') and + name not in keyword.kwlist] + builtin = r"([^.'\"\\#]\b|^)" + any("BUILTIN", builtinlist) + r"\b" + comment = any("COMMENT", [r"#[^\n]*"]) + stringprefix = r"(?i:r|u|f|fr|rf|b|br|rb|t|rt|tr)?" + sqstring = stringprefix + r"'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = stringprefix + r'"[^"\\\n]*(\\.[^"\\\n]*)*"?' + sq3string = stringprefix + r"'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" + dq3string = stringprefix + r'"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' + string = any("STRING", [sq3string, dq3string, sqstring, dqstring]) + prog = re.compile("|".join([ + builtin, comment, string, kw, + match_softkw, case_default, + case_softkw_and_pattern, + any("SYNC", [r"\n"]), + ]), + re.DOTALL | re.MULTILINE) + return prog + + +prog = make_pat() +idprog = re.compile(r"\s+(\w+)") +prog_group_name_to_tag = { + "MATCH_SOFTKW": "KEYWORD", + "CASE_SOFTKW": "KEYWORD", + "CASE_DEFAULT_UNDERSCORE": "KEYWORD", + "CASE_SOFTKW2": "KEYWORD", +} + + +def matched_named_groups(re_match): + "Get only the non-empty named groups from an re.Match object." + return ((k, v) for (k, v) in re_match.groupdict().items() if v) + + +def color_config(text): + """Set color options of Text widget. + + If ColorDelegator is used, this should be called first. + """ + # Called from htest, TextFrame, Editor, and Turtledemo. + # Not automatic because ColorDelegator does not know 'text'. + theme = idleConf.CurrentTheme() + normal_colors = idleConf.GetHighlight(theme, 'normal') + cursor_color = idleConf.GetHighlight(theme, 'cursor')['foreground'] + select_colors = idleConf.GetHighlight(theme, 'hilite') + text.config( + foreground=normal_colors['foreground'], + background=normal_colors['background'], + insertbackground=cursor_color, + selectforeground=select_colors['foreground'], + selectbackground=select_colors['background'], + inactiveselectbackground=select_colors['background'], # new in 8.5 + ) + + +class ColorDelegator(Delegator): + """Delegator for syntax highlighting (text coloring). + + Instance variables: + delegate: Delegator below this one in the stack, meaning the + one this one delegates to. + + Used to track state: + after_id: Identifier for scheduled after event, which is a + timer for colorizing the text. + allow_colorizing: Boolean toggle for applying colorizing. + colorizing: Boolean flag when colorizing is in process. + stop_colorizing: Boolean flag to end an active colorizing + process. + """ + + def __init__(self): + Delegator.__init__(self) + self.init_state() + self.prog = prog + self.idprog = idprog + self.LoadTagDefs() + + def init_state(self): + "Initialize variables that track colorizing state." + self.after_id = None + self.allow_colorizing = True + self.stop_colorizing = False + self.colorizing = False + + def setdelegate(self, delegate): + """Set the delegate for this instance. + + A delegate is an instance of a Delegator class and each + delegate points to the next delegator in the stack. This + allows multiple delegators to be chained together for a + widget. The bottom delegate for a colorizer is a Text + widget. + + If there is a delegate, also start the colorizing process. + """ + if self.delegate is not None: + self.unbind("<>") + Delegator.setdelegate(self, delegate) + if delegate is not None: + self.config_colors() + self.bind("<>", self.toggle_colorize_event) + self.notify_range("1.0", "end") + else: + # No delegate - stop any colorizing. + self.stop_colorizing = True + self.allow_colorizing = False + + def config_colors(self): + "Configure text widget tags with colors from tagdefs." + for tag, cnf in self.tagdefs.items(): + self.tag_configure(tag, **cnf) + self.tag_raise('sel') + + def LoadTagDefs(self): + "Create dictionary of tag names to text colors." + theme = idleConf.CurrentTheme() + self.tagdefs = { + "COMMENT": idleConf.GetHighlight(theme, "comment"), + "KEYWORD": idleConf.GetHighlight(theme, "keyword"), + "BUILTIN": idleConf.GetHighlight(theme, "builtin"), + "STRING": idleConf.GetHighlight(theme, "string"), + "DEFINITION": idleConf.GetHighlight(theme, "definition"), + "SYNC": {'background': None, 'foreground': None}, + "TODO": {'background': None, 'foreground': None}, + "ERROR": idleConf.GetHighlight(theme, "error"), + # "hit" is used by ReplaceDialog to mark matches. It shouldn't be changed by Colorizer, but + # that currently isn't technically possible. This should be moved elsewhere in the future + # when fixing the "hit" tag's visibility, or when the replace dialog is replaced with a + # non-modal alternative. + "hit": idleConf.GetHighlight(theme, "hit"), + } + if DEBUG: print('tagdefs', self.tagdefs) + + def insert(self, index, chars, tags=None): + "Insert chars into widget at index and mark for colorizing." + index = self.index(index) + self.delegate.insert(index, chars, tags) + self.notify_range(index, index + "+%dc" % len(chars)) + + def delete(self, index1, index2=None): + "Delete chars between indexes and mark for colorizing." + index1 = self.index(index1) + self.delegate.delete(index1, index2) + self.notify_range(index1) + + def notify_range(self, index1, index2=None): + "Mark text changes for processing and restart colorizing, if active." + self.tag_add("TODO", index1, index2) + if self.after_id: + if DEBUG: print("colorizing already scheduled") + return + if self.colorizing: + self.stop_colorizing = True + if DEBUG: print("stop colorizing") + if self.allow_colorizing: + if DEBUG: print("schedule colorizing") + self.after_id = self.after(1, self.recolorize) + return + + def close(self): + if self.after_id: + after_id = self.after_id + self.after_id = None + if DEBUG: print("cancel scheduled recolorizer") + self.after_cancel(after_id) + self.allow_colorizing = False + self.stop_colorizing = True + + def toggle_colorize_event(self, event=None): + """Toggle colorizing on and off. + + When toggling off, if colorizing is scheduled or is in + process, it will be cancelled and/or stopped. + + When toggling on, colorizing will be scheduled. + """ + if self.after_id: + after_id = self.after_id + self.after_id = None + if DEBUG: print("cancel scheduled recolorizer") + self.after_cancel(after_id) + if self.allow_colorizing and self.colorizing: + if DEBUG: print("stop colorizing") + self.stop_colorizing = True + self.allow_colorizing = not self.allow_colorizing + if self.allow_colorizing and not self.colorizing: + self.after_id = self.after(1, self.recolorize) + if DEBUG: + print("auto colorizing turned", + "on" if self.allow_colorizing else "off") + return "break" + + def recolorize(self): + """Timer event (every 1ms) to colorize text. + + Colorizing is only attempted when the text widget exists, + when colorizing is toggled on, and when the colorizing + process is not already running. + + After colorizing is complete, some cleanup is done to + make sure that all the text has been colorized. + """ + self.after_id = None + if not self.delegate: + if DEBUG: print("no delegate") + return + if not self.allow_colorizing: + if DEBUG: print("auto colorizing is off") + return + if self.colorizing: + if DEBUG: print("already colorizing") + return + try: + self.stop_colorizing = False + self.colorizing = True + if DEBUG: print("colorizing...") + t0 = time.perf_counter() + self.recolorize_main() + t1 = time.perf_counter() + if DEBUG: print("%.3f seconds" % (t1-t0)) + finally: + self.colorizing = False + if self.allow_colorizing and self.tag_nextrange("TODO", "1.0"): + if DEBUG: print("reschedule colorizing") + self.after_id = self.after(1, self.recolorize) + + def recolorize_main(self): + "Evaluate text and apply colorizing tags." + next = "1.0" + while todo_tag_range := self.tag_nextrange("TODO", next): + self.tag_remove("SYNC", todo_tag_range[0], todo_tag_range[1]) + sync_tag_range = self.tag_prevrange("SYNC", todo_tag_range[0]) + head = sync_tag_range[1] if sync_tag_range else "1.0" + + chars = "" + next = head + lines_to_get = 1 + ok = False + while not ok: + mark = next + next = self.index(mark + "+%d lines linestart" % + lines_to_get) + lines_to_get = min(lines_to_get * 2, 100) + ok = "SYNC" in self.tag_names(next + "-1c") + line = self.get(mark, next) + ##print head, "get", mark, next, "->", repr(line) + if not line: + return + for tag in self.tagdefs: + self.tag_remove(tag, mark, next) + chars += line + self._add_tags_in_section(chars, head) + if "SYNC" in self.tag_names(next + "-1c"): + head = next + chars = "" + else: + ok = False + if not ok: + # We're in an inconsistent state, and the call to + # update may tell us to stop. It may also change + # the correct value for "next" (since this is a + # line.col string, not a true mark). So leave a + # crumb telling the next invocation to resume here + # in case update tells us to leave. + self.tag_add("TODO", next) + self.update_idletasks() + if self.stop_colorizing: + if DEBUG: print("colorizing stopped") + return + + def _add_tag(self, start, end, head, matched_group_name): + """Add a tag to a given range in the text widget. + + This is a utility function, receiving the range as `start` and + `end` positions, each of which is a number of characters + relative to the given `head` index in the text widget. + + The tag to add is determined by `matched_group_name`, which is + the name of a regular expression "named group" as matched by + by the relevant highlighting regexps. + """ + tag = prog_group_name_to_tag.get(matched_group_name, + matched_group_name) + self.tag_add(tag, + f"{head}+{start:d}c", + f"{head}+{end:d}c") + + def _add_tags_in_section(self, chars, head): + """Parse and add highlighting tags to a given part of the text. + + `chars` is a string with the text to parse and to which + highlighting is to be applied. + + `head` is the index in the text widget where the text is found. + """ + for m in self.prog.finditer(chars): + for name, matched_text in matched_named_groups(m): + a, b = m.span(name) + self._add_tag(a, b, head, name) + if matched_text in ("def", "class"): + if m1 := self.idprog.match(chars, b): + a, b = m1.span(1) + self._add_tag(a, b, head, "DEFINITION") + + def removecolors(self): + "Remove all colorizing tags." + for tag in self.tagdefs: + self.tag_remove(tag, "1.0", "end") + + +def _color_delegator(parent): # htest # + from tkinter import Toplevel, Text + from idlelib.idle_test.test_colorizer import source + from idlelib.percolator import Percolator + + top = Toplevel(parent) + top.title("Test ColorDelegator") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("700x550+%d+%d" % (x + 20, y + 175)) + + text = Text(top, background="white") + text.pack(expand=1, fill="both") + text.insert("insert", source) + text.focus_set() + + color_config(text) + p = Percolator(text) + d = ColorDelegator() + p.insertfilter(d) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_colorizer', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_color_delegator) diff --git a/Lib/idlelib/config-extensions.def b/Lib/idlelib/config-extensions.def new file mode 100644 index 00000000000..7e23fb0a73d --- /dev/null +++ b/Lib/idlelib/config-extensions.def @@ -0,0 +1,62 @@ +# config-extensions.def +# +# The following sections are for features that are no longer extensions. +# Their options values are left here for back-compatibility. + +[AutoComplete] +popupwait= 2000 + +[CodeContext] +maxlines= 15 + +[FormatParagraph] +max-width= 72 + +[ParenMatch] +style= expression +flash-delay= 500 +bell= True + +# IDLE reads several config files to determine user preferences. This +# file is the default configuration file for IDLE extensions settings. +# +# Each extension must have at least one section, named after the +# extension module. This section must contain an 'enable' item (=True to +# enable the extension, =False to disable it), it may contain +# 'enable_editor' or 'enable_shell' items, to apply it only to editor ir +# shell windows, and may also contain any other general configuration +# items for the extension. Other True/False values will also be +# recognized as boolean by the Extension Configuration dialog. +# +# Each extension must define at least one section named +# ExtensionName_bindings or ExtensionName_cfgBindings. If present, +# ExtensionName_bindings defines virtual event bindings for the +# extension that are not user re-configurable. If present, +# ExtensionName_cfgBindings defines virtual event bindings for the +# extension that may be sensibly re-configured. +# +# If there are no keybindings for a menus' virtual events, include lines +# like <>=. +# +# Currently it is necessary to manually modify this file to change +# extension key bindings and default values. To customize, create +# ~/.idlerc/config-extensions.cfg and append the appropriate customized +# section(s). Those sections will override the defaults in this file. +# +# Note: If a keybinding is already in use when the extension is loaded, +# the extension's virtual event's keybinding will be set to ''. +# +# See config-keys.def for notes on specifying keys and extend.txt for +# information on creating IDLE extensions. + +# A fake extension for testing and example purposes. When enabled and +# invoked, inserts or deletes z-text at beginning of every line. +[ZzDummy] +enable= False +enable_shell = False +enable_editor = True +z-text= Z +[ZzDummy_cfgBindings] +z-in= +[ZzDummy_bindings] +z-out= diff --git a/Lib/idlelib/config-highlight.def b/Lib/idlelib/config-highlight.def new file mode 100644 index 00000000000..a7b0433831c --- /dev/null +++ b/Lib/idlelib/config-highlight.def @@ -0,0 +1,105 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for idle highlight theme settings. + +[IDLE Classic] +normal-foreground= #000000 +normal-background= #ffffff +keyword-foreground= #ff7700 +keyword-background= #ffffff +builtin-foreground= #900090 +builtin-background= #ffffff +comment-foreground= #dd0000 +comment-background= #ffffff +string-foreground= #00aa00 +string-background= #ffffff +definition-foreground= #0000ff +definition-background= #ffffff +hilite-foreground= #000000 +hilite-background= gray +break-foreground= black +break-background= #ffff55 +hit-foreground= #ffffff +hit-background= #000000 +error-foreground= #000000 +error-background= #ff7777 +context-foreground= #000000 +context-background= lightgray +linenumber-foreground= gray +linenumber-background= #ffffff +#cursor (only foreground can be set, restart IDLE) +cursor-foreground= black +#shell window +stdout-foreground= blue +stdout-background= #ffffff +stderr-foreground= red +stderr-background= #ffffff +console-foreground= #770000 +console-background= #ffffff + +[IDLE New] +normal-foreground= #000000 +normal-background= #ffffff +keyword-foreground= #ff7700 +keyword-background= #ffffff +builtin-foreground= #900090 +builtin-background= #ffffff +comment-foreground= #dd0000 +comment-background= #ffffff +string-foreground= #00aa00 +string-background= #ffffff +definition-foreground= #0000ff +definition-background= #ffffff +hilite-foreground= #000000 +hilite-background= gray +break-foreground= black +break-background= #ffff55 +hit-foreground= #ffffff +hit-background= #000000 +error-foreground= #000000 +error-background= #ff7777 +context-foreground= #000000 +context-background= lightgray +linenumber-foreground= gray +linenumber-background= #ffffff +#cursor (only foreground can be set, restart IDLE) +cursor-foreground= black +#shell window +stdout-foreground= blue +stdout-background= #ffffff +stderr-foreground= red +stderr-background= #ffffff +console-foreground= #770000 +console-background= #ffffff + +[IDLE Dark] +comment-foreground = #dd0000 +console-foreground = #ff4d4d +error-foreground = #FFFFFF +hilite-background = #7e7e7e +string-foreground = #02ff02 +stderr-background = #002240 +stderr-foreground = #ffb3b3 +console-background = #002240 +hit-background = #fbfbfb +string-background = #002240 +normal-background = #002240 +hilite-foreground = #FFFFFF +keyword-foreground = #ff8000 +error-background = #c86464 +keyword-background = #002240 +builtin-background = #002240 +break-background = #808000 +builtin-foreground = #ff00ff +definition-foreground = #5e5eff +stdout-foreground = #c2d1fa +definition-background = #002240 +normal-foreground = #FFFFFF +cursor-foreground = #ffffff +stdout-background = #002240 +hit-foreground = #002240 +comment-background = #002240 +break-foreground = #FFFFFF +context-foreground= #ffffff +context-background= #454545 +linenumber-foreground= gray +linenumber-background= #002240 diff --git a/Lib/idlelib/config-keys.def b/Lib/idlelib/config-keys.def new file mode 100644 index 00000000000..f71269b5b49 --- /dev/null +++ b/Lib/idlelib/config-keys.def @@ -0,0 +1,309 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for idle key binding settings. +# Where multiple keys are specified for an action: if they are separated +# by a space (eg. action= ) then the keys are alternatives, if +# there is no space (eg. action=) then the keys comprise a +# single 'emacs style' multi-keystoke binding. The tk event specifier 'Key' +# is used in all cases, for consistency in auto key conflict checking in the +# configuration gui. + +[IDLE Classic Windows] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +python-docs= +python-context-help= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +redo= +remove-selection= +save-copy-of-window-as-file= +save-window-as-file= +save-window= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic Unix] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +python-docs= +python-context-help= +redo= +remove-selection= +save-copy-of-window-as-file= +save-window-as-file= +save-window= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Modern Unix] +copy = +cut = +paste = +beginning-of-line = +center-insert = +close-all-windows = +close-window = +do-nothing = +end-of-file = +history-next = +history-previous = +interrupt-execution = +view-restart = +restart-shell = +open-class-browser = +open-module = +open-new-window = +open-window-from-file = +plain-newline-and-indent = +print-window = +python-context-help = +python-docs = +redo = +remove-selection = +save-copy-of-window-as-file = +save-window-as-file = +save-window = +select-all = +toggle-auto-coloring = +undo = +find = +find-again = +find-in-files = +find-selection = +replace = +goto-line = +smart-backspace = +newline-and-indent = +smart-indent = +indent-region = +dedent-region = +comment-region = +uncomment-region = +tabify-region = +untabify-region = +toggle-tabs = +change-indentwidth = +del-word-left = +del-word-right = +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic Mac] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +python-docs= +python-context-help= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +redo= +remove-selection= +save-window-as-file= +save-window= +save-copy-of-window-as-file= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic OSX] +toggle-tabs = +interrupt-execution = +untabify-region = +remove-selection = +print-window = +replace = +goto-line = +plain-newline-and-indent = +history-previous = +beginning-of-line = +end-of-line = +comment-region = +redo = +close-window = +restart-shell = +save-window-as-file = +close-all-windows = +view-restart = +tabify-region = +find-again = +find = +toggle-auto-coloring = +select-all = +smart-backspace = +change-indentwidth = +do-nothing = +smart-indent = +center-insert = +history-next = +del-word-right = +undo = +save-window = +uncomment-region = +cut = +find-in-files = +dedent-region = +copy = +paste = +indent-region = +del-word-left = +newline-and-indent = +end-of-file = +open-class-browser = +open-new-window = +open-module = +find-selection = +python-context-help = +save-copy-of-window-as-file = +open-window-from-file = +python-docs = +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def new file mode 100644 index 00000000000..54bdce34af3 --- /dev/null +++ b/Lib/idlelib/config-main.def @@ -0,0 +1,92 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for general idle settings. +# +# When IDLE starts, it will look in +# the following two sets of files, in order: +# +# default configuration files in idlelib +# -------------------------------------- +# config-main.def default general config file +# config-extensions.def default extension config file +# config-highlight.def default highlighting config file +# config-keys.def default keybinding config file +# +# user configuration files in ~/.idlerc +# ------------------------------------- +# config-main.cfg user general config file +# config-extensions.cfg user extension config file +# config-highlight.cfg user highlighting config file +# config-keys.cfg user keybinding config file +# +# On Windows, the default location of the home directory ('~' above) +# depends on the version. For Windows 10, it is C:\Users\. +# +# Any options the user saves through the config dialog will be saved to +# the relevant user config file. Reverting any general or extension +# setting to the default causes that entry to be wiped from the user +# file and re-read from the default file. This rule applies to each +# item, except that the three editor font items are saved as a group. +# +# User highlighting themes and keybinding sets must have (section) names +# distinct from the default names. All items are added and saved as a +# group. They are retained unless specifically deleted within the config +# dialog. Choosing one of the default themes or keysets just applies the +# relevant settings from the default file. +# +# Additional help sources are listed in the [HelpFiles] section below +# and should be viewable by a web browser. These sources will be listed +# on the Help menu. The pattern, and two examples, are: +# +# +# 1 = IDLE;C:/Programs/Python36/Lib/idlelib/help.html +# 2 = Pillow;https://pillow.readthedocs.io/en/latest/ +# +# You can't use a semi-colon in a menu item or path. The path will be +# platform specific because of path separators, drive specs etc. +# +# The default files should not be edited except to add new sections to +# config-extensions.def for added extensions. The user files should be +# modified through the Settings dialog. + +[General] +editor-on-startup= 0 +autosave= 0 +print-command-posix=lpr %%s +print-command-win=start /min notepad /p %%s +delete-exitfunc= 1 + +[EditorWindow] +width= 80 +height= 40 +cursor-blink= 1 +font= TkFixedFont +# For TkFixedFont, the actual size and boldness are obtained from tk +# and override 10 and 0. See idlelib.config.IdleConf.GetFont +font-size= 10 +font-bold= 0 +encoding= none +line-numbers-default= 0 + +[PyShell] +auto-squeeze-min-lines= 50 + +[Indent] +use-spaces= 1 +num-spaces= 4 + +[Theme] +default= 1 +name= IDLE Classic +name2= +# name2 set in user config-main.cfg for themes added after 2015 Oct 1 + +[Keys] +default= 1 +name= +name2= +# name2 set in user config-main.cfg for keys added after 2016 July 1 + +[History] +cyclic=1 + +[HelpFiles] diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py new file mode 100644 index 00000000000..d10c88a43f9 --- /dev/null +++ b/Lib/idlelib/config.py @@ -0,0 +1,917 @@ +"""idlelib.config -- Manage IDLE configuration information. + +The comments at the beginning of config-main.def describe the +configuration files and the design implemented to update user +configuration information. In particular, user configuration choices +which duplicate the defaults will be removed from the user's +configuration files, and if a user file becomes empty, it will be +deleted. + +The configuration database maps options to values. Conceptually, the +database keys are tuples (config-type, section, item). As implemented, +there are separate dicts for default and user values. Each has +config-type keys 'main', 'extensions', 'highlight', and 'keys'. The +value for each key is a ConfigParser instance that maps section and item +to values. For 'main' and 'extensions', user values override +default values. For 'highlight' and 'keys', user sections augment the +default sections (and must, therefore, have distinct names). + +Throughout this module there is an emphasis on returning usable defaults +when a problem occurs in returning a requested configuration value back to +idle. This is to allow IDLE to continue to function in spite of errors in +the retrieval of config information. When a default is returned instead of +a requested config value, a message is printed to stderr to aid in +configuration problem notification and resolution. +""" +# TODOs added Oct 2014, tjr + +from configparser import ConfigParser +import os +import sys + +from tkinter.font import Font +import idlelib + +class InvalidConfigType(Exception): pass +class InvalidConfigSet(Exception): pass +class InvalidTheme(Exception): pass + +class IdleConfParser(ConfigParser): + """ + A ConfigParser specialised for idle configuration file handling + """ + def __init__(self, cfgFile, cfgDefaults=None): + """ + cfgFile - string, fully specified configuration file name + """ + self.file = cfgFile # This is currently '' when testing. + ConfigParser.__init__(self, defaults=cfgDefaults, strict=False) + + def Get(self, section, option, type=None, default=None, raw=False): + """ + Get an option value for given section/option or return default. + If type is specified, return as type. + """ + # TODO Use default as fallback, at least if not None + # Should also print Warning(file, section, option). + # Currently may raise ValueError + if not self.has_option(section, option): + return default + if type == 'bool': + return self.getboolean(section, option) + elif type == 'int': + return self.getint(section, option) + else: + return self.get(section, option, raw=raw) + + def GetOptionList(self, section): + "Return a list of options for given section, else []." + if self.has_section(section): + return self.options(section) + else: #return a default value + return [] + + def Load(self): + "Load the configuration file from disk." + if self.file: + self.read(self.file) + +class IdleUserConfParser(IdleConfParser): + """ + IdleConfigParser specialised for user configuration handling. + """ + + def SetOption(self, section, option, value): + """Return True if option is added or changed to value, else False. + + Add section if required. False means option already had value. + """ + if self.has_option(section, option): + if self.get(section, option) == value: + return False + else: + self.set(section, option, value) + return True + else: + if not self.has_section(section): + self.add_section(section) + self.set(section, option, value) + return True + + def RemoveOption(self, section, option): + """Return True if option is removed from section, else False. + + False if either section does not exist or did not have option. + """ + if self.has_section(section): + return self.remove_option(section, option) + return False + + def AddSection(self, section): + "If section doesn't exist, add it." + if not self.has_section(section): + self.add_section(section) + + def RemoveEmptySections(self): + "Remove any sections that have no options." + for section in self.sections(): + if not self.GetOptionList(section): + self.remove_section(section) + + def IsEmpty(self): + "Return True if no sections after removing empty sections." + self.RemoveEmptySections() + return not self.sections() + + def Save(self): + """Update user configuration file. + + If self not empty after removing empty sections, write the file + to disk. Otherwise, remove the file from disk if it exists. + """ + fname = self.file + if fname and fname[0] != '#': + if not self.IsEmpty(): + try: + cfgFile = open(fname, 'w') + except OSError: + os.unlink(fname) + cfgFile = open(fname, 'w') + with cfgFile: + self.write(cfgFile) + elif os.path.exists(self.file): + os.remove(self.file) + +class IdleConf: + """Hold config parsers for all idle config files in singleton instance. + + Default config files, self.defaultCfg -- + for config_type in self.config_types: + (idle install dir)/config-{config-type}.def + + User config files, self.userCfg -- + for config_type in self.config_types: + (user home dir)/.idlerc/config-{config-type}.cfg + """ + def __init__(self, _utest=False): + self.config_types = ('main', 'highlight', 'keys', 'extensions') + self.defaultCfg = {} + self.userCfg = {} + self.cfg = {} # TODO use to select userCfg vs defaultCfg + + # See https://bugs.python.org/issue4630#msg356516 for following. + # self.blink_off_time = ['insertofftime'] + + if not _utest: + self.CreateConfigHandlers() + self.LoadCfgFiles() + + def CreateConfigHandlers(self): + "Populate default and user config parser dictionaries." + idledir = os.path.dirname(__file__) + self.userdir = userdir = '' if idlelib.testing else self.GetUserCfgDir() + for cfg_type in self.config_types: + self.defaultCfg[cfg_type] = IdleConfParser( + os.path.join(idledir, f'config-{cfg_type}.def')) + self.userCfg[cfg_type] = IdleUserConfParser( + os.path.join(userdir or '#', f'config-{cfg_type}.cfg')) + + def GetUserCfgDir(self): + """Return a filesystem directory for storing user config files. + + Creates it if required. + """ + cfgDir = '.idlerc' + userDir = os.path.expanduser('~') + if userDir != '~': # expanduser() found user home dir + if not os.path.exists(userDir): + if not idlelib.testing: + warn = ('\n Warning: os.path.expanduser("~") points to\n ' + + userDir + ',\n but the path does not exist.') + try: + print(warn, file=sys.stderr) + except OSError: + pass + userDir = '~' + if userDir == "~": # still no path to home! + # traditionally IDLE has defaulted to os.getcwd(), is this adequate? + userDir = os.getcwd() + userDir = os.path.join(userDir, cfgDir) + if not os.path.exists(userDir): + try: + os.mkdir(userDir) + except OSError: + if not idlelib.testing: + warn = ('\n Warning: unable to create user config directory\n' + + userDir + '\n Check path and permissions.\n Exiting!\n') + try: + print(warn, file=sys.stderr) + except OSError: + pass + raise SystemExit + # TODO continue without userDIr instead of exit + return userDir + + def GetOption(self, configType, section, option, default=None, type=None, + warn_on_default=True, raw=False): + """Return a value for configType section option, or default. + + If type is not None, return a value of that type. Also pass raw + to the config parser. First try to return a valid value + (including type) from a user configuration. If that fails, try + the default configuration. If that fails, return default, with a + default of None. + + Warn if either user or default configurations have an invalid value. + Warn if default is returned and warn_on_default is True. + """ + try: + if self.userCfg[configType].has_option(section, option): + return self.userCfg[configType].Get(section, option, + type=type, raw=raw) + except ValueError: + warning = ('\n Warning: config.py - IdleConf.GetOption -\n' + ' invalid %r value for configuration option %r\n' + ' from section %r: %r' % + (type, option, section, + self.userCfg[configType].Get(section, option, raw=raw))) + _warn(warning, configType, section, option) + try: + if self.defaultCfg[configType].has_option(section,option): + return self.defaultCfg[configType].Get( + section, option, type=type, raw=raw) + except ValueError: + pass + #returning default, print warning + if warn_on_default: + warning = ('\n Warning: config.py - IdleConf.GetOption -\n' + ' problem retrieving configuration option %r\n' + ' from section %r.\n' + ' returning default value: %r' % + (option, section, default)) + _warn(warning, configType, section, option) + return default + + def SetOption(self, configType, section, option, value): + """Set section option to value in user config file.""" + self.userCfg[configType].SetOption(section, option, value) + + def GetSectionList(self, configSet, configType): + """Return sections for configSet configType configuration. + + configSet must be either 'user' or 'default' + configType must be in self.config_types. + """ + if not (configType in self.config_types): + raise InvalidConfigType('Invalid configType specified') + if configSet == 'user': + cfgParser = self.userCfg[configType] + elif configSet == 'default': + cfgParser=self.defaultCfg[configType] + else: + raise InvalidConfigSet('Invalid configSet specified') + return cfgParser.sections() + + def GetHighlight(self, theme, element): + """Return dict of theme element highlight colors. + + The keys are 'foreground' and 'background'. The values are + tkinter color strings for configuring backgrounds and tags. + """ + cfg = ('default' if self.defaultCfg['highlight'].has_section(theme) + else 'user') + theme_dict = self.GetThemeDict(cfg, theme) + fore = theme_dict[element + '-foreground'] + if element == 'cursor': + element = 'normal' + back = theme_dict[element + '-background'] + return {"foreground": fore, "background": back} + + def GetThemeDict(self, type, themeName): + """Return {option:value} dict for elements in themeName. + + type - string, 'default' or 'user' theme type + themeName - string, theme name + Values are loaded over ultimate fallback defaults to guarantee + that all theme elements are present in a newly created theme. + """ + if type == 'user': + cfgParser = self.userCfg['highlight'] + elif type == 'default': + cfgParser = self.defaultCfg['highlight'] + else: + raise InvalidTheme('Invalid theme type specified') + # Provide foreground and background colors for each theme + # element (other than cursor) even though some values are not + # yet used by idle, to allow for their use in the future. + # Default values are generally black and white. + # TODO copy theme from a class attribute. + theme ={'normal-foreground':'#000000', + 'normal-background':'#ffffff', + 'keyword-foreground':'#000000', + 'keyword-background':'#ffffff', + 'builtin-foreground':'#000000', + 'builtin-background':'#ffffff', + 'comment-foreground':'#000000', + 'comment-background':'#ffffff', + 'string-foreground':'#000000', + 'string-background':'#ffffff', + 'definition-foreground':'#000000', + 'definition-background':'#ffffff', + 'hilite-foreground':'#000000', + 'hilite-background':'gray', + 'break-foreground':'#ffffff', + 'break-background':'#000000', + 'hit-foreground':'#ffffff', + 'hit-background':'#000000', + 'error-foreground':'#ffffff', + 'error-background':'#000000', + 'context-foreground':'#000000', + 'context-background':'#ffffff', + 'linenumber-foreground':'#000000', + 'linenumber-background':'#ffffff', + #cursor (only foreground can be set) + 'cursor-foreground':'#000000', + #shell window + 'stdout-foreground':'#000000', + 'stdout-background':'#ffffff', + 'stderr-foreground':'#000000', + 'stderr-background':'#ffffff', + 'console-foreground':'#000000', + 'console-background':'#ffffff', + } + for element in theme: + if not (cfgParser.has_option(themeName, element) or + # Skip warning for new elements. + element.startswith(('context-', 'linenumber-'))): + # Print warning that will return a default color + warning = ('\n Warning: config.IdleConf.GetThemeDict' + ' -\n problem retrieving theme element %r' + '\n from theme %r.\n' + ' returning default color: %r' % + (element, themeName, theme[element])) + _warn(warning, 'highlight', themeName, element) + theme[element] = cfgParser.Get( + themeName, element, default=theme[element]) + return theme + + def CurrentTheme(self): + "Return the name of the currently active text color theme." + return self.current_colors_and_keys('Theme') + + def CurrentKeys(self): + """Return the name of the currently active key set.""" + return self.current_colors_and_keys('Keys') + + def current_colors_and_keys(self, section): + """Return the currently active name for Theme or Keys section. + + idlelib.config-main.def ('default') includes these sections + + [Theme] + default= 1 + name= IDLE Classic + name2= + + [Keys] + default= 1 + name= + name2= + + Item 'name2', is used for built-in ('default') themes and keys + added after 2015 Oct 1 and 2016 July 1. This kludge is needed + because setting 'name' to a builtin not defined in older IDLEs + to display multiple error messages or quit. + See https://bugs.python.org/issue25313. + When default = True, 'name2' takes precedence over 'name', + while older IDLEs will just use name. When default = False, + 'name2' may still be set, but it is ignored. + """ + cfgname = 'highlight' if section == 'Theme' else 'keys' + default = self.GetOption('main', section, 'default', + type='bool', default=True) + name = '' + if default: + name = self.GetOption('main', section, 'name2', default='') + if not name: + name = self.GetOption('main', section, 'name', default='') + if name: + source = self.defaultCfg if default else self.userCfg + if source[cfgname].has_section(name): + return name + return "IDLE Classic" if section == 'Theme' else self.default_keys() + + @staticmethod + def default_keys(): + if sys.platform[:3] == 'win': + return 'IDLE Classic Windows' + elif sys.platform == 'darwin': + return 'IDLE Classic OSX' + else: + return 'IDLE Modern Unix' + + def GetExtensions(self, active_only=True, + editor_only=False, shell_only=False): + """Return extensions in default and user config-extensions files. + + If active_only True, only return active (enabled) extensions + and optionally only editor or shell extensions. + If active_only False, return all extensions. + """ + extns = self.RemoveKeyBindNames( + self.GetSectionList('default', 'extensions')) + userExtns = self.RemoveKeyBindNames( + self.GetSectionList('user', 'extensions')) + for extn in userExtns: + if extn not in extns: #user has added own extension + extns.append(extn) + for extn in ('AutoComplete','CodeContext', + 'FormatParagraph','ParenMatch'): + extns.remove(extn) + # specific exclusions because we are storing config for mainlined old + # extensions in config-extensions.def for backward compatibility + if active_only: + activeExtns = [] + for extn in extns: + if self.GetOption('extensions', extn, 'enable', default=True, + type='bool'): + #the extension is enabled + if editor_only or shell_only: # TODO both True contradict + if editor_only: + option = "enable_editor" + else: + option = "enable_shell" + if self.GetOption('extensions', extn,option, + default=True, type='bool', + warn_on_default=False): + activeExtns.append(extn) + else: + activeExtns.append(extn) + return activeExtns + else: + return extns + + def RemoveKeyBindNames(self, extnNameList): + "Return extnNameList with keybinding section names removed." + return [n for n in extnNameList if not n.endswith(('_bindings', '_cfgBindings'))] + + def GetExtnNameForEvent(self, virtualEvent): + """Return the name of the extension binding virtualEvent, or None. + + virtualEvent - string, name of the virtual event to test for, + without the enclosing '<< >>' + """ + extName = None + vEvent = '<<' + virtualEvent + '>>' + for extn in self.GetExtensions(active_only=0): + for event in self.GetExtensionKeys(extn): + if event == vEvent: + extName = extn # TODO return here? + return extName + + def GetExtensionKeys(self, extensionName): + """Return dict: {configurable extensionName event : active keybinding}. + + Events come from default config extension_cfgBindings section. + Keybindings come from GetCurrentKeySet() active key dict, + where previously used bindings are disabled. + """ + keysName = extensionName + '_cfgBindings' + activeKeys = self.GetCurrentKeySet() + extKeys = {} + if self.defaultCfg['extensions'].has_section(keysName): + eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) + for eventName in eventNames: + event = '<<' + eventName + '>>' + binding = activeKeys[event] + extKeys[event] = binding + return extKeys + + def __GetRawExtensionKeys(self,extensionName): + """Return dict {configurable extensionName event : keybinding list}. + + Events come from default config extension_cfgBindings section. + Keybindings list come from the splitting of GetOption, which + tries user config before default config. + """ + keysName = extensionName+'_cfgBindings' + extKeys = {} + if self.defaultCfg['extensions'].has_section(keysName): + eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) + for eventName in eventNames: + binding = self.GetOption( + 'extensions', keysName, eventName, default='').split() + event = '<<' + eventName + '>>' + extKeys[event] = binding + return extKeys + + def GetExtensionBindings(self, extensionName): + """Return dict {extensionName event : active or defined keybinding}. + + Augment self.GetExtensionKeys(extensionName) with mapping of non- + configurable events (from default config) to GetOption splits, + as in self.__GetRawExtensionKeys. + """ + bindsName = extensionName + '_bindings' + extBinds = self.GetExtensionKeys(extensionName) + #add the non-configurable bindings + if self.defaultCfg['extensions'].has_section(bindsName): + eventNames = self.defaultCfg['extensions'].GetOptionList(bindsName) + for eventName in eventNames: + binding = self.GetOption( + 'extensions', bindsName, eventName, default='').split() + event = '<<' + eventName + '>>' + extBinds[event] = binding + + return extBinds + + def GetKeyBinding(self, keySetName, eventStr): + """Return the keybinding list for keySetName eventStr. + + keySetName - name of key binding set (config-keys section). + eventStr - virtual event, including brackets, as in '<>'. + """ + eventName = eventStr[2:-2] #trim off the angle brackets + binding = self.GetOption('keys', keySetName, eventName, default='', + warn_on_default=False).split() + return binding + + def GetCurrentKeySet(self): + "Return CurrentKeys with 'darwin' modifications." + result = self.GetKeySet(self.CurrentKeys()) + + if sys.platform == "darwin": + # macOS (OS X) Tk variants do not support the "Alt" + # keyboard modifier. Replace it with "Option". + # TODO (Ned?): the "Option" modifier does not work properly + # for Cocoa Tk and XQuartz Tk so we should not use it + # in the default 'OSX' keyset. + for k, v in result.items(): + v2 = [ x.replace('>' + """ + return ('<<'+virtualEvent+'>>') in self.GetCoreKeys() + +# TODO make keyBindings a file or class attribute used for test above +# and copied in function below. + + former_extension_events = { # Those with user-configurable keys. + '<>', '<>', + '<>', '<>', '<>', + '<>', '<>', '<>', + '<>', + } + + def GetCoreKeys(self, keySetName=None): + """Return dict of core virtual-key keybindings for keySetName. + + The default keySetName None corresponds to the keyBindings base + dict. If keySetName is not None, bindings from the config + file(s) are loaded _over_ these defaults, so if there is a + problem getting any core binding there will be an 'ultimate last + resort fallback' to the CUA-ish bindings defined here. + """ + # TODO: = dict(sorted([(v-event, keys), ...]))? + keyBindings={ + # virtual-event: list of key events. + '<>': ['', ''], + '<>': ['', ''], + '<>': ['', ''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + } + + if keySetName: + if not (self.userCfg['keys'].has_section(keySetName) or + self.defaultCfg['keys'].has_section(keySetName)): + warning = ( + '\n Warning: config.py - IdleConf.GetCoreKeys -\n' + ' key set %r is not defined, using default bindings.' % + (keySetName,) + ) + _warn(warning, 'keys', keySetName) + else: + for event in keyBindings: + binding = self.GetKeyBinding(keySetName, event) + if binding: + keyBindings[event] = binding + # Otherwise return default in keyBindings. + elif event not in self.former_extension_events: + warning = ( + '\n Warning: config.py - IdleConf.GetCoreKeys -\n' + ' problem retrieving key binding for event %r\n' + ' from key set %r.\n' + ' returning default value: %r' % + (event, keySetName, keyBindings[event]) + ) + _warn(warning, 'keys', keySetName, event) + return keyBindings + + def GetExtraHelpSourceList(self, configSet): + """Return list of extra help sources from a given configSet. + + Valid configSets are 'user' or 'default'. Return a list of tuples of + the form (menu_item , path_to_help_file , option), or return the empty + list. 'option' is the sequence number of the help resource. 'option' + values determine the position of the menu items on the Help menu, + therefore the returned list must be sorted by 'option'. + + """ + helpSources = [] + if configSet == 'user': + cfgParser = self.userCfg['main'] + elif configSet == 'default': + cfgParser = self.defaultCfg['main'] + else: + raise InvalidConfigSet('Invalid configSet specified') + options=cfgParser.GetOptionList('HelpFiles') + for option in options: + value=cfgParser.Get('HelpFiles', option, default=';') + if value.find(';') == -1: #malformed config entry with no ';' + menuItem = '' #make these empty + helpPath = '' #so value won't be added to list + else: #config entry contains ';' as expected + value=value.split(';') + menuItem=value[0].strip() + helpPath=value[1].strip() + if menuItem and helpPath: #neither are empty strings + helpSources.append( (menuItem,helpPath,option) ) + helpSources.sort(key=lambda x: x[2]) + return helpSources + + def GetAllExtraHelpSourcesList(self): + """Return a list of the details of all additional help sources. + + Tuples in the list are those of GetExtraHelpSourceList. + """ + allHelpSources = (self.GetExtraHelpSourceList('default') + + self.GetExtraHelpSourceList('user') ) + return allHelpSources + + def GetFont(self, root, configType, section): + """Retrieve a font from configuration (font, font-size, font-bold) + Intercept the special value 'TkFixedFont' and substitute + the actual font, factoring in some tweaks if needed for + appearance sakes. + + The 'root' parameter can normally be any valid Tkinter widget. + + Return a tuple (family, size, weight) suitable for passing + to tkinter.Font + """ + family = self.GetOption(configType, section, 'font', default='courier') + size = self.GetOption(configType, section, 'font-size', type='int', + default='10') + bold = self.GetOption(configType, section, 'font-bold', default=0, + type='bool') + if (family == 'TkFixedFont'): + f = Font(name='TkFixedFont', exists=True, root=root) + actualFont = Font.actual(f) + family = actualFont['family'] + size = actualFont['size'] + if size <= 0: + size = 10 # if font in pixels, ignore actual size + bold = actualFont['weight'] == 'bold' + return (family, size, 'bold' if bold else 'normal') + + def LoadCfgFiles(self): + "Load all configuration files." + for key in self.defaultCfg: + self.defaultCfg[key].Load() + self.userCfg[key].Load() #same keys + + def SaveUserCfgFiles(self): + "Write all loaded user configuration files to disk." + for key in self.userCfg: + self.userCfg[key].Save() + + +idleConf = IdleConf() + +_warned = set() +def _warn(msg, *key): + key = (msg,) + key + if key not in _warned: + try: + print(msg, file=sys.stderr) + except OSError: + pass + _warned.add(key) + + +class ConfigChanges(dict): + """Manage a user's proposed configuration option changes. + + Names used across multiple methods: + page -- one of the 4 top-level dicts representing a + .idlerc/config-x.cfg file. + config_type -- name of a page. + section -- a section within a page/file. + option -- name of an option within a section. + value -- value for the option. + + Methods + add_option: Add option and value to changes. + save_option: Save option and value to config parser. + save_all: Save all the changes to the config parser and file. + delete_section: If section exists, + delete from changes, userCfg, and file. + clear: Clear all changes by clearing each page. + """ + def __init__(self): + "Create a page for each configuration file" + self.pages = [] # List of unhashable dicts. + for config_type in idleConf.config_types: + self[config_type] = {} + self.pages.append(self[config_type]) + + def add_option(self, config_type, section, item, value): + "Add item/value pair for config_type and section." + page = self[config_type] + value = str(value) # Make sure we use a string. + if section not in page: + page[section] = {} + page[section][item] = value + + @staticmethod + def save_option(config_type, section, item, value): + """Return True if the configuration value was added or changed. + + Helper for save_all. + """ + if idleConf.defaultCfg[config_type].has_option(section, item): + if idleConf.defaultCfg[config_type].Get(section, item) == value: + # The setting equals a default setting, remove it from user cfg. + return idleConf.userCfg[config_type].RemoveOption(section, item) + # If we got here, set the option. + return idleConf.userCfg[config_type].SetOption(section, item, value) + + def save_all(self): + """Save configuration changes to the user config file. + + Clear self in preparation for additional changes. + Return changed for testing. + """ + idleConf.userCfg['main'].Save() + + changed = False + for config_type in self: + cfg_type_changed = False + page = self[config_type] + for section in page: + if section == 'HelpFiles': # Remove it for replacement. + idleConf.userCfg['main'].remove_section('HelpFiles') + cfg_type_changed = True + for item, value in page[section].items(): + if self.save_option(config_type, section, item, value): + cfg_type_changed = True + if cfg_type_changed: + idleConf.userCfg[config_type].Save() + changed = True + for config_type in ['keys', 'highlight']: + # Save these even if unchanged! + idleConf.userCfg[config_type].Save() + self.clear() + # ConfigDialog caller must add the following call + # self.save_all_changed_extensions() # Uses a different mechanism. + return changed + + def delete_section(self, config_type, section): + """Delete a section from self, userCfg, and file. + + Used to delete custom themes and keysets. + """ + if section in self[config_type]: + del self[config_type][section] + configpage = idleConf.userCfg[config_type] + configpage.remove_section(section) + configpage.Save() + + def clear(self): + """Clear all 4 pages. + + Called in save_all after saving to idleConf. + XXX Mark window *title* when there are changes; unmark here. + """ + for page in self.pages: + page.clear() + + +# TODO Revise test output, write expanded unittest +def _dump(): # htest # (not really, but ignore in coverage) + from zlib import crc32 + line, crc = 0, 0 + + def sprint(obj): + nonlocal line, crc + txt = str(obj) + line += 1 + crc = crc32(txt.encode(encoding='utf-8'), crc) + print(txt) + #print('***', line, crc, '***') # Uncomment for diagnosis. + + def dumpCfg(cfg): + print('\n', cfg, '\n') # Cfg has variable '0xnnnnnnnn' address. + for key in sorted(cfg): + sections = cfg[key].sections() + sprint(key) + sprint(sections) + for section in sections: + options = cfg[key].options(section) + sprint(section) + sprint(options) + for option in options: + sprint(option + ' = ' + cfg[key].Get(section, option)) + + dumpCfg(idleConf.defaultCfg) + dumpCfg(idleConf.userCfg) + print('\nlines = ', line, ', crc = ', crc, sep='') + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_config', verbosity=2, exit=False) + + _dump() + # Run revised _dump() (700+ lines) as htest? More sorting. + # Perhaps as window with tabs for textviews, making it config viewer. diff --git a/Lib/idlelib/config_key.py b/Lib/idlelib/config_key.py new file mode 100644 index 00000000000..e5f67e8d406 --- /dev/null +++ b/Lib/idlelib/config_key.py @@ -0,0 +1,354 @@ +""" +Dialog for building Tkinter accelerator key bindings +""" +from tkinter import Toplevel, Listbox, StringVar, TclError +from tkinter.ttk import Frame, Button, Checkbutton, Entry, Label, Scrollbar +from tkinter import messagebox +from tkinter.simpledialog import _setup_dialog +import string +import sys + + +FUNCTION_KEYS = ('F1', 'F2' ,'F3' ,'F4' ,'F5' ,'F6', + 'F7', 'F8' ,'F9' ,'F10' ,'F11' ,'F12') +ALPHANUM_KEYS = tuple(string.ascii_lowercase + string.digits) +PUNCTUATION_KEYS = tuple('~!@#%^&*()_-+={}[]|;:,.<>/?') +WHITESPACE_KEYS = ('Tab', 'Space', 'Return') +EDIT_KEYS = ('BackSpace', 'Delete', 'Insert') +MOVE_KEYS = ('Home', 'End', 'Page Up', 'Page Down', 'Left Arrow', + 'Right Arrow', 'Up Arrow', 'Down Arrow') +AVAILABLE_KEYS = (ALPHANUM_KEYS + PUNCTUATION_KEYS + FUNCTION_KEYS + + WHITESPACE_KEYS + EDIT_KEYS + MOVE_KEYS) + + +def translate_key(key, modifiers): + "Translate from keycap symbol to the Tkinter keysym." + mapping = {'Space':'space', + '~':'asciitilde', '!':'exclam', '@':'at', '#':'numbersign', + '%':'percent', '^':'asciicircum', '&':'ampersand', + '*':'asterisk', '(':'parenleft', ')':'parenright', + '_':'underscore', '-':'minus', '+':'plus', '=':'equal', + '{':'braceleft', '}':'braceright', + '[':'bracketleft', ']':'bracketright', '|':'bar', + ';':'semicolon', ':':'colon', ',':'comma', '.':'period', + '<':'less', '>':'greater', '/':'slash', '?':'question', + 'Page Up':'Prior', 'Page Down':'Next', + 'Left Arrow':'Left', 'Right Arrow':'Right', + 'Up Arrow':'Up', 'Down Arrow': 'Down', 'Tab':'Tab'} + key = mapping.get(key, key) + if 'Shift' in modifiers and key in string.ascii_lowercase: + key = key.upper() + return f'Key-{key}' + + +class GetKeysFrame(Frame): + + # Dialog title for invalid key sequence + keyerror_title = 'Key Sequence Error' + + def __init__(self, parent, action, current_key_sequences): + """ + parent - parent of this dialog + action - the name of the virtual event these keys will be + mapped to + current_key_sequences - a list of all key sequence lists + currently mapped to virtual events, for overlap checking + """ + super().__init__(parent) + self['borderwidth'] = 2 + self['relief'] = 'sunken' + self.parent = parent + self.action = action + self.current_key_sequences = current_key_sequences + self.result = '' + self.key_string = StringVar(self) + self.key_string.set('') + # Set self.modifiers, self.modifier_label. + self.set_modifiers_for_platform() + self.modifier_vars = [] + for modifier in self.modifiers: + variable = StringVar(self) + variable.set('') + self.modifier_vars.append(variable) + self.advanced = False + self.create_widgets() + + def showerror(self, *args, **kwargs): + # Make testing easier. Replace in #30751. + messagebox.showerror(*args, **kwargs) + + def create_widgets(self): + # Basic entry key sequence. + self.frame_keyseq_basic = Frame(self, name='keyseq_basic') + self.frame_keyseq_basic.grid(row=0, column=0, sticky='nsew', + padx=5, pady=5) + basic_title = Label(self.frame_keyseq_basic, + text=f"New keys for '{self.action}' :") + basic_title.pack(anchor='w') + + basic_keys = Label(self.frame_keyseq_basic, justify='left', + textvariable=self.key_string, relief='groove', + borderwidth=2) + basic_keys.pack(ipadx=5, ipady=5, fill='x') + + # Basic entry controls. + self.frame_controls_basic = Frame(self) + self.frame_controls_basic.grid(row=1, column=0, sticky='nsew', padx=5) + + # Basic entry modifiers. + self.modifier_checkbuttons = {} + column = 0 + for modifier, variable in zip(self.modifiers, self.modifier_vars): + label = self.modifier_label.get(modifier, modifier) + check = Checkbutton(self.frame_controls_basic, + command=self.build_key_string, text=label, + variable=variable, onvalue=modifier, offvalue='') + check.grid(row=0, column=column, padx=2, sticky='w') + self.modifier_checkbuttons[modifier] = check + column += 1 + + # Basic entry help text. + help_basic = Label(self.frame_controls_basic, justify='left', + text="Select the desired modifier keys\n"+ + "above, and the final key from the\n"+ + "list on the right.\n\n" + + "Use upper case Symbols when using\n" + + "the Shift modifier. (Letters will be\n" + + "converted automatically.)") + help_basic.grid(row=1, column=0, columnspan=4, padx=2, sticky='w') + + # Basic entry key list. + self.list_keys_final = Listbox(self.frame_controls_basic, width=15, + height=10, selectmode='single') + self.list_keys_final.insert('end', *AVAILABLE_KEYS) + self.list_keys_final.bind('', self.final_key_selected) + self.list_keys_final.grid(row=0, column=4, rowspan=4, sticky='ns') + scroll_keys_final = Scrollbar(self.frame_controls_basic, + orient='vertical', + command=self.list_keys_final.yview) + self.list_keys_final.config(yscrollcommand=scroll_keys_final.set) + scroll_keys_final.grid(row=0, column=5, rowspan=4, sticky='ns') + self.button_clear = Button(self.frame_controls_basic, + text='Clear Keys', + command=self.clear_key_seq) + self.button_clear.grid(row=2, column=0, columnspan=4) + + # Advanced entry key sequence. + self.frame_keyseq_advanced = Frame(self, name='keyseq_advanced') + self.frame_keyseq_advanced.grid(row=0, column=0, sticky='nsew', + padx=5, pady=5) + advanced_title = Label(self.frame_keyseq_advanced, justify='left', + text=f"Enter new binding(s) for '{self.action}' :\n" + + "(These bindings will not be checked for validity!)") + advanced_title.pack(anchor='w') + self.advanced_keys = Entry(self.frame_keyseq_advanced, + textvariable=self.key_string) + self.advanced_keys.pack(fill='x') + + # Advanced entry help text. + self.frame_help_advanced = Frame(self) + self.frame_help_advanced.grid(row=1, column=0, sticky='nsew', padx=5) + help_advanced = Label(self.frame_help_advanced, justify='left', + text="Key bindings are specified using Tkinter keysyms as\n"+ + "in these samples: , , ,\n" + ", , .\n" + "Upper case is used when the Shift modifier is present!\n\n" + + "'Emacs style' multi-keystroke bindings are specified as\n" + + "follows: , where the first key\n" + + "is the 'do-nothing' keybinding.\n\n" + + "Multiple separate bindings for one action should be\n"+ + "separated by a space, eg., ." ) + help_advanced.grid(row=0, column=0, sticky='nsew') + + # Switch between basic and advanced. + self.button_level = Button(self, command=self.toggle_level, + text='<< Basic Key Binding Entry') + self.button_level.grid(row=2, column=0, stick='ew', padx=5, pady=5) + self.toggle_level() + + def set_modifiers_for_platform(self): + """Determine list of names of key modifiers for this platform. + + The names are used to build Tk bindings -- it doesn't matter if the + keyboard has these keys; it matters if Tk understands them. The + order is also important: key binding equality depends on it, so + config-keys.def must use the same ordering. + """ + if sys.platform == "darwin": + self.modifiers = ['Shift', 'Control', 'Option', 'Command'] + else: + self.modifiers = ['Control', 'Alt', 'Shift'] + self.modifier_label = {'Control': 'Ctrl'} # Short name. + + def toggle_level(self): + "Toggle between basic and advanced keys." + if self.button_level.cget('text').startswith('Advanced'): + self.clear_key_seq() + self.button_level.config(text='<< Basic Key Binding Entry') + self.frame_keyseq_advanced.lift() + self.frame_help_advanced.lift() + self.advanced_keys.focus_set() + self.advanced = True + else: + self.clear_key_seq() + self.button_level.config(text='Advanced Key Binding Entry >>') + self.frame_keyseq_basic.lift() + self.frame_controls_basic.lift() + self.advanced = False + + def final_key_selected(self, event=None): + "Handler for clicking on key in basic settings list." + self.build_key_string() + + def build_key_string(self): + "Create formatted string of modifiers plus the key." + keylist = modifiers = self.get_modifiers() + final_key = self.list_keys_final.get('anchor') + if final_key: + final_key = translate_key(final_key, modifiers) + keylist.append(final_key) + self.key_string.set(f"<{'-'.join(keylist)}>") + + def get_modifiers(self): + "Return ordered list of modifiers that have been selected." + mod_list = [variable.get() for variable in self.modifier_vars] + return [mod for mod in mod_list if mod] + + def clear_key_seq(self): + "Clear modifiers and keys selection." + self.list_keys_final.select_clear(0, 'end') + self.list_keys_final.yview('moveto', '0.0') + for variable in self.modifier_vars: + variable.set('') + self.key_string.set('') + + def ok(self): + self.result = '' + keys = self.key_string.get().strip() + if not keys: + self.showerror(title=self.keyerror_title, parent=self, + message="No key specified.") + return + if (self.advanced or self.keys_ok(keys)) and self.bind_ok(keys): + self.result = keys + return + + def keys_ok(self, keys): + """Validity check on user's 'basic' keybinding selection. + + Doesn't check the string produced by the advanced dialog because + 'modifiers' isn't set. + """ + final_key = self.list_keys_final.get('anchor') + modifiers = self.get_modifiers() + title = self.keyerror_title + key_sequences = [key for keylist in self.current_key_sequences + for key in keylist] + if not keys.endswith('>'): + self.showerror(title, parent=self, + message='Missing the final Key') + elif (not modifiers + and final_key not in FUNCTION_KEYS + MOVE_KEYS): + self.showerror(title=title, parent=self, + message='No modifier key(s) specified.') + elif (modifiers == ['Shift']) \ + and (final_key not in + FUNCTION_KEYS + MOVE_KEYS + ('Tab', 'Space')): + msg = 'The shift modifier by itself may not be used with'\ + ' this key symbol.' + self.showerror(title=title, parent=self, message=msg) + elif keys in key_sequences: + msg = 'This key combination is already in use.' + self.showerror(title=title, parent=self, message=msg) + else: + return True + return False + + def bind_ok(self, keys): + "Return True if Tcl accepts the new keys else show message." + try: + binding = self.bind(keys, lambda: None) + except TclError as err: + self.showerror( + title=self.keyerror_title, parent=self, + message=(f'The entered key sequence is not accepted.\n\n' + f'Error: {err}')) + return False + else: + self.unbind(keys, binding) + return True + + +class GetKeysWindow(Toplevel): + + def __init__(self, parent, title, action, current_key_sequences, + *, _htest=False, _utest=False): + """ + parent - parent of this dialog + title - string which is the title of the popup dialog + action - string, the name of the virtual event these keys will be + mapped to + current_key_sequences - list, a list of all key sequence lists + currently mapped to virtual events, for overlap checking + _htest - bool, change box location when running htest + _utest - bool, do not wait when running unittest + """ + super().__init__(parent) + self.withdraw() # Hide while setting geometry. + self['borderwidth'] = 5 + self.resizable(height=False, width=False) + # Needed for winfo_reqwidth(). + self.update_idletasks() + # Center dialog over parent (or below htest box). + x = (parent.winfo_rootx() + + (parent.winfo_width()//2 - self.winfo_reqwidth()//2)) + y = (parent.winfo_rooty() + + ((parent.winfo_height()//2 - self.winfo_reqheight()//2) + if not _htest else 150)) + self.geometry(f"+{x}+{y}") + + self.title(title) + self.frame = frame = GetKeysFrame(self, action, current_key_sequences) + self.protocol("WM_DELETE_WINDOW", self.cancel) + frame_buttons = Frame(self) + self.button_ok = Button(frame_buttons, text='OK', + width=8, command=self.ok) + self.button_cancel = Button(frame_buttons, text='Cancel', + width=8, command=self.cancel) + self.button_ok.grid(row=0, column=0, padx=5, pady=5) + self.button_cancel.grid(row=0, column=1, padx=5, pady=5) + frame.pack(side='top', expand=True, fill='both') + frame_buttons.pack(side='bottom', fill='x') + + self.transient(parent) + _setup_dialog(self) + self.grab_set() + if not _utest: + self.deiconify() # Geometry set, unhide. + self.wait_window() + + @property + def result(self): + return self.frame.result + + @result.setter + def result(self, value): + self.frame.result = value + + def ok(self, event=None): + self.frame.ok() + self.grab_release() + self.destroy() + + def cancel(self, event=None): + self.result = '' + self.grab_release() + self.destroy() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_config_key', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(GetKeysWindow) diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py new file mode 100644 index 00000000000..e618ef07a90 --- /dev/null +++ b/Lib/idlelib/configdialog.py @@ -0,0 +1,2408 @@ +"""IDLE Configuration Dialog: support user customization of IDLE by GUI + +Customize font faces, sizes, and colorization attributes. Set indentation +defaults. Customize keybindings. Colorization and keybindings can be +saved as user defined sets. Select startup options including shell/editor +and default window size. Define additional help sources. + +Note that tab width in IDLE is currently fixed at eight due to Tk issues. +Refer to comments in EditorWindow autoindent code for details. + +""" +import re + +from tkinter import (Toplevel, Listbox, Canvas, + StringVar, BooleanVar, IntVar, TRUE, FALSE, + TOP, BOTTOM, RIGHT, LEFT, SOLID, GROOVE, + NONE, BOTH, X, Y, W, E, EW, NS, NSEW, NW, + HORIZONTAL, VERTICAL, ANCHOR, ACTIVE, END, TclError) +from tkinter.ttk import (Frame, LabelFrame, Button, Checkbutton, Entry, Label, + OptionMenu, Notebook, Radiobutton, Scrollbar, Style, + Spinbox, Combobox) +from tkinter import colorchooser +import tkinter.font as tkfont +from tkinter import messagebox + +from idlelib.config import idleConf, ConfigChanges +from idlelib.config_key import GetKeysWindow +from idlelib.dynoption import DynOptionMenu +from idlelib import macosx +from idlelib.query import SectionName, HelpSource +from idlelib.textview import view_text +from idlelib.autocomplete import AutoComplete +from idlelib.codecontext import CodeContext +from idlelib.parenmatch import ParenMatch +from idlelib.format import FormatParagraph +from idlelib.squeezer import Squeezer +from idlelib.textview import ScrollableTextFrame + +changes = ConfigChanges() +# Reload changed options in the following classes. +reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph, + Squeezer) + + +class ConfigDialog(Toplevel): + """Config dialog for IDLE. + """ + + def __init__(self, parent, title='', *, _htest=False, _utest=False): + """Show the tabbed dialog for user configuration. + + Args: + parent - parent of this dialog + title - string which is the title of this popup dialog + _htest - bool, change box location when running htest + _utest - bool, don't wait_window when running unittest + + Note: Focus set on font page fontlist. + + Methods: + create_widgets + cancel: Bound to DELETE_WINDOW protocol. + """ + Toplevel.__init__(self, parent) + self.parent = parent + if _htest: + parent.instance_dict = {} + if not _utest: + self.withdraw() + + self.title(title or 'IDLE Preferences') + x = parent.winfo_rootx() + 20 + y = parent.winfo_rooty() + (30 if not _htest else 150) + self.geometry(f'+{x}+{y}') + # Each theme element key is its display name. + # The first value of the tuple is the sample area tag name. + # The second value is the display name list sort index. + self.create_widgets() + self.resizable(height=FALSE, width=FALSE) + self.transient(parent) + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.fontpage.fontlist.focus_set() + # XXX Decide whether to keep or delete these key bindings. + # Key bindings for this dialog. + # self.bind('', self.Cancel) #dismiss dialog, no save + # self.bind('', self.Apply) #apply changes, save + # self.bind('', self.Help) #context help + # Attach callbacks after loading config to avoid calling them. + tracers.attach() + + if not _utest: + self.grab_set() + self.wm_deiconify() + self.wait_window() + + def create_widgets(self): + """Create and place widgets for tabbed dialog. + + Widgets Bound to self: + frame: encloses all other widgets + note: Notebook + highpage: HighPage + fontpage: FontPage + keyspage: KeysPage + winpage: WinPage + shedpage: ShedPage + extpage: ExtPage + + Methods: + create_action_buttons + load_configs: Load pages except for extensions. + activate_config_changes: Tell editors to reload. + """ + self.frame = frame = Frame(self, padding=5) + self.frame.grid(sticky="nwes") + self.note = note = Notebook(frame) + self.extpage = ExtPage(note) + self.highpage = HighPage(note, self.extpage) + self.fontpage = FontPage(note, self.highpage) + self.keyspage = KeysPage(note, self.extpage) + self.winpage = WinPage(note) + self.shedpage = ShedPage(note) + + note.add(self.fontpage, text=' Fonts ') + note.add(self.highpage, text='Highlights') + note.add(self.keyspage, text=' Keys ') + note.add(self.winpage, text=' Windows ') + note.add(self.shedpage, text=' Shell/Ed ') + note.add(self.extpage, text='Extensions') + note.enable_traversal() + note.pack(side=TOP, expand=TRUE, fill=BOTH) + self.create_action_buttons().pack(side=BOTTOM) + + def create_action_buttons(self): + """Return frame of action buttons for dialog. + + Methods: + ok + apply + cancel + help + + Widget Structure: + outer: Frame + buttons: Frame + (no assignment): Button (ok) + (no assignment): Button (apply) + (no assignment): Button (cancel) + (no assignment): Button (help) + (no assignment): Frame + """ + if macosx.isAquaTk(): + # Changing the default padding on OSX results in unreadable + # text in the buttons. + padding_args = {} + else: + padding_args = {'padding': (6, 3)} + outer = Frame(self.frame, padding=2) + buttons_frame = Frame(outer, padding=2) + self.buttons = {} + for txt, cmd in ( + ('Ok', self.ok), + ('Apply', self.apply), + ('Cancel', self.cancel), + ('Help', self.help)): + self.buttons[txt] = Button(buttons_frame, text=txt, command=cmd, + takefocus=FALSE, **padding_args) + self.buttons[txt].pack(side=LEFT, padx=5) + # Add space above buttons. + Frame(outer, height=2, borderwidth=0).pack(side=TOP) + buttons_frame.pack(side=BOTTOM) + return outer + + def ok(self): + """Apply config changes, then dismiss dialog.""" + self.apply() + self.destroy() + + def apply(self): + """Apply config changes and leave dialog open.""" + self.deactivate_current_config() + changes.save_all() + self.extpage.save_all_changed_extensions() + self.activate_config_changes() + + def cancel(self): + """Dismiss config dialog. + + Methods: + destroy: inherited + """ + changes.clear() + self.destroy() + + def destroy(self): + global font_sample_text + font_sample_text = self.fontpage.font_sample.get('1.0', 'end') + self.grab_release() + super().destroy() + + def help(self): + """Create textview for config dialog help. + + Attributes accessed: + note + Methods: + view_text: Method from textview module. + """ + page = self.note.tab(self.note.select(), option='text').strip() + view_text(self, title='Help for IDLE preferences', + contents=help_common+help_pages.get(page, '')) + + def deactivate_current_config(self): + """Remove current key bindings in current windows.""" + for instance in self.parent.instance_dict: + instance.RemoveKeybindings() + + def activate_config_changes(self): + """Apply configuration changes to current windows. + + Dynamically update the current parent window instances + with some of the configuration changes. + """ + for instance in self.parent.instance_dict: + instance.ResetColorizer() + instance.ResetFont() + instance.set_notabs_indentwidth() + instance.ApplyKeybindings() + instance.reset_help_menu_entries() + instance.update_cursor_blink() + for klass in reloadables: + klass.reload() + + +# class TabPage(Frame): # A template for Page classes. +# def __init__(self, master): +# super().__init__(master) +# self.create_page_tab() +# self.load_tab_cfg() +# def create_page_tab(self): +# # Define tk vars and register var and callback with tracers. +# # Create subframes and widgets. +# # Pack widgets. +# def load_tab_cfg(self): +# # Initialize widgets with data from idleConf. +# def var_changed_var_name(): +# # For each tk var that needs other than default callback. +# def other_methods(): +# # Define tab-specific behavior. + +font_sample_text = ( + '\n' + 'AaBbCcDdEeFfGgHhIiJj\n1234567890#:+=(){}[]\n' + '\u00a2\u00a3\u00a5\u00a7\u00a9\u00ab\u00ae\u00b6\u00bd\u011e' + '\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u00c7\u00d0\u00d8\u00df\n' + '\n\n' + '\u0250\u0255\u0258\u025e\u025f\u0264\u026b\u026e\u0270\u0277' + '\u027b\u0281\u0283\u0286\u028e\u029e\u02a2\u02ab\u02ad\u02af\n' + '\u0391\u03b1\u0392\u03b2\u0393\u03b3\u0394\u03b4\u0395\u03b5' + '\u0396\u03b6\u0397\u03b7\u0398\u03b8\u0399\u03b9\u039a\u03ba\n' + '\u0411\u0431\u0414\u0434\u0416\u0436\u041f\u043f\u0424\u0444' + '\u0427\u0447\u042a\u044a\u042d\u044d\u0460\u0464\u046c\u04dc\n' + '\n\n' + '\u05d0\u05d1\u05d2\u05d3\u05d4\u05d5\u05d6\u05d7\u05d8\u05d9' + '\u05da\u05db\u05dc\u05dd\u05de\u05df\u05e0\u05e1\u05e2\u05e3\n' + '\u0627\u0628\u062c\u062f\u0647\u0648\u0632\u062d\u0637\u064a' + '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669\n' + '\n\n' + '\u0966\u0967\u0968\u0969\u096a\u096b\u096c\u096d\u096e\u096f' + '\u0905\u0906\u0907\u0908\u0909\u090a\u090f\u0910\u0913\u0914\n' + '\u0be6\u0be7\u0be8\u0be9\u0bea\u0beb\u0bec\u0bed\u0bee\u0bef' + '\u0b85\u0b87\u0b89\u0b8e\n' + '\n\n' + '\u3007\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\n' + '\u6c49\u5b57\u6f22\u5b57\u4eba\u6728\u706b\u571f\u91d1\u6c34\n' + '\uac00\ub0d0\ub354\ub824\ubaa8\ubd64\uc218\uc720\uc988\uce58\n' + '\u3042\u3044\u3046\u3048\u304a\u30a2\u30a4\u30a6\u30a8\u30aa\n' + ) + + +class FontPage(Frame): + + def __init__(self, master, highpage): + super().__init__(master) + self.highlight_sample = highpage.highlight_sample + self.create_page_font() + self.load_font_cfg() + + def create_page_font(self): + """Return frame of widgets for Font tab. + + Fonts: Enable users to provisionally change font face, size, or + boldness and to see the consequence of proposed choices. Each + action set 3 options in changes structuree and changes the + corresponding aspect of the font sample on this page and + highlight sample on highlight page. + + Function load_font_cfg initializes font vars and widgets from + idleConf entries and tk. + + Fontlist: mouse button 1 click or up or down key invoke + on_fontlist_select(), which sets var font_name. + + Sizelist: clicking the menubutton opens the dropdown menu. A + mouse button 1 click or return key sets var font_size. + + Bold_toggle: clicking the box toggles var font_bold. + + Changing any of the font vars invokes var_changed_font, which + adds all 3 font options to changes and calls set_samples. + Set_samples applies a new font constructed from the font vars to + font_sample and to highlight_sample on the highlight page. + + Widgets for FontPage(Frame): (*) widgets bound to self + frame_font: LabelFrame + frame_font_name: Frame + font_name_title: Label + (*)fontlist: ListBox - font_name + scroll_font: Scrollbar + frame_font_param: Frame + font_size_title: Label + (*)sizelist: DynOptionMenu - font_size + (*)bold_toggle: Checkbutton - font_bold + frame_sample: LabelFrame + (*)font_sample: Label + """ + self.font_name = tracers.add(StringVar(self), self.var_changed_font) + self.font_size = tracers.add(StringVar(self), self.var_changed_font) + self.font_bold = tracers.add(BooleanVar(self), self.var_changed_font) + + # Define frames and widgets. + frame_font = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Shell/Editor Font ') + frame_sample = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Font Sample (Editable) ') + # frame_font. + frame_font_name = Frame(frame_font) + frame_font_param = Frame(frame_font) + font_name_title = Label( + frame_font_name, justify=LEFT, text='Font Face :') + self.fontlist = Listbox(frame_font_name, height=15, + takefocus=True, exportselection=FALSE) + self.fontlist.bind('', self.on_fontlist_select) + self.fontlist.bind('', self.on_fontlist_select) + self.fontlist.bind('', self.on_fontlist_select) + scroll_font = Scrollbar(frame_font_name) + scroll_font.config(command=self.fontlist.yview) + self.fontlist.config(yscrollcommand=scroll_font.set) + font_size_title = Label(frame_font_param, text='Size :') + self.sizelist = DynOptionMenu(frame_font_param, self.font_size, None) + self.bold_toggle = Checkbutton( + frame_font_param, variable=self.font_bold, + onvalue=1, offvalue=0, text='Bold') + # frame_sample. + font_sample_frame = ScrollableTextFrame(frame_sample) + self.font_sample = font_sample_frame.text + self.font_sample.config(wrap=NONE, width=1, height=1) + self.font_sample.insert(END, font_sample_text) + + # Grid and pack widgets: + self.columnconfigure(1, weight=1) + self.rowconfigure(2, weight=1) + frame_font.grid(row=0, column=0, padx=5, pady=5) + frame_sample.grid(row=0, column=1, rowspan=3, padx=5, pady=5, + sticky='nsew') + # frame_font. + frame_font_name.pack(side=TOP, padx=5, pady=5, fill=X) + frame_font_param.pack(side=TOP, padx=5, pady=5, fill=X) + font_name_title.pack(side=TOP, anchor=W) + self.fontlist.pack(side=LEFT, expand=TRUE, fill=X) + scroll_font.pack(side=LEFT, fill=Y) + font_size_title.pack(side=LEFT, anchor=W) + self.sizelist.pack(side=LEFT, anchor=W) + self.bold_toggle.pack(side=LEFT, anchor=W, padx=20) + # frame_sample. + font_sample_frame.pack(expand=TRUE, fill=BOTH) + + def load_font_cfg(self): + """Load current configuration settings for the font options. + + Retrieve current font with idleConf.GetFont and font families + from tk. Setup fontlist and set font_name. Setup sizelist, + which sets font_size. Set font_bold. Call set_samples. + """ + configured_font = idleConf.GetFont(self, 'main', 'EditorWindow') + font_name = configured_font[0].lower() + font_size = configured_font[1] + font_bold = configured_font[2]=='bold' + + # Set sorted no-duplicate editor font selection list and font_name. + fonts = sorted(set(tkfont.families(self))) + for font in fonts: + self.fontlist.insert(END, font) + self.font_name.set(font_name) + lc_fonts = [s.lower() for s in fonts] + try: + current_font_index = lc_fonts.index(font_name) + self.fontlist.see(current_font_index) + self.fontlist.select_set(current_font_index) + self.fontlist.select_anchor(current_font_index) + self.fontlist.activate(current_font_index) + except ValueError: + pass + # Set font size dropdown. + self.sizelist.SetMenu(('7', '8', '9', '10', '11', '12', '13', '14', + '16', '18', '20', '22', '25', '29', '34', '40'), + font_size) + # Set font weight. + self.font_bold.set(font_bold) + self.set_samples() + + def var_changed_font(self, *params): + """Store changes to font attributes. + + When one font attribute changes, save them all, as they are + not independent from each other. In particular, when we are + overriding the default font, we need to write out everything. + """ + value = self.font_name.get() + changes.add_option('main', 'EditorWindow', 'font', value) + value = self.font_size.get() + changes.add_option('main', 'EditorWindow', 'font-size', value) + value = self.font_bold.get() + changes.add_option('main', 'EditorWindow', 'font-bold', value) + self.set_samples() + + def on_fontlist_select(self, event): + """Handle selecting a font from the list. + + Event can result from either mouse click or Up or Down key. + Set font_name and example displays to selection. + """ + font = self.fontlist.get( + ACTIVE if event.type.name == 'KeyRelease' else ANCHOR) + self.font_name.set(font.lower()) + + def set_samples(self, event=None): + """Update both screen samples with the font settings. + + Called on font initialization and change events. + Accesses font_name, font_size, and font_bold Variables. + Updates font_sample and highlight page highlight_sample. + """ + font_name = self.font_name.get() + font_weight = tkfont.BOLD if self.font_bold.get() else tkfont.NORMAL + new_font = (font_name, self.font_size.get(), font_weight) + self.font_sample['font'] = new_font + self.highlight_sample['font'] = new_font + + +class HighPage(Frame): + + def __init__(self, master, extpage): + super().__init__(master) + self.extpage = extpage + self.cd = master.winfo_toplevel() + self.style = Style(master) + self.create_page_highlight() + self.load_theme_cfg() + + def create_page_highlight(self): + """Return frame of widgets for Highlights tab. + + Enable users to provisionally change foreground and background + colors applied to textual tags. Color mappings are stored in + complete listings called themes. Built-in themes in + idlelib/config-highlight.def are fixed as far as the dialog is + concerned. Any theme can be used as the base for a new custom + theme, stored in .idlerc/config-highlight.cfg. + + Function load_theme_cfg() initializes tk variables and theme + lists and calls paint_theme_sample() and set_highlight_target() + for the current theme. Radiobuttons builtin_theme_on and + custom_theme_on toggle var theme_source, which controls if the + current set of colors are from a builtin or custom theme. + DynOptionMenus builtinlist and customlist contain lists of the + builtin and custom themes, respectively, and the current item + from each list is stored in vars builtin_name and custom_name. + + Function paint_theme_sample() applies the colors from the theme + to the tags in text widget highlight_sample and then invokes + set_color_sample(). Function set_highlight_target() sets the state + of the radiobuttons fg_on and bg_on based on the tag and it also + invokes set_color_sample(). + + Function set_color_sample() sets the background color for the frame + holding the color selector. This provides a larger visual of the + color for the current tag and plane (foreground/background). + + Note: set_color_sample() is called from many places and is often + called more than once when a change is made. It is invoked when + foreground or background is selected (radiobuttons), from + paint_theme_sample() (theme is changed or load_cfg is called), and + from set_highlight_target() (target tag is changed or load_cfg called). + + Button delete_custom invokes delete_custom() to delete + a custom theme from idleConf.userCfg['highlight'] and changes. + Button save_custom invokes save_as_new_theme() which calls + get_new_theme_name() and create_new() to save a custom theme + and its colors to idleConf.userCfg['highlight']. + + Radiobuttons fg_on and bg_on toggle var fg_bg_toggle to control + if the current selected color for a tag is for the foreground or + background. + + DynOptionMenu targetlist contains a readable description of the + tags applied to Python source within IDLE. Selecting one of the + tags from this list populates highlight_target, which has a callback + function set_highlight_target(). + + Text widget highlight_sample displays a block of text (which is + mock Python code) in which is embedded the defined tags and reflects + the color attributes of the current theme and changes for those tags. + Mouse button 1 allows for selection of a tag and updates + highlight_target with that tag value. + + Note: The font in highlight_sample is set through the config in + the fonts tab. + + In other words, a tag can be selected either from targetlist or + by clicking on the sample text within highlight_sample. The + plane (foreground/background) is selected via the radiobutton. + Together, these two (tag and plane) control what color is + shown in set_color_sample() for the current theme. Button set_color + invokes get_color() which displays a ColorChooser to change the + color for the selected tag/plane. If a new color is picked, + it will be saved to changes and the highlight_sample and + frame background will be updated. + + Tk Variables: + color: Color of selected target. + builtin_name: Menu variable for built-in theme. + custom_name: Menu variable for custom theme. + fg_bg_toggle: Toggle for foreground/background color. + Note: this has no callback. + theme_source: Selector for built-in or custom theme. + highlight_target: Menu variable for the highlight tag target. + + Instance Data Attributes: + theme_elements: Dictionary of tags for text highlighting. + The key is the display name and the value is a tuple of + (tag name, display sort order). + + Methods [attachment]: + load_theme_cfg: Load current highlight colors. + get_color: Invoke colorchooser [button_set_color]. + set_color_sample_binding: Call set_color_sample [fg_bg_toggle]. + set_highlight_target: set fg_bg_toggle, set_color_sample(). + set_color_sample: Set frame background to target. + on_new_color_set: Set new color and add option. + paint_theme_sample: Recolor sample. + get_new_theme_name: Get from popup. + create_new: Combine theme with changes and save. + save_as_new_theme: Save [button_save_custom]. + set_theme_type: Command for [theme_source]. + delete_custom: Activate default [button_delete_custom]. + save_new: Save to userCfg['theme'] (is function). + + Widgets of highlights page frame: (*) widgets bound to self + frame_custom: LabelFrame + (*)highlight_sample: Text + (*)frame_color_set: Frame + (*)button_set_color: Button + (*)targetlist: DynOptionMenu - highlight_target + frame_fg_bg_toggle: Frame + (*)fg_on: Radiobutton - fg_bg_toggle + (*)bg_on: Radiobutton - fg_bg_toggle + (*)button_save_custom: Button + frame_theme: LabelFrame + theme_type_title: Label + (*)builtin_theme_on: Radiobutton - theme_source + (*)custom_theme_on: Radiobutton - theme_source + (*)builtinlist: DynOptionMenu - builtin_name + (*)customlist: DynOptionMenu - custom_name + (*)button_delete_custom: Button + (*)theme_message: Label + """ + self.theme_elements = { + # Display-name: internal-config-tag-name. + 'Normal Code or Text': 'normal', + 'Code Context': 'context', + 'Python Keywords': 'keyword', + 'Python Definitions': 'definition', + 'Python Builtins': 'builtin', + 'Python Comments': 'comment', + 'Python Strings': 'string', + 'Selected Text': 'hilite', + 'Found Text': 'hit', + 'Cursor': 'cursor', + 'Editor Breakpoint': 'break', + 'Shell Prompt': 'console', + 'Error Text': 'error', + 'Shell User Output': 'stdout', + 'Shell User Exception': 'stderr', + 'Line Number': 'linenumber', + } + self.builtin_name = tracers.add( + StringVar(self), self.var_changed_builtin_name) + self.custom_name = tracers.add( + StringVar(self), self.var_changed_custom_name) + self.fg_bg_toggle = BooleanVar(self) + self.color = tracers.add( + StringVar(self), self.var_changed_color) + self.theme_source = tracers.add( + BooleanVar(self), self.var_changed_theme_source) + self.highlight_target = tracers.add( + StringVar(self), self.var_changed_highlight_target) + + # Create widgets: + # body frame and section frames. + frame_custom = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Custom Highlighting ') + frame_theme = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Highlighting Theme ') + # frame_custom. + sample_frame = ScrollableTextFrame( + frame_custom, relief=SOLID, borderwidth=1) + text = self.highlight_sample = sample_frame.text + text.configure( + font=('courier', 12, ''), cursor='hand2', width=1, height=1, + takefocus=FALSE, highlightthickness=0, wrap=NONE) + # Prevent perhaps invisible selection of word or slice. + text.bind('', lambda e: 'break') + text.bind('', lambda e: 'break') + string_tags=( + ('# Click selects item.', 'comment'), ('\n', 'normal'), + ('code context section', 'context'), ('\n', 'normal'), + ('| cursor', 'cursor'), ('\n', 'normal'), + ('def', 'keyword'), (' ', 'normal'), + ('func', 'definition'), ('(param):\n ', 'normal'), + ('"Return None."', 'string'), ('\n var0 = ', 'normal'), + ("'string'", 'string'), ('\n var1 = ', 'normal'), + ("'selected'", 'hilite'), ('\n var2 = ', 'normal'), + ("'found'", 'hit'), ('\n var3 = ', 'normal'), + ('list', 'builtin'), ('(', 'normal'), + ('None', 'keyword'), (')\n', 'normal'), + (' breakpoint("line")', 'break'), ('\n\n', 'normal'), + ('>>>', 'console'), (' 3.14**2\n', 'normal'), + ('9.8596', 'stdout'), ('\n', 'normal'), + ('>>>', 'console'), (' pri ', 'normal'), + ('n', 'error'), ('t(\n', 'normal'), + ('SyntaxError', 'stderr'), ('\n', 'normal')) + for string, tag in string_tags: + text.insert(END, string, tag) + n_lines = len(text.get('1.0', END).splitlines()) + for lineno in range(1, n_lines): + text.insert(f'{lineno}.0', + f'{lineno:{len(str(n_lines))}d} ', + 'linenumber') + for element in self.theme_elements: + def tem(event, elem=element): + # event.widget.winfo_top_level().highlight_target.set(elem) + self.highlight_target.set(elem) + text.tag_bind( + self.theme_elements[element], '', tem) + text['state'] = 'disabled' + self.style.configure('frame_color_set.TFrame', borderwidth=1, + relief='solid') + self.frame_color_set = Frame(frame_custom, style='frame_color_set.TFrame') + frame_fg_bg_toggle = Frame(frame_custom) + self.button_set_color = Button( + self.frame_color_set, text='Choose Color for :', + command=self.get_color) + self.targetlist = DynOptionMenu( + self.frame_color_set, self.highlight_target, None, + highlightthickness=0) #, command=self.set_highlight_targetBinding + self.fg_on = Radiobutton( + frame_fg_bg_toggle, variable=self.fg_bg_toggle, value=1, + text='Foreground', command=self.set_color_sample_binding) + self.bg_on = Radiobutton( + frame_fg_bg_toggle, variable=self.fg_bg_toggle, value=0, + text='Background', command=self.set_color_sample_binding) + self.fg_bg_toggle.set(1) + self.button_save_custom = Button( + frame_custom, text='Save as New Custom Theme', + command=self.save_as_new_theme) + # frame_theme. + theme_type_title = Label(frame_theme, text='Select : ') + self.builtin_theme_on = Radiobutton( + frame_theme, variable=self.theme_source, value=1, + command=self.set_theme_type, text='a Built-in Theme') + self.custom_theme_on = Radiobutton( + frame_theme, variable=self.theme_source, value=0, + command=self.set_theme_type, text='a Custom Theme') + self.builtinlist = DynOptionMenu( + frame_theme, self.builtin_name, None, command=None) + self.customlist = DynOptionMenu( + frame_theme, self.custom_name, None, command=None) + self.button_delete_custom = Button( + frame_theme, text='Delete Custom Theme', + command=self.delete_custom) + self.theme_message = Label(frame_theme, borderwidth=2) + # Pack widgets: + # body. + frame_custom.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_theme.pack(side=TOP, padx=5, pady=5, fill=X) + # frame_custom. + self.frame_color_set.pack(side=TOP, padx=5, pady=5, fill=X) + frame_fg_bg_toggle.pack(side=TOP, padx=5, pady=0) + sample_frame.pack( + side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + self.button_set_color.pack(side=TOP, expand=TRUE, fill=X, padx=8, pady=4) + self.targetlist.pack(side=TOP, expand=TRUE, fill=X, padx=8, pady=3) + self.fg_on.pack(side=LEFT, anchor=E) + self.bg_on.pack(side=RIGHT, anchor=W) + self.button_save_custom.pack(side=BOTTOM, fill=X, padx=5, pady=5) + # frame_theme. + theme_type_title.pack(side=TOP, anchor=W, padx=5, pady=5) + self.builtin_theme_on.pack(side=TOP, anchor=W, padx=5) + self.custom_theme_on.pack(side=TOP, anchor=W, padx=5, pady=2) + self.builtinlist.pack(side=TOP, fill=X, padx=5, pady=5) + self.customlist.pack(side=TOP, fill=X, anchor=W, padx=5, pady=5) + self.button_delete_custom.pack(side=TOP, fill=X, padx=5, pady=5) + self.theme_message.pack(side=TOP, fill=X, pady=5) + + def load_theme_cfg(self): + """Load current configuration settings for the theme options. + + Based on the theme_source toggle, the theme is set as + either builtin or custom and the initial widget values + reflect the current settings from idleConf. + + Attributes updated: + theme_source: Set from idleConf. + builtinlist: List of default themes from idleConf. + customlist: List of custom themes from idleConf. + custom_theme_on: Disabled if there are no custom themes. + custom_theme: Message with additional information. + targetlist: Create menu from self.theme_elements. + + Methods: + set_theme_type + paint_theme_sample + set_highlight_target + """ + # Set current theme type radiobutton. + self.theme_source.set(idleConf.GetOption( + 'main', 'Theme', 'default', type='bool', default=1)) + # Set current theme. + current_option = idleConf.CurrentTheme() + # Load available theme option menus. + if self.theme_source.get(): # Default theme selected. + item_list = idleConf.GetSectionList('default', 'highlight') + item_list.sort() + self.builtinlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + if not item_list: + self.custom_theme_on.state(('disabled',)) + self.custom_name.set('- no custom themes -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + else: # User theme selected. + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + self.customlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('default', 'highlight') + item_list.sort() + self.builtinlist.SetMenu(item_list, item_list[0]) + self.set_theme_type() + # Load theme element option menu. + theme_names = list(self.theme_elements) + self.targetlist.SetMenu(theme_names, theme_names[0]) + self.paint_theme_sample() + self.set_highlight_target() + + def var_changed_builtin_name(self, *params): + """Process new builtin theme selection. + + Add the changed theme's name to the changed_items and recreate + the sample with the values from the selected theme. + """ + old_themes = ('IDLE Classic', 'IDLE New') + value = self.builtin_name.get() + if value not in old_themes: + if idleConf.GetOption('main', 'Theme', 'name') not in old_themes: + changes.add_option('main', 'Theme', 'name', old_themes[0]) + changes.add_option('main', 'Theme', 'name2', value) + self.theme_message['text'] = 'New theme, see Help' + else: + changes.add_option('main', 'Theme', 'name', value) + changes.add_option('main', 'Theme', 'name2', '') + self.theme_message['text'] = '' + self.paint_theme_sample() + + def var_changed_custom_name(self, *params): + """Process new custom theme selection. + + If a new custom theme is selected, add the name to the + changed_items and apply the theme to the sample. + """ + value = self.custom_name.get() + if value != '- no custom themes -': + changes.add_option('main', 'Theme', 'name', value) + self.paint_theme_sample() + + def var_changed_theme_source(self, *params): + """Process toggle between builtin and custom theme. + + Update the default toggle value and apply the newly + selected theme type. + """ + value = self.theme_source.get() + changes.add_option('main', 'Theme', 'default', value) + if value: + self.var_changed_builtin_name() + else: + self.var_changed_custom_name() + + def var_changed_color(self, *params): + "Process change to color choice." + self.on_new_color_set() + + def var_changed_highlight_target(self, *params): + "Process selection of new target tag for highlighting." + self.set_highlight_target() + + def set_theme_type(self): + """Set available screen options based on builtin or custom theme. + + Attributes accessed: + theme_source + + Attributes updated: + builtinlist + customlist + button_delete_custom + custom_theme_on + + Called from: + handler for builtin_theme_on and custom_theme_on + delete_custom + create_new + load_theme_cfg + """ + if self.theme_source.get(): + self.builtinlist['state'] = 'normal' + self.customlist['state'] = 'disabled' + self.button_delete_custom.state(('disabled',)) + else: + self.builtinlist['state'] = 'disabled' + self.custom_theme_on.state(('!disabled',)) + self.customlist['state'] = 'normal' + self.button_delete_custom.state(('!disabled',)) + + def get_color(self): + """Handle button to select a new color for the target tag. + + If a new color is selected while using a builtin theme, a + name must be supplied to create a custom theme. + + Attributes accessed: + highlight_target + frame_color_set + theme_source + + Attributes updated: + color + + Methods: + get_new_theme_name + create_new + """ + target = self.highlight_target.get() + prev_color = self.style.lookup(self.frame_color_set['style'], + 'background') + rgbTuplet, color_string = colorchooser.askcolor( + parent=self, title='Pick new color for : '+target, + initialcolor=prev_color) + if color_string and (color_string != prev_color): + # User didn't cancel and they chose a new color. + if self.theme_source.get(): # Current theme is a built-in. + message = ('Your changes will be saved as a new Custom Theme. ' + 'Enter a name for your new Custom Theme below.') + new_theme = self.get_new_theme_name(message) + if not new_theme: # User cancelled custom theme creation. + return + else: # Create new custom theme based on previously active theme. + self.create_new(new_theme) + self.color.set(color_string) + else: # Current theme is user defined. + self.color.set(color_string) + + def on_new_color_set(self): + "Display sample of new color selection on the dialog." + new_color = self.color.get() + self.style.configure('frame_color_set.TFrame', background=new_color) + plane = 'foreground' if self.fg_bg_toggle.get() else 'background' + sample_element = self.theme_elements[self.highlight_target.get()] + self.highlight_sample.tag_config(sample_element, **{plane: new_color}) + theme = self.custom_name.get() + theme_element = sample_element + '-' + plane + changes.add_option('highlight', theme, theme_element, new_color) + + def get_new_theme_name(self, message): + "Return name of new theme from query popup." + used_names = (idleConf.GetSectionList('user', 'highlight') + + idleConf.GetSectionList('default', 'highlight')) + new_theme = SectionName( + self, 'New Custom Theme', message, used_names).result + return new_theme + + def save_as_new_theme(self): + """Prompt for new theme name and create the theme. + + Methods: + get_new_theme_name + create_new + """ + new_theme_name = self.get_new_theme_name('New Theme Name:') + if new_theme_name: + self.create_new(new_theme_name) + + def create_new(self, new_theme_name): + """Create a new custom theme with the given name. + + Create the new theme based on the previously active theme + with the current changes applied. Once it is saved, then + activate the new theme. + + Attributes accessed: + builtin_name + custom_name + + Attributes updated: + customlist + theme_source + + Method: + save_new + set_theme_type + """ + if self.theme_source.get(): + theme_type = 'default' + theme_name = self.builtin_name.get() + else: + theme_type = 'user' + theme_name = self.custom_name.get() + new_theme = idleConf.GetThemeDict(theme_type, theme_name) + # Apply any of the old theme's unsaved changes to the new theme. + if theme_name in changes['highlight']: + theme_changes = changes['highlight'][theme_name] + for element in theme_changes: + new_theme[element] = theme_changes[element] + # Save the new theme. + self.save_new(new_theme_name, new_theme) + # Change GUI over to the new theme. + custom_theme_list = idleConf.GetSectionList('user', 'highlight') + custom_theme_list.sort() + self.customlist.SetMenu(custom_theme_list, new_theme_name) + self.theme_source.set(0) + self.set_theme_type() + + def set_highlight_target(self): + """Set fg/bg toggle and color based on highlight tag target. + + Instance variables accessed: + highlight_target + + Attributes updated: + fg_on + bg_on + fg_bg_toggle + + Methods: + set_color_sample + + Called from: + var_changed_highlight_target + load_theme_cfg + """ + if self.highlight_target.get() == 'Cursor': # bg not possible + self.fg_on.state(('disabled',)) + self.bg_on.state(('disabled',)) + self.fg_bg_toggle.set(1) + else: # Both fg and bg can be set. + self.fg_on.state(('!disabled',)) + self.bg_on.state(('!disabled',)) + self.fg_bg_toggle.set(1) + self.set_color_sample() + + def set_color_sample_binding(self, *args): + """Change color sample based on foreground/background toggle. + + Methods: + set_color_sample + """ + self.set_color_sample() + + def set_color_sample(self): + """Set the color of the frame background to reflect the selected target. + + Instance variables accessed: + theme_elements + highlight_target + fg_bg_toggle + highlight_sample + + Attributes updated: + frame_color_set + """ + # Set the color sample area. + tag = self.theme_elements[self.highlight_target.get()] + plane = 'foreground' if self.fg_bg_toggle.get() else 'background' + color = self.highlight_sample.tag_cget(tag, plane) + self.style.configure('frame_color_set.TFrame', background=color) + + def paint_theme_sample(self): + """Apply the theme colors to each element tag in the sample text. + + Instance attributes accessed: + theme_elements + theme_source + builtin_name + custom_name + + Attributes updated: + highlight_sample: Set the tag elements to the theme. + + Methods: + set_color_sample + + Called from: + var_changed_builtin_name + var_changed_custom_name + load_theme_cfg + """ + if self.theme_source.get(): # Default theme + theme = self.builtin_name.get() + else: # User theme + theme = self.custom_name.get() + for element_title in self.theme_elements: + element = self.theme_elements[element_title] + colors = idleConf.GetHighlight(theme, element) + if element == 'cursor': # Cursor sample needs special painting. + colors['background'] = idleConf.GetHighlight( + theme, 'normal')['background'] + # Handle any unsaved changes to this theme. + if theme in changes['highlight']: + theme_dict = changes['highlight'][theme] + if element + '-foreground' in theme_dict: + colors['foreground'] = theme_dict[element + '-foreground'] + if element + '-background' in theme_dict: + colors['background'] = theme_dict[element + '-background'] + self.highlight_sample.tag_config(element, **colors) + self.set_color_sample() + + def save_new(self, theme_name, theme): + """Save a newly created theme to idleConf. + + theme_name - string, the name of the new theme + theme - dictionary containing the new theme + """ + idleConf.userCfg['highlight'].AddSection(theme_name) + for element in theme: + value = theme[element] + idleConf.userCfg['highlight'].SetOption(theme_name, element, value) + + def askyesno(self, *args, **kwargs): + # Make testing easier. Could change implementation. + return messagebox.askyesno(*args, **kwargs) + + def delete_custom(self): + """Handle event to delete custom theme. + + The current theme is deactivated and the default theme is + activated. The custom theme is permanently removed from + the config file. + + Attributes accessed: + custom_name + + Attributes updated: + custom_theme_on + customlist + theme_source + builtin_name + + Methods: + deactivate_current_config + save_all_changed_extensions + activate_config_changes + set_theme_type + """ + theme_name = self.custom_name.get() + delmsg = 'Are you sure you wish to delete the theme %r ?' + if not self.askyesno( + 'Delete Theme', delmsg % theme_name, parent=self): + return + self.cd.deactivate_current_config() + # Remove theme from changes, config, and file. + changes.delete_section('highlight', theme_name) + # Reload user theme list. + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + if not item_list: + self.custom_theme_on.state(('disabled',)) + self.customlist.SetMenu(item_list, '- no custom themes -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + # Revert to default theme. + self.theme_source.set(idleConf.defaultCfg['main'].Get('Theme', 'default')) + self.builtin_name.set(idleConf.defaultCfg['main'].Get('Theme', 'name')) + # User can't back out of these changes, they must be applied now. + changes.save_all() + self.extpage.save_all_changed_extensions() + self.cd.activate_config_changes() + self.set_theme_type() + + +class KeysPage(Frame): + + def __init__(self, master, extpage): + super().__init__(master) + self.extpage = extpage + self.cd = master.winfo_toplevel() + self.create_page_keys() + self.load_key_cfg() + + def create_page_keys(self): + """Return frame of widgets for Keys tab. + + Enable users to provisionally change both individual and sets of + keybindings (shortcut keys). Except for features implemented as + extensions, keybindings are stored in complete sets called + keysets. Built-in keysets in idlelib/config-keys.def are fixed + as far as the dialog is concerned. Any keyset can be used as the + base for a new custom keyset, stored in .idlerc/config-keys.cfg. + + Function load_key_cfg() initializes tk variables and keyset + lists and calls load_keys_list for the current keyset. + Radiobuttons builtin_keyset_on and custom_keyset_on toggle var + keyset_source, which controls if the current set of keybindings + are from a builtin or custom keyset. DynOptionMenus builtinlist + and customlist contain lists of the builtin and custom keysets, + respectively, and the current item from each list is stored in + vars builtin_name and custom_name. + + Button delete_custom_keys invokes delete_custom_keys() to delete + a custom keyset from idleConf.userCfg['keys'] and changes. Button + save_custom_keys invokes save_as_new_key_set() which calls + get_new_keys_name() and create_new_key_set() to save a custom keyset + and its keybindings to idleConf.userCfg['keys']. + + Listbox bindingslist contains all of the keybindings for the + selected keyset. The keybindings are loaded in load_keys_list() + and are pairs of (event, [keys]) where keys can be a list + of one or more key combinations to bind to the same event. + Mouse button 1 click invokes on_bindingslist_select(), which + allows button_new_keys to be clicked. + + So, an item is selected in listbindings, which activates + button_new_keys, and clicking button_new_keys calls function + get_new_keys(). Function get_new_keys() gets the key mappings from the + current keyset for the binding event item that was selected. The + function then displays another dialog, GetKeysDialog, with the + selected binding event and current keys and allows new key sequences + to be entered for that binding event. If the keys aren't + changed, nothing happens. If the keys are changed and the keyset + is a builtin, function get_new_keys_name() will be called + for input of a custom keyset name. If no name is given, then the + change to the keybinding will abort and no updates will be made. If + a custom name is entered in the prompt or if the current keyset was + already custom (and thus didn't require a prompt), then + idleConf.userCfg['keys'] is updated in function create_new_key_set() + with the change to the event binding. The item listing in bindingslist + is updated with the new keys. Var keybinding is also set which invokes + the callback function, var_changed_keybinding, to add the change to + the 'keys' or 'extensions' changes tracker based on the binding type. + + Tk Variables: + keybinding: Action/key bindings. + + Methods: + load_keys_list: Reload active set. + create_new_key_set: Combine active keyset and changes. + set_keys_type: Command for keyset_source. + save_new_key_set: Save to idleConf.userCfg['keys'] (is function). + deactivate_current_config: Remove keys bindings in editors. + + Widgets for KeysPage(frame): (*) widgets bound to self + frame_key_sets: LabelFrame + frames[0]: Frame + (*)builtin_keyset_on: Radiobutton - var keyset_source + (*)custom_keyset_on: Radiobutton - var keyset_source + (*)builtinlist: DynOptionMenu - var builtin_name, + func keybinding_selected + (*)customlist: DynOptionMenu - var custom_name, + func keybinding_selected + (*)keys_message: Label + frames[1]: Frame + (*)button_delete_custom_keys: Button - delete_custom_keys + (*)button_save_custom_keys: Button - save_as_new_key_set + frame_custom: LabelFrame + frame_target: Frame + target_title: Label + scroll_target_y: Scrollbar + scroll_target_x: Scrollbar + (*)bindingslist: ListBox - on_bindingslist_select + (*)button_new_keys: Button - get_new_keys & ..._name + """ + self.builtin_name = tracers.add( + StringVar(self), self.var_changed_builtin_name) + self.custom_name = tracers.add( + StringVar(self), self.var_changed_custom_name) + self.keyset_source = tracers.add( + BooleanVar(self), self.var_changed_keyset_source) + self.keybinding = tracers.add( + StringVar(self), self.var_changed_keybinding) + + # Create widgets: + # body and section frames. + frame_custom = LabelFrame( + self, borderwidth=2, relief=GROOVE, + text=' Custom Key Bindings ') + frame_key_sets = LabelFrame( + self, borderwidth=2, relief=GROOVE, text=' Key Set ') + # frame_custom. + frame_target = Frame(frame_custom) + target_title = Label(frame_target, text='Action - Key(s)') + scroll_target_y = Scrollbar(frame_target) + scroll_target_x = Scrollbar(frame_target, orient=HORIZONTAL) + self.bindingslist = Listbox( + frame_target, takefocus=FALSE, exportselection=FALSE) + self.bindingslist.bind('', + self.on_bindingslist_select) + scroll_target_y['command'] = self.bindingslist.yview + scroll_target_x['command'] = self.bindingslist.xview + self.bindingslist['yscrollcommand'] = scroll_target_y.set + self.bindingslist['xscrollcommand'] = scroll_target_x.set + self.button_new_keys = Button( + frame_custom, text='Get New Keys for Selection', + command=self.get_new_keys, state='disabled') + # frame_key_sets. + frames = [Frame(frame_key_sets, padding=2, borderwidth=0) + for i in range(2)] + self.builtin_keyset_on = Radiobutton( + frames[0], variable=self.keyset_source, value=1, + command=self.set_keys_type, text='Use a Built-in Key Set') + self.custom_keyset_on = Radiobutton( + frames[0], variable=self.keyset_source, value=0, + command=self.set_keys_type, text='Use a Custom Key Set') + self.builtinlist = DynOptionMenu( + frames[0], self.builtin_name, None, command=None) + self.customlist = DynOptionMenu( + frames[0], self.custom_name, None, command=None) + self.button_delete_custom_keys = Button( + frames[1], text='Delete Custom Key Set', + command=self.delete_custom_keys) + self.button_save_custom_keys = Button( + frames[1], text='Save as New Custom Key Set', + command=self.save_as_new_key_set) + self.keys_message = Label(frames[0], borderwidth=2) + + # Pack widgets: + # body. + frame_custom.pack(side=BOTTOM, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_key_sets.pack(side=BOTTOM, padx=5, pady=5, fill=BOTH) + # frame_custom. + self.button_new_keys.pack(side=BOTTOM, fill=X, padx=5, pady=5) + frame_target.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + # frame_target. + frame_target.columnconfigure(0, weight=1) + frame_target.rowconfigure(1, weight=1) + target_title.grid(row=0, column=0, columnspan=2, sticky=W) + self.bindingslist.grid(row=1, column=0, sticky=NSEW) + scroll_target_y.grid(row=1, column=1, sticky=NS) + scroll_target_x.grid(row=2, column=0, sticky=EW) + # frame_key_sets. + self.builtin_keyset_on.grid(row=0, column=0, sticky=W+NS) + self.custom_keyset_on.grid(row=1, column=0, sticky=W+NS) + self.builtinlist.grid(row=0, column=1, sticky=NSEW) + self.customlist.grid(row=1, column=1, sticky=NSEW) + self.keys_message.grid(row=0, column=2, sticky=NSEW, padx=5, pady=5) + self.button_delete_custom_keys.pack(side=LEFT, fill=X, expand=True, padx=2) + self.button_save_custom_keys.pack(side=LEFT, fill=X, expand=True, padx=2) + frames[0].pack(side=TOP, fill=BOTH, expand=True) + frames[1].pack(side=TOP, fill=X, expand=True, pady=2) + + def load_key_cfg(self): + "Load current configuration settings for the keybinding options." + # Set current keys type radiobutton. + self.keyset_source.set(idleConf.GetOption( + 'main', 'Keys', 'default', type='bool', default=1)) + # Set current keys. + current_option = idleConf.CurrentKeys() + # Load available keyset option menus. + if self.keyset_source.get(): # Default theme selected. + item_list = idleConf.GetSectionList('default', 'keys') + item_list.sort() + self.builtinlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + if not item_list: + self.custom_keyset_on.state(('disabled',)) + self.custom_name.set('- no custom keys -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + else: # User key set selected. + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + self.customlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('default', 'keys') + item_list.sort() + self.builtinlist.SetMenu(item_list, idleConf.default_keys()) + self.set_keys_type() + # Load keyset element list. + keyset_name = idleConf.CurrentKeys() + self.load_keys_list(keyset_name) + + def var_changed_builtin_name(self, *params): + "Process selection of builtin key set." + old_keys = ( + 'IDLE Classic Windows', + 'IDLE Classic Unix', + 'IDLE Classic Mac', + 'IDLE Classic OSX', + ) + value = self.builtin_name.get() + if value not in old_keys: + if idleConf.GetOption('main', 'Keys', 'name') not in old_keys: + changes.add_option('main', 'Keys', 'name', old_keys[0]) + changes.add_option('main', 'Keys', 'name2', value) + self.keys_message['text'] = 'New key set, see Help' + else: + changes.add_option('main', 'Keys', 'name', value) + changes.add_option('main', 'Keys', 'name2', '') + self.keys_message['text'] = '' + self.load_keys_list(value) + + def var_changed_custom_name(self, *params): + "Process selection of custom key set." + value = self.custom_name.get() + if value != '- no custom keys -': + changes.add_option('main', 'Keys', 'name', value) + self.load_keys_list(value) + + def var_changed_keyset_source(self, *params): + "Process toggle between builtin key set and custom key set." + value = self.keyset_source.get() + changes.add_option('main', 'Keys', 'default', value) + if value: + self.var_changed_builtin_name() + else: + self.var_changed_custom_name() + + def var_changed_keybinding(self, *params): + "Store change to a keybinding." + value = self.keybinding.get() + key_set = self.custom_name.get() + event = self.bindingslist.get(ANCHOR).split()[0] + if idleConf.IsCoreBinding(event): + changes.add_option('keys', key_set, event, value) + else: # Event is an extension binding. + ext_name = idleConf.GetExtnNameForEvent(event) + ext_keybind_section = ext_name + '_cfgBindings' + changes.add_option('extensions', ext_keybind_section, event, value) + + def set_keys_type(self): + "Set available screen options based on builtin or custom key set." + if self.keyset_source.get(): + self.builtinlist['state'] = 'normal' + self.customlist['state'] = 'disabled' + self.button_delete_custom_keys.state(('disabled',)) + else: + self.builtinlist['state'] = 'disabled' + self.custom_keyset_on.state(('!disabled',)) + self.customlist['state'] = 'normal' + self.button_delete_custom_keys.state(('!disabled',)) + + def get_new_keys(self): + """Handle event to change key binding for selected line. + + A selection of a key/binding in the list of current + bindings pops up a dialog to enter a new binding. If + the current key set is builtin and a binding has + changed, then a name for a custom key set needs to be + entered for the change to be applied. + """ + list_index = self.bindingslist.index(ANCHOR) + binding = self.bindingslist.get(list_index) + bind_name = binding.split()[0] + if self.keyset_source.get(): + current_key_set_name = self.builtin_name.get() + else: + current_key_set_name = self.custom_name.get() + current_bindings = idleConf.GetCurrentKeySet() + if current_key_set_name in changes['keys']: # unsaved changes + key_set_changes = changes['keys'][current_key_set_name] + for event in key_set_changes: + current_bindings[event] = key_set_changes[event].split() + current_key_sequences = list(current_bindings.values()) + new_keys = GetKeysWindow(self, 'Get New Keys', bind_name, + current_key_sequences).result + if new_keys: + if self.keyset_source.get(): # Current key set is a built-in. + message = ('Your changes will be saved as a new Custom Key Set.' + ' Enter a name for your new Custom Key Set below.') + new_keyset = self.get_new_keys_name(message) + if not new_keyset: # User cancelled custom key set creation. + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + return + else: # Create new custom key set based on previously active key set. + self.create_new_key_set(new_keyset) + self.bindingslist.delete(list_index) + self.bindingslist.insert(list_index, bind_name+' - '+new_keys) + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + self.keybinding.set(new_keys) + else: + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + + def get_new_keys_name(self, message): + "Return new key set name from query popup." + used_names = (idleConf.GetSectionList('user', 'keys') + + idleConf.GetSectionList('default', 'keys')) + new_keyset = SectionName( + self, 'New Custom Key Set', message, used_names).result + return new_keyset + + def save_as_new_key_set(self): + "Prompt for name of new key set and save changes using that name." + new_keys_name = self.get_new_keys_name('New Key Set Name:') + if new_keys_name: + self.create_new_key_set(new_keys_name) + + def on_bindingslist_select(self, event): + "Activate button to assign new keys to selected action." + self.button_new_keys.state(('!disabled',)) + + def create_new_key_set(self, new_key_set_name): + """Create a new custom key set with the given name. + + Copy the bindings/keys from the previously active keyset + to the new keyset and activate the new custom keyset. + """ + if self.keyset_source.get(): + prev_key_set_name = self.builtin_name.get() + else: + prev_key_set_name = self.custom_name.get() + prev_keys = idleConf.GetCoreKeys(prev_key_set_name) + new_keys = {} + for event in prev_keys: # Add key set to changed items. + event_name = event[2:-2] # Trim off the angle brackets. + binding = ' '.join(prev_keys[event]) + new_keys[event_name] = binding + # Handle any unsaved changes to prev key set. + if prev_key_set_name in changes['keys']: + key_set_changes = changes['keys'][prev_key_set_name] + for event in key_set_changes: + new_keys[event] = key_set_changes[event] + # Save the new key set. + self.save_new_key_set(new_key_set_name, new_keys) + # Change GUI over to the new key set. + custom_key_list = idleConf.GetSectionList('user', 'keys') + custom_key_list.sort() + self.customlist.SetMenu(custom_key_list, new_key_set_name) + self.keyset_source.set(0) + self.set_keys_type() + + def load_keys_list(self, keyset_name): + """Reload the list of action/key binding pairs for the active key set. + + An action/key binding can be selected to change the key binding. + """ + reselect = False + if self.bindingslist.curselection(): + reselect = True + list_index = self.bindingslist.index(ANCHOR) + keyset = idleConf.GetKeySet(keyset_name) + # 'set' is dict mapping virtual event to list of key events. + bind_names = list(keyset) + bind_names.sort() + self.bindingslist.delete(0, END) + for bind_name in bind_names: + key = ' '.join(keyset[bind_name]) + bind_name = bind_name[2:-2] # Trim double angle brackets. + if keyset_name in changes['keys']: + # Handle any unsaved changes to this key set. + if bind_name in changes['keys'][keyset_name]: + key = changes['keys'][keyset_name][bind_name] + self.bindingslist.insert(END, bind_name+' - '+key) + if reselect: + self.bindingslist.see(list_index) + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + + @staticmethod + def save_new_key_set(keyset_name, keyset): + """Save a newly created core key set. + + Add keyset to idleConf.userCfg['keys'], not to disk. + If the keyset doesn't exist, it is created. The + binding/keys are taken from the keyset argument. + + keyset_name - string, the name of the new key set + keyset - dictionary containing the new keybindings + """ + idleConf.userCfg['keys'].AddSection(keyset_name) + for event in keyset: + value = keyset[event] + idleConf.userCfg['keys'].SetOption(keyset_name, event, value) + + def askyesno(self, *args, **kwargs): + # Make testing easier. Could change implementation. + return messagebox.askyesno(*args, **kwargs) + + def delete_custom_keys(self): + """Handle event to delete a custom key set. + + Applying the delete deactivates the current configuration and + reverts to the default. The custom key set is permanently + deleted from the config file. + """ + keyset_name = self.custom_name.get() + delmsg = 'Are you sure you wish to delete the key set %r ?' + if not self.askyesno( + 'Delete Key Set', delmsg % keyset_name, parent=self): + return + self.cd.deactivate_current_config() + # Remove key set from changes, config, and file. + changes.delete_section('keys', keyset_name) + # Reload user key set list. + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + if not item_list: + self.custom_keyset_on.state(('disabled',)) + self.customlist.SetMenu(item_list, '- no custom keys -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + # Revert to default key set. + self.keyset_source.set(idleConf.defaultCfg['main'] + .Get('Keys', 'default')) + self.builtin_name.set(idleConf.defaultCfg['main'].Get('Keys', 'name') + or idleConf.default_keys()) + # User can't back out of these changes, they must be applied now. + changes.save_all() + self.extpage.save_all_changed_extensions() + self.cd.activate_config_changes() + self.set_keys_type() + + +class WinPage(Frame): + + def __init__(self, master): + super().__init__(master) + + self.init_validators() + self.create_page_windows() + self.load_windows_cfg() + + def init_validators(self): + digits_or_empty_re = re.compile(r'[0-9]*') + def is_digits_or_empty(s): + "Return 's is blank or contains only digits'" + return digits_or_empty_re.fullmatch(s) is not None + self.digits_only = (self.register(is_digits_or_empty), '%P',) + + def create_page_windows(self): + """Return frame of widgets for Windows tab. + + Enable users to provisionally change general window options. + Function load_windows_cfg initializes tk variable idleConf. + Radiobuttons startup_shell_on and startup_editor_on set var + startup_edit. Entry boxes win_width_int and win_height_int set var + win_width and win_height. Setting var_name invokes the default + callback that adds option to changes. + + Widgets for WinPage(Frame): > vars, bound to self + frame_window: LabelFrame + frame_run: Frame + startup_title: Label + startup_editor_on: Radiobutton > startup_edit + startup_shell_on: Radiobutton > startup_edit + frame_win_size: Frame + win_size_title: Label + win_width_title: Label + win_width_int: Entry > win_width + win_height_title: Label + win_height_int: Entry > win_height + frame_cursor: Frame + indent_title: Label + indent_chooser: Spinbox > indent_spaces + blink_on: Checkbutton > cursor_blink + frame_autocomplete: Frame + auto_wait_title: Label + auto_wait_int: Entry > autocomplete_wait + frame_paren1: Frame + paren_style_title: Label + paren_style_type: OptionMenu > paren_style + frame_paren2: Frame + paren_time_title: Label + paren_flash_time: Entry > flash_delay + bell_on: Checkbutton > paren_bell + frame_format: Frame + format_width_title: Label + format_width_int: Entry > format_width + """ + # Integer values need StringVar because int('') raises. + self.startup_edit = tracers.add( + IntVar(self), ('main', 'General', 'editor-on-startup')) + self.win_width = tracers.add( + StringVar(self), ('main', 'EditorWindow', 'width')) + self.win_height = tracers.add( + StringVar(self), ('main', 'EditorWindow', 'height')) + self.indent_spaces = tracers.add( + StringVar(self), ('main', 'Indent', 'num-spaces')) + self.cursor_blink = tracers.add( + BooleanVar(self), ('main', 'EditorWindow', 'cursor-blink')) + self.autocomplete_wait = tracers.add( + StringVar(self), ('extensions', 'AutoComplete', 'popupwait')) + self.paren_style = tracers.add( + StringVar(self), ('extensions', 'ParenMatch', 'style')) + self.flash_delay = tracers.add( + StringVar(self), ('extensions', 'ParenMatch', 'flash-delay')) + self.paren_bell = tracers.add( + BooleanVar(self), ('extensions', 'ParenMatch', 'bell')) + self.format_width = tracers.add( + StringVar(self), ('extensions', 'FormatParagraph', 'max-width')) + + # Create widgets: + frame_window = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Window Preferences') + + frame_run = Frame(frame_window, borderwidth=0) + startup_title = Label(frame_run, text='At Startup') + self.startup_editor_on = Radiobutton( + frame_run, variable=self.startup_edit, value=1, + text="Open Edit Window") + self.startup_shell_on = Radiobutton( + frame_run, variable=self.startup_edit, value=0, + text='Open Shell Window') + + frame_win_size = Frame(frame_window, borderwidth=0) + win_size_title = Label( + frame_win_size, text='Initial Window Size (in characters)') + win_width_title = Label(frame_win_size, text='Width') + self.win_width_int = Entry( + frame_win_size, textvariable=self.win_width, width=3, + validatecommand=self.digits_only, validate='key', + ) + win_height_title = Label(frame_win_size, text='Height') + self.win_height_int = Entry( + frame_win_size, textvariable=self.win_height, width=3, + validatecommand=self.digits_only, validate='key', + ) + + frame_cursor = Frame(frame_window, borderwidth=0) + indent_title = Label(frame_cursor, + text='Indent spaces (4 is standard)') + try: + self.indent_chooser = Spinbox( + frame_cursor, textvariable=self.indent_spaces, + from_=1, to=10, width=2, + validatecommand=self.digits_only, validate='key') + except TclError: + self.indent_chooser = Combobox( + frame_cursor, textvariable=self.indent_spaces, + state="readonly", values=list(range(1,11)), width=3) + cursor_blink_title = Label(frame_cursor, text='Cursor Blink') + self.cursor_blink_bool = Checkbutton(frame_cursor, text="Cursor blink", + variable=self.cursor_blink) + + frame_autocomplete = Frame(frame_window, borderwidth=0,) + auto_wait_title = Label(frame_autocomplete, + text='Completions Popup Wait (milliseconds)') + self.auto_wait_int = Entry( + frame_autocomplete, textvariable=self.autocomplete_wait, + width=6, validatecommand=self.digits_only, validate='key') + + frame_paren1 = Frame(frame_window, borderwidth=0) + paren_style_title = Label(frame_paren1, text='Paren Match Style') + self.paren_style_type = OptionMenu( + frame_paren1, self.paren_style, 'expression', + "opener","parens","expression") + frame_paren2 = Frame(frame_window, borderwidth=0) + paren_time_title = Label( + frame_paren2, text='Time Match Displayed (milliseconds)\n' + '(0 is until next input)') + self.paren_flash_time = Entry( + frame_paren2, textvariable=self.flash_delay, width=6, + validatecommand=self.digits_only, validate='key') + self.bell_on = Checkbutton( + frame_paren2, text="Bell on Mismatch", variable=self.paren_bell) + frame_format = Frame(frame_window, borderwidth=0) + format_width_title = Label(frame_format, + text='Format Paragraph Max Width') + self.format_width_int = Entry( + frame_format, textvariable=self.format_width, width=4, + validatecommand=self.digits_only, validate='key', + ) + + # Pack widgets: + frame_window.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + # frame_run. + frame_run.pack(side=TOP, padx=5, pady=0, fill=X) + startup_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.startup_shell_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + self.startup_editor_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + # frame_win_size. + frame_win_size.pack(side=TOP, padx=5, pady=0, fill=X) + win_size_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.win_height_int.pack(side=RIGHT, anchor=E, padx=10, pady=5) + win_height_title.pack(side=RIGHT, anchor=E, pady=5) + self.win_width_int.pack(side=RIGHT, anchor=E, padx=10, pady=5) + win_width_title.pack(side=RIGHT, anchor=E, pady=5) + # frame_cursor. + frame_cursor.pack(side=TOP, padx=5, pady=0, fill=X) + indent_title.pack(side=LEFT, anchor=W, padx=5) + self.indent_chooser.pack(side=LEFT, anchor=W, padx=10) + self.cursor_blink_bool.pack(side=RIGHT, anchor=E, padx=15, pady=5) + # frame_autocomplete. + frame_autocomplete.pack(side=TOP, padx=5, pady=0, fill=X) + auto_wait_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.auto_wait_int.pack(side=TOP, padx=10, pady=5) + # frame_paren. + frame_paren1.pack(side=TOP, padx=5, pady=0, fill=X) + paren_style_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.paren_style_type.pack(side=TOP, padx=10, pady=5) + frame_paren2.pack(side=TOP, padx=5, pady=0, fill=X) + paren_time_title.pack(side=LEFT, anchor=W, padx=5) + self.bell_on.pack(side=RIGHT, anchor=E, padx=15, pady=5) + self.paren_flash_time.pack(side=TOP, anchor=W, padx=15, pady=5) + # frame_format. + frame_format.pack(side=TOP, padx=5, pady=0, fill=X) + format_width_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.format_width_int.pack(side=TOP, padx=10, pady=5) + + def load_windows_cfg(self): + # Set variables for all windows. + self.startup_edit.set(idleConf.GetOption( + 'main', 'General', 'editor-on-startup', type='bool')) + self.win_width.set(idleConf.GetOption( + 'main', 'EditorWindow', 'width', type='int')) + self.win_height.set(idleConf.GetOption( + 'main', 'EditorWindow', 'height', type='int')) + self.indent_spaces.set(idleConf.GetOption( + 'main', 'Indent', 'num-spaces', type='int')) + self.cursor_blink.set(idleConf.GetOption( + 'main', 'EditorWindow', 'cursor-blink', type='bool')) + self.autocomplete_wait.set(idleConf.GetOption( + 'extensions', 'AutoComplete', 'popupwait', type='int')) + self.paren_style.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'style')) + self.flash_delay.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'flash-delay', type='int')) + self.paren_bell.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'bell')) + self.format_width.set(idleConf.GetOption( + 'extensions', 'FormatParagraph', 'max-width', type='int')) + + +class ShedPage(Frame): + + def __init__(self, master): + super().__init__(master) + + self.init_validators() + self.create_page_shed() + self.load_shelled_cfg() + + def init_validators(self): + digits_or_empty_re = re.compile(r'[0-9]*') + def is_digits_or_empty(s): + "Return 's is blank or contains only digits'" + return digits_or_empty_re.fullmatch(s) is not None + self.digits_only = (self.register(is_digits_or_empty), '%P',) + + def create_page_shed(self): + """Return frame of widgets for Shell/Ed tab. + + Enable users to provisionally change shell and editor options. + Function load_shed_cfg initializes tk variables using idleConf. + Entry box auto_squeeze_min_lines_int sets + auto_squeeze_min_lines_int. Setting var_name invokes the + default callback that adds option to changes. + + Widgets for ShedPage(Frame): (*) widgets bound to self + frame_shell: LabelFrame + frame_auto_squeeze_min_lines: Frame + auto_squeeze_min_lines_title: Label + (*)auto_squeeze_min_lines_int: Entry - + auto_squeeze_min_lines + frame_editor: LabelFrame + frame_save: Frame + run_save_title: Label + (*)save_ask_on: Radiobutton - autosave + (*)save_auto_on: Radiobutton - autosave + frame_format: Frame + format_width_title: Label + (*)format_width_int: Entry - format_width + frame_line_numbers_default: Frame + line_numbers_default_title: Label + (*)line_numbers_default_bool: Checkbutton - line_numbers_default + frame_context: Frame + context_title: Label + (*)context_int: Entry - context_lines + """ + # Integer values need StringVar because int('') raises. + self.auto_squeeze_min_lines = tracers.add( + StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines')) + + self.autosave = tracers.add( + IntVar(self), ('main', 'General', 'autosave')) + self.line_numbers_default = tracers.add( + BooleanVar(self), + ('main', 'EditorWindow', 'line-numbers-default')) + self.context_lines = tracers.add( + StringVar(self), ('extensions', 'CodeContext', 'maxlines')) + + # Create widgets: + frame_shell = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Shell Preferences') + frame_editor = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Editor Preferences') + # Frame_shell. + frame_auto_squeeze_min_lines = Frame(frame_shell, borderwidth=0) + auto_squeeze_min_lines_title = Label(frame_auto_squeeze_min_lines, + text='Auto-Squeeze Min. Lines:') + self.auto_squeeze_min_lines_int = Entry( + frame_auto_squeeze_min_lines, width=4, + textvariable=self.auto_squeeze_min_lines, + validatecommand=self.digits_only, validate='key', + ) + # Frame_editor. + frame_save = Frame(frame_editor, borderwidth=0) + run_save_title = Label(frame_save, text='At Start of Run (F5) ') + + self.save_ask_on = Radiobutton( + frame_save, variable=self.autosave, value=0, + text="Prompt to Save") + self.save_auto_on = Radiobutton( + frame_save, variable=self.autosave, value=1, + text='No Prompt') + + frame_line_numbers_default = Frame(frame_editor, borderwidth=0) + line_numbers_default_title = Label( + frame_line_numbers_default, text='Show line numbers in new windows') + self.line_numbers_default_bool = Checkbutton( + frame_line_numbers_default, + variable=self.line_numbers_default, + width=1) + + frame_context = Frame(frame_editor, borderwidth=0) + context_title = Label(frame_context, text='Max Context Lines :') + self.context_int = Entry( + frame_context, textvariable=self.context_lines, width=3, + validatecommand=self.digits_only, validate='key', + ) + + # Pack widgets: + frame_shell.pack(side=TOP, padx=5, pady=5, fill=BOTH) + Label(self).pack() # Spacer -- better solution? + frame_editor.pack(side=TOP, padx=5, pady=5, fill=BOTH) + # frame_auto_squeeze_min_lines + frame_auto_squeeze_min_lines.pack(side=TOP, padx=5, pady=0, fill=X) + auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.auto_squeeze_min_lines_int.pack(side=TOP, padx=5, pady=5) + # frame_save. + frame_save.pack(side=TOP, padx=5, pady=0, fill=X) + run_save_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.save_auto_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + self.save_ask_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + # frame_line_numbers_default. + frame_line_numbers_default.pack(side=TOP, padx=5, pady=0, fill=X) + line_numbers_default_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.line_numbers_default_bool.pack(side=LEFT, padx=5, pady=5) + # frame_context. + frame_context.pack(side=TOP, padx=5, pady=0, fill=X) + context_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.context_int.pack(side=TOP, padx=5, pady=5) + + def load_shelled_cfg(self): + # Set variables for shell windows. + self.auto_squeeze_min_lines.set(idleConf.GetOption( + 'main', 'PyShell', 'auto-squeeze-min-lines', type='int')) + # Set variables for editor windows. + self.autosave.set(idleConf.GetOption( + 'main', 'General', 'autosave', default=0, type='bool')) + self.line_numbers_default.set(idleConf.GetOption( + 'main', 'EditorWindow', 'line-numbers-default', type='bool')) + self.context_lines.set(idleConf.GetOption( + 'extensions', 'CodeContext', 'maxlines', type='int')) + + +class ExtPage(Frame): + def __init__(self, master): + super().__init__(master) + self.ext_defaultCfg = idleConf.defaultCfg['extensions'] + self.ext_userCfg = idleConf.userCfg['extensions'] + self.is_int = self.register(is_int) + self.load_extensions() + self.create_page_extensions() # Requires extension names. + + def create_page_extensions(self): + """Configure IDLE feature extensions and help menu extensions. + + List the feature extensions and a configuration box for the + selected extension. Help menu extensions are in a HelpFrame. + + This code reads the current configuration using idleConf, + supplies a GUI interface to change the configuration values, + and saves the changes using idleConf. + + Some changes may require restarting IDLE. This depends on each + extension's implementation. + + All values are treated as text, and it is up to the user to + supply reasonable values. The only exception to this are the + 'enable*' options, which are boolean, and can be toggled with a + True/False button. + + Methods: + extension_selected: Handle selection from list. + create_extension_frame: Hold widgets for one extension. + set_extension_value: Set in userCfg['extensions']. + save_all_changed_extensions: Call extension page Save(). + """ + self.extension_names = StringVar(self) + + frame_ext = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Feature Extensions ') + self.frame_help = HelpFrame(self, borderwidth=2, relief=GROOVE, + text=' Help Menu Extensions ') + + frame_ext.rowconfigure(0, weight=1) + frame_ext.columnconfigure(2, weight=1) + self.extension_list = Listbox(frame_ext, listvariable=self.extension_names, + selectmode='browse') + self.extension_list.bind('<>', self.extension_selected) + scroll = Scrollbar(frame_ext, command=self.extension_list.yview) + self.extension_list.yscrollcommand=scroll.set + self.details_frame = LabelFrame(frame_ext, width=250, height=250) + self.extension_list.grid(column=0, row=0, sticky='nws') + scroll.grid(column=1, row=0, sticky='ns') + self.details_frame.grid(column=2, row=0, sticky='nsew', padx=[10, 0]) + frame_ext.configure(padding=10) + self.config_frame = {} + self.current_extension = None + + self.outerframe = self # TEMPORARY + self.tabbed_page_set = self.extension_list # TEMPORARY + + # Create the frame holding controls for each extension. + ext_names = '' + for ext_name in sorted(self.extensions): + self.create_extension_frame(ext_name) + ext_names = ext_names + '{' + ext_name + '} ' + self.extension_names.set(ext_names) + self.extension_list.selection_set(0) + self.extension_selected(None) + + + frame_ext.grid(row=0, column=0, sticky='nsew') + Label(self).grid(row=1, column=0) # Spacer. Replace with config? + self.frame_help.grid(row=2, column=0, sticky='sew') + + def load_extensions(self): + "Fill self.extensions with data from the default and user configs." + self.extensions = {} + for ext_name in idleConf.GetExtensions(active_only=False): + # Former built-in extensions are already filtered out. + self.extensions[ext_name] = [] + + for ext_name in self.extensions: + opt_list = sorted(self.ext_defaultCfg.GetOptionList(ext_name)) + + # Bring 'enable' options to the beginning of the list. + enables = [opt_name for opt_name in opt_list + if opt_name.startswith('enable')] + for opt_name in enables: + opt_list.remove(opt_name) + opt_list = enables + opt_list + + for opt_name in opt_list: + def_str = self.ext_defaultCfg.Get( + ext_name, opt_name, raw=True) + try: + def_obj = {'True':True, 'False':False}[def_str] + opt_type = 'bool' + except KeyError: + try: + def_obj = int(def_str) + opt_type = 'int' + except ValueError: + def_obj = def_str + opt_type = None + try: + value = self.ext_userCfg.Get( + ext_name, opt_name, type=opt_type, raw=True, + default=def_obj) + except ValueError: # Need this until .Get fixed. + value = def_obj # Bad values overwritten by entry. + var = StringVar(self) + var.set(str(value)) + + self.extensions[ext_name].append({'name': opt_name, + 'type': opt_type, + 'default': def_str, + 'value': value, + 'var': var, + }) + + def extension_selected(self, event): + "Handle selection of an extension from the list." + newsel = self.extension_list.curselection() + if newsel: + newsel = self.extension_list.get(newsel) + if newsel is None or newsel != self.current_extension: + if self.current_extension: + self.details_frame.config(text='') + self.config_frame[self.current_extension].grid_forget() + self.current_extension = None + if newsel: + self.details_frame.config(text=newsel) + self.config_frame[newsel].grid(column=0, row=0, sticky='nsew') + self.current_extension = newsel + + def create_extension_frame(self, ext_name): + """Create a frame holding the widgets to configure one extension""" + f = VerticalScrolledFrame(self.details_frame, height=250, width=250) + self.config_frame[ext_name] = f + entry_area = f.interior + # Create an entry for each configuration option. + for row, opt in enumerate(self.extensions[ext_name]): + # Create a row with a label and entry/checkbutton. + label = Label(entry_area, text=opt['name']) + label.grid(row=row, column=0, sticky=NW) + var = opt['var'] + if opt['type'] == 'bool': + Checkbutton(entry_area, variable=var, + onvalue='True', offvalue='False', width=8 + ).grid(row=row, column=1, sticky=W, padx=7) + elif opt['type'] == 'int': + Entry(entry_area, textvariable=var, validate='key', + validatecommand=(self.is_int, '%P'), width=10 + ).grid(row=row, column=1, sticky=NSEW, padx=7) + + else: # type == 'str' + # Limit size to fit non-expanding space with larger font. + Entry(entry_area, textvariable=var, width=15 + ).grid(row=row, column=1, sticky=NSEW, padx=7) + return + + def set_extension_value(self, section, opt): + """Return True if the configuration was added or changed. + + If the value is the same as the default, then remove it + from user config file. + """ + name = opt['name'] + default = opt['default'] + value = opt['var'].get().strip() or default + opt['var'].set(value) + # if self.defaultCfg.has_section(section): + # Currently, always true; if not, indent to return. + if (value == default): + return self.ext_userCfg.RemoveOption(section, name) + # Set the option. + return self.ext_userCfg.SetOption(section, name, value) + + def save_all_changed_extensions(self): + """Save configuration changes to the user config file. + + Attributes accessed: + extensions + + Methods: + set_extension_value + """ + has_changes = False + for ext_name in self.extensions: + options = self.extensions[ext_name] + for opt in options: + if self.set_extension_value(ext_name, opt): + has_changes = True + if has_changes: + self.ext_userCfg.Save() + + +class HelpFrame(LabelFrame): + + def __init__(self, master, **cfg): + super().__init__(master, **cfg) + self.create_frame_help() + self.load_helplist() + + def create_frame_help(self): + """Create LabelFrame for additional help menu sources. + + load_helplist loads list user_helplist with + name, position pairs and copies names to listbox helplist. + Clicking a name invokes help_source selected. Clicking + button_helplist_name invokes helplist_item_name, which also + changes user_helplist. These functions all call + set_add_delete_state. All but load call update_help_changes to + rewrite changes['main']['HelpFiles']. + + Widgets for HelpFrame(LabelFrame): (*) widgets bound to self + frame_helplist: Frame + (*)helplist: ListBox + scroll_helplist: Scrollbar + frame_buttons: Frame + (*)button_helplist_edit + (*)button_helplist_add + (*)button_helplist_remove + """ + # self = frame_help in dialog (until ExtPage class). + frame_helplist = Frame(self) + self.helplist = Listbox( + frame_helplist, height=5, takefocus=True, + exportselection=FALSE) + scroll_helplist = Scrollbar(frame_helplist) + scroll_helplist['command'] = self.helplist.yview + self.helplist['yscrollcommand'] = scroll_helplist.set + self.helplist.bind('', self.help_source_selected) + + frame_buttons = Frame(self) + self.button_helplist_edit = Button( + frame_buttons, text='Edit', state='disabled', + width=8, command=self.helplist_item_edit) + self.button_helplist_add = Button( + frame_buttons, text='Add', + width=8, command=self.helplist_item_add) + self.button_helplist_remove = Button( + frame_buttons, text='Remove', state='disabled', + width=8, command=self.helplist_item_remove) + + # Pack frame_help. + frame_helplist.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + self.helplist.pack(side=LEFT, anchor=E, expand=TRUE, fill=BOTH) + scroll_helplist.pack(side=RIGHT, anchor=W, fill=Y) + frame_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y) + self.button_helplist_edit.pack(side=TOP, anchor=W, pady=5) + self.button_helplist_add.pack(side=TOP, anchor=W) + self.button_helplist_remove.pack(side=TOP, anchor=W, pady=5) + + def help_source_selected(self, event): + "Handle event for selecting additional help." + self.set_add_delete_state() + + def set_add_delete_state(self): + "Toggle the state for the help list buttons based on list entries." + if self.helplist.size() < 1: # No entries in list. + self.button_helplist_edit.state(('disabled',)) + self.button_helplist_remove.state(('disabled',)) + else: # Some entries. + if self.helplist.curselection(): # There currently is a selection. + self.button_helplist_edit.state(('!disabled',)) + self.button_helplist_remove.state(('!disabled',)) + else: # There currently is not a selection. + self.button_helplist_edit.state(('disabled',)) + self.button_helplist_remove.state(('disabled',)) + + def helplist_item_add(self): + """Handle add button for the help list. + + Query for name and location of new help sources and add + them to the list. + """ + help_source = HelpSource(self, 'New Help Source').result + if help_source: + self.user_helplist.append(help_source) + self.helplist.insert(END, help_source[0]) + self.update_help_changes() + + def helplist_item_edit(self): + """Handle edit button for the help list. + + Query with existing help source information and update + config if the values are changed. + """ + item_index = self.helplist.index(ANCHOR) + help_source = self.user_helplist[item_index] + new_help_source = HelpSource( + self, 'Edit Help Source', + menuitem=help_source[0], + filepath=help_source[1], + ).result + if new_help_source and new_help_source != help_source: + self.user_helplist[item_index] = new_help_source + self.helplist.delete(item_index) + self.helplist.insert(item_index, new_help_source[0]) + self.update_help_changes() + self.set_add_delete_state() # Selected will be un-selected + + def helplist_item_remove(self): + """Handle remove button for the help list. + + Delete the help list item from config. + """ + item_index = self.helplist.index(ANCHOR) + del(self.user_helplist[item_index]) + self.helplist.delete(item_index) + self.update_help_changes() + self.set_add_delete_state() + + def update_help_changes(self): + "Clear and rebuild the HelpFiles section in changes" + changes['main']['HelpFiles'] = {} + for num in range(1, len(self.user_helplist) + 1): + changes.add_option( + 'main', 'HelpFiles', str(num), + ';'.join(self.user_helplist[num-1][:2])) + + def load_helplist(self): + # Set additional help sources. + self.user_helplist = idleConf.GetAllExtraHelpSourcesList() + self.helplist.delete(0, 'end') + for help_item in self.user_helplist: + self.helplist.insert(END, help_item[0]) + self.set_add_delete_state() + + +class VarTrace: + """Maintain Tk variables trace state.""" + + def __init__(self): + """Store Tk variables and callbacks. + + untraced: List of tuples (var, callback) + that do not have the callback attached + to the Tk var. + traced: List of tuples (var, callback) where + that callback has been attached to the var. + """ + self.untraced = [] + self.traced = [] + + def clear(self): + "Clear lists (for tests)." + # Call after all tests in a module to avoid memory leaks. + self.untraced.clear() + self.traced.clear() + + def add(self, var, callback): + """Add (var, callback) tuple to untraced list. + + Args: + var: Tk variable instance. + callback: Either function name to be used as a callback + or a tuple with IdleConf config-type, section, and + option names used in the default callback. + + Return: + Tk variable instance. + """ + if isinstance(callback, tuple): + callback = self.make_callback(var, callback) + self.untraced.append((var, callback)) + return var + + @staticmethod + def make_callback(var, config): + "Return default callback function to add values to changes instance." + def default_callback(*params): + "Add config values to changes instance." + changes.add_option(*config, var.get()) + return default_callback + + def attach(self): + "Attach callback to all vars that are not traced." + while self.untraced: + var, callback = self.untraced.pop() + var.trace_add('write', callback) + self.traced.append((var, callback)) + + def detach(self): + "Remove callback from traced vars." + while self.traced: + var, callback = self.traced.pop() + var.trace_remove('write', var.trace_info()[0][1]) + self.untraced.append((var, callback)) + + +tracers = VarTrace() + +help_common = '''\ +When you click either the Apply or Ok buttons, settings in this +dialog that are different from IDLE's default are saved in +a .idlerc directory in your home directory. Except as noted, +these changes apply to all versions of IDLE installed on this +machine. [Cancel] only cancels changes made since the last save. +''' +help_pages = { + 'Fonts/Tabs':''' +Font sample: This shows what a selection of Basic Multilingual Plane +unicode characters look like for the current font selection. If the +selected font does not define a character, Tk attempts to find another +font that does. Substitute glyphs depend on what is available on a +particular system and will not necessarily have the same size as the +font selected. Line contains 20 characters up to Devanagari, 14 for +Tamil, and 10 for East Asia. + +Hebrew and Arabic letters should display right to left, starting with +alef, \u05d0 and \u0627. Arabic digits display left to right. The +Devanagari and Tamil lines start with digits. The East Asian lines +are Chinese digits, Chinese Hanzi, Korean Hangul, and Japanese +Hiragana and Katakana. + +You can edit the font sample. Changes remain until IDLE is closed. +''', + 'Highlights': ''' +Highlighting: +The IDLE Dark color theme is new in October 2015. It can only +be used with older IDLE releases if it is saved as a custom +theme, with a different name. +''', + 'Keys': ''' +Keys: +The IDLE Modern Unix key set is new in June 2016. It can only +be used with older IDLE releases if it is saved as a custom +key set, with a different name. +''', + 'General': ''' +General: + +AutoComplete: Popupwait is milliseconds to wait after key char, without +cursor movement, before popping up completion box. Key char is '.' after +identifier or a '/' (or '\\' on Windows) within a string. + +FormatParagraph: Max-width is max chars in lines after re-formatting. +Use with paragraphs in both strings and comment blocks. + +ParenMatch: Style indicates what is highlighted when closer is entered: +'opener' - opener '({[' corresponding to closer; 'parens' - both chars; +'expression' (default) - also everything in between. Flash-delay is how +long to highlight if cursor is not moved (0 means forever). + +CodeContext: Maxlines is the maximum number of code context lines to +display when Code Context is turned on for an editor window. + +Shell Preferences: Auto-Squeeze Min. Lines is the minimum number of lines +of output to automatically "squeeze". +''', + 'Extensions': ''' +ZzDummy: This extension is provided as an example for how to create and +use an extension. Enable indicates whether the extension is active or +not; likewise enable_editor and enable_shell indicate which windows it +will be active on. For this extension, z-text is the text that will be +inserted at or removed from the beginning of the lines of selected text, +or the current line if no selection. +''', +} + + +def is_int(s): + "Return 's is blank or represents an int'" + if not s: + return True + try: + int(s) + return True + except ValueError: + return False + + +class VerticalScrolledFrame(Frame): + """A pure Tkinter vertically scrollable frame. + + * Use the 'interior' attribute to place widgets inside the scrollable frame + * Construct and pack/place/grid normally + * This frame only allows vertical scrolling + """ + def __init__(self, parent, *args, **kw): + Frame.__init__(self, parent, *args, **kw) + + # Create a canvas object and a vertical scrollbar for scrolling it. + vscrollbar = Scrollbar(self, orient=VERTICAL) + vscrollbar.pack(fill=Y, side=RIGHT, expand=FALSE) + canvas = Canvas(self, borderwidth=0, highlightthickness=0, + yscrollcommand=vscrollbar.set, width=240) + canvas.pack(side=LEFT, fill=BOTH, expand=TRUE) + vscrollbar.config(command=canvas.yview) + + # Reset the view. + canvas.xview_moveto(0) + canvas.yview_moveto(0) + + # Create a frame inside the canvas which will be scrolled with it. + self.interior = interior = Frame(canvas) + interior_id = canvas.create_window(0, 0, window=interior, anchor=NW) + + # Track changes to the canvas and frame width and sync them, + # also updating the scrollbar. + def _configure_interior(event): + # Update the scrollbars to match the size of the inner frame. + size = (interior.winfo_reqwidth(), interior.winfo_reqheight()) + canvas.config(scrollregion="0 0 %s %s" % size) + interior.bind('', _configure_interior) + + def _configure_canvas(event): + if interior.winfo_reqwidth() != canvas.winfo_width(): + # Update the inner frame's width to fill the canvas. + canvas.itemconfigure(interior_id, width=canvas.winfo_width()) + canvas.bind('', _configure_canvas) + + return + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_configdialog', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(ConfigDialog) diff --git a/Lib/idlelib/debugger.py b/Lib/idlelib/debugger.py new file mode 100644 index 00000000000..1fae1d4b0ad --- /dev/null +++ b/Lib/idlelib/debugger.py @@ -0,0 +1,602 @@ +"""Debug user code with a GUI interface to a subclass of bdb.Bdb. + +The Idb instance 'idb' and Debugger instance 'gui' need references to each +other or to an rpc proxy for each other. + +If IDLE is started with '-n', so that user code and idb both run in the +IDLE process, Debugger is called without an idb. Debugger.__init__ +calls Idb with its incomplete self. Idb.__init__ stores gui and gui +then stores idb. + +If IDLE is started normally, so that user code executes in a separate +process, debugger_r.start_remote_debugger is called, executing in the +IDLE process. It calls 'start the debugger' in the remote process, +which calls Idb with a gui proxy. Then Debugger is called in the IDLE +for more. +""" + +import bdb +import os + +from tkinter import * +from tkinter.ttk import Frame, Scrollbar + +from idlelib import macosx +from idlelib.scrolledlist import ScrolledList +from idlelib.window import ListedToplevel + + +class Idb(bdb.Bdb): + "Supply user_line and user_exception functions for Bdb." + + def __init__(self, gui): + self.gui = gui # An instance of Debugger or proxy thereof. + super().__init__() + + def user_line(self, frame): + """Handle a user stopping or breaking at a line. + + Convert frame to a string and send it to gui. + """ + if _in_rpc_code(frame): + self.set_step() + return + message = _frame2message(frame) + try: + self.gui.interaction(message, frame) + except TclError: # When closing debugger window with [x] in 3.x + pass + + def user_exception(self, frame, exc_info): + """Handle an the occurrence of an exception.""" + if _in_rpc_code(frame): + self.set_step() + return + message = _frame2message(frame) + self.gui.interaction(message, frame, exc_info) + +def _in_rpc_code(frame): + "Determine if debugger is within RPC code." + if frame.f_code.co_filename.count('rpc.py'): + return True # Skip this frame. + else: + prev_frame = frame.f_back + if prev_frame is None: + return False + prev_name = prev_frame.f_code.co_filename + if 'idlelib' in prev_name and 'debugger' in prev_name: + # catch both idlelib/debugger.py and idlelib/debugger_r.py + # on both Posix and Windows + return False + return _in_rpc_code(prev_frame) + +def _frame2message(frame): + """Return a message string for frame.""" + code = frame.f_code + filename = code.co_filename + lineno = frame.f_lineno + basename = os.path.basename(filename) + message = f"{basename}:{lineno}" + if code.co_name != "?": + message = f"{message}: {code.co_name}()" + return message + + +class Debugger: + """The debugger interface. + + This class handles the drawing of the debugger window and + the interactions with the underlying debugger session. + """ + vstack = None + vsource = None + vlocals = None + vglobals = None + stackviewer = None + localsviewer = None + globalsviewer = None + + def __init__(self, pyshell, idb=None): + """Instantiate and draw a debugger window. + + :param pyshell: An instance of the PyShell Window + :type pyshell: :class:`idlelib.pyshell.PyShell` + + :param idb: An instance of the IDLE debugger (optional) + :type idb: :class:`idlelib.debugger.Idb` + """ + if idb is None: + idb = Idb(self) + self.pyshell = pyshell + self.idb = idb # If passed, a proxy of remote instance. + self.frame = None + self.make_gui() + self.interacting = False + self.nesting_level = 0 + + def run(self, *args): + """Run the debugger.""" + # Deal with the scenario where we've already got a program running + # in the debugger and we want to start another. If that is the case, + # our second 'run' was invoked from an event dispatched not from + # the main event loop, but from the nested event loop in 'interaction' + # below. So our stack looks something like this: + # outer main event loop + # run() + # + # callback to debugger's interaction() + # nested event loop + # run() for second command + # + # This kind of nesting of event loops causes all kinds of problems + # (see e.g. issue #24455) especially when dealing with running as a + # subprocess, where there's all kinds of extra stuff happening in + # there - insert a traceback.print_stack() to check it out. + # + # By this point, we've already called restart_subprocess() in + # ScriptBinding. However, we also need to unwind the stack back to + # that outer event loop. To accomplish this, we: + # - return immediately from the nested run() + # - abort_loop ensures the nested event loop will terminate + # - the debugger's interaction routine completes normally + # - the restart_subprocess() will have taken care of stopping + # the running program, which will also let the outer run complete + # + # That leaves us back at the outer main event loop, at which point our + # after event can fire, and we'll come back to this routine with a + # clean stack. + if self.nesting_level > 0: + self.abort_loop() + self.root.after(100, lambda: self.run(*args)) + return + try: + self.interacting = True + return self.idb.run(*args) + finally: + self.interacting = False + + def close(self, event=None): + """Close the debugger and window.""" + try: + self.quit() + except Exception: + pass + if self.interacting: + self.top.bell() + return + if self.stackviewer: + self.stackviewer.close(); self.stackviewer = None + # Clean up pyshell if user clicked debugger control close widget. + # (Causes a harmless extra cycle through close_debugger() if user + # toggled debugger from pyshell Debug menu) + self.pyshell.close_debugger() + # Now close the debugger control window.... + self.top.destroy() + + def make_gui(self): + """Draw the debugger gui on the screen.""" + pyshell = self.pyshell + self.flist = pyshell.flist + self.root = root = pyshell.root + self.top = top = ListedToplevel(root) + self.top.wm_title("Debug Control") + self.top.wm_iconname("Debug") + top.wm_protocol("WM_DELETE_WINDOW", self.close) + self.top.bind("", self.close) + + self.bframe = bframe = Frame(top) + self.bframe.pack(anchor="w") + self.buttons = bl = [] + + self.bcont = b = Button(bframe, text="Go", command=self.cont) + bl.append(b) + self.bstep = b = Button(bframe, text="Step", command=self.step) + bl.append(b) + self.bnext = b = Button(bframe, text="Over", command=self.next) + bl.append(b) + self.bret = b = Button(bframe, text="Out", command=self.ret) + bl.append(b) + self.bret = b = Button(bframe, text="Quit", command=self.quit) + bl.append(b) + + for b in bl: + b.configure(state="disabled") + b.pack(side="left") + + self.cframe = cframe = Frame(bframe) + self.cframe.pack(side="left") + + if not self.vstack: + self.__class__.vstack = BooleanVar(top) + self.vstack.set(1) + self.bstack = Checkbutton(cframe, + text="Stack", command=self.show_stack, variable=self.vstack) + self.bstack.grid(row=0, column=0) + if not self.vsource: + self.__class__.vsource = BooleanVar(top) + self.bsource = Checkbutton(cframe, + text="Source", command=self.show_source, variable=self.vsource) + self.bsource.grid(row=0, column=1) + if not self.vlocals: + self.__class__.vlocals = BooleanVar(top) + self.vlocals.set(1) + self.blocals = Checkbutton(cframe, + text="Locals", command=self.show_locals, variable=self.vlocals) + self.blocals.grid(row=1, column=0) + if not self.vglobals: + self.__class__.vglobals = BooleanVar(top) + self.bglobals = Checkbutton(cframe, + text="Globals", command=self.show_globals, variable=self.vglobals) + self.bglobals.grid(row=1, column=1) + + self.status = Label(top, anchor="w") + self.status.pack(anchor="w") + self.error = Label(top, anchor="w") + self.error.pack(anchor="w", fill="x") + self.errorbg = self.error.cget("background") + + self.fstack = Frame(top, height=1) + self.fstack.pack(expand=1, fill="both") + self.flocals = Frame(top) + self.flocals.pack(expand=1, fill="both") + self.fglobals = Frame(top, height=1) + self.fglobals.pack(expand=1, fill="both") + + if self.vstack.get(): + self.show_stack() + if self.vlocals.get(): + self.show_locals() + if self.vglobals.get(): + self.show_globals() + + def interaction(self, message, frame, info=None): + self.frame = frame + self.status.configure(text=message) + + if info: + type, value, tb = info + try: + m1 = type.__name__ + except AttributeError: + m1 = "%s" % str(type) + if value is not None: + try: + # TODO redo entire section, tries not needed. + m1 = f"{m1}: {value}" + except: + pass + bg = "yellow" + else: + m1 = "" + tb = None + bg = self.errorbg + self.error.configure(text=m1, background=bg) + + sv = self.stackviewer + if sv: + stack, i = self.idb.get_stack(self.frame, tb) + sv.load_stack(stack, i) + + self.show_variables(1) + + if self.vsource.get(): + self.sync_source_line() + + for b in self.buttons: + b.configure(state="normal") + + self.top.wakeup() + # Nested main loop: Tkinter's main loop is not reentrant, so use + # Tcl's vwait facility, which reenters the event loop until an + # event handler sets the variable we're waiting on. + self.nesting_level += 1 + self.root.tk.call('vwait', '::idledebugwait') + self.nesting_level -= 1 + + for b in self.buttons: + b.configure(state="disabled") + self.status.configure(text="") + self.error.configure(text="", background=self.errorbg) + self.frame = None + + def sync_source_line(self): + frame = self.frame + if not frame: + return + filename, lineno = self.__frame2fileline(frame) + if filename[:1] + filename[-1:] != "<>" and os.path.exists(filename): + self.flist.gotofileline(filename, lineno) + + def __frame2fileline(self, frame): + code = frame.f_code + filename = code.co_filename + lineno = frame.f_lineno + return filename, lineno + + def cont(self): + self.idb.set_continue() + self.abort_loop() + + def step(self): + self.idb.set_step() + self.abort_loop() + + def next(self): + self.idb.set_next(self.frame) + self.abort_loop() + + def ret(self): + self.idb.set_return(self.frame) + self.abort_loop() + + def quit(self): + self.idb.set_quit() + self.abort_loop() + + def abort_loop(self): + self.root.tk.call('set', '::idledebugwait', '1') + + def show_stack(self): + if not self.stackviewer and self.vstack.get(): + self.stackviewer = sv = StackViewer(self.fstack, self.flist, self) + if self.frame: + stack, i = self.idb.get_stack(self.frame, None) + sv.load_stack(stack, i) + else: + sv = self.stackviewer + if sv and not self.vstack.get(): + self.stackviewer = None + sv.close() + self.fstack['height'] = 1 + + def show_source(self): + if self.vsource.get(): + self.sync_source_line() + + def show_frame(self, stackitem): + self.frame = stackitem[0] # lineno is stackitem[1] + self.show_variables() + + def show_locals(self): + lv = self.localsviewer + if self.vlocals.get(): + if not lv: + self.localsviewer = NamespaceViewer(self.flocals, "Locals") + else: + if lv: + self.localsviewer = None + lv.close() + self.flocals['height'] = 1 + self.show_variables() + + def show_globals(self): + gv = self.globalsviewer + if self.vglobals.get(): + if not gv: + self.globalsviewer = NamespaceViewer(self.fglobals, "Globals") + else: + if gv: + self.globalsviewer = None + gv.close() + self.fglobals['height'] = 1 + self.show_variables() + + def show_variables(self, force=0): + lv = self.localsviewer + gv = self.globalsviewer + frame = self.frame + if not frame: + ldict = gdict = None + else: + ldict = frame.f_locals + gdict = frame.f_globals + if lv and gv and ldict is gdict: + ldict = None + if lv: + lv.load_dict(ldict, force, self.pyshell.interp.rpcclt) + if gv: + gv.load_dict(gdict, force, self.pyshell.interp.rpcclt) + + def set_breakpoint(self, filename, lineno): + """Set a filename-lineno breakpoint in the debugger. + + Called from self.load_breakpoints and EW.setbreakpoint + """ + self.idb.set_break(filename, lineno) + + def clear_breakpoint(self, filename, lineno): + self.idb.clear_break(filename, lineno) + + def clear_file_breaks(self, filename): + self.idb.clear_all_file_breaks(filename) + + def load_breakpoints(self): + """Load PyShellEditorWindow breakpoints into subprocess debugger.""" + for editwin in self.pyshell.flist.inversedict: + filename = editwin.io.filename + try: + for lineno in editwin.breakpoints: + self.set_breakpoint(filename, lineno) + except AttributeError: + continue + + +class StackViewer(ScrolledList): + "Code stack viewer for debugger GUI." + + def __init__(self, master, flist, gui): + if macosx.isAquaTk(): + # At least on with the stock AquaTk version on OSX 10.4 you'll + # get a shaking GUI that eventually kills IDLE if the width + # argument is specified. + ScrolledList.__init__(self, master) + else: + ScrolledList.__init__(self, master, width=80) + self.flist = flist + self.gui = gui + self.stack = [] + + def load_stack(self, stack, index=None): + self.stack = stack + self.clear() + for i in range(len(stack)): + frame, lineno = stack[i] + try: + modname = frame.f_globals["__name__"] + except: + modname = "?" + code = frame.f_code + filename = code.co_filename + funcname = code.co_name + import linecache + sourceline = linecache.getline(filename, lineno) + sourceline = sourceline.strip() + if funcname in ("?", "", None): + item = "%s, line %d: %s" % (modname, lineno, sourceline) + else: + item = "%s.%s(), line %d: %s" % (modname, funcname, + lineno, sourceline) + if i == index: + item = "> " + item + self.append(item) + if index is not None: + self.select(index) + + def popup_event(self, event): + "Override base method." + if self.stack: + return ScrolledList.popup_event(self, event) + + def fill_menu(self): + "Override base method." + menu = self.menu + menu.add_command(label="Go to source line", + command=self.goto_source_line) + menu.add_command(label="Show stack frame", + command=self.show_stack_frame) + + def on_select(self, index): + "Override base method." + if 0 <= index < len(self.stack): + self.gui.show_frame(self.stack[index]) + + def on_double(self, index): + "Override base method." + self.show_source(index) + + def goto_source_line(self): + index = self.listbox.index("active") + self.show_source(index) + + def show_stack_frame(self): + index = self.listbox.index("active") + if 0 <= index < len(self.stack): + self.gui.show_frame(self.stack[index]) + + def show_source(self, index): + if not (0 <= index < len(self.stack)): + return + frame, lineno = self.stack[index] + code = frame.f_code + filename = code.co_filename + if os.path.isfile(filename): + edit = self.flist.open(filename) + if edit: + edit.gotoline(lineno) + + +class NamespaceViewer: + "Global/local namespace viewer for debugger GUI." + + def __init__(self, master, title, odict=None): # XXX odict never passed. + width = 0 + height = 40 + if odict: + height = 20*len(odict) # XXX 20 == observed height of Entry widget + self.master = master + self.title = title + import reprlib + self.repr = reprlib.Repr() + self.repr.maxstring = 60 + self.repr.maxother = 60 + self.frame = frame = Frame(master) + self.frame.pack(expand=1, fill="both") + self.label = Label(frame, text=title, borderwidth=2, relief="groove") + self.label.pack(fill="x") + self.vbar = vbar = Scrollbar(frame, name="vbar") + vbar.pack(side="right", fill="y") + self.canvas = canvas = Canvas(frame, + height=min(300, max(40, height)), + scrollregion=(0, 0, width, height)) + canvas.pack(side="left", fill="both", expand=1) + vbar["command"] = canvas.yview + canvas["yscrollcommand"] = vbar.set + self.subframe = subframe = Frame(canvas) + self.sfid = canvas.create_window(0, 0, window=subframe, anchor="nw") + self.load_dict(odict) + + prev_odict = -1 # Needed for initial comparison below. + + def load_dict(self, odict, force=0, rpc_client=None): + if odict is self.prev_odict and not force: + return + subframe = self.subframe + frame = self.frame + for c in list(subframe.children.values()): + c.destroy() + self.prev_odict = None + if not odict: + l = Label(subframe, text="None") + l.grid(row=0, column=0) + else: + #names = sorted(dict) + # + # Because of (temporary) limitations on the dict_keys type (not yet + # public or pickleable), have the subprocess to send a list of + # keys, not a dict_keys object. sorted() will take a dict_keys + # (no subprocess) or a list. + # + # There is also an obscure bug in sorted(dict) where the + # interpreter gets into a loop requesting non-existing dict[0], + # dict[1], dict[2], etc from the debugger_r.DictProxy. + # TODO recheck above; see debugger_r 159ff, debugobj 60. + keys_list = odict.keys() + names = sorted(keys_list) + + row = 0 + for name in names: + value = odict[name] + svalue = self.repr.repr(value) # repr(value) + # Strip extra quotes caused by calling repr on the (already) + # repr'd value sent across the RPC interface: + if rpc_client: + svalue = svalue[1:-1] + l = Label(subframe, text=name) + l.grid(row=row, column=0, sticky="nw") + l = Entry(subframe, width=0, borderwidth=0) + l.insert(0, svalue) + l.grid(row=row, column=1, sticky="nw") + row = row+1 + self.prev_odict = odict + # XXX Could we use a callback for the following? + subframe.update_idletasks() # Alas! + width = subframe.winfo_reqwidth() + height = subframe.winfo_reqheight() + canvas = self.canvas + self.canvas["scrollregion"] = (0, 0, width, height) + if height > 300: + canvas["height"] = 300 + frame.pack(expand=1) + else: + canvas["height"] = height + frame.pack(expand=0) + + def close(self): + self.frame.destroy() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_debugger', verbosity=2, exit=False) + +# TODO: htest? diff --git a/Lib/idlelib/debugger_r.py b/Lib/idlelib/debugger_r.py new file mode 100644 index 00000000000..ad3355d9f82 --- /dev/null +++ b/Lib/idlelib/debugger_r.py @@ -0,0 +1,390 @@ +"""Support for remote Python debugging. + +Some ASCII art to describe the structure: + + IN PYTHON SUBPROCESS # IN IDLE PROCESS + # + # oid='gui_adapter' + +----------+ # +------------+ +-----+ + | GUIProxy |--remote#call-->| GUIAdapter |--calls-->| GUI | ++-----+--calls-->+----------+ # +------------+ +-----+ +| Idb | # / ++-----+<-calls--+------------+ # +----------+<--calls-/ + | IdbAdapter |<--remote#call--| IdbProxy | + +------------+ # +----------+ + oid='idb_adapter' # + +The purpose of the Proxy and Adapter classes is to translate certain +arguments and return values that cannot be transported through the RPC +barrier, in particular frame and traceback objects. + +""" +import reprlib +import types +from idlelib import debugger + +debugging = 0 + +idb_adap_oid = "idb_adapter" +gui_adap_oid = "gui_adapter" + +#======================================= +# +# In the PYTHON subprocess: + +frametable = {} +dicttable = {} +codetable = {} +tracebacktable = {} + +def wrap_frame(frame): + fid = id(frame) + frametable[fid] = frame + return fid + +def wrap_info(info): + "replace info[2], a traceback instance, by its ID" + if info is None: + return None + else: + traceback = info[2] + assert isinstance(traceback, types.TracebackType) + traceback_id = id(traceback) + tracebacktable[traceback_id] = traceback + modified_info = (info[0], info[1], traceback_id) + return modified_info + +class GUIProxy: + + def __init__(self, conn, gui_adap_oid): + self.conn = conn + self.oid = gui_adap_oid + + def interaction(self, message, frame, info=None): + # calls rpc.SocketIO.remotecall() via run.MyHandler instance + # pass frame and traceback object IDs instead of the objects themselves + self.conn.remotecall(self.oid, "interaction", + (message, wrap_frame(frame), wrap_info(info)), + {}) + +class IdbAdapter: + + def __init__(self, idb): + self.idb = idb + + #----------called by an IdbProxy---------- + + def set_step(self): + self.idb.set_step() + + def set_quit(self): + self.idb.set_quit() + + def set_continue(self): + self.idb.set_continue() + + def set_next(self, fid): + frame = frametable[fid] + self.idb.set_next(frame) + + def set_return(self, fid): + frame = frametable[fid] + self.idb.set_return(frame) + + def get_stack(self, fid, tbid): + frame = frametable[fid] + if tbid is None: + tb = None + else: + tb = tracebacktable[tbid] + stack, i = self.idb.get_stack(frame, tb) + stack = [(wrap_frame(frame2), k) for frame2, k in stack] + return stack, i + + def run(self, cmd): + import __main__ + self.idb.run(cmd, __main__.__dict__) + + def set_break(self, filename, lineno): + msg = self.idb.set_break(filename, lineno) + return msg + + def clear_break(self, filename, lineno): + msg = self.idb.clear_break(filename, lineno) + return msg + + def clear_all_file_breaks(self, filename): + msg = self.idb.clear_all_file_breaks(filename) + return msg + + #----------called by a FrameProxy---------- + + def frame_attr(self, fid, name): + frame = frametable[fid] + return getattr(frame, name) + + def frame_globals(self, fid): + frame = frametable[fid] + gdict = frame.f_globals + did = id(gdict) + dicttable[did] = gdict + return did + + def frame_locals(self, fid): + frame = frametable[fid] + ldict = frame.f_locals + did = id(ldict) + dicttable[did] = ldict + return did + + def frame_code(self, fid): + frame = frametable[fid] + code = frame.f_code + cid = id(code) + codetable[cid] = code + return cid + + #----------called by a CodeProxy---------- + + def code_name(self, cid): + code = codetable[cid] + return code.co_name + + def code_filename(self, cid): + code = codetable[cid] + return code.co_filename + + #----------called by a DictProxy---------- + + def dict_keys(self, did): + raise NotImplementedError("dict_keys not public or pickleable") +## return dicttable[did].keys() + + ### Needed until dict_keys type is finished and pickleable. + # xxx finished. pickleable? + ### Will probably need to extend rpc.py:SocketIO._proxify at that time. + def dict_keys_list(self, did): + return list(dicttable[did].keys()) + + def dict_item(self, did, key): + value = dicttable[did][key] + return reprlib.repr(value) # Can't pickle module 'builtins'. + +#----------end class IdbAdapter---------- + + +def start_debugger(rpchandler, gui_adap_oid): + """Start the debugger and its RPC link in the Python subprocess + + Start the subprocess side of the split debugger and set up that side of the + RPC link by instantiating the GUIProxy, Idb debugger, and IdbAdapter + objects and linking them together. Register the IdbAdapter with the + RPCServer to handle RPC requests from the split debugger GUI via the + IdbProxy. + + """ + gui_proxy = GUIProxy(rpchandler, gui_adap_oid) + idb = debugger.Idb(gui_proxy) + idb_adap = IdbAdapter(idb) + rpchandler.register(idb_adap_oid, idb_adap) + return idb_adap_oid + + +#======================================= +# +# In the IDLE process: + + +class FrameProxy: + + def __init__(self, conn, fid): + self._conn = conn + self._fid = fid + self._oid = "idb_adapter" + self._dictcache = {} + + def __getattr__(self, name): + if name[:1] == "_": + raise AttributeError(name) + if name == "f_code": + return self._get_f_code() + if name == "f_globals": + return self._get_f_globals() + if name == "f_locals": + return self._get_f_locals() + return self._conn.remotecall(self._oid, "frame_attr", + (self._fid, name), {}) + + def _get_f_code(self): + cid = self._conn.remotecall(self._oid, "frame_code", (self._fid,), {}) + return CodeProxy(self._conn, self._oid, cid) + + def _get_f_globals(self): + did = self._conn.remotecall(self._oid, "frame_globals", + (self._fid,), {}) + return self._get_dict_proxy(did) + + def _get_f_locals(self): + did = self._conn.remotecall(self._oid, "frame_locals", + (self._fid,), {}) + return self._get_dict_proxy(did) + + def _get_dict_proxy(self, did): + if did in self._dictcache: + return self._dictcache[did] + dp = DictProxy(self._conn, self._oid, did) + self._dictcache[did] = dp + return dp + + +class CodeProxy: + + def __init__(self, conn, oid, cid): + self._conn = conn + self._oid = oid + self._cid = cid + + def __getattr__(self, name): + if name == "co_name": + return self._conn.remotecall(self._oid, "code_name", + (self._cid,), {}) + if name == "co_filename": + return self._conn.remotecall(self._oid, "code_filename", + (self._cid,), {}) + + +class DictProxy: + + def __init__(self, conn, oid, did): + self._conn = conn + self._oid = oid + self._did = did + +## def keys(self): +## return self._conn.remotecall(self._oid, "dict_keys", (self._did,), {}) + + # 'temporary' until dict_keys is a pickleable built-in type + def keys(self): + return self._conn.remotecall(self._oid, + "dict_keys_list", (self._did,), {}) + + def __getitem__(self, key): + return self._conn.remotecall(self._oid, "dict_item", + (self._did, key), {}) + + def __getattr__(self, name): + ##print("*** Failed DictProxy.__getattr__:", name) + raise AttributeError(name) + + +class GUIAdapter: + + def __init__(self, conn, gui): + self.conn = conn + self.gui = gui + + def interaction(self, message, fid, modified_info): + ##print("*** Interaction: (%s, %s, %s)" % (message, fid, modified_info)) + frame = FrameProxy(self.conn, fid) + self.gui.interaction(message, frame, modified_info) + + +class IdbProxy: + + def __init__(self, conn, shell, oid): + self.oid = oid + self.conn = conn + self.shell = shell + + def call(self, methodname, /, *args, **kwargs): + ##print("*** IdbProxy.call %s %s %s" % (methodname, args, kwargs)) + value = self.conn.remotecall(self.oid, methodname, args, kwargs) + ##print("*** IdbProxy.call %s returns %r" % (methodname, value)) + return value + + def run(self, cmd, locals): + # Ignores locals on purpose! + seq = self.conn.asyncqueue(self.oid, "run", (cmd,), {}) + self.shell.interp.active_seq = seq + + def get_stack(self, frame, tbid): + # passing frame and traceback IDs, not the objects themselves + stack, i = self.call("get_stack", frame._fid, tbid) + stack = [(FrameProxy(self.conn, fid), k) for fid, k in stack] + return stack, i + + def set_continue(self): + self.call("set_continue") + + def set_step(self): + self.call("set_step") + + def set_next(self, frame): + self.call("set_next", frame._fid) + + def set_return(self, frame): + self.call("set_return", frame._fid) + + def set_quit(self): + self.call("set_quit") + + def set_break(self, filename, lineno): + msg = self.call("set_break", filename, lineno) + return msg + + def clear_break(self, filename, lineno): + msg = self.call("clear_break", filename, lineno) + return msg + + def clear_all_file_breaks(self, filename): + msg = self.call("clear_all_file_breaks", filename) + return msg + +def start_remote_debugger(rpcclt, pyshell): + """Start the subprocess debugger, initialize the debugger GUI and RPC link + + Request the RPCServer start the Python subprocess debugger and link. Set + up the Idle side of the split debugger by instantiating the IdbProxy, + debugger GUI, and debugger GUIAdapter objects and linking them together. + + Register the GUIAdapter with the RPCClient to handle debugger GUI + interaction requests coming from the subprocess debugger via the GUIProxy. + + The IdbAdapter will pass execution and environment requests coming from the + Idle debugger GUI to the subprocess debugger via the IdbProxy. + + """ + global idb_adap_oid + + idb_adap_oid = rpcclt.remotecall("exec", "start_the_debugger",\ + (gui_adap_oid,), {}) + idb_proxy = IdbProxy(rpcclt, pyshell, idb_adap_oid) + gui = debugger.Debugger(pyshell, idb_proxy) + gui_adap = GUIAdapter(rpcclt, gui) + rpcclt.register(gui_adap_oid, gui_adap) + return gui + +def close_remote_debugger(rpcclt): + """Shut down subprocess debugger and Idle side of debugger RPC link + + Request that the RPCServer shut down the subprocess debugger and link. + Unregister the GUIAdapter, which will cause a GC on the Idle process + debugger and RPC link objects. (The second reference to the debugger GUI + is deleted in pyshell.close_remote_debugger().) + + """ + close_subprocess_debugger(rpcclt) + rpcclt.unregister(gui_adap_oid) + +def close_subprocess_debugger(rpcclt): + rpcclt.remotecall("exec", "stop_the_debugger", (idb_adap_oid,), {}) + +def restart_subprocess_debugger(rpcclt): + idb_adap_oid_ret = rpcclt.remotecall("exec", "start_the_debugger",\ + (gui_adap_oid,), {}) + assert idb_adap_oid_ret == idb_adap_oid, 'Idb restarted with different oid' + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_debugger_r', verbosity=2, exit=False) diff --git a/Lib/idlelib/debugobj.py b/Lib/idlelib/debugobj.py new file mode 100644 index 00000000000..fb448ece2fa --- /dev/null +++ b/Lib/idlelib/debugobj.py @@ -0,0 +1,146 @@ +"""Define tree items for debug stackviewer, which is only user. +""" +# XXX TO DO: +# - popup menu +# - support partial or total redisplay +# - more doc strings +# - tooltips + +# object browser + +# XXX TO DO: +# - for classes/modules, add "open source" to object browser +from reprlib import Repr + +from idlelib.tree import TreeItem, TreeNode, ScrolledCanvas + +myrepr = Repr() +myrepr.maxstring = 100 +myrepr.maxother = 100 + +class ObjectTreeItem(TreeItem): + def __init__(self, labeltext, object_, setfunction=None): + self.labeltext = labeltext + self.object = object_ + self.setfunction = setfunction + def GetLabelText(self): + return self.labeltext + def GetText(self): + return myrepr.repr(self.object) + def GetIconName(self): + if not self.IsExpandable(): + return "python" + def IsEditable(self): + return self.setfunction is not None + def SetText(self, text): + try: + value = eval(text) + self.setfunction(value) + except: + pass + else: + self.object = value + def IsExpandable(self): + return not not dir(self.object) + def GetSubList(self): + keys = dir(self.object) + sublist = [] + for key in keys: + try: + value = getattr(self.object, key) + except AttributeError: + continue + item = make_objecttreeitem( + str(key) + " =", + value, + lambda value, key=key, object_=self.object: + setattr(object_, key, value)) + sublist.append(item) + return sublist + +class ClassTreeItem(ObjectTreeItem): + def IsExpandable(self): + return True + def GetSubList(self): + sublist = ObjectTreeItem.GetSubList(self) + if len(self.object.__bases__) == 1: + item = make_objecttreeitem("__bases__[0] =", + self.object.__bases__[0]) + else: + item = make_objecttreeitem("__bases__ =", self.object.__bases__) + sublist.insert(0, item) + return sublist + +class AtomicObjectTreeItem(ObjectTreeItem): + def IsExpandable(self): + return False + +class SequenceTreeItem(ObjectTreeItem): + def IsExpandable(self): + return len(self.object) > 0 + def keys(self): + return range(len(self.object)) + def GetSubList(self): + sublist = [] + for key in self.keys(): + try: + value = self.object[key] + except KeyError: + continue + def setfunction(value, key=key, object_=self.object): + object_[key] = value + item = make_objecttreeitem(f"{key!r}:", value, setfunction) + sublist.append(item) + return sublist + +class DictTreeItem(SequenceTreeItem): + def keys(self): + # TODO return sorted(self.object) + keys = list(self.object) + try: + keys.sort() + except: + pass + return keys + +dispatch = { + int: AtomicObjectTreeItem, + float: AtomicObjectTreeItem, + str: AtomicObjectTreeItem, + tuple: SequenceTreeItem, + list: SequenceTreeItem, + dict: DictTreeItem, + type: ClassTreeItem, +} + +def make_objecttreeitem(labeltext, object_, setfunction=None): + t = type(object_) + if t in dispatch: + c = dispatch[t] + else: + c = ObjectTreeItem + return c(labeltext, object_, setfunction) + + +def _debug_object_browser(parent): # htest # + import sys + from tkinter import Toplevel + top = Toplevel(parent) + top.title("Test debug object browser") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x + 100, y + 175)) + top.configure(bd=0, bg="yellow") + top.focus_set() + sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) + sc.frame.pack(expand=1, fill="both") + item = make_objecttreeitem("sys", sys) + node = TreeNode(sc.canvas, None, item) + node.update() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_debugobj', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_debug_object_browser) diff --git a/Lib/idlelib/debugobj_r.py b/Lib/idlelib/debugobj_r.py new file mode 100644 index 00000000000..75e75ebe5ac --- /dev/null +++ b/Lib/idlelib/debugobj_r.py @@ -0,0 +1,41 @@ +from idlelib import rpc + +def remote_object_tree_item(item): + wrapper = WrappedObjectTreeItem(item) + oid = id(wrapper) + rpc.objecttable[oid] = wrapper + return oid + +class WrappedObjectTreeItem: + # Lives in PYTHON subprocess + + def __init__(self, item): + self.__item = item + + def __getattr__(self, name): + value = getattr(self.__item, name) + return value + + def _GetSubList(self): + sub_list = self.__item._GetSubList() + return list(map(remote_object_tree_item, sub_list)) + +class StubObjectTreeItem: + # Lives in IDLE process + + def __init__(self, sockio, oid): + self.sockio = sockio + self.oid = oid + + def __getattr__(self, name): + value = rpc.MethodProxy(self.sockio, self.oid, name) + return value + + def _GetSubList(self): + sub_list = self.sockio.remotecall(self.oid, "_GetSubList", (), {}) + return [StubObjectTreeItem(self.sockio, oid) for oid in sub_list] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_debugobj_r', verbosity=2) diff --git a/Lib/idlelib/delegator.py b/Lib/idlelib/delegator.py new file mode 100644 index 00000000000..93ae8bbd43f --- /dev/null +++ b/Lib/idlelib/delegator.py @@ -0,0 +1,34 @@ +class Delegator: + + def __init__(self, delegate=None): + self.delegate = delegate + self.__cache = set() + # Cache is used to only remove added attributes + # when changing the delegate. + + def __getattr__(self, name): + attr = getattr(self.delegate, name) # May raise AttributeError + setattr(self, name, attr) + self.__cache.add(name) + return attr + + def resetcache(self): + "Removes added attributes while leaving original attributes." + # Function is really about resetting delegator dict + # to original state. Cache is just a means + for key in self.__cache: + try: + delattr(self, key) + except AttributeError: + pass + self.__cache.clear() + + def setdelegate(self, delegate): + "Reset attributes and change delegate." + self.resetcache() + self.delegate = delegate + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_delegator', verbosity=2) diff --git a/Lib/idlelib/dynoption.py b/Lib/idlelib/dynoption.py new file mode 100644 index 00000000000..b8937f7106c --- /dev/null +++ b/Lib/idlelib/dynoption.py @@ -0,0 +1,57 @@ +""" +OptionMenu widget modified to allow dynamic menu reconfiguration +and setting of highlightthickness +""" +from tkinter import OptionMenu, _setit, StringVar, Button + +class DynOptionMenu(OptionMenu): + """Add SetMenu and highlightthickness to OptionMenu. + + Highlightthickness adds space around menu button. + """ + def __init__(self, master, variable, value, *values, **kwargs): + highlightthickness = kwargs.pop('highlightthickness', None) + OptionMenu.__init__(self, master, variable, value, *values, **kwargs) + self['highlightthickness'] = highlightthickness + self.variable = variable + self.command = kwargs.get('command') + + def SetMenu(self,valueList,value=None): + """ + clear and reload the menu with a new set of options. + valueList - list of new options + value - initial value to set the optionmenu's menubutton to + """ + self['menu'].delete(0,'end') + for item in valueList: + self['menu'].add_command(label=item, + command=_setit(self.variable,item,self.command)) + if value: + self.variable.set(value) + + +def _dyn_option_menu(parent): # htest # + from tkinter import Toplevel # + StringVar, Button + + top = Toplevel(parent) + top.title("Test dynamic option menu") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("200x100+%d+%d" % (x + 250, y + 175)) + top.focus_set() + + var = StringVar(top) + var.set("Old option set") #Set the default value + dyn = DynOptionMenu(top, var, "old1","old2","old3","old4", + highlightthickness=5) + dyn.pack() + + def update(): + dyn.SetMenu(["new1","new2","new3","new4"], value="new option set") + button = Button(top, text="Change option set", command=update) + button.pack() + + +if __name__ == '__main__': + # Only module without unittests because of intention to replace. + from idlelib.idle_test.htest import run + run(_dyn_option_menu) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py new file mode 100644 index 00000000000..3128934763a --- /dev/null +++ b/Lib/idlelib/editor.py @@ -0,0 +1,1721 @@ +import importlib.abc +import importlib.util +import os +import platform +import re +import string +import sys +import tokenize +import traceback +import webbrowser + +from tkinter import * +from tkinter.font import Font +from tkinter.ttk import Scrollbar +from tkinter import simpledialog +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib import configdialog +from idlelib import grep +from idlelib import help +from idlelib import help_about +from idlelib import macosx +from idlelib.multicall import MultiCallCreator +from idlelib import pyparse +from idlelib import query +from idlelib import replace +from idlelib import search +from idlelib.tree import wheel_event +from idlelib.util import py_extensions +from idlelib import window +from idlelib.help import _get_dochome + +# The default tab setting for a Text widget, in average-width characters. +TK_TABWIDTH_DEFAULT = 8 +darwin = sys.platform == 'darwin' + +class EditorWindow: + from idlelib.percolator import Percolator + from idlelib.colorizer import ColorDelegator, color_config + from idlelib.undo import UndoDelegator + from idlelib.iomenu import IOBinding, encoding + from idlelib import mainmenu + from idlelib.statusbar import MultiStatusBar + from idlelib.autocomplete import AutoComplete + from idlelib.autoexpand import AutoExpand + from idlelib.calltip import Calltip + from idlelib.codecontext import CodeContext + from idlelib.sidebar import LineNumbers + from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip + from idlelib.parenmatch import ParenMatch + from idlelib.zoomheight import ZoomHeight + + filesystemencoding = sys.getfilesystemencoding() # for file names + help_url = None + + allow_code_context = True + allow_line_numbers = True + user_input_insert_tags = None + + def __init__(self, flist=None, filename=None, key=None, root=None): + # Delay import: runscript imports pyshell imports EditorWindow. + from idlelib.runscript import ScriptBinding + + if EditorWindow.help_url is None: + EditorWindow.help_url = _get_dochome() + self.flist = flist + root = root or flist.root + self.root = root + self.menubar = Menu(root) + self.top = top = window.ListedToplevel(root, menu=self.menubar) + if flist: + self.tkinter_vars = flist.vars + #self.top.instance_dict makes flist.inversedict available to + #configdialog.py so it can access all EditorWindow instances + self.top.instance_dict = flist.inversedict + else: + self.tkinter_vars = {} # keys: Tkinter event names + # values: Tkinter variable instances + self.top.instance_dict = {} + self.recent_files_path = idleConf.userdir and os.path.join( + idleConf.userdir, 'recent-files.lst') + + self.prompt_last_line = '' # Override in PyShell + self.text_frame = text_frame = Frame(top) + self.vbar = vbar = Scrollbar(text_frame, name='vbar') + width = idleConf.GetOption('main', 'EditorWindow', 'width', type='int') + text_options = { + 'name': 'text', + 'padx': 5, + 'wrap': 'none', + 'highlightthickness': 0, + 'width': width, + 'tabstyle': 'wordprocessor', # new in 8.5 + 'height': idleConf.GetOption( + 'main', 'EditorWindow', 'height', type='int'), + } + self.text = text = MultiCallCreator(Text)(text_frame, **text_options) + self.top.focused_widget = self.text + + self.createmenubar() + self.apply_bindings() + + self.top.protocol("WM_DELETE_WINDOW", self.close) + self.top.bind("<>", self.close_event) + if macosx.isAquaTk(): + # Command-W on editor windows doesn't work without this. + text.bind('<>', self.close_event) + # Some OS X systems have only one mouse button, so use + # control-click for popup context menus there. For two + # buttons, AquaTk defines <2> as the right button, not <3>. + text.bind("",self.right_menu_event) + text.bind("<2>", self.right_menu_event) + else: + # Elsewhere, use right-click for popup menus. + text.bind("<3>",self.right_menu_event) + + text.bind('', wheel_event) + if text._windowingsystem == 'x11': + text.bind('', wheel_event) + text.bind('', wheel_event) + text.bind('', self.handle_winconfig) + text.bind("<>", self.cut) + text.bind("<>", self.copy) + text.bind("<>", self.paste) + text.bind("<>", self.center_insert_event) + text.bind("<>", self.help_dialog) + text.bind("<>", self.python_docs) + text.bind("<>", self.about_dialog) + text.bind("<>", self.config_dialog) + text.bind("<>", self.open_module_event) + text.bind("<>", lambda event: "break") + text.bind("<>", self.select_all) + text.bind("<>", self.remove_selection) + text.bind("<>", self.find_event) + text.bind("<>", self.find_again_event) + text.bind("<>", self.find_in_files_event) + text.bind("<>", self.find_selection_event) + text.bind("<>", self.replace_event) + text.bind("<>", self.goto_line_event) + text.bind("<>",self.smart_backspace_event) + text.bind("<>",self.newline_and_indent_event) + text.bind("<>",self.smart_indent_event) + self.fregion = fregion = self.FormatRegion(self) + # self.fregion used in smart_indent_event to access indent_region. + text.bind("<>", fregion.indent_region_event) + text.bind("<>", fregion.dedent_region_event) + text.bind("<>", fregion.comment_region_event) + text.bind("<>", fregion.uncomment_region_event) + text.bind("<>", fregion.tabify_region_event) + text.bind("<>", fregion.untabify_region_event) + indents = self.Indents(self) + text.bind("<>", indents.toggle_tabs_event) + text.bind("<>", indents.change_indentwidth_event) + text.bind("", self.move_at_edge_if_selection(0)) + text.bind("", self.move_at_edge_if_selection(1)) + text.bind("<>", self.del_word_left) + text.bind("<>", self.del_word_right) + text.bind("<>", self.home_callback) + + if flist: + flist.inversedict[self] = key + if key: + flist.dict[key] = self + text.bind("<>", self.new_callback) + text.bind("<>", self.flist.close_all_callback) + text.bind("<>", self.open_module_browser) + text.bind("<>", self.open_path_browser) + text.bind("<>", self.open_turtle_demo) + + self.set_status_bar() + text_frame.pack(side=LEFT, fill=BOTH, expand=1) + text_frame.rowconfigure(1, weight=1) + text_frame.columnconfigure(1, weight=1) + vbar['command'] = self.handle_yview + vbar.grid(row=1, column=2, sticky=NSEW) + text['yscrollcommand'] = vbar.set + text['font'] = idleConf.GetFont(self.root, 'main', 'EditorWindow') + text.grid(row=1, column=1, sticky=NSEW) + text.focus_set() + self.set_width() + + # usetabs true -> literal tab characters are used by indent and + # dedent cmds, possibly mixed with spaces if + # indentwidth is not a multiple of tabwidth, + # which will cause Tabnanny to nag! + # false -> tab characters are converted to spaces by indent + # and dedent cmds, and ditto TAB keystrokes + # Although use-spaces=0 can be configured manually in config-main.def, + # configuration of tabs v. spaces is not supported in the configuration + # dialog. IDLE promotes the preferred Python indentation: use spaces! + usespaces = idleConf.GetOption('main', 'Indent', + 'use-spaces', type='bool') + self.usetabs = not usespaces + + # tabwidth is the display width of a literal tab character. + # CAUTION: telling Tk to use anything other than its default + # tab setting causes it to use an entirely different tabbing algorithm, + # treating tab stops as fixed distances from the left margin. + # Nobody expects this, so for now tabwidth should never be changed. + self.tabwidth = 8 # must remain 8 until Tk is fixed. + + # indentwidth is the number of screen characters per indent level. + # The recommended Python indentation is four spaces. + self.indentwidth = self.tabwidth + self.set_notabs_indentwidth() + + # Store the current value of the insertofftime now so we can restore + # it if needed. + if not hasattr(idleConf, 'blink_off_time'): + idleConf.blink_off_time = self.text['insertofftime'] + self.update_cursor_blink() + + # When searching backwards for a reliable place to begin parsing, + # first start num_context_lines[0] lines back, then + # num_context_lines[1] lines back if that didn't work, and so on. + # The last value should be huge (larger than the # of lines in a + # conceivable file). + # Making the initial values larger slows things down more often. + self.num_context_lines = 50, 500, 5000000 + self.per = per = self.Percolator(text) + self.undo = undo = self.UndoDelegator() + per.insertfilter(undo) + text.undo_block_start = undo.undo_block_start + text.undo_block_stop = undo.undo_block_stop + undo.set_saved_change_hook(self.saved_change_hook) + # IOBinding implements file I/O and printing functionality + self.io = io = self.IOBinding(self) + io.set_filename_change_hook(self.filename_change_hook) + self.good_load = False + self.set_indentation_params(False) + self.color = None # initialized below in self.ResetColorizer + self.code_context = None # optionally initialized later below + self.line_numbers = None # optionally initialized later below + if filename: + if os.path.exists(filename) and not os.path.isdir(filename): + if io.loadfile(filename): + self.good_load = True + is_py_src = self.ispythonsource(filename) + self.set_indentation_params(is_py_src) + else: + io.set_filename(filename) + self.good_load = True + + self.ResetColorizer() + self.saved_change_hook() + self.update_recent_files_list() + self.load_extensions() + menu = self.menudict.get('window') + if menu: + end = menu.index("end") + if end is None: + end = -1 + if end >= 0: + menu.add_separator() + end = end + 1 + self.wmenu_end = end + window.register_callback(self.postwindowsmenu) + + # Some abstractions so IDLE extensions are cross-IDE + self.askinteger = simpledialog.askinteger + self.askyesno = messagebox.askyesno + self.showerror = messagebox.showerror + + # Add pseudoevents for former extension fixed keys. + # (This probably needs to be done once in the process.) + text.event_add('<>', '') + text.event_add('<>', '', + '', '') + text.event_add('<>', '') + text.event_add('<>', '') + text.event_add('<>', '', + '', '') + + # Former extension bindings depends on frame.text being packed + # (called from self.ResetColorizer()). + autocomplete = self.AutoComplete(self, self.user_input_insert_tags) + text.bind("<>", autocomplete.autocomplete_event) + text.bind("<>", + autocomplete.try_open_completions_event) + text.bind("<>", + autocomplete.force_open_completions_event) + text.bind("<>", self.AutoExpand(self).expand_word_event) + text.bind("<>", + self.FormatParagraph(self).format_paragraph_event) + parenmatch = self.ParenMatch(self) + text.bind("<>", parenmatch.flash_paren_event) + text.bind("<>", parenmatch.paren_closed_event) + scriptbinding = ScriptBinding(self) + text.bind("<>", scriptbinding.check_module_event) + text.bind("<>", scriptbinding.run_module_event) + text.bind("<>", scriptbinding.run_custom_event) + text.bind("<>", self.Rstrip(self).do_rstrip) + self.ctip = ctip = self.Calltip(self) + text.bind("<>", ctip.try_open_calltip_event) + #refresh-calltip must come after paren-closed to work right + text.bind("<>", ctip.refresh_calltip_event) + text.bind("<>", ctip.force_open_calltip_event) + text.bind("<>", self.ZoomHeight(self).zoom_height_event) + if self.allow_code_context: + self.code_context = self.CodeContext(self) + text.bind("<>", + self.code_context.toggle_code_context_event) + else: + self.update_menu_state('options', '*ode*ontext', 'disabled') + if self.allow_line_numbers: + self.line_numbers = self.LineNumbers(self) + if idleConf.GetOption('main', 'EditorWindow', + 'line-numbers-default', type='bool'): + self.toggle_line_numbers_event() + text.bind("<>", self.toggle_line_numbers_event) + else: + self.update_menu_state('options', '*ine*umbers', 'disabled') + + def handle_winconfig(self, event=None): + self.set_width() + + def set_width(self): + text = self.text + inner_padding = sum(map(text.tk.getint, [text.cget('border'), + text.cget('padx')])) + pixel_width = text.winfo_width() - 2 * inner_padding + + # Divide the width of the Text widget by the font width, + # which is taken to be the width of '0' (zero). + # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21 + zero_char_width = \ + Font(text, font=text.cget('font')).measure('0') + self.width = pixel_width // zero_char_width + + def new_callback(self, event): + dirname, basename = self.io.defaultfilename() + self.flist.new(dirname) + return "break" + + def home_callback(self, event): + if (event.state & 4) != 0 and event.keysym == "Home": + # state&4==Control. If , use the Tk binding. + return None + if self.text.index("iomark") and \ + self.text.compare("iomark", "<=", "insert lineend") and \ + self.text.compare("insert linestart", "<=", "iomark"): + # In Shell on input line, go to just after prompt + insertpt = int(self.text.index("iomark").split(".")[1]) + else: + line = self.text.get("insert linestart", "insert lineend") + for insertpt in range(len(line)): + if line[insertpt] not in (' ','\t'): + break + else: + insertpt=len(line) + lineat = int(self.text.index("insert").split('.')[1]) + if insertpt == lineat: + insertpt = 0 + dest = "insert linestart+"+str(insertpt)+"c" + if (event.state&1) == 0: + # shift was not pressed + self.text.tag_remove("sel", "1.0", "end") + else: + if not self.text.index("sel.first"): + # there was no previous selection + self.text.mark_set("my_anchor", "insert") + else: + if self.text.compare(self.text.index("sel.first"), "<", + self.text.index("insert")): + self.text.mark_set("my_anchor", "sel.first") # extend back + else: + self.text.mark_set("my_anchor", "sel.last") # extend forward + first = self.text.index(dest) + last = self.text.index("my_anchor") + if self.text.compare(first,">",last): + first,last = last,first + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", first, last) + self.text.mark_set("insert", dest) + self.text.see("insert") + return "break" + + def set_status_bar(self): + self.status_bar = self.MultiStatusBar(self.top) + sep = Frame(self.top, height=1, borderwidth=1, background='grey75') + if sys.platform == "darwin": + # Insert some padding to avoid obscuring some of the statusbar + # by the resize widget. + self.status_bar.set_label('_padding1', ' ', side=RIGHT) + self.status_bar.set_label('column', 'Col: ?', side=RIGHT) + self.status_bar.set_label('line', 'Ln: ?', side=RIGHT) + self.status_bar.pack(side=BOTTOM, fill=X) + sep.pack(side=BOTTOM, fill=X) + self.text.bind("<>", self.set_line_and_column) + self.text.event_add("<>", + "", "") + self.text.after_idle(self.set_line_and_column) + + def set_line_and_column(self, event=None): + line, column = self.text.index(INSERT).split('.') + self.status_bar.set_label('column', 'Col: %s' % column) + self.status_bar.set_label('line', 'Ln: %s' % line) + + + """ Menu definitions and functions. + * self.menubar - the always visible horizontal menu bar. + * mainmenu.menudefs - a list of tuples, one for each menubar item. + Each tuple pairs a lower-case name and list of dropdown items. + Each item is a name, virtual event pair or None for separator. + * mainmenu.default_keydefs - maps events to keys. + * text.keydefs - same. + * cls.menu_specs - menubar name, titlecase display form pairs + with Alt-hotkey indicator. A subset of menudefs items. + * self.menudict - map menu name to dropdown menu. + * self.recent_files_menu - 2nd level cascade in the file cascade. + * self.wmenu_end - set in __init__ (purpose unclear). + + createmenubar, postwindowsmenu, update_menu_label, update_menu_state, + ApplyKeybings (2nd part), reset_help_menu_entries, + _extra_help_callback, update_recent_files_list, + apply_bindings, fill_menus, (other functions?) + """ + + menu_specs = [ + ("file", "_File"), + ("edit", "_Edit"), + ("format", "F_ormat"), + ("run", "_Run"), + ("options", "_Options"), + ("window", "_Window"), + ("help", "_Help"), + ] + + def createmenubar(self): + """Populate the menu bar widget for the editor window. + + Each option on the menubar is itself a cascade-type Menu widget + with the menubar as the parent. The names, labels, and menu + shortcuts for the menubar items are stored in menu_specs. Each + submenu is subsequently populated in fill_menus(), except for + 'Recent Files' which is added to the File menu here. + + Instance variables: + menubar: Menu widget containing first level menu items. + menudict: Dictionary of {menuname: Menu instance} items. The keys + represent the valid menu items for this window and may be a + subset of all the menudefs available. + recent_files_menu: Menu widget contained within the 'file' menudict. + """ + mbar = self.menubar + self.menudict = menudict = {} + for name, label in self.menu_specs: + underline, label = prepstr(label) + postcommand = getattr(self, f'{name}_menu_postcommand', None) + menudict[name] = menu = Menu(mbar, name=name, tearoff=0, + postcommand=postcommand) + mbar.add_cascade(label=label, menu=menu, underline=underline) + if macosx.isCarbonTk(): + # Insert the application menu + menudict['application'] = menu = Menu(mbar, name='apple', + tearoff=0) + mbar.add_cascade(label='IDLE', menu=menu) + self.fill_menus() + self.recent_files_menu = Menu(self.menubar, tearoff=0) + self.menudict['file'].insert_cascade(3, label='Recent Files', + underline=0, + menu=self.recent_files_menu) + self.base_helpmenu_length = self.menudict['help'].index(END) + self.reset_help_menu_entries() + + def postwindowsmenu(self): + """Callback to register window. + + Only called when Window menu exists. + """ + menu = self.menudict['window'] + end = menu.index("end") + if end is None: + end = -1 + if end > self.wmenu_end: + menu.delete(self.wmenu_end+1, end) + window.add_windows_to_menu(menu) + + def update_menu_label(self, menu, index, label): + "Update label for menu item at index." + menuitem = self.menudict[menu] + menuitem.entryconfig(index, label=label) + + def update_menu_state(self, menu, index, state): + "Update state for menu item at index." + menuitem = self.menudict[menu] + menuitem.entryconfig(index, state=state) + + def handle_yview(self, event, *args): + "Handle scrollbar." + if event == 'moveto': + fraction = float(args[0]) + lines = (round(self.getlineno('end') * fraction) - + self.getlineno('@0,0')) + event = 'scroll' + args = (lines, 'units') + self.text.yview(event, *args) + return 'break' + + rmenu = None + + def right_menu_event(self, event): + text = self.text + newdex = text.index(f'@{event.x},{event.y}') + try: + in_selection = (text.compare('sel.first', '<=', newdex) and + text.compare(newdex, '<=', 'sel.last')) + except TclError: + in_selection = False + if not in_selection: + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", newdex) + if not self.rmenu: + self.make_rmenu() + rmenu = self.rmenu + self.event = event + iswin = sys.platform[:3] == 'win' + if iswin: + text.config(cursor="arrow") + + for item in self.rmenu_specs: + try: + label, eventname, verify_state = item + except ValueError: # see issue1207589 + continue + + if verify_state is None: + continue + state = getattr(self, verify_state)() + rmenu.entryconfigure(label, state=state) + + rmenu.tk_popup(event.x_root, event.y_root) + if iswin: + self.text.config(cursor="ibeam") + return "break" + + rmenu_specs = [ + # ("Label", "<>", "statefuncname"), ... + ("Close", "<>", None), # Example + ] + + def make_rmenu(self): + rmenu = Menu(self.text, tearoff=0) + for item in self.rmenu_specs: + label, eventname = item[0], item[1] + if label is not None: + def command(text=self.text, eventname=eventname): + text.event_generate(eventname) + rmenu.add_command(label=label, command=command) + else: + rmenu.add_separator() + self.rmenu = rmenu + + def rmenu_check_cut(self): + return self.rmenu_check_copy() + + def rmenu_check_copy(self): + try: + indx = self.text.index('sel.first') + except TclError: + return 'disabled' + else: + return 'normal' if indx else 'disabled' + + def rmenu_check_paste(self): + try: + self.text.tk.call('tk::GetSelection', self.text, 'CLIPBOARD') + except TclError: + return 'disabled' + else: + return 'normal' + + def about_dialog(self, event=None): + "Handle Help 'About IDLE' event." + # Synchronize with macosx.overrideRootMenu.about_dialog. + help_about.AboutDialog(self.top) + return "break" + + def config_dialog(self, event=None): + "Handle Options 'Configure IDLE' event." + # Synchronize with macosx.overrideRootMenu.config_dialog. + configdialog.ConfigDialog(self.top,'Settings') + return "break" + + def help_dialog(self, event=None): + "Handle Help 'IDLE Help' event." + # Synchronize with macosx.overrideRootMenu.help_dialog. + if self.root: + parent = self.root + else: + parent = self.top + help.show_idlehelp(parent) + return "break" + + def python_docs(self, event=None): + if sys.platform[:3] == 'win': + try: + os.startfile(self.help_url) + except OSError as why: + messagebox.showerror(title='Document Start Failure', + message=str(why), parent=self.text) + else: + webbrowser.open(self.help_url) + return "break" + + def cut(self,event): + self.text.event_generate("<>") + return "break" + + def copy(self,event): + if not self.text.tag_ranges("sel"): + # There is no selection, so do nothing and maybe interrupt. + return None + self.text.event_generate("<>") + return "break" + + def paste(self,event): + self.text.event_generate("<>") + self.text.see("insert") + return "break" + + def select_all(self, event=None): + self.text.tag_add("sel", "1.0", "end-1c") + self.text.mark_set("insert", "1.0") + self.text.see("insert") + return "break" + + def remove_selection(self, event=None): + self.text.tag_remove("sel", "1.0", "end") + self.text.see("insert") + return "break" + + def move_at_edge_if_selection(self, edge_index): + """Cursor move begins at start or end of selection + + When a left/right cursor key is pressed create and return to Tkinter a + function which causes a cursor move from the associated edge of the + selection. + + """ + self_text_index = self.text.index + self_text_mark_set = self.text.mark_set + edges_table = ("sel.first+1c", "sel.last-1c") + def move_at_edge(event): + if (event.state & 5) == 0: # no shift(==1) or control(==4) pressed + try: + self_text_index("sel.first") + self_text_mark_set("insert", edges_table[edge_index]) + except TclError: + pass + return move_at_edge + + def del_word_left(self, event): + self.text.event_generate('') + return "break" + + def del_word_right(self, event): + self.text.event_generate('') + return "break" + + def find_event(self, event): + search.find(self.text) + return "break" + + def find_again_event(self, event): + search.find_again(self.text) + return "break" + + def find_selection_event(self, event): + search.find_selection(self.text) + return "break" + + def find_in_files_event(self, event): + grep.grep(self.text, self.io, self.flist) + return "break" + + def replace_event(self, event): + replace.replace(self.text) + return "break" + + def goto_line_event(self, event): + text = self.text + lineno = query.Goto( + text, "Go To Line", + "Enter a positive integer\n" + "('big' = end of file):" + ).result + if lineno is not None: + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", f'{lineno}.0') + text.see("insert") + self.set_line_and_column() + return "break" + + def open_module(self): + """Get module name from user and open it. + + Return module path or None for calls by open_module_browser + when latter is not invoked in named editor window. + """ + # XXX This, open_module_browser, and open_path_browser + # would fit better in iomenu.IOBinding. + try: + name = self.text.get("sel.first", "sel.last").strip() + except TclError: + name = '' + file_path = query.ModuleName( + self.text, "Open Module", + "Enter the name of a Python module\n" + "to search on sys.path and open:", + name).result + if file_path is not None: + if self.flist: + self.flist.open(file_path) + else: + self.io.loadfile(file_path) + return file_path + + def open_module_event(self, event): + self.open_module() + return "break" + + def open_module_browser(self, event=None): + filename = self.io.filename + if not (self.__class__.__name__ == 'PyShellEditorWindow' + and filename): + filename = self.open_module() + if filename is None: + return "break" + from idlelib import browser + browser.ModuleBrowser(self.root, filename) + return "break" + + def open_path_browser(self, event=None): + from idlelib import pathbrowser + pathbrowser.PathBrowser(self.root) + return "break" + + def open_turtle_demo(self, event = None): + import subprocess + + cmd = [sys.executable, + '-c', + 'from turtledemo.__main__ import main; main()'] + subprocess.Popen(cmd, shell=False) + return "break" + + def gotoline(self, lineno): + if lineno is not None and lineno > 0: + self.text.mark_set("insert", "%d.0" % lineno) + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", "insert", "insert +1l") + self.center() + + def ispythonsource(self, filename): + if not filename or os.path.isdir(filename): + return True + base, ext = os.path.splitext(os.path.basename(filename)) + if os.path.normcase(ext) in py_extensions: + return True + line = self.text.get('1.0', '1.0 lineend') + return line.startswith('#!') and 'python' in line + + def close_hook(self): + if self.flist: + self.flist.unregister_maybe_terminate(self) + self.flist = None + + def set_close_hook(self, close_hook): + self.close_hook = close_hook + + def filename_change_hook(self): + if self.flist: + self.flist.filename_changed_edit(self) + self.saved_change_hook() + self.top.update_windowlist_registry(self) + self.ResetColorizer() + + def _addcolorizer(self): + if self.color: + return + if self.ispythonsource(self.io.filename): + self.color = self.ColorDelegator() + # can add more colorizers here... + if self.color: + self.per.insertfilterafter(filter=self.color, after=self.undo) + + def _rmcolorizer(self): + if not self.color: + return + self.color.removecolors() + self.per.removefilter(self.color) + self.color = None + + def ResetColorizer(self): + "Update the color theme" + # Called from self.filename_change_hook and from configdialog.py + self._rmcolorizer() + self._addcolorizer() + EditorWindow.color_config(self.text) + + if self.code_context is not None: + self.code_context.update_highlight_colors() + + if self.line_numbers is not None: + self.line_numbers.update_colors() + + IDENTCHARS = string.ascii_letters + string.digits + "_" + + def colorize_syntax_error(self, text, pos): + text.tag_add("ERROR", pos) + char = text.get(pos) + if char and char in self.IDENTCHARS: + text.tag_add("ERROR", pos + " wordstart", pos) + if '\n' == text.get(pos): # error at line end + text.mark_set("insert", pos) + else: + text.mark_set("insert", pos + "+1c") + text.see(pos) + + def update_cursor_blink(self): + "Update the cursor blink configuration." + cursorblink = idleConf.GetOption( + 'main', 'EditorWindow', 'cursor-blink', type='bool') + if not cursorblink: + self.text['insertofftime'] = 0 + else: + # Restore the original value + self.text['insertofftime'] = idleConf.blink_off_time + + def ResetFont(self): + "Update the text widgets' font if it is changed" + # Called from configdialog.py + + # Update the code context widget first, since its height affects + # the height of the text widget. This avoids double re-rendering. + if self.code_context is not None: + self.code_context.update_font() + # Next, update the line numbers widget, since its width affects + # the width of the text widget. + if self.line_numbers is not None: + self.line_numbers.update_font() + # Finally, update the main text widget. + new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow') + self.text['font'] = new_font + self.set_width() + + def RemoveKeybindings(self): + """Remove the virtual, configurable keybindings. + + Leaves the default Tk Text keybindings. + """ + # Called from configdialog.deactivate_current_config. + self.mainmenu.default_keydefs = keydefs = idleConf.GetCurrentKeySet() + for event, keylist in keydefs.items(): + self.text.event_delete(event, *keylist) + for extensionName in self.get_standard_extension_names(): + xkeydefs = idleConf.GetExtensionBindings(extensionName) + if xkeydefs: + for event, keylist in xkeydefs.items(): + self.text.event_delete(event, *keylist) + + def ApplyKeybindings(self): + """Apply the virtual, configurable keybindings. + + Also update hotkeys to current keyset. + """ + # Called from configdialog.activate_config_changes. + self.mainmenu.default_keydefs = keydefs = idleConf.GetCurrentKeySet() + self.apply_bindings() + for extensionName in self.get_standard_extension_names(): + xkeydefs = idleConf.GetExtensionBindings(extensionName) + if xkeydefs: + self.apply_bindings(xkeydefs) + + # Update menu accelerators. + menuEventDict = {} + for menu in self.mainmenu.menudefs: + menuEventDict[menu[0]] = {} + for item in menu[1]: + if item: + menuEventDict[menu[0]][prepstr(item[0])[1]] = item[1] + for menubarItem in self.menudict: + menu = self.menudict[menubarItem] + end = menu.index(END) + if end is None: + # Skip empty menus + continue + end += 1 + for index in range(0, end): + if menu.type(index) == 'command': + accel = menu.entrycget(index, 'accelerator') + if accel: + itemName = menu.entrycget(index, 'label') + event = '' + if menubarItem in menuEventDict: + if itemName in menuEventDict[menubarItem]: + event = menuEventDict[menubarItem][itemName] + if event: + accel = get_accelerator(keydefs, event) + menu.entryconfig(index, accelerator=accel) + + def set_notabs_indentwidth(self): + "Update the indentwidth if changed and not using tabs in this window" + # Called from configdialog.py + if not self.usetabs: + self.indentwidth = idleConf.GetOption('main', 'Indent','num-spaces', + type='int') + + def reset_help_menu_entries(self): + """Update the additional help entries on the Help menu.""" + help_list = idleConf.GetAllExtraHelpSourcesList() + helpmenu = self.menudict['help'] + # First delete the extra help entries, if any. + helpmenu_length = helpmenu.index(END) + if helpmenu_length > self.base_helpmenu_length: + helpmenu.delete((self.base_helpmenu_length + 1), helpmenu_length) + # Then rebuild them. + if help_list: + helpmenu.add_separator() + for entry in help_list: + cmd = self._extra_help_callback(entry[1]) + helpmenu.add_command(label=entry[0], command=cmd) + # And update the menu dictionary. + self.menudict['help'] = helpmenu + + def _extra_help_callback(self, resource): + """Return a callback that loads resource (file or web page).""" + def display_extra_help(helpfile=resource): + if not helpfile.startswith(('www', 'http')): + helpfile = os.path.normpath(helpfile) + if sys.platform[:3] == 'win': + try: + os.startfile(helpfile) + except OSError as why: + messagebox.showerror(title='Document Start Failure', + message=str(why), parent=self.text) + else: + webbrowser.open(helpfile) + return display_extra_help + + def update_recent_files_list(self, new_file=None): + "Load and update the recent files list and menus" + # TODO: move to iomenu. + rf_list = [] + file_path = self.recent_files_path + if file_path and os.path.exists(file_path): + with open(file_path, + encoding='utf_8', errors='replace') as rf_list_file: + rf_list = rf_list_file.readlines() + if new_file: + new_file = os.path.abspath(new_file) + '\n' + if new_file in rf_list: + rf_list.remove(new_file) # move to top + rf_list.insert(0, new_file) + # clean and save the recent files list + bad_paths = [] + for path in rf_list: + if '\0' in path or not os.path.exists(path[0:-1]): + bad_paths.append(path) + rf_list = [path for path in rf_list if path not in bad_paths] + ulchars = "1234567890ABCDEFGHIJK" + rf_list = rf_list[0:len(ulchars)] + if file_path: + try: + with open(file_path, 'w', + encoding='utf_8', errors='replace') as rf_file: + rf_file.writelines(rf_list) + except OSError as err: + if not getattr(self.root, "recentfiles_message", False): + self.root.recentfiles_message = True + messagebox.showwarning(title='IDLE Warning', + message="Cannot save Recent Files list to disk.\n" + f" {err}\n" + "Select OK to continue.", + parent=self.text) + # for each edit window instance, construct the recent files menu + for instance in self.top.instance_dict: + menu = instance.recent_files_menu + menu.delete(0, END) # clear, and rebuild: + for i, file_name in enumerate(rf_list): + file_name = file_name.rstrip() # zap \n + callback = instance.__recent_file_callback(file_name) + menu.add_command(label=ulchars[i] + " " + file_name, + command=callback, + underline=0) + + def __recent_file_callback(self, file_name): + def open_recent_file(fn_closure=file_name): + self.io.open(editFile=fn_closure) + return open_recent_file + + def saved_change_hook(self): + short = self.short_title() + long = self.long_title() + _py_version = ' (%s)' % platform.python_version() + if short and long and not macosx.isCocoaTk(): + # Don't use both values on macOS because + # that doesn't match platform conventions. + title = short + " - " + long + _py_version + elif short: + if short == "IDLE Shell": + title = short + " " + platform.python_version() + else: + title = short + _py_version + elif long: + title = long + else: + title = "untitled" + icon = short or long or title + if not self.get_saved(): + title = "*%s*" % title + icon = "*%s" % icon + self.top.wm_title(title) + self.top.wm_iconname(icon) + + if macosx.isCocoaTk(): + # Add a proxy icon to the window title + self.top.wm_attributes("-titlepath", long) + + # Maintain the modification status for the window + self.top.wm_attributes("-modified", not self.get_saved()) + + def get_saved(self): + return self.undo.get_saved() + + def set_saved(self, flag): + self.undo.set_saved(flag) + + def reset_undo(self): + self.undo.reset_undo() + + def short_title(self): + filename = self.io.filename + return os.path.basename(filename) if filename else "untitled" + + def long_title(self): + return self.io.filename or "" + + def center_insert_event(self, event): + self.center() + return "break" + + def center(self, mark="insert"): + text = self.text + top, bot = self.getwindowlines() + lineno = self.getlineno(mark) + height = bot - top + newtop = max(1, lineno - height//2) + text.yview(float(newtop)) + + def getwindowlines(self): + text = self.text + top = self.getlineno("@0,0") + bot = self.getlineno("@0,65535") + if top == bot and text.winfo_height() == 1: + # Geometry manager hasn't run yet + height = int(text['height']) + bot = top + height - 1 + return top, bot + + def getlineno(self, mark="insert"): + text = self.text + return int(float(text.index(mark))) + + def get_geometry(self): + "Return (width, height, x, y)" + geom = self.top.wm_geometry() + m = re.match(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", geom) + return list(map(int, m.groups())) + + def close_event(self, event): + self.close() + return "break" + + def maybesave(self): + if self.io: + if not self.get_saved(): + if self.top.state()!='normal': + self.top.deiconify() + self.top.lower() + self.top.lift() + return self.io.maybesave() + + def close(self): + try: + reply = self.maybesave() + if str(reply) != "cancel": + self._close() + return reply + except AttributeError: # bpo-35379: close called twice + pass + + def _close(self): + if self.io.filename: + self.update_recent_files_list(new_file=self.io.filename) + window.unregister_callback(self.postwindowsmenu) + self.unload_extensions() + self.io.close() + self.io = None + self.undo = None + if self.color: + self.color.close() + self.color = None + self.text = None + self.tkinter_vars = None + self.per.close() + self.per = None + self.top.destroy() + if self.close_hook: + # unless override: unregister from flist, terminate if last window + self.close_hook() + + def load_extensions(self): + self.extensions = {} + self.load_standard_extensions() + + def unload_extensions(self): + for ins in list(self.extensions.values()): + if hasattr(ins, "close"): + ins.close() + self.extensions = {} + + def load_standard_extensions(self): + for name in self.get_standard_extension_names(): + try: + self.load_extension(name) + except: + print("Failed to load extension", repr(name)) + traceback.print_exc() + + def get_standard_extension_names(self): + return idleConf.GetExtensions(editor_only=True) + + extfiles = { # Map built-in config-extension section names to file names. + 'ZzDummy': 'zzdummy', + } + + def load_extension(self, name): + fname = self.extfiles.get(name, name) + try: + try: + mod = importlib.import_module('.' + fname, package=__package__) + except (ImportError, TypeError): + mod = importlib.import_module(fname) + except ImportError: + print("\nFailed to import extension: ", name) + raise + cls = getattr(mod, name) + keydefs = idleConf.GetExtensionBindings(name) + if hasattr(cls, "menudefs"): + self.fill_menus(cls.menudefs, keydefs) + ins = cls(self) + self.extensions[name] = ins + if keydefs: + self.apply_bindings(keydefs) + for vevent in keydefs: + methodname = vevent.replace("-", "_") + while methodname[:1] == '<': + methodname = methodname[1:] + while methodname[-1:] == '>': + methodname = methodname[:-1] + methodname = methodname + "_event" + if hasattr(ins, methodname): + self.text.bind(vevent, getattr(ins, methodname)) + + def apply_bindings(self, keydefs=None): + """Add events with keys to self.text.""" + if keydefs is None: + keydefs = self.mainmenu.default_keydefs + text = self.text + text.keydefs = keydefs + for event, keylist in keydefs.items(): + if keylist: + text.event_add(event, *keylist) + + def fill_menus(self, menudefs=None, keydefs=None): + """Fill in dropdown menus used by this window. + + Items whose name begins with '!' become checkbuttons. + Other names indicate commands. None becomes a separator. + """ + if menudefs is None: + menudefs = self.mainmenu.menudefs + if keydefs is None: + keydefs = self.mainmenu.default_keydefs + menudict = self.menudict + text = self.text + for mname, entrylist in menudefs: + menu = menudict.get(mname) + if not menu: + continue + for entry in entrylist: + if entry is None: + menu.add_separator() + else: + label, eventname = entry + checkbutton = (label[:1] == '!') + if checkbutton: + label = label[1:] + underline, label = prepstr(label) + accelerator = get_accelerator(keydefs, eventname) + def command(text=text, eventname=eventname): + text.event_generate(eventname) + if checkbutton: + var = self.get_var_obj(eventname, BooleanVar) + menu.add_checkbutton(label=label, underline=underline, + command=command, accelerator=accelerator, + variable=var) + else: + menu.add_command(label=label, underline=underline, + command=command, + accelerator=accelerator) + + def getvar(self, name): + var = self.get_var_obj(name) + if var: + value = var.get() + return value + else: + raise NameError(name) + + def setvar(self, name, value, vartype=None): + var = self.get_var_obj(name, vartype) + if var: + var.set(value) + else: + raise NameError(name) + + def get_var_obj(self, eventname, vartype=None): + """Return a tkinter variable instance for the event. + """ + var = self.tkinter_vars.get(eventname) + if not var and vartype: + # Create a Tkinter variable object. + self.tkinter_vars[eventname] = var = vartype(self.text) + return var + + # Tk implementations of "virtual text methods" -- each platform + # reusing IDLE's support code needs to define these for its GUI's + # flavor of widget. + + # Is character at text_index in a Python string? Return 0 for + # "guaranteed no", true for anything else. This info is expensive + # to compute ab initio, but is probably already known by the + # platform's colorizer. + + def is_char_in_string(self, text_index): + if self.color: + # Return true iff colorizer hasn't (re)gotten this far + # yet, or the character is tagged as being in a string + return self.text.tag_prevrange("TODO", text_index) or \ + "STRING" in self.text.tag_names(text_index) + else: + # The colorizer is missing: assume the worst + return 1 + + # If a selection is defined in the text widget, return (start, + # end) as Tkinter text indices, otherwise return (None, None) + def get_selection_indices(self): + try: + first = self.text.index("sel.first") + last = self.text.index("sel.last") + return first, last + except TclError: + return None, None + + # Return the text widget's current view of what a tab stop means + # (equivalent width in spaces). + + def get_tk_tabwidth(self): + current = self.text['tabs'] or TK_TABWIDTH_DEFAULT + return int(current) + + # Set the text widget's current view of what a tab stop means. + + def set_tk_tabwidth(self, newtabwidth): + text = self.text + if self.get_tk_tabwidth() != newtabwidth: + # Set text widget tab width + pixels = text.tk.call("font", "measure", text["font"], + "-displayof", text.master, + "n" * newtabwidth) + text.configure(tabs=pixels) + +### begin autoindent code ### (configuration was moved to beginning of class) + + def set_indentation_params(self, is_py_src, guess=True): + if is_py_src and guess: + i = self.guess_indent() + if 2 <= i <= 8: + self.indentwidth = i + if self.indentwidth != self.tabwidth: + self.usetabs = False + self.set_tk_tabwidth(self.tabwidth) + + def smart_backspace_event(self, event): + text = self.text + first, last = self.get_selection_indices() + if first and last: + text.delete(first, last) + text.mark_set("insert", first) + return "break" + # Delete whitespace left, until hitting a real char or closest + # preceding virtual tab stop. + chars = text.get("insert linestart", "insert") + if chars == '': + if text.compare("insert", ">", "1.0"): + # easy: delete preceding newline + text.delete("insert-1c") + else: + text.bell() # at start of buffer + return "break" + if chars[-1] not in " \t": + # easy: delete preceding real char + text.delete("insert-1c") + return "break" + # Ick. It may require *inserting* spaces if we back up over a + # tab character! This is written to be clear, not fast. + tabwidth = self.tabwidth + have = len(chars.expandtabs(tabwidth)) + assert have > 0 + want = ((have - 1) // self.indentwidth) * self.indentwidth + # Debug prompt is multilined.... + ncharsdeleted = 0 + while True: + chars = chars[:-1] + ncharsdeleted = ncharsdeleted + 1 + have = len(chars.expandtabs(tabwidth)) + if have <= want or chars[-1] not in " \t": + break + text.undo_block_start() + text.delete("insert-%dc" % ncharsdeleted, "insert") + if have < want: + text.insert("insert", ' ' * (want - have), + self.user_input_insert_tags) + text.undo_block_stop() + return "break" + + def smart_indent_event(self, event): + # if intraline selection: + # delete it + # elif multiline selection: + # do indent-region + # else: + # indent one level + text = self.text + first, last = self.get_selection_indices() + text.undo_block_start() + try: + if first and last: + if index2line(first) != index2line(last): + return self.fregion.indent_region_event(event) + text.delete(first, last) + text.mark_set("insert", first) + prefix = text.get("insert linestart", "insert") + raw, effective = get_line_indent(prefix, self.tabwidth) + if raw == len(prefix): + # only whitespace to the left + self.reindent_to(effective + self.indentwidth) + else: + # tab to the next 'stop' within or to right of line's text: + if self.usetabs: + pad = '\t' + else: + effective = len(prefix.expandtabs(self.tabwidth)) + n = self.indentwidth + pad = ' ' * (n - effective % n) + text.insert("insert", pad, self.user_input_insert_tags) + text.see("insert") + return "break" + finally: + text.undo_block_stop() + + def newline_and_indent_event(self, event): + """Insert a newline and indentation after Enter keypress event. + + Properly position the cursor on the new line based on information + from the current line. This takes into account if the current line + is a shell prompt, is empty, has selected text, contains a block + opener, contains a block closer, is a continuation line, or + is inside a string. + """ + text = self.text + first, last = self.get_selection_indices() + text.undo_block_start() + try: # Close undo block and expose new line in finally clause. + if first and last: + text.delete(first, last) + text.mark_set("insert", first) + line = text.get("insert linestart", "insert") + + # Count leading whitespace for indent size. + i, n = 0, len(line) + while i < n and line[i] in " \t": + i += 1 + if i == n: + # The cursor is in or at leading indentation in a continuation + # line; just inject an empty line at the start. + text.insert("insert linestart", '\n', + self.user_input_insert_tags) + return "break" + indent = line[:i] + + # Strip whitespace before insert point unless it's in the prompt. + i = 0 + while line and line[-1] in " \t": + line = line[:-1] + i += 1 + if i: + text.delete("insert - %d chars" % i, "insert") + + # Strip whitespace after insert point. + while text.get("insert") in " \t": + text.delete("insert") + + # Insert new line. + text.insert("insert", '\n', self.user_input_insert_tags) + + # Adjust indentation for continuations and block open/close. + # First need to find the last statement. + lno = index2line(text.index('insert')) + y = pyparse.Parser(self.indentwidth, self.tabwidth) + if not self.prompt_last_line: + for context in self.num_context_lines: + startat = max(lno - context, 1) + startatindex = repr(startat) + ".0" + rawtext = text.get(startatindex, "insert") + y.set_code(rawtext) + bod = y.find_good_parse_start( + self._build_char_in_string_func(startatindex)) + if bod is not None or startat == 1: + break + y.set_lo(bod or 0) + else: + r = text.tag_prevrange("console", "insert") + if r: + startatindex = r[1] + else: + startatindex = "1.0" + rawtext = text.get(startatindex, "insert") + y.set_code(rawtext) + y.set_lo(0) + + c = y.get_continuation_type() + if c != pyparse.C_NONE: + # The current statement hasn't ended yet. + if c == pyparse.C_STRING_FIRST_LINE: + # After the first line of a string do not indent at all. + pass + elif c == pyparse.C_STRING_NEXT_LINES: + # Inside a string which started before this line; + # just mimic the current indent. + text.insert("insert", indent, self.user_input_insert_tags) + elif c == pyparse.C_BRACKET: + # Line up with the first (if any) element of the + # last open bracket structure; else indent one + # level beyond the indent of the line with the + # last open bracket. + self.reindent_to(y.compute_bracket_indent()) + elif c == pyparse.C_BACKSLASH: + # If more than one line in this statement already, just + # mimic the current indent; else if initial line + # has a start on an assignment stmt, indent to + # beyond leftmost =; else to beyond first chunk of + # non-whitespace on initial line. + if y.get_num_lines_in_stmt() > 1: + text.insert("insert", indent, + self.user_input_insert_tags) + else: + self.reindent_to(y.compute_backslash_indent()) + else: + assert 0, f"bogus continuation type {c!r}" + return "break" + + # This line starts a brand new statement; indent relative to + # indentation of initial line of closest preceding + # interesting statement. + indent = y.get_base_indent_string() + text.insert("insert", indent, self.user_input_insert_tags) + if y.is_block_opener(): + self.smart_indent_event(event) + elif indent and y.is_block_closer(): + self.smart_backspace_event(event) + return "break" + finally: + text.see("insert") + text.undo_block_stop() + + # Our editwin provides an is_char_in_string function that works + # with a Tk text index, but PyParse only knows about offsets into + # a string. This builds a function for PyParse that accepts an + # offset. + + def _build_char_in_string_func(self, startindex): + def inner(offset, _startindex=startindex, + _icis=self.is_char_in_string): + return _icis(_startindex + "+%dc" % offset) + return inner + + # XXX this isn't bound to anything -- see tabwidth comments +## def change_tabwidth_event(self, event): +## new = self._asktabwidth() +## if new != self.tabwidth: +## self.tabwidth = new +## self.set_indentation_params(0, guess=0) +## return "break" + + # Make string that displays as n leading blanks. + + def _make_blanks(self, n): + if self.usetabs: + ntabs, nspaces = divmod(n, self.tabwidth) + return '\t' * ntabs + ' ' * nspaces + else: + return ' ' * n + + # Delete from beginning of line to insert point, then reinsert + # column logical (meaning use tabs if appropriate) spaces. + + def reindent_to(self, column): + text = self.text + text.undo_block_start() + if text.compare("insert linestart", "!=", "insert"): + text.delete("insert linestart", "insert") + if column: + text.insert("insert", self._make_blanks(column), + self.user_input_insert_tags) + text.undo_block_stop() + + # Guess indentwidth from text content. + # Return guessed indentwidth. This should not be believed unless + # it's in a reasonable range (e.g., it will be 0 if no indented + # blocks are found). + + def guess_indent(self): + opener, indented = IndentSearcher(self.text).run() + if opener and indented: + raw, indentsmall = get_line_indent(opener, self.tabwidth) + raw, indentlarge = get_line_indent(indented, self.tabwidth) + else: + indentsmall = indentlarge = 0 + return indentlarge - indentsmall + + def toggle_line_numbers_event(self, event=None): + if self.line_numbers is None: + return + + if self.line_numbers.is_shown: + self.line_numbers.hide_sidebar() + menu_label = "Show" + else: + self.line_numbers.show_sidebar() + menu_label = "Hide" + self.update_menu_label(menu='options', index='*ine*umbers', + label=f'{menu_label} Line Numbers') + +# "line.col" -> line, as an int +def index2line(index): + return int(float(index)) + + +_line_indent_re = re.compile(r'[ \t]*') +def get_line_indent(line, tabwidth): + """Return a line's indentation as (# chars, effective # of spaces). + + The effective # of spaces is the length after properly "expanding" + the tabs into spaces, as done by str.expandtabs(tabwidth). + """ + m = _line_indent_re.match(line) + return m.end(), len(m.group().expandtabs(tabwidth)) + + +class IndentSearcher: + "Manage initial indent guess, returned by run method." + + def __init__(self, text): + self.text = text + self.i = self.finished = 0 + self.blkopenline = self.indentedline = None + + def readline(self): + if self.finished: + return "" + i = self.i = self.i + 1 + mark = repr(i) + ".0" + if self.text.compare(mark, ">=", "end"): + return "" + return self.text.get(mark, mark + " lineend+1c") + + def tokeneater(self, type, token, start, end, line, + INDENT=tokenize.INDENT, + NAME=tokenize.NAME, + OPENERS=('class', 'def', 'for', 'if', 'match', 'try', + 'while', 'with')): + if self.finished: + pass + elif type == NAME and token in OPENERS: + self.blkopenline = line + elif type == INDENT and self.blkopenline: + self.indentedline = line + self.finished = 1 + + def run(self): + """Return 2 lines containing block opener and indent. + + Either the indent line or both may be None. + """ + try: + tokens = tokenize.generate_tokens(self.readline) + for token in tokens: + self.tokeneater(*token) + except (tokenize.TokenError, SyntaxError): + # Stopping the tokenizer early can trigger spurious errors. + pass + return self.blkopenline, self.indentedline + +### end autoindent code ### + + +def prepstr(s): + """Extract the underscore from a string. + + For example, prepstr("Co_py") returns (2, "Copy"). + + Args: + s: String with underscore. + + Returns: + Tuple of (position of underscore, string without underscore). + """ + i = s.find('_') + if i >= 0: + s = s[:i] + s[i+1:] + return i, s + + +keynames = { + 'bracketleft': '[', + 'bracketright': ']', + 'slash': '/', +} + +def get_accelerator(keydefs, eventname): + """Return a formatted string for the keybinding of an event. + + Convert the first keybinding for a given event to a form that + can be displayed as an accelerator on the menu. + + Args: + keydefs: Dictionary of valid events to keybindings. + eventname: Event to retrieve keybinding for. + + Returns: + Formatted string of the keybinding. + """ + keylist = keydefs.get(eventname) + # issue10940: temporary workaround to prevent hang with OS X Cocoa Tk 8.5 + # if not keylist: + if (not keylist) or (macosx.isCocoaTk() and eventname in { + "<>", + "<>", + "<>"}): + return "" + s = keylist[0] + # Convert strings of the form -singlelowercase to -singleuppercase. + s = re.sub(r"-[a-z]\b", lambda m: m.group().upper(), s) + # Convert certain keynames to their symbol. + s = re.sub(r"\b\w+\b", lambda m: keynames.get(m.group(), m.group()), s) + # Remove Key- from string. + s = re.sub("Key-", "", s) + # Convert Cancel to Ctrl-Break. + s = re.sub("Cancel", "Ctrl-Break", s) # dscherer@cmu.edu + # Convert Control to Ctrl-. + s = re.sub("Control-", "Ctrl-", s) + # Change - to +. + s = re.sub("-", "+", s) + # Change >< to space. + s = re.sub("><", " ", s) + # Remove <. + s = re.sub("<", "", s) + # Remove >. + s = re.sub(">", "", s) + return s + + +def fixwordbreaks(root): + # On Windows, tcl/tk breaks 'words' only on spaces, as in Command Prompt. + # We want Motif style everywhere. See #21474, msg218992 and followup. + tk = root.tk + tk.call('tcl_wordBreakAfter', 'a b', 0) # make sure word.tcl is loaded + tk.call('set', 'tcl_wordchars', r'\w') + tk.call('set', 'tcl_nonwordchars', r'\W') + + +def _editor_window(parent): # htest # + # error if close master window first - timer event, after script + root = parent + fixwordbreaks(root) + if sys.argv[1:]: + filename = sys.argv[1] + else: + filename = None + macosx.setupApp(root, None) + edit = EditorWindow(root=root, filename=filename) + text = edit.text + text['height'] = 10 + for i in range(20): + text.insert('insert', ' '*i + str(i) + '\n') + # text.bind("<>", edit.close_event) + # Does not stop error, neither does following + # edit.text.bind("<>", edit.close_event) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_editor', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_editor_window) diff --git a/Lib/idlelib/extend.txt b/Lib/idlelib/extend.txt new file mode 100644 index 00000000000..2522758ceb4 --- /dev/null +++ b/Lib/idlelib/extend.txt @@ -0,0 +1,83 @@ +Writing an IDLE extension +========================= + +An IDLE extension can define new key bindings and menu entries for IDLE +edit windows. There is a simple mechanism to load extensions when IDLE +starts up and to attach them to each edit window. (It is also possible +to make other changes to IDLE, but this must be done by editing the IDLE +source code.) + +The list of extensions loaded at startup time is configured by editing +the file config-extensions.def. See below for details. + +An IDLE extension is defined by a class. Methods of the class define +actions that are invoked by event bindings or menu entries. Class (or +instance) variables define the bindings and menu additions; these are +automatically applied by IDLE when the extension is linked to an edit +window. + +An IDLE extension class is instantiated with a single argument, +`editwin', an EditorWindow instance. The extension cannot assume much +about this argument, but it is guaranteed to have the following instance +variables: + + text a Text instance (a widget) + io an IOBinding instance (more about this later) + flist the FileList instance (shared by all edit windows) + +(There are a few more, but they are rarely useful.) + +The extension class must not directly bind Window Manager (e.g. X) events. +Rather, it must define one or more virtual events, e.g. <>, and +corresponding methods, e.g. z_in_event(). The virtual events will be +bound to the corresponding methods, and Window Manager events can then be bound +to the virtual events. (This indirection is done so that the key bindings can +easily be changed, and so that other sources of virtual events can exist, such +as menu entries.) + +An extension can define menu entries. This is done with a class or instance +variable named menudefs; it should be a list of pairs, where each pair is a +menu name (lowercase) and a list of menu entries. Each menu entry is either +None (to insert a separator entry) or a pair of strings (menu_label, +virtual_event). Here, menu_label is the label of the menu entry, and +virtual_event is the virtual event to be generated when the entry is selected. +An underscore in the menu label is removed; the character following the +underscore is displayed underlined, to indicate the shortcut character (for +Windows). + +At the moment, extensions cannot define whole new menus; they must define +entries in existing menus. Some menus are not present on some windows; such +entry definitions are then ignored, but key bindings are still applied. (This +should probably be refined in the future.) + +Extensions are not required to define menu entries for all the events they +implement. (They are also not required to create keybindings, but in that +case there must be empty bindings in config-extensions.def) + +Here is a partial example from zzdummy.py: + +class ZzDummy: + + menudefs = [ + ('format', [ + ('Z in', '<>'), + ('Z out', '<>'), + ] ) + ] + + def __init__(self, editwin): + self.editwin = editwin + + def z_in_event(self, event=None): + "...Do what you want here..." + +The final piece of the puzzle is the file "config-extensions.def", which is +used to configure the loading of extensions and to establish key (or, more +generally, event) bindings to the virtual events defined in the extensions. + +See the comments at the top of config-extensions.def for information. It's +currently necessary to manually modify that file to change IDLE's extension +loading or extension key bindings. + +For further information on binding refer to the Tkinter Resources web page at +python.org and to the Tk Command "bind" man page. diff --git a/Lib/idlelib/filelist.py b/Lib/idlelib/filelist.py new file mode 100644 index 00000000000..e27e5d32a0f --- /dev/null +++ b/Lib/idlelib/filelist.py @@ -0,0 +1,132 @@ +"idlelib.filelist" + +import os +from tkinter import messagebox + + +class FileList: + + # N.B. this import overridden in PyShellFileList. + from idlelib.editor import EditorWindow + + def __init__(self, root): + self.root = root + self.dict = {} + self.inversedict = {} + self.vars = {} # For EditorWindow.getrawvar (shared Tcl variables) + + def open(self, filename, action=None): + assert filename + filename = self.canonize(filename) + if os.path.isdir(filename): + # This can happen when bad filename is passed on command line: + messagebox.showerror( + "File Error", + f"{filename!r} is a directory.", + master=self.root) + return None + key = os.path.normcase(filename) + if key in self.dict: + edit = self.dict[key] + edit.top.wakeup() + return edit + if action: + # Don't create window, perform 'action', e.g. open in same window + return action(filename) + else: + edit = self.EditorWindow(self, filename, key) + if edit.good_load: + return edit + else: + edit._close() + return None + + def gotofileline(self, filename, lineno=None): + edit = self.open(filename) + if edit is not None and lineno is not None: + edit.gotoline(lineno) + + def new(self, filename=None): + return self.EditorWindow(self, filename) + + def close_all_callback(self, *args, **kwds): + for edit in list(self.inversedict): + reply = edit.close() + if reply == "cancel": + break + return "break" + + def unregister_maybe_terminate(self, edit): + try: + key = self.inversedict[edit] + except KeyError: + print("Don't know this EditorWindow object. (close)") + return + if key: + del self.dict[key] + del self.inversedict[edit] + if not self.inversedict: + self.root.quit() + + def filename_changed_edit(self, edit): + edit.saved_change_hook() + try: + key = self.inversedict[edit] + except KeyError: + print("Don't know this EditorWindow object. (rename)") + return + filename = edit.io.filename + if not filename: + if key: + del self.dict[key] + self.inversedict[edit] = None + return + filename = self.canonize(filename) + newkey = os.path.normcase(filename) + if newkey == key: + return + if newkey in self.dict: + conflict = self.dict[newkey] + self.inversedict[conflict] = None + messagebox.showerror( + "Name Conflict", + f"You now have multiple edit windows open for {filename!r}", + master=self.root) + self.dict[newkey] = edit + self.inversedict[edit] = newkey + if key: + try: + del self.dict[key] + except KeyError: + pass + + def canonize(self, filename): + if not os.path.isabs(filename): + try: + pwd = os.getcwd() + except OSError: + pass + else: + filename = os.path.join(pwd, filename) + return os.path.normpath(filename) + + +def _test(): # TODO check and convert to htest + from tkinter import Tk + from idlelib.editor import fixwordbreaks + from idlelib.run import fix_scaling + root = Tk() + fix_scaling(root) + fixwordbreaks(root) + root.withdraw() + flist = FileList(root) + flist.new() + if flist.inversedict: + root.mainloop() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_filelist', verbosity=2) + +# _test() diff --git a/Lib/idlelib/format.py b/Lib/idlelib/format.py new file mode 100644 index 00000000000..4b57a182c9a --- /dev/null +++ b/Lib/idlelib/format.py @@ -0,0 +1,426 @@ +"""Format all or a selected region (line slice) of text. + +Region formatting options: paragraph, comment block, indent, deindent, +comment, uncomment, tabify, and untabify. + +File renamed from paragraph.py with functions added from editor.py. +""" +import re +from tkinter.messagebox import askyesno +from tkinter.simpledialog import askinteger +from idlelib.config import idleConf + + +class FormatParagraph: + """Format a paragraph, comment block, or selection to a max width. + + Does basic, standard text formatting, and also understands Python + comment blocks. Thus, for editing Python source code, this + extension is really only suitable for reformatting these comment + blocks or triple-quoted strings. + + Known problems with comment reformatting: + * If there is a selection marked, and the first line of the + selection is not complete, the block will probably not be detected + as comments, and will have the normal "text formatting" rules + applied. + * If a comment block has leading whitespace that mixes tabs and + spaces, they will not be considered part of the same block. + * Fancy comments, like this bulleted list, aren't handled :-) + """ + def __init__(self, editwin): + self.editwin = editwin + + @classmethod + def reload(cls): + cls.max_width = idleConf.GetOption('extensions', 'FormatParagraph', + 'max-width', type='int', default=72) + + def close(self): + self.editwin = None + + def format_paragraph_event(self, event, limit=None): + """Formats paragraph to a max width specified in idleConf. + + If text is selected, format_paragraph_event will start breaking lines + at the max width, starting from the beginning selection. + + If no text is selected, format_paragraph_event uses the current + cursor location to determine the paragraph (lines of text surrounded + by blank lines) and formats it. + + The length limit parameter is for testing with a known value. + """ + limit = self.max_width if limit is None else limit + text = self.editwin.text + first, last = self.editwin.get_selection_indices() + if first and last: + data = text.get(first, last) + comment_header = get_comment_header(data) + else: + first, last, comment_header, data = \ + find_paragraph(text, text.index("insert")) + if comment_header: + newdata = reformat_comment(data, limit, comment_header) + else: + newdata = reformat_paragraph(data, limit) + text.tag_remove("sel", "1.0", "end") + + if newdata != data: + text.mark_set("insert", first) + text.undo_block_start() + text.delete(first, last) + text.insert(first, newdata) + text.undo_block_stop() + else: + text.mark_set("insert", last) + text.see("insert") + return "break" + + +FormatParagraph.reload() + +def find_paragraph(text, mark): + """Returns the start/stop indices enclosing the paragraph that mark is in. + + Also returns the comment format string, if any, and paragraph of text + between the start/stop indices. + """ + lineno, col = map(int, mark.split(".")) + line = text.get("%d.0" % lineno, "%d.end" % lineno) + + # Look for start of next paragraph if the index passed in is a blank line + while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line): + lineno = lineno + 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + first_lineno = lineno + comment_header = get_comment_header(line) + comment_header_len = len(comment_header) + + # Once start line found, search for end of paragraph (a blank line) + while get_comment_header(line)==comment_header and \ + not is_all_white(line[comment_header_len:]): + lineno = lineno + 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + last = "%d.0" % lineno + + # Search back to beginning of paragraph (first blank line before) + lineno = first_lineno - 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + while lineno > 0 and \ + get_comment_header(line)==comment_header and \ + not is_all_white(line[comment_header_len:]): + lineno = lineno - 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + first = "%d.0" % (lineno+1) + + return first, last, comment_header, text.get(first, last) + +# This should perhaps be replaced with textwrap.wrap +def reformat_paragraph(data, limit): + """Return data reformatted to specified width (limit).""" + lines = data.split("\n") + i = 0 + n = len(lines) + while i < n and is_all_white(lines[i]): + i = i+1 + if i >= n: + return data + indent1 = get_indent(lines[i]) + if i+1 < n and not is_all_white(lines[i+1]): + indent2 = get_indent(lines[i+1]) + else: + indent2 = indent1 + new = lines[:i] + partial = indent1 + while i < n and not is_all_white(lines[i]): + # XXX Should take double space after period (etc.) into account + words = re.split(r"(\s+)", lines[i]) + for j in range(0, len(words), 2): + word = words[j] + if not word: + continue # Can happen when line ends in whitespace + if len((partial + word).expandtabs()) > limit and \ + partial != indent1: + new.append(partial.rstrip()) + partial = indent2 + partial = partial + word + " " + if j+1 < len(words) and words[j+1] != " ": + partial = partial + " " + i = i+1 + new.append(partial.rstrip()) + # XXX Should reformat remaining paragraphs as well + new.extend(lines[i:]) + return "\n".join(new) + +def reformat_comment(data, limit, comment_header): + """Return data reformatted to specified width with comment header.""" + + # Remove header from the comment lines + lc = len(comment_header) + data = "\n".join(line[lc:] for line in data.split("\n")) + # Reformat to maxformatwidth chars or a 20 char width, + # whichever is greater. + format_width = max(limit - len(comment_header), 20) + newdata = reformat_paragraph(data, format_width) + # re-split and re-insert the comment header. + newdata = newdata.split("\n") + # If the block ends in a \n, we don't want the comment prefix + # inserted after it. (Im not sure it makes sense to reformat a + # comment block that is not made of complete lines, but whatever!) + # Can't think of a clean solution, so we hack away + block_suffix = "" + if not newdata[-1]: + block_suffix = "\n" + newdata = newdata[:-1] + return '\n'.join(comment_header+line for line in newdata) + block_suffix + +def is_all_white(line): + """Return True if line is empty or all whitespace.""" + + return re.match(r"^\s*$", line) is not None + +def get_indent(line): + """Return the initial space or tab indent of line.""" + return re.match(r"^([ \t]*)", line).group() + +def get_comment_header(line): + """Return string with leading whitespace and '#' from line or ''. + + A null return indicates that the line is not a comment line. A non- + null return, such as ' #', will be used to find the other lines of + a comment block with the same indent. + """ + m = re.match(r"^([ \t]*#*)", line) + if m is None: return "" + return m.group(1) + + +# Copied from editor.py; importing it would cause an import cycle. +_line_indent_re = re.compile(r'[ \t]*') + +def get_line_indent(line, tabwidth): + """Return a line's indentation as (# chars, effective # of spaces). + + The effective # of spaces is the length after properly "expanding" + the tabs into spaces, as done by str.expandtabs(tabwidth). + """ + m = _line_indent_re.match(line) + return m.end(), len(m.group().expandtabs(tabwidth)) + + +class FormatRegion: + "Format selected text (region)." + + def __init__(self, editwin): + self.editwin = editwin + + def get_region(self): + """Return line information about the selected text region. + + If text is selected, the first and last indices will be + for the selection. If there is no text selected, the + indices will be the current cursor location. + + Return a tuple containing (first index, last index, + string representation of text, list of text lines). + """ + text = self.editwin.text + first, last = self.editwin.get_selection_indices() + if first and last: + head = text.index(first + " linestart") + tail = text.index(last + "-1c lineend +1c") + else: + head = text.index("insert linestart") + tail = text.index("insert lineend +1c") + chars = text.get(head, tail) + lines = chars.split("\n") + return head, tail, chars, lines + + def set_region(self, head, tail, chars, lines): + """Replace the text between the given indices. + + Args: + head: Starting index of text to replace. + tail: Ending index of text to replace. + chars: Expected to be string of current text + between head and tail. + lines: List of new lines to insert between head + and tail. + """ + text = self.editwin.text + newchars = "\n".join(lines) + if newchars == chars: + text.bell() + return + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", head) + text.undo_block_start() + text.delete(head, tail) + text.insert(head, newchars) + text.undo_block_stop() + text.tag_add("sel", head, "insert") + + def indent_region_event(self, event=None): + "Indent region by indentwidth spaces." + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, self.editwin.tabwidth) + effective = effective + self.editwin.indentwidth + lines[pos] = self.editwin._make_blanks(effective) + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def dedent_region_event(self, event=None): + "Dedent region by indentwidth spaces." + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, self.editwin.tabwidth) + effective = max(effective - self.editwin.indentwidth, 0) + lines[pos] = self.editwin._make_blanks(effective) + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def comment_region_event(self, event=None): + """Comment out each line in region. + + ## is appended to the beginning of each line to comment it out. + """ + head, tail, chars, lines = self.get_region() + for pos in range(len(lines) - 1): + line = lines[pos] + lines[pos] = '##' + line + self.set_region(head, tail, chars, lines) + return "break" + + def uncomment_region_event(self, event=None): + """Uncomment each line in region. + + Remove ## or # in the first positions of a line. If the comment + is not in the beginning position, this command will have no effect. + """ + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if not line: + continue + if line[:2] == '##': + line = line[2:] + elif line[:1] == '#': + line = line[1:] + lines[pos] = line + self.set_region(head, tail, chars, lines) + return "break" + + def tabify_region_event(self, event=None): + "Convert leading spaces to tabs for each line in selected region." + head, tail, chars, lines = self.get_region() + tabwidth = self._asktabwidth() + if tabwidth is None: + return + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, tabwidth) + ntabs, nspaces = divmod(effective, tabwidth) + lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def untabify_region_event(self, event=None): + "Expand tabs to spaces for each line in region." + head, tail, chars, lines = self.get_region() + tabwidth = self._asktabwidth() + if tabwidth is None: + return + for pos in range(len(lines)): + lines[pos] = lines[pos].expandtabs(tabwidth) + self.set_region(head, tail, chars, lines) + return "break" + + def _asktabwidth(self): + "Return value for tab width." + return askinteger( + "Tab width", + "Columns per tab? (2-16)", + parent=self.editwin.text, + initialvalue=self.editwin.indentwidth, + minvalue=2, + maxvalue=16) + + +class Indents: + "Change future indents." + + def __init__(self, editwin): + self.editwin = editwin + + def toggle_tabs_event(self, event): + editwin = self.editwin + usetabs = editwin.usetabs + if askyesno( + "Toggle tabs", + "Turn tabs " + ("on", "off")[usetabs] + + "?\nIndent width " + + ("will be", "remains at")[usetabs] + " 8." + + "\n Note: a tab is always 8 columns", + parent=editwin.text): + editwin.usetabs = not usetabs + # Try to prevent inconsistent indentation. + # User must change indent width manually after using tabs. + editwin.indentwidth = 8 + return "break" + + def change_indentwidth_event(self, event): + editwin = self.editwin + new = askinteger( + "Indent width", + "New indent width (2-16)\n(Always use 8 when using tabs)", + parent=editwin.text, + initialvalue=editwin.indentwidth, + minvalue=2, + maxvalue=16) + if new and new != editwin.indentwidth and not editwin.usetabs: + editwin.indentwidth = new + return "break" + + +class Rstrip: # 'Strip Trailing Whitespace" on "Format" menu. + def __init__(self, editwin): + self.editwin = editwin + + def do_rstrip(self, event=None): + text = self.editwin.text + undo = self.editwin.undo + undo.undo_block_start() + + end_line = int(float(text.index('end'))) + for cur in range(1, end_line): + txt = text.get('%i.0' % cur, '%i.end' % cur) + raw = len(txt) + cut = len(txt.rstrip()) + # Since text.delete() marks file as changed, even if not, + # only call it when needed to actually delete something. + if cut < raw: + text.delete('%i.%i' % (cur, cut), '%i.end' % cur) + + if (text.get('end-2c') == '\n' # File ends with at least 1 newline; + and not hasattr(self.editwin, 'interp')): # & is not Shell. + # Delete extra user endlines. + while (text.index('end-1c') > '1.0' # Stop if file empty. + and text.get('end-3c') == '\n'): + text.delete('end-3c') + # Because tk indexes are slice indexes and never raise, + # a file with only newlines will be emptied. + # patchcheck.py does the same. + + undo.undo_block_stop() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_format', verbosity=2, exit=False) diff --git a/Lib/idlelib/grep.py b/Lib/idlelib/grep.py new file mode 100644 index 00000000000..42048ff2395 --- /dev/null +++ b/Lib/idlelib/grep.py @@ -0,0 +1,223 @@ +"""Grep dialog for Find in Files functionality. + + Inherits from SearchDialogBase for GUI and uses searchengine + to prepare search pattern. +""" +import fnmatch +import os +import sys + +from tkinter import StringVar, BooleanVar +from tkinter.ttk import Checkbutton # Frame imported in ...Base + +from idlelib.searchbase import SearchDialogBase +from idlelib import searchengine + +# Importing OutputWindow here fails due to import loop +# EditorWindow -> GrepDialog -> OutputWindow -> EditorWindow + + +def grep(text, io=None, flist=None): + """Open the Find in Files dialog. + + Module-level function to access the singleton GrepDialog + instance and open the dialog. If text is selected, it is + used as the search phrase; otherwise, the previous entry + is used. + + Args: + text: Text widget that contains the selected text for + default search phrase. + io: iomenu.IOBinding instance with default path to search. + flist: filelist.FileList instance for OutputWindow parent. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_grepdialog"): + engine._grepdialog = GrepDialog(root, engine, flist) + dialog = engine._grepdialog + searchphrase = text.get("sel.first", "sel.last") + dialog.open(text, searchphrase, io) + + +def walk_error(msg): + "Handle os.walk error." + print(msg) + + +def findfiles(folder, pattern, recursive): + """Generate file names in dir that match pattern. + + Args: + folder: Root directory to search. + pattern: File pattern to match. + recursive: True to include subdirectories. + """ + for dirpath, _, filenames in os.walk(folder, onerror=walk_error): + yield from (os.path.join(dirpath, name) + for name in filenames + if fnmatch.fnmatch(name, pattern)) + if not recursive: + break + + +class GrepDialog(SearchDialogBase): + "Dialog for searching multiple files." + + title = "Find in Files Dialog" + icon = "Grep" + needwrapbutton = 0 + + def __init__(self, root, engine, flist): + """Create search dialog for searching for a phrase in the file system. + + Uses SearchDialogBase as the basis for the GUI and a + searchengine instance to prepare the search. + + Attributes: + flist: filelist.Filelist instance for OutputWindow parent. + globvar: String value of Entry widget for path to search. + globent: Entry widget for globvar. Created in + create_entries(). + recvar: Boolean value of Checkbutton widget for + traversing through subdirectories. + """ + super().__init__(root, engine) + self.flist = flist + self.globvar = StringVar(root) + self.recvar = BooleanVar(root) + + def open(self, text, searchphrase, io=None): + """Make dialog visible on top of others and ready to use. + + Extend the SearchDialogBase open() to set the initial value + for globvar. + + Args: + text: Multicall object containing the text information. + searchphrase: String phrase to search. + io: iomenu.IOBinding instance containing file path. + """ + SearchDialogBase.open(self, text, searchphrase) + if io: + path = io.filename or "" + else: + path = "" + dir, base = os.path.split(path) + head, tail = os.path.splitext(base) + if not tail: + tail = ".py" + self.globvar.set(os.path.join(dir, "*" + tail)) + + def create_entries(self): + "Create base entry widgets and add widget for search path." + SearchDialogBase.create_entries(self) + self.globent = self.make_entry("In files:", self.globvar)[0] + + def create_other_buttons(self): + "Add check button to recurse down subdirectories." + btn = Checkbutton( + self.make_frame()[0], variable=self.recvar, + text="Recurse down subdirectories") + btn.pack(side="top", fill="both") + + def create_command_buttons(self): + "Create base command buttons and add button for Search Files." + SearchDialogBase.create_command_buttons(self) + self.make_button("Search Files", self.default_command, isdef=True) + + def default_command(self, event=None): + """Grep for search pattern in file path. The default command is bound + to . + + If entry values are populated, set OutputWindow as stdout + and perform search. The search dialog is closed automatically + when the search begins. + """ + prog = self.engine.getprog() + if not prog: + return + path = self.globvar.get() + if not path: + self.top.bell() + return + from idlelib.outwin import OutputWindow # leave here! + save = sys.stdout + try: + sys.stdout = OutputWindow(self.flist) + self.grep_it(prog, path) + finally: + sys.stdout = save + + def grep_it(self, prog, path): + """Search for prog within the lines of the files in path. + + For the each file in the path directory, open the file and + search each line for the matching pattern. If the pattern is + found, write the file and line information to stdout (which + is an OutputWindow). + + Args: + prog: The compiled, cooked search pattern. + path: String containing the search path. + """ + folder, filepat = os.path.split(path) + if not folder: + folder = os.curdir + filelist = sorted(findfiles(folder, filepat, self.recvar.get())) + self.close() + pat = self.engine.getpat() + print(f"Searching {pat!r} in {path} ...") + hits = 0 + try: + for fn in filelist: + try: + with open(fn, errors='replace') as f: + for lineno, line in enumerate(f, 1): + if line[-1:] == '\n': + line = line[:-1] + if prog.search(line): + sys.stdout.write(f"{fn}: {lineno}: {line}\n") + hits += 1 + except OSError as msg: + print(msg) + print(f"Hits found: {hits}\n(Hint: right-click to open locations.)" + if hits else "No hits.") + except AttributeError: + # Tk window has been closed, OutputWindow.text = None, + # so in OW.write, OW.text.insert fails. + pass + + +def _grep_dialog(parent): # htest # + from tkinter import Toplevel, Text, SEL + from tkinter.ttk import Frame, Button + from idlelib.pyshell import PyShellFileList + + top = Toplevel(parent) + top.title("Test GrepDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry(f"+{x}+{y + 175}") + + flist = PyShellFileList(top) + frame = Frame(top) + frame.pack() + text = Text(frame, height=5) + text.pack() + text.insert('1.0', 'import grep') + + def show_grep_dialog(): + text.tag_add(SEL, "1.0", '1.end') + grep(text, flist=flist) + text.tag_remove(SEL, "1.0", '1.end') + + button = Button(frame, text="Show GrepDialog", command=show_grep_dialog) + button.pack() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_grep', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_grep_dialog) diff --git a/Lib/idlelib/help.html b/Lib/idlelib/help.html new file mode 100644 index 00000000000..eda16ac5bed --- /dev/null +++ b/Lib/idlelib/help.html @@ -0,0 +1,868 @@ +
+

IDLE — Python editor and shell

+

Source code: Lib/idlelib/

+
+

IDLE is Python’s Integrated Development and Learning Environment.

+

IDLE has the following features:

+
    +
  • cross-platform: works mostly the same on Windows, Unix, and macOS

  • +
  • Python shell window (interactive interpreter) with colorizing +of code input, output, and error messages

  • +
  • multi-window text editor with multiple undo, Python colorizing, +smart indent, call tips, auto completion, and other features

  • +
  • search within any window, replace within editor windows, and search +through multiple files (grep)

  • +
  • debugger with persistent breakpoints, stepping, and viewing +of global and local namespaces

  • +
  • configuration, browsers, and other dialogs

  • +
+

The IDLE application is implemented in the idlelib package.

+

This is an optional module. +If it is missing from your copy of CPython, +look for documentation from your distributor (that is, +whoever provided Python to you). +If you are the distributor, see Requirements for optional modules.

+ +
+

Editing and Navigation

+
+

Editor windows

+

IDLE may open editor windows when it starts, depending on settings +and how you start IDLE. Thereafter, use the File menu. There can be only +one open editor window for a given file.

+

The title bar contains the name of the file, the full path, and the version +of Python and IDLE running the window. The status bar contains the line +number (‘Ln’) and column number (‘Col’). Line numbers start with 1; +column numbers with 0.

+

IDLE assumes that files with a known .py* extension contain Python code +and that other files do not. Run Python code with the Run menu.

+
+
+

Key bindings

+

The IDLE insertion cursor is a thin vertical bar between character +positions. When characters are entered, the insertion cursor and +everything to its right moves right one character and +the new character is entered in the new space.

+

Several non-character keys move the cursor and possibly +delete characters. Deletion does not puts text on the clipboard, +but IDLE has an undo list. Wherever this doc discusses keys, +‘C’ refers to the Control key on Windows and +Unix and the Command key on macOS. (And all such discussions +assume that the keys have not been re-bound to something else.)

+
    +
  • Arrow keys move the cursor one character or line.

  • +
  • C-LeftArrow and C-RightArrow moves left or right one word.

  • +
  • Home and End go to the beginning or end of the line.

  • +
  • Page Up and Page Down go up or down one screen.

  • +
  • C-Home and C-End go to beginning or end of the file.

  • +
  • Backspace and Del (or C-d) delete the previous +or next character.

  • +
  • C-Backspace and C-Del delete one word left or right.

  • +
  • C-k deletes (‘kills’) everything to the right.

  • +
+

Standard keybindings (like C-c to copy and C-v to paste) +may work. Keybindings are selected in the Configure IDLE dialog.

+
+
+

Automatic indentation

+

After a block-opening statement, the next line is indented by 4 spaces (in the +Python Shell window by one tab). After certain keywords (break, return etc.) +the next line is dedented. In leading indentation, Backspace deletes up +to 4 spaces if they are there. Tab inserts spaces (in the Python +Shell window one tab), number depends on Indent width. Currently, tabs +are restricted to four spaces due to Tcl/Tk limitations.

+

See also the indent/dedent region commands on the +Format menu.

+
+
+

Search and Replace

+

Any selection becomes a search target. However, only selections within +a line work because searches are only performed within lines with the +terminal newline removed. If [x] Regular expression is checked, the +target is interpreted according to the Python re module.

+
+
+

Completions

+

Completions are supplied, when requested and available, for module +names, attributes of classes or functions, or filenames. Each request +method displays a completion box with existing names. (See tab +completions below for an exception.) For any box, change the name +being completed and the item highlighted in the box by +typing and deleting characters; by hitting Up, Down, +PageUp, PageDown, Home, and End keys; +and by a single click within the box. Close the box with Escape, +Enter, and double Tab keys or clicks outside the box. +A double click within the box selects and closes.

+

One way to open a box is to type a key character and wait for a +predefined interval. This defaults to 2 seconds; customize it +in the settings dialog. (To prevent auto popups, set the delay to a +large number of milliseconds, such as 100000000.) For imported module +names or class or function attributes, type ‘.’. +For filenames in the root directory, type os.sep or +os.altsep immediately after an opening quote. (On Windows, +one can specify a drive first.) Move into subdirectories by typing a +directory name and a separator.

+

Instead of waiting, or after a box is closed, open a completion box +immediately with Show Completions on the Edit menu. The default hot +key is C-space. If one types a prefix for the desired name +before opening the box, the first match or near miss is made visible. +The result is the same as if one enters a prefix +after the box is displayed. Show Completions after a quote completes +filenames in the current directory instead of a root directory.

+

Hitting Tab after a prefix usually has the same effect as Show +Completions. (With no prefix, it indents.) However, if there is only +one match to the prefix, that match is immediately added to the editor +text without opening a box.

+

Invoking ‘Show Completions’, or hitting Tab after a prefix, +outside of a string and without a preceding ‘.’ opens a box with +keywords, builtin names, and available module-level names.

+

When editing code in an editor (as oppose to Shell), increase the +available module-level names by running your code +and not restarting the Shell thereafter. This is especially useful +after adding imports at the top of a file. This also increases +possible attribute completions.

+

Completion boxes initially exclude names beginning with ‘_’ or, for +modules, not included in ‘__all__’. The hidden names can be accessed +by typing ‘_’ after ‘.’, either before or after the box is opened.

+
+
+

Calltips

+

A calltip is shown automatically when one types ( after the name +of an accessible function. A function name expression may include +dots and subscripts. A calltip remains until it is clicked, the cursor +is moved out of the argument area, or ) is typed. Whenever the +cursor is in the argument part of a definition, select Edit and “Show +Call Tip” on the menu or enter its shortcut to display a calltip.

+

The calltip consists of the function’s signature and docstring up to +the latter’s first blank line or the fifth non-blank line. (Some builtin +functions lack an accessible signature.) A ‘/’ or ‘*’ in the signature +indicates that the preceding or following arguments are passed by +position or name (keyword) only. Details are subject to change.

+

In Shell, the accessible functions depends on what modules have been +imported into the user process, including those imported by Idle itself, +and which definitions have been run, all since the last restart.

+

For example, restart the Shell and enter itertools.count(. A calltip +appears because Idle imports itertools into the user process for its own +use. (This could change.) Enter turtle.write( and nothing appears. +Idle does not itself import turtle. The menu entry and shortcut also do +nothing. Enter import turtle. Thereafter, turtle.write( +will display a calltip.

+

In an editor, import statements have no effect until one runs the file. +One might want to run a file after writing import statements, after +adding function definitions, or after opening an existing file.

+
+
+

Format block

+

Reformat Paragraph rewraps a block (‘paragraph’) of contiguous equally +indented non-blank comments, a similar block of text within a multiline +string, or a selected subset of either. +If needed, add a blank line to separate string from code. +Partial lines in a selection expand to complete lines. +The resulting lines have the same indent as before +but have maximum total length of N columns (characters). +Change the default N of 72 on the Window tab of IDLE Settings.

+
+
+

Code Context

+

Within an editor window containing Python code, code context can be toggled +in order to show or hide a pane at the top of the window. When shown, this +pane freezes the opening lines for block code, such as those beginning with +class, def, or if keywords, that would have otherwise scrolled +out of view. The size of the pane will be expanded and contracted as needed +to show the all current levels of context, up to the maximum number of +lines defined in the Configure IDLE dialog (which defaults to 15). If there +are no current context lines and the feature is toggled on, a single blank +line will display. Clicking on a line in the context pane will move that +line to the top of the editor.

+

The text and background colors for the context pane can be configured under +the Highlights tab in the Configure IDLE dialog.

+
+
+

Shell window

+

In IDLE’s Shell, enter, edit, and recall complete statements. (Most +consoles and terminals only work with a single physical line at a time).

+

Submit a single-line statement for execution by hitting Return +with the cursor anywhere on the line. If a line is extended with +Backslash (\), the cursor must be on the last physical line. +Submit a multi-line compound statement by entering a blank line after +the statement.

+

When one pastes code into Shell, it is not compiled and possibly executed +until one hits Return, as specified above. +One may edit pasted code first. +If one pastes more than one statement into Shell, the result will be a +SyntaxError when multiple statements are compiled as if they were one.

+

Lines containing RESTART mean that the user execution process has been +re-started. This occurs when the user execution process has crashed, +when one requests a restart on the Shell menu, or when one runs code +in an editor window.

+

The editing features described in previous subsections work when entering +code interactively. IDLE’s Shell window also responds to the following:

+
    +
  • C-c attempts to interrupt statement execution (but may fail).

  • +
  • C-d closes Shell if typed at a >>> prompt.

  • +
  • Alt-p and Alt-n (C-p and C-n on macOS) +retrieve to the current prompt the previous or next previously +entered statement that matches anything already typed.

  • +
  • Return while the cursor is on any previous statement +appends the latter to anything already typed at the prompt.

  • +
+
+
+

Text colors

+

Idle defaults to black on white text, but colors text with special meanings. +For the shell, these are shell output, shell error, user output, and +user error. For Python code, at the shell prompt or in an editor, these are +keywords, builtin class and function names, names following class and +def, strings, and comments. For any text window, these are the cursor (when +present), found text (when possible), and selected text.

+

IDLE also highlights the soft keywords match, +case, and _ in +pattern-matching statements. However, this highlighting is not perfect and +will be incorrect in some rare cases, including some _-s in case +patterns.

+

Text coloring is done in the background, so uncolorized text is occasionally +visible. To change the color scheme, use the Configure IDLE dialog +Highlighting tab. The marking of debugger breakpoint lines in the editor and +text in popups and dialogs is not user-configurable.

+
+
+
+

Startup and Code Execution

+

Upon startup with the -s option, IDLE will execute the file referenced by +the environment variables IDLESTARTUP or PYTHONSTARTUP. +IDLE first checks for IDLESTARTUP; if IDLESTARTUP is present the file +referenced is run. If IDLESTARTUP is not present, IDLE checks for +PYTHONSTARTUP. Files referenced by these environment variables are +convenient places to store functions that are used frequently from the IDLE +shell, or for executing import statements to import common modules.

+

In addition, Tk also loads a startup file if it is present. Note that the +Tk file is loaded unconditionally. This additional file is .Idle.py and is +looked for in the user’s home directory. Statements in this file will be +executed in the Tk namespace, so this file is not useful for importing +functions to be used from IDLE’s Python shell.

+
+

Command-line usage

+

IDLE can be invoked from the command line with various options. The general syntax is:

+
python -m idlelib [options] [file ...]
+
+
+

The following options are available:

+
+
+-c <command>
+

Run the specified Python command in the shell window. +For example, pass -c "print('Hello, World!')". +On Windows, the outer quotes must be double quotes as shown.

+
+ +
+
+-d
+

Enable the debugger and open the shell window.

+
+ +
+
+-e
+

Open an editor window.

+
+ +
+
+-h
+

Print a help message with legal combinations of options and exit.

+
+ +
+
+-i
+

Open a shell window.

+
+ +
+
+-r <file>
+

Run the specified file in the shell window.

+
+ +
+
+-s
+

Run the startup file (as defined by the environment variables IDLESTARTUP or PYTHONSTARTUP) before opening the shell window.

+
+ +
+
+-t <title>
+

Set the title of the shell window.

+
+ +
+
+-
+

Read and execute standard input in the shell window. This option must be the last one before any arguments.

+
+ +

If arguments are provided:

+
    +
  • If -, -c, or -r is used, all arguments are placed in sys.argv[1:], +and sys.argv[0] is set to '', '-c', or '-r' respectively. +No editor window is opened, even if that is the default set in the Options dialog.

  • +
  • Otherwise, arguments are treated as files to be opened for editing, and sys.argv reflects the arguments passed to IDLE itself.

  • +
+
+
+

Startup failure

+

IDLE uses a socket to communicate between the IDLE GUI process and the user +code execution process. A connection must be established whenever the Shell +starts or restarts. (The latter is indicated by a divider line that says +‘RESTART’). If the user process fails to connect to the GUI process, it +usually displays a Tk error box with a ‘cannot connect’ message +that directs the user here. It then exits.

+

One specific connection failure on Unix systems results from +misconfigured masquerading rules somewhere in a system’s network setup. +When IDLE is started from a terminal, one will see a message starting +with ** Invalid host:. +The valid value is 127.0.0.1 (idlelib.rpc.LOCALHOST). +One can diagnose with tcpconnect -irv 127.0.0.1 6543 in one +terminal window and tcplisten <same args> in another.

+

A common cause of failure is a user-written file with the same name as a +standard library module, such as random.py and tkinter.py. When such a +file is located in the same directory as a file that is about to be run, +IDLE cannot import the stdlib file. The current fix is to rename the +user file.

+

Though less common than in the past, an antivirus or firewall program may +stop the connection. If the program cannot be taught to allow the +connection, then it must be turned off for IDLE to work. It is safe to +allow this internal connection because no data is visible on external +ports. A similar problem is a network mis-configuration that blocks +connections.

+

Python installation issues occasionally stop IDLE: multiple versions can +clash, or a single installation might need admin access. If one undo the +clash, or cannot or does not want to run as admin, it might be easiest to +completely remove Python and start over.

+

A zombie pythonw.exe process could be a problem. On Windows, use Task +Manager to check for one and stop it if there is. Sometimes a restart +initiated by a program crash or Keyboard Interrupt (control-C) may fail +to connect. Dismissing the error box or using Restart Shell on the Shell +menu may fix a temporary problem.

+

When IDLE first starts, it attempts to read user configuration files in +~/.idlerc/ (~ is one’s home directory). If there is a problem, an error +message should be displayed. Leaving aside random disk glitches, this can +be prevented by never editing the files by hand. Instead, use the +configuration dialog, under Options. Once there is an error in a user +configuration file, the best solution may be to delete it and start over +with the settings dialog.

+

If IDLE quits with no message, and it was not started from a console, try +starting it from a console or terminal (python -m idlelib) and see if +this results in an error message.

+

On Unix-based systems with tcl/tk older than 8.6.11 (see +About IDLE) certain characters of certain fonts can cause +a tk failure with a message to the terminal. This can happen either +if one starts IDLE to edit a file with such a character or later +when entering such a character. If one cannot upgrade tcl/tk, +then re-configure IDLE to use a font that works better.

+
+
+

Running user code

+

With rare exceptions, the result of executing Python code with IDLE is +intended to be the same as executing the same code by the default method, +directly with Python in a text-mode system console or terminal window. +However, the different interface and operation occasionally affect +visible results. For instance, sys.modules starts with more entries, +and threading.active_count() returns 2 instead of 1.

+

By default, IDLE runs user code in a separate OS process rather than in +the user interface process that runs the shell and editor. In the execution +process, it replaces sys.stdin, sys.stdout, and sys.stderr +with objects that get input from and send output to the Shell window. +The original values stored in sys.__stdin__, sys.__stdout__, and +sys.__stderr__ are not touched, but may be None.

+

Sending print output from one process to a text widget in another is +slower than printing to a system terminal in the same process. +This has the most effect when printing multiple arguments, as the string +for each argument, each separator, the newline are sent separately. +For development, this is usually not a problem, but if one wants to +print faster in IDLE, format and join together everything one wants +displayed together and then print a single string. Both format strings +and str.join() can help combine fields and lines.

+

IDLE’s standard stream replacements are not inherited by subprocesses +created in the execution process, whether directly by user code or by +modules such as multiprocessing. If such subprocess use input from +sys.stdin or print or write to sys.stdout or sys.stderr, +IDLE should be started in a command line window. (On Windows, +use python or py rather than pythonw or pyw.) +The secondary subprocess +will then be attached to that window for input and output.

+

If sys is reset by user code, such as with importlib.reload(sys), +IDLE’s changes are lost and input from the keyboard and output to the screen +will not work correctly.

+

When Shell has the focus, it controls the keyboard and screen. This is +normally transparent, but functions that directly access the keyboard +and screen will not work. These include system-specific functions that +determine whether a key has been pressed and if so, which.

+

The IDLE code running in the execution process adds frames to the call stack +that would not be there otherwise. IDLE wraps sys.getrecursionlimit and +sys.setrecursionlimit to reduce the effect of the additional stack +frames.

+

When user code raises SystemExit either directly or by calling sys.exit, +IDLE returns to a Shell prompt instead of exiting.

+
+
+

User output in Shell

+

When a program outputs text, the result is determined by the +corresponding output device. When IDLE executes user code, sys.stdout +and sys.stderr are connected to the display area of IDLE’s Shell. Some of +its features are inherited from the underlying Tk Text widget. Others +are programmed additions. Where it matters, Shell is designed for development +rather than production runs.

+

For instance, Shell never throws away output. A program that sends unlimited +output to Shell will eventually fill memory, resulting in a memory error. +In contrast, some system text windows only keep the last n lines of output. +A Windows console, for instance, keeps a user-settable 1 to 9999 lines, +with 300 the default.

+

A Tk Text widget, and hence IDLE’s Shell, displays characters (codepoints) in +the BMP (Basic Multilingual Plane) subset of Unicode. Which characters are +displayed with a proper glyph and which with a replacement box depends on the +operating system and installed fonts. Tab characters cause the following text +to begin after the next tab stop. (They occur every 8 ‘characters’). Newline +characters cause following text to appear on a new line. Other control +characters are ignored or displayed as a space, box, or something else, +depending on the operating system and font. (Moving the text cursor through +such output with arrow keys may exhibit some surprising spacing behavior.)

+
>>> s = 'a\tb\a<\x02><\r>\bc\nd'  # Enter 22 chars.
+>>> len(s)
+14
+>>> s  # Display repr(s)
+'a\tb\x07<\x02><\r>\x08c\nd'
+>>> print(s, end='')  # Display s as is.
+# Result varies by OS and font.  Try it.
+
+
+

The repr function is used for interactive echo of expression +values. It returns an altered version of the input string in which +control codes, some BMP codepoints, and all non-BMP codepoints are +replaced with escape codes. As demonstrated above, it allows one to +identify the characters in a string, regardless of how they are displayed.

+

Normal and error output are generally kept separate (on separate lines) +from code input and each other. They each get different highlight colors.

+

For SyntaxError tracebacks, the normal ‘^’ marking where the error was +detected is replaced by coloring the text with an error highlight. +When code run from a file causes other exceptions, one may right click +on a traceback line to jump to the corresponding line in an IDLE editor. +The file will be opened if necessary.

+

Shell has a special facility for squeezing output lines down to a +‘Squeezed text’ label. This is done automatically +for output over N lines (N = 50 by default). +N can be changed in the PyShell section of the General +page of the Settings dialog. Output with fewer lines can be squeezed by +right clicking on the output. This can be useful lines long enough to slow +down scrolling.

+

Squeezed output is expanded in place by double-clicking the label. +It can also be sent to the clipboard or a separate view window by +right-clicking the label.

+
+
+

Developing tkinter applications

+

IDLE is intentionally different from standard Python in order to +facilitate development of tkinter programs. Enter import tkinter as tk; +root = tk.Tk() in standard Python and nothing appears. Enter the same +in IDLE and a tk window appears. In standard Python, one must also enter +root.update() to see the window. IDLE does the equivalent in the +background, about 20 times a second, which is about every 50 milliseconds. +Next enter b = tk.Button(root, text='button'); b.pack(). Again, +nothing visibly changes in standard Python until one enters root.update().

+

Most tkinter programs run root.mainloop(), which usually does not +return until the tk app is destroyed. If the program is run with +python -i or from an IDLE editor, a >>> shell prompt does not +appear until mainloop() returns, at which time there is nothing left +to interact with.

+

When running a tkinter program from an IDLE editor, one can comment out +the mainloop call. One then gets a shell prompt immediately and can +interact with the live application. One just has to remember to +re-enable the mainloop call when running in standard Python.

+
+
+

Running without a subprocess

+

By default, IDLE executes user code in a separate subprocess via a socket, +which uses the internal loopback interface. This connection is not +externally visible and no data is sent to or received from the internet. +If firewall software complains anyway, you can ignore it.

+

If the attempt to make the socket connection fails, Idle will notify you. +Such failures are sometimes transient, but if persistent, the problem +may be either a firewall blocking the connection or misconfiguration of +a particular system. Until the problem is fixed, one can run Idle with +the -n command line switch.

+

If IDLE is started with the -n command line switch it will run in a +single process and will not create the subprocess which runs the RPC +Python execution server. This can be useful if Python cannot create +the subprocess or the RPC socket interface on your platform. However, +in this mode user code is not isolated from IDLE itself. Also, the +environment is not restarted when Run/Run Module (F5) is selected. If +your code has been modified, you must reload() the affected modules and +re-import any specific items (e.g. from foo import baz) if the changes +are to take effect. For these reasons, it is preferable to run IDLE +with the default subprocess if at all possible.

+
+

Deprecated since version 3.4.

+
+
+
+
+

Help and Preferences

+
+

Help sources

+

Help menu entry “IDLE Help” displays a formatted html version of the +IDLE chapter of the Library Reference. The result, in a read-only +tkinter text window, is close to what one sees in a web browser. +Navigate through the text with a mousewheel, +the scrollbar, or up and down arrow keys held down. +Or click the TOC (Table of Contents) button and select a section +header in the opened box.

+

Help menu entry “Python Docs” opens the extensive sources of help, +including tutorials, available at docs.python.org/x.y, where ‘x.y’ +is the currently running Python version. If your system +has an off-line copy of the docs (this may be an installation option), +that will be opened instead.

+

Selected URLs can be added or removed from the help menu at any time using the +General tab of the Configure IDLE dialog.

+
+
+

Setting preferences

+

The font preferences, highlighting, keys, and general preferences can be +changed via Configure IDLE on the Option menu. +Non-default user settings are saved in a .idlerc directory in the user’s +home directory. Problems caused by bad user configuration files are solved +by editing or deleting one or more of the files in .idlerc.

+

On the Font tab, see the text sample for the effect of font face and size +on multiple characters in multiple languages. Edit the sample to add +other characters of personal interest. Use the sample to select +monospaced fonts. If particular characters have problems in Shell or an +editor, add them to the top of the sample and try changing first size +and then font.

+

On the Highlights and Keys tab, select a built-in or custom color theme +and key set. To use a newer built-in color theme or key set with older +IDLEs, save it as a new custom theme or key set and it well be accessible +to older IDLEs.

+
+
+

IDLE on macOS

+

Under System Preferences: Dock, one can set “Prefer tabs when opening +documents” to “Always”. This setting is not compatible with the tk/tkinter +GUI framework used by IDLE, and it breaks a few IDLE features.

+
+
+

Extensions

+

IDLE contains an extension facility. Preferences for extensions can be +changed with the Extensions tab of the preferences dialog. See the +beginning of config-extensions.def in the idlelib directory for further +information. The only current default extension is zzdummy, an example +also used for testing.

+
+
+
+

idlelib — implementation of IDLE application

+

Source code: Lib/idlelib

+
+

The Lib/idlelib package implements the IDLE application. See the rest +of this page for how to use IDLE.

+

The files in idlelib are described in idlelib/README.txt. Access it +either in idlelib or click Help => About IDLE on the IDLE menu. This +file also maps IDLE menu items to the code that implements the item. +Except for files listed under ‘Startup’, the idlelib code is ‘private’ in +sense that feature changes can be backported (see PEP 434).

+
+
+ + diff --git a/Lib/idlelib/help.py b/Lib/idlelib/help.py new file mode 100644 index 00000000000..48e7eca280e --- /dev/null +++ b/Lib/idlelib/help.py @@ -0,0 +1,343 @@ +""" help.py: Implement the Idle help menu. +Contents are subject to revision at any time, without notice. + + +Help => About IDLE: display About Idle dialog + + + + +Help => IDLE Help: Display help.html with proper formatting. +Doc/library/idle.rst (Sphinx)=> Doc/build/html/library/idle.html +(help.copy_strip)=> Lib/idlelib/help.html + +HelpParser - Parse help.html and render to tk Text. + +HelpText - Display formatted help.html. + +HelpFrame - Contain text, scrollbar, and table-of-contents. +(This will be needed for display in a future tabbed window.) + +HelpWindow - Display HelpFrame in a standalone window. + +copy_strip - Copy the text part of idle.html to help.html while rstripping each line. + +show_idlehelp - Create HelpWindow. Called in EditorWindow.help_dialog. + +_get_dochome() - Return path to docs on user's system if present, +otherwise return link to docs.python.org. +""" +import os +import sys +from html.parser import HTMLParser +from os.path import abspath, dirname, isfile, join +from platform import python_version + +from tkinter import Toplevel, Text, Menu +from tkinter.ttk import Frame, Menubutton, Scrollbar, Style +from tkinter import font as tkfont + +from idlelib.config import idleConf +from idlelib.colorizer import color_config + +## About IDLE ## + + +## IDLE Help ## + +class HelpParser(HTMLParser): + """Render help.html into a text widget. + + The overridden handle_xyz methods handle a subset of html tags. + The supplied text should have the needed tag configurations. + The behavior for unsupported tags, such as table, is undefined. + If the tags generated by Sphinx change, this class, especially + the handle_starttag and handle_endtags methods, might have to also. + """ + def __init__(self, text): + HTMLParser.__init__(self, convert_charrefs=True) + self.text = text # Text widget we're rendering into. + self.tags = '' # Current block level text tags to apply. + self.chartags = '' # Current character level text tags. + self.hdrlink = False # Exclude html header links. + self.level = 0 # Track indentation level. + self.pre = False # Displaying preformatted text? + self.hprefix = '' # Heading prefix (like '25.5'?) to remove. + self.nested_dl = False # In a nested
? + self.simplelist = False # In a simple list (no double spacing)? + self.toc = [] # Pair headers with text indexes for toc. + self.header = '' # Text within header tags for toc. + self.prevtag = None # Previous tag info (opener?, tag). + + def indent(self, amt=1): + "Change indent (+1, 0, -1) and tags." + self.level += amt + self.tags = '' if self.level == 0 else 'l'+str(self.level) + + def handle_starttag(self, tag, attrs): + "Handle starttags in help.html." + class_ = '' + for a, v in attrs: + if a == 'class': + class_ = v + s = '' + if tag == 'p' and self.prevtag and not self.prevtag[0]: + # Begin a new block for

tags after a closed tag. + # Avoid extra lines, e.g. after

 tags.
+            lastline = self.text.get('end-1c linestart', 'end-1c')
+            s = '\n\n' if lastline and not lastline.isspace() else '\n'
+        elif tag == 'span' and class_ == 'pre':
+            self.chartags = 'pre'
+        elif tag == 'span' and class_ == 'versionmodified':
+            self.chartags = 'em'
+        elif tag == 'em':
+            self.chartags = 'em'
+        elif tag in ['ul', 'ol']:
+            if class_.find('simple') != -1:
+                s = '\n'
+                self.simplelist = True
+            else:
+                self.simplelist = False
+            self.indent()
+        elif tag == 'dl':
+            if self.level > 0:
+                self.nested_dl = True
+        elif tag == 'li':
+            s = '\n* '
+        elif tag == 'dt':
+            s = '\n\n' if not self.nested_dl else '\n'  # Avoid extra line.
+            self.nested_dl = False
+        elif tag == 'dd':
+            self.indent()
+            s = '\n'
+        elif tag == 'pre':
+            self.pre = True
+            self.text.insert('end', '\n\n')
+            self.tags = 'preblock'
+        elif tag == 'a' and class_ == 'headerlink':
+            self.hdrlink = True
+        elif tag == 'h1':
+            self.tags = tag
+        elif tag in ['h2', 'h3']:
+            self.header = ''
+            self.text.insert('end', '\n\n')
+            self.tags = tag
+        self.text.insert('end', s, (self.tags, self.chartags))
+        self.prevtag = (True, tag)
+
+    def handle_endtag(self, tag):
+        "Handle endtags in help.html."
+        if tag in ['h1', 'h2', 'h3']:
+            assert self.level == 0
+            indent = ('        ' if tag == 'h3' else
+                      '    ' if tag == 'h2' else
+                      '')
+            self.toc.append((indent+self.header, self.text.index('insert')))
+            self.tags = ''
+        elif tag in ['span', 'em']:
+            self.chartags = ''
+        elif tag == 'a':
+            self.hdrlink = False
+        elif tag == 'pre':
+            self.pre = False
+            self.tags = ''
+        elif tag in ['ul', 'dd', 'ol']:
+            self.indent(-1)
+        self.prevtag = (False, tag)
+
+    def handle_data(self, data):
+        "Handle date segments in help.html."
+        if not self.hdrlink:
+            d = data if self.pre else data.replace('\n', ' ')
+            if self.tags == 'h1':
+                try:
+                    self.hprefix = d[:d.index(' ')]
+                    if not self.hprefix.isdigit():
+                        self.hprefix = ''
+                except ValueError:
+                    self.hprefix = ''
+            if self.tags in ['h1', 'h2', 'h3']:
+                if (self.hprefix != '' and
+                    d[0:len(self.hprefix)] == self.hprefix):
+                    d = d[len(self.hprefix):]
+                self.header += d.strip()
+            self.text.insert('end', d, (self.tags, self.chartags))
+
+
+class HelpText(Text):
+    "Display help.html."
+    def __init__(self, parent, filename):
+        "Configure tags and feed file to parser."
+        uwide = idleConf.GetOption('main', 'EditorWindow', 'width', type='int')
+        uhigh = idleConf.GetOption('main', 'EditorWindow', 'height', type='int')
+        uhigh = 3 * uhigh // 4  # Lines average 4/3 of editor line height.
+        Text.__init__(self, parent, wrap='word', highlightthickness=0,
+                      padx=5, borderwidth=0, width=uwide, height=uhigh)
+
+        normalfont = self.findfont(['TkDefaultFont', 'arial', 'helvetica'])
+        fixedfont = self.findfont(['TkFixedFont', 'monaco', 'courier'])
+        color_config(self)
+        self['font'] = (normalfont, 12)
+        self.tag_configure('em', font=(normalfont, 12, 'italic'))
+        self.tag_configure('h1', font=(normalfont, 20, 'bold'))
+        self.tag_configure('h2', font=(normalfont, 18, 'bold'))
+        self.tag_configure('h3', font=(normalfont, 15, 'bold'))
+        self.tag_configure('pre', font=(fixedfont, 12))
+        preback = self['selectbackground']
+        self.tag_configure('preblock', font=(fixedfont, 10), lmargin1=25,
+                           background=preback)
+        self.tag_configure('l1', lmargin1=25, lmargin2=25)
+        self.tag_configure('l2', lmargin1=50, lmargin2=50)
+        self.tag_configure('l3', lmargin1=75, lmargin2=75)
+        self.tag_configure('l4', lmargin1=100, lmargin2=100)
+
+        self.parser = HelpParser(self)
+        with open(filename, encoding='utf-8') as f:
+            contents = f.read()
+        self.parser.feed(contents)
+        self['state'] = 'disabled'
+
+    def findfont(self, names):
+        "Return name of first font family derived from names."
+        for name in names:
+            if name.lower() in (x.lower() for x in tkfont.names(root=self)):
+                font = tkfont.Font(name=name, exists=True, root=self)
+                return font.actual()['family']
+            elif name.lower() in (x.lower()
+                                  for x in tkfont.families(root=self)):
+                return name
+
+
+class HelpFrame(Frame):
+    "Display html text, scrollbar, and toc."
+    def __init__(self, parent, filename):
+        Frame.__init__(self, parent)
+        self.text = text = HelpText(self, filename)
+        self.style = Style(parent)
+        self['style'] = 'helpframe.TFrame'
+        self.style.configure('helpframe.TFrame', background=text['background'])
+        self.toc = toc = self.toc_menu(text)
+        self.scroll = scroll = Scrollbar(self, command=text.yview)
+        text['yscrollcommand'] = scroll.set
+
+        self.rowconfigure(0, weight=1)
+        self.columnconfigure(1, weight=1)  # Only expand the text widget.
+        toc.grid(row=0, column=0, sticky='nw')
+        text.grid(row=0, column=1, sticky='nsew')
+        scroll.grid(row=0, column=2, sticky='ns')
+
+    def toc_menu(self, text):
+        "Create table of contents as drop-down menu."
+        toc = Menubutton(self, text='TOC')
+        drop = Menu(toc, tearoff=False)
+        for lbl, dex in text.parser.toc:
+            drop.add_command(label=lbl, command=lambda dex=dex:text.yview(dex))
+        toc['menu'] = drop
+        return toc
+
+
+class HelpWindow(Toplevel):
+    "Display frame with rendered html."
+    def __init__(self, parent, filename, title):
+        Toplevel.__init__(self, parent)
+        self.wm_title(title)
+        self.protocol("WM_DELETE_WINDOW", self.destroy)
+        self.frame = HelpFrame(self, filename)
+        self.frame.grid(column=0, row=0, sticky='nsew')
+        self.grid_columnconfigure(0, weight=1)
+        self.grid_rowconfigure(0, weight=1)
+
+
+def copy_strip():  # pragma: no cover
+    """Copy the text part of idle.html to idlelib/help.html while stripping trailing whitespace.
+
+    Files with trailing whitespace cannot be pushed to the git cpython
+    repository.  For 3.x (on Windows), help.html is generated, after
+    editing idle.rst on the master branch, with
+      sphinx-build -bhtml . build/html
+      python_d.exe -c "from idlelib.help import copy_strip; copy_strip()"
+    Check build/html/library/idle.html, the help.html diff, and the text
+    displayed by Help => IDLE Help.  Add a blurb and create a PR.
+
+    It can be worthwhile to occasionally generate help.html without
+    touching idle.rst.  Changes to the master version and to the doc
+    build system may result in changes that should not change
+    the displayed text, but might break HelpParser.
+
+    As long as master and maintenance versions of idle.rst remain the
+    same, help.html can be backported.  The internal Python version
+    number is not displayed.  If maintenance idle.rst diverges from
+    the master version, then instead of backporting help.html from
+    master, repeat the procedure above to generate a maintenance
+    version.
+    """
+    src = join(abspath(dirname(dirname(dirname(__file__)))),
+            'Doc', 'build', 'html', 'library', 'idle.html')
+    dst = join(abspath(dirname(__file__)), 'help.html')
+
+    with open(src, 'r', encoding="utf-8") as inn, open(dst, 'w', encoding="utf-8") as out:
+        copy = False
+        for line in inn:
+            if '
' in line: copy = True + if '
' in line: break + if copy: out.write(line.strip() + '\n') + + print(f'{src} copied to {dst}') + + +def show_idlehelp(parent): + "Create HelpWindow; called from Idle Help event handler." + filename = join(abspath(dirname(__file__)), 'help.html') + if not isfile(filename): # pragma: no cover + # Try copy_strip, present message. + return + return HelpWindow(parent, filename, 'IDLE Doc (%s)' % python_version()) + + +def _get_dochome(): + "Return path to local docs if present, otherwise link to docs.python.org." + + dochome = os.path.join(sys.base_prefix, 'Doc', 'index.html') + if sys.platform.count('linux'): + # look for html docs in a couple of standard places + pyver = 'python-docs-' + '%s.%s.%s' % sys.version_info[:3] + if os.path.isdir('/var/www/html/python/'): # rpm package manager + dochome = '/var/www/html/python/index.html' + else: + basepath = '/usr/share/doc/' # dnf/apt package managers + dochome = os.path.join(basepath, pyver, 'Doc', 'index.html') + + elif sys.platform[:3] == 'win': + import winreg # Windows only, block only executed once. + docfile = '' + KEY = (rf"Software\Python\PythonCore\{sys.winver}" + r"\Help\Main Python Documentation") + try: + docfile = winreg.QueryValue(winreg.HKEY_CURRENT_USER, KEY) + except FileNotFoundError: + try: + docfile = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, KEY) + except FileNotFoundError: + pass + if os.path.isfile(docfile): + dochome = docfile + elif sys.platform == 'darwin': + # documentation may be stored inside a python framework + dochome = os.path.join(sys.base_prefix, + 'Resources/English.lproj/Documentation/index.html') + dochome = os.path.normpath(dochome) + if os.path.isfile(dochome): + if sys.platform == 'darwin': + # Safari requires real file:-URLs + return 'file://' + dochome + return dochome + else: + return "https://docs.python.org/%d.%d/" % sys.version_info[:2] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_help', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(show_idlehelp) diff --git a/Lib/idlelib/help_about.py b/Lib/idlelib/help_about.py new file mode 100644 index 00000000000..750da373272 --- /dev/null +++ b/Lib/idlelib/help_about.py @@ -0,0 +1,211 @@ +"""About Dialog for IDLE + +""" +import os +import sys +import webbrowser +from platform import python_version, architecture + +from tkinter import Toplevel, Frame, Label, Button, PhotoImage +from tkinter import SUNKEN, TOP, BOTTOM, LEFT, X, BOTH, W, EW, NSEW, E + +from idlelib import textview + +pyver = python_version() + +if sys.platform == 'darwin': + bits = '64' if sys.maxsize > 2**32 else '32' +else: + bits = architecture()[0][:2] + + +class AboutDialog(Toplevel): + """Modal about dialog for idle + + """ + def __init__(self, parent, title=None, *, _htest=False, _utest=False): + """Create popup, do not return until tk widget destroyed. + + parent - parent of this dialog + title - string which is title of popup dialog + _htest - bool, change box location when running htest + _utest - bool, don't wait_window when running unittest + """ + Toplevel.__init__(self, parent) + self.configure(borderwidth=5) + # place dialog below parent if running htest + self.geometry("+%d+%d" % ( + parent.winfo_rootx()+30, + parent.winfo_rooty()+(30 if not _htest else 100))) + self.bg = "#bbbbbb" + self.fg = "#000000" + self.create_widgets() + self.resizable(height=False, width=False) + self.title(title or + f'About IDLE {pyver} ({bits} bit)') + self.transient(parent) + self.grab_set() + self.protocol("WM_DELETE_WINDOW", self.ok) + self.parent = parent + self.button_ok.focus_set() + self.bind('', self.ok) # dismiss dialog + self.bind('', self.ok) # dismiss dialog + self._current_textview = None + self._utest = _utest + + if not _utest: + self.deiconify() + self.wait_window() + + def create_widgets(self): + frame = Frame(self, borderwidth=2, relief=SUNKEN) + frame_buttons = Frame(self) + frame_buttons.pack(side=BOTTOM, fill=X) + frame.pack(side=TOP, expand=True, fill=BOTH) + self.button_ok = Button(frame_buttons, text='Close', + command=self.ok) + self.button_ok.pack(padx=5, pady=5) + + frame_background = Frame(frame, bg=self.bg) + frame_background.pack(expand=True, fill=BOTH) + + header = Label(frame_background, text='IDLE', fg=self.fg, + bg=self.bg, font=('courier', 24, 'bold')) + header.grid(row=0, column=0, sticky=E, padx=10, pady=10) + + tkpatch = self._root().getvar('tk_patchLevel') + ext = '.png' if tkpatch >= '8.6' else '.gif' + icon = os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'Icons', f'idle_48{ext}') + self.icon_image = PhotoImage(master=self._root(), file=icon) + logo = Label(frame_background, image=self.icon_image, bg=self.bg) + logo.grid(row=0, column=0, sticky=W, rowspan=2, padx=10, pady=10) + + byline_text = "Python's Integrated Development\nand Learning Environment" + 5*'\n' + byline = Label(frame_background, text=byline_text, justify=LEFT, + fg=self.fg, bg=self.bg) + byline.grid(row=2, column=0, sticky=W, columnspan=3, padx=10, pady=5) + + forums_url = "https://discuss.python.org" + forums = Button(frame_background, text='Python (and IDLE) Discussion', width=35, + highlightbackground=self.bg, + command=lambda: webbrowser.open(forums_url)) + forums.grid(row=6, column=0, sticky=W, padx=10, pady=10) + + + docs_url = ("https://docs.python.org/%d.%d/library/idle.html" % + sys.version_info[:2]) + docs = Button(frame_background, text='IDLE Documentation', width=35, + highlightbackground=self.bg, + command=lambda: webbrowser.open(docs_url)) + docs.grid(row=7, column=0, columnspan=2, sticky=W, padx=10, pady=10) + + + Frame(frame_background, borderwidth=1, relief=SUNKEN, + height=2, bg=self.bg).grid(row=8, column=0, sticky=EW, + columnspan=3, padx=5, pady=5) + + tclver = str(self.info_patchlevel()) + tkver = ' and ' + tkpatch if tkpatch != tclver else '' + versions = f"Python {pyver} with tcl/tk {tclver}{tkver}" + vers = Label(frame_background, text=versions, fg=self.fg, bg=self.bg) + vers.grid(row=9, column=0, sticky=W, padx=10, pady=0) + py_buttons = Frame(frame_background, bg=self.bg) + py_buttons.grid(row=10, column=0, columnspan=2, sticky=NSEW) + self.py_license = Button(py_buttons, text='License', width=8, + highlightbackground=self.bg, + command=self.show_py_license) + self.py_license.pack(side=LEFT, padx=10, pady=10) + self.py_copyright = Button(py_buttons, text='Copyright', width=8, + highlightbackground=self.bg, + command=self.show_py_copyright) + self.py_copyright.pack(side=LEFT, padx=10, pady=10) + self.py_credits = Button(py_buttons, text='Credits', width=8, + highlightbackground=self.bg, + command=self.show_py_credits) + self.py_credits.pack(side=LEFT, padx=10, pady=10) + + Frame(frame_background, borderwidth=1, relief=SUNKEN, + height=2, bg=self.bg).grid(row=11, column=0, sticky=EW, + columnspan=3, padx=5, pady=5) + + idle = Label(frame_background, text='IDLE', fg=self.fg, bg=self.bg) + idle.grid(row=12, column=0, sticky=W, padx=10, pady=0) + idle_buttons = Frame(frame_background, bg=self.bg) + idle_buttons.grid(row=13, column=0, columnspan=3, sticky=NSEW) + self.readme = Button(idle_buttons, text='Readme', width=8, + highlightbackground=self.bg, + command=self.show_readme) + self.readme.pack(side=LEFT, padx=10, pady=10) + self.idle_news = Button(idle_buttons, text='News', width=8, + highlightbackground=self.bg, + command=self.show_idle_news) + self.idle_news.pack(side=LEFT, padx=10, pady=10) + self.idle_credits = Button(idle_buttons, text='Credits', width=8, + highlightbackground=self.bg, + command=self.show_idle_credits) + self.idle_credits.pack(side=LEFT, padx=10, pady=10) + + # License, copyright, and credits are of type _sitebuiltins._Printer + def show_py_license(self): + "Handle License button event." + self.display_printer_text('About - License', license) + + def show_py_copyright(self): + "Handle Copyright button event." + self.display_printer_text('About - Copyright', copyright) + + def show_py_credits(self): + "Handle Python Credits button event." + self.display_printer_text('About - Python Credits', credits) + + # Encode CREDITS.txt to utf-8 for proper version of Loewis. + # Specify others as ascii until need utf-8, so catch errors. + def show_idle_credits(self): + "Handle Idle Credits button event." + self.display_file_text('About - Credits', 'CREDITS.txt', 'utf-8') + + def show_readme(self): + "Handle Readme button event." + self.display_file_text('About - Readme', 'README.txt', 'ascii') + + def show_idle_news(self): + "Handle News button event." + self.display_file_text('About - News', 'News3.txt', 'utf-8') + + def display_printer_text(self, title, printer): + """Create textview for built-in constants. + + Built-in constants have type _sitebuiltins._Printer. The + text is extracted from the built-in and then sent to a text + viewer with self as the parent and title as the title of + the popup. + """ + printer._Printer__setup() + text = '\n'.join(printer._Printer__lines) + self._current_textview = textview.view_text( + self, title, text, _utest=self._utest) + + def display_file_text(self, title, filename, encoding=None): + """Create textview for filename. + + The filename needs to be in the current directory. The path + is sent to a text viewer with self as the parent, title as + the title of the popup, and the file encoding. + """ + fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), filename) + self._current_textview = textview.view_file( + self, title, fn, encoding, _utest=self._utest) + + def ok(self, event=None): + "Dismiss help_about dialog." + self.grab_release() + self.destroy() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_help_about', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(AboutDialog) diff --git a/Lib/idlelib/history.py b/Lib/idlelib/history.py new file mode 100644 index 00000000000..5a9b32aaf6b --- /dev/null +++ b/Lib/idlelib/history.py @@ -0,0 +1,106 @@ +"Implement Idle Shell history mechanism with History class" + +from idlelib.config import idleConf + + +class History: + ''' Implement Idle Shell history mechanism. + + store - Store source statement (called from pyshell.resetoutput). + fetch - Fetch stored statement matching prefix already entered. + history_next - Bound to <> event (default Alt-N). + history_prev - Bound to <> event (default Alt-P). + ''' + def __init__(self, text): + '''Initialize data attributes and bind event methods. + + .text - Idle wrapper of tk Text widget, with .bell(). + .history - source statements, possibly with multiple lines. + .prefix - source already entered at prompt; filters history list. + .pointer - index into history. + .cyclic - wrap around history list (or not). + ''' + self.text = text + self.history = [] + self.prefix = None + self.pointer = None + self.cyclic = idleConf.GetOption("main", "History", "cyclic", 1, "bool") + text.bind("<>", self.history_prev) + text.bind("<>", self.history_next) + + def history_next(self, event): + "Fetch later statement; start with earliest if cyclic." + self.fetch(reverse=False) + return "break" + + def history_prev(self, event): + "Fetch earlier statement; start with most recent." + self.fetch(reverse=True) + return "break" + + def fetch(self, reverse): + '''Fetch statement and replace current line in text widget. + + Set prefix and pointer as needed for successive fetches. + Reset them to None, None when returning to the start line. + Sound bell when return to start line or cannot leave a line + because cyclic is False. + ''' + nhist = len(self.history) + pointer = self.pointer + prefix = self.prefix + if pointer is not None and prefix is not None: + if self.text.compare("insert", "!=", "end-1c") or \ + self.text.get("iomark", "end-1c") != self.history[pointer]: + pointer = prefix = None + self.text.mark_set("insert", "end-1c") # != after cursor move + if pointer is None or prefix is None: + prefix = self.text.get("iomark", "end-1c") + if reverse: + pointer = nhist # will be decremented + else: + if self.cyclic: + pointer = -1 # will be incremented + else: # abort history_next + self.text.bell() + return + nprefix = len(prefix) + while True: + pointer += -1 if reverse else 1 + if pointer < 0 or pointer >= nhist: + self.text.bell() + if not self.cyclic and pointer < 0: # abort history_prev + return + else: + if self.text.get("iomark", "end-1c") != prefix: + self.text.delete("iomark", "end-1c") + self.text.insert("iomark", prefix, "stdin") + pointer = prefix = None + break + item = self.history[pointer] + if item[:nprefix] == prefix and len(item) > nprefix: + self.text.delete("iomark", "end-1c") + self.text.insert("iomark", item, "stdin") + break + self.text.see("insert") + self.text.tag_remove("sel", "1.0", "end") + self.pointer = pointer + self.prefix = prefix + + def store(self, source): + "Store Shell input statement into history list." + source = source.strip() + if len(source) > 2: + # avoid duplicates + try: + self.history.remove(source) + except ValueError: + pass + self.history.append(source) + self.pointer = None + self.prefix = None + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_history', verbosity=2, exit=False) diff --git a/Lib/idlelib/hyperparser.py b/Lib/idlelib/hyperparser.py new file mode 100644 index 00000000000..76144ee8fb3 --- /dev/null +++ b/Lib/idlelib/hyperparser.py @@ -0,0 +1,312 @@ +"""Provide advanced parsing abilities for ParenMatch and other extensions. + +HyperParser uses PyParser. PyParser mostly gives information on the +proper indentation of code. HyperParser gives additional information on +the structure of code. +""" +from keyword import iskeyword +import string + +from idlelib import pyparse + +# all ASCII chars that may be in an identifier +_ASCII_ID_CHARS = frozenset(string.ascii_letters + string.digits + "_") +# all ASCII chars that may be the first char of an identifier +_ASCII_ID_FIRST_CHARS = frozenset(string.ascii_letters + "_") + +# lookup table for whether 7-bit ASCII chars are valid in a Python identifier +_IS_ASCII_ID_CHAR = [(chr(x) in _ASCII_ID_CHARS) for x in range(128)] +# lookup table for whether 7-bit ASCII chars are valid as the first +# char in a Python identifier +_IS_ASCII_ID_FIRST_CHAR = \ + [(chr(x) in _ASCII_ID_FIRST_CHARS) for x in range(128)] + + +class HyperParser: + def __init__(self, editwin, index): + "To initialize, analyze the surroundings of the given index." + + self.editwin = editwin + self.text = text = editwin.text + + parser = pyparse.Parser(editwin.indentwidth, editwin.tabwidth) + + def index2line(index): + return int(float(index)) + lno = index2line(text.index(index)) + + if not editwin.prompt_last_line: + for context in editwin.num_context_lines: + startat = max(lno - context, 1) + startatindex = repr(startat) + ".0" + stopatindex = "%d.end" % lno + # We add the newline because PyParse requires a newline + # at end. We add a space so that index won't be at end + # of line, so that its status will be the same as the + # char before it, if should. + parser.set_code(text.get(startatindex, stopatindex)+' \n') + bod = parser.find_good_parse_start( + editwin._build_char_in_string_func(startatindex)) + if bod is not None or startat == 1: + break + parser.set_lo(bod or 0) + else: + r = text.tag_prevrange("console", index) + if r: + startatindex = r[1] + else: + startatindex = "1.0" + stopatindex = "%d.end" % lno + # We add the newline because PyParse requires it. We add a + # space so that index won't be at end of line, so that its + # status will be the same as the char before it, if should. + parser.set_code(text.get(startatindex, stopatindex)+' \n') + parser.set_lo(0) + + # We want what the parser has, minus the last newline and space. + self.rawtext = parser.code[:-2] + # Parser.code apparently preserves the statement we are in, so + # that stopatindex can be used to synchronize the string with + # the text box indices. + self.stopatindex = stopatindex + self.bracketing = parser.get_last_stmt_bracketing() + # find which pairs of bracketing are openers. These always + # correspond to a character of rawtext. + self.isopener = [i>0 and self.bracketing[i][1] > + self.bracketing[i-1][1] + for i in range(len(self.bracketing))] + + self.set_index(index) + + def set_index(self, index): + """Set the index to which the functions relate. + + The index must be in the same statement. + """ + indexinrawtext = (len(self.rawtext) - + len(self.text.get(index, self.stopatindex))) + if indexinrawtext < 0: + raise ValueError("Index %s precedes the analyzed statement" + % index) + self.indexinrawtext = indexinrawtext + # find the rightmost bracket to which index belongs + self.indexbracket = 0 + while (self.indexbracket < len(self.bracketing)-1 and + self.bracketing[self.indexbracket+1][0] < self.indexinrawtext): + self.indexbracket += 1 + if (self.indexbracket < len(self.bracketing)-1 and + self.bracketing[self.indexbracket+1][0] == self.indexinrawtext and + not self.isopener[self.indexbracket+1]): + self.indexbracket += 1 + + def is_in_string(self): + """Is the index given to the HyperParser in a string?""" + # The bracket to which we belong should be an opener. + # If it's an opener, it has to have a character. + return (self.isopener[self.indexbracket] and + self.rawtext[self.bracketing[self.indexbracket][0]] + in ('"', "'")) + + def is_in_code(self): + """Is the index given to the HyperParser in normal code?""" + return (not self.isopener[self.indexbracket] or + self.rawtext[self.bracketing[self.indexbracket][0]] + not in ('#', '"', "'")) + + def get_surrounding_brackets(self, openers='([{', mustclose=False): + """Return bracket indexes or None. + + If the index given to the HyperParser is surrounded by a + bracket defined in openers (or at least has one before it), + return the indices of the opening bracket and the closing + bracket (or the end of line, whichever comes first). + + If it is not surrounded by brackets, or the end of line comes + before the closing bracket and mustclose is True, returns None. + """ + + bracketinglevel = self.bracketing[self.indexbracket][1] + before = self.indexbracket + while (not self.isopener[before] or + self.rawtext[self.bracketing[before][0]] not in openers or + self.bracketing[before][1] > bracketinglevel): + before -= 1 + if before < 0: + return None + bracketinglevel = min(bracketinglevel, self.bracketing[before][1]) + after = self.indexbracket + 1 + while (after < len(self.bracketing) and + self.bracketing[after][1] >= bracketinglevel): + after += 1 + + beforeindex = self.text.index("%s-%dc" % + (self.stopatindex, len(self.rawtext)-self.bracketing[before][0])) + if (after >= len(self.bracketing) or + self.bracketing[after][0] > len(self.rawtext)): + if mustclose: + return None + afterindex = self.stopatindex + else: + # We are after a real char, so it is a ')' and we give the + # index before it. + afterindex = self.text.index( + "%s-%dc" % (self.stopatindex, + len(self.rawtext)-(self.bracketing[after][0]-1))) + + return beforeindex, afterindex + + # the set of built-in identifiers which are also keywords, + # i.e. keyword.iskeyword() returns True for them + _ID_KEYWORDS = frozenset({"True", "False", "None"}) + + @classmethod + def _eat_identifier(cls, str, limit, pos): + """Given a string and pos, return the number of chars in the + identifier which ends at pos, or 0 if there is no such one. + + This ignores non-identifier eywords are not identifiers. + """ + is_ascii_id_char = _IS_ASCII_ID_CHAR + + # Start at the end (pos) and work backwards. + i = pos + + # Go backwards as long as the characters are valid ASCII + # identifier characters. This is an optimization, since it + # is faster in the common case where most of the characters + # are ASCII. + while i > limit and ( + ord(str[i - 1]) < 128 and + is_ascii_id_char[ord(str[i - 1])] + ): + i -= 1 + + # If the above loop ended due to reaching a non-ASCII + # character, continue going backwards using the most generic + # test for whether a string contains only valid identifier + # characters. + if i > limit and ord(str[i - 1]) >= 128: + while i - 4 >= limit and ('a' + str[i - 4:pos]).isidentifier(): + i -= 4 + if i - 2 >= limit and ('a' + str[i - 2:pos]).isidentifier(): + i -= 2 + if i - 1 >= limit and ('a' + str[i - 1:pos]).isidentifier(): + i -= 1 + + # The identifier candidate starts here. If it isn't a valid + # identifier, don't eat anything. At this point that is only + # possible if the first character isn't a valid first + # character for an identifier. + if not str[i:pos].isidentifier(): + return 0 + elif i < pos: + # All characters in str[i:pos] are valid ASCII identifier + # characters, so it is enough to check that the first is + # valid as the first character of an identifier. + if not _IS_ASCII_ID_FIRST_CHAR[ord(str[i])]: + return 0 + + # All keywords are valid identifiers, but should not be + # considered identifiers here, except for True, False and None. + if i < pos and ( + iskeyword(str[i:pos]) and + str[i:pos] not in cls._ID_KEYWORDS + ): + return 0 + + return pos - i + + # This string includes all chars that may be in a white space + _whitespace_chars = " \t\n\\" + + def get_expression(self): + """Return a string with the Python expression which ends at the + given index, which is empty if there is no real one. + """ + if not self.is_in_code(): + raise ValueError("get_expression should only be called " + "if index is inside a code.") + + rawtext = self.rawtext + bracketing = self.bracketing + + brck_index = self.indexbracket + brck_limit = bracketing[brck_index][0] + pos = self.indexinrawtext + + last_identifier_pos = pos + postdot_phase = True + + while True: + # Eat whitespaces, comments, and if postdot_phase is False - a dot + while True: + if pos>brck_limit and rawtext[pos-1] in self._whitespace_chars: + # Eat a whitespace + pos -= 1 + elif (not postdot_phase and + pos > brck_limit and rawtext[pos-1] == '.'): + # Eat a dot + pos -= 1 + postdot_phase = True + # The next line will fail if we are *inside* a comment, + # but we shouldn't be. + elif (pos == brck_limit and brck_index > 0 and + rawtext[bracketing[brck_index-1][0]] == '#'): + # Eat a comment + brck_index -= 2 + brck_limit = bracketing[brck_index][0] + pos = bracketing[brck_index+1][0] + else: + # If we didn't eat anything, quit. + break + + if not postdot_phase: + # We didn't find a dot, so the expression end at the + # last identifier pos. + break + + ret = self._eat_identifier(rawtext, brck_limit, pos) + if ret: + # There is an identifier to eat + pos = pos - ret + last_identifier_pos = pos + # Now, to continue the search, we must find a dot. + postdot_phase = False + # (the loop continues now) + + elif pos == brck_limit: + # We are at a bracketing limit. If it is a closing + # bracket, eat the bracket, otherwise, stop the search. + level = bracketing[brck_index][1] + while brck_index > 0 and bracketing[brck_index-1][1] > level: + brck_index -= 1 + if bracketing[brck_index][0] == brck_limit: + # We were not at the end of a closing bracket + break + pos = bracketing[brck_index][0] + brck_index -= 1 + brck_limit = bracketing[brck_index][0] + last_identifier_pos = pos + if rawtext[pos] in "([": + # [] and () may be used after an identifier, so we + # continue. postdot_phase is True, so we don't allow a dot. + pass + else: + # We can't continue after other types of brackets + if rawtext[pos] in "'\"": + # Scan a string prefix + while pos > 0 and rawtext[pos - 1] in "rRbBuU": + pos -= 1 + last_identifier_pos = pos + break + + else: + # We've found an operator or something. + break + + return rawtext[last_identifier_pos:self.indexinrawtext] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_hyperparser', verbosity=2) diff --git a/Lib/idlelib/idle.bat b/Lib/idlelib/idle.bat new file mode 100644 index 00000000000..3d619a37eed --- /dev/null +++ b/Lib/idlelib/idle.bat @@ -0,0 +1,4 @@ +@echo off +rem Start IDLE using the appropriate Python interpreter +set CURRDIR=%~dp0 +start "IDLE" "%CURRDIR%..\..\pythonw.exe" "%CURRDIR%idle.pyw" %1 %2 %3 %4 %5 %6 %7 %8 %9 diff --git a/Lib/idlelib/idle.py b/Lib/idlelib/idle.py new file mode 100644 index 00000000000..485d5a75a29 --- /dev/null +++ b/Lib/idlelib/idle.py @@ -0,0 +1,14 @@ +import os.path +import sys + + +# Enable running IDLE with idlelib in a non-standard location. +# This was once used to run development versions of IDLE. +# Because PEP 434 declared idle.py a public interface, +# removal should require deprecation. +idlelib_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if idlelib_dir not in sys.path: + sys.path.insert(0, idlelib_dir) + +from idlelib.pyshell import main # This is subject to change +main() diff --git a/Lib/idlelib/idle.pyw b/Lib/idlelib/idle.pyw new file mode 100644 index 00000000000..e73c049b70c --- /dev/null +++ b/Lib/idlelib/idle.pyw @@ -0,0 +1,17 @@ +try: + import idlelib.pyshell +except ImportError: + # IDLE is not installed, but maybe pyshell is on sys.path: + from . import pyshell + import os + idledir = os.path.dirname(os.path.abspath(pyshell.__file__)) + if idledir != os.getcwd(): + # We're not in the IDLE directory, help the subprocess find run.py + pypath = os.environ.get('PYTHONPATH', '') + if pypath: + os.environ['PYTHONPATH'] = pypath + ':' + idledir + else: + os.environ['PYTHONPATH'] = idledir + pyshell.main() +else: + idlelib.pyshell.main() diff --git a/Lib/idlelib/idle_test/README.txt b/Lib/idlelib/idle_test/README.txt new file mode 100644 index 00000000000..cacd06db873 --- /dev/null +++ b/Lib/idlelib/idle_test/README.txt @@ -0,0 +1,241 @@ +README FOR IDLE TESTS IN IDLELIB.IDLE_TEST + +0. Quick Start + +Automated unit tests were added in 3.3 for Python 3.x. +To run the tests from a command line: + +python -m test.test_idle + +Human-mediated tests were added later in 3.4. + +python -m idlelib.idle_test.htest + + +1. Test Files + +The idle directory, idlelib, has over 60 xyz.py files. The idle_test +subdirectory contains test_xyz.py for each implementation file xyz.py. +To add a test for abc.py, open idle_test/template.py and immediately +Save As test_abc.py. Insert 'abc' on the first line, and replace +'zzdummy' with 'abc. + +Remove the imports of requires and tkinter if not needed. Otherwise, +add to the tkinter imports as needed. + +Add a prefix to 'Test' for the initial test class. The template class +contains code needed or possibly needed for gui tests. See the next +section if doing gui tests. If not, and not needed for further classes, +this code can be removed. + +Add the following at the end of abc.py. If an htest was added first, +insert the import and main lines before the htest lines. + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_abc', verbosity=2, exit=False) + +The ', exit=False' is only needed if an htest follows. + + + +2. GUI Tests + +When run as part of the Python test suite, Idle GUI tests need to run +test.support.requires('gui'). A test is a GUI test if it creates a +tkinter.Tk root or master object either directly or indirectly by +instantiating a tkinter or idle class. GUI tests cannot run in test +processes that either have no graphical environment available or are not +allowed to use it. + +To guard a module consisting entirely of GUI tests, start with + +from test.support import requires +requires('gui') + +To guard a test class, put "requires('gui')" in its setUpClass function. +The template.py file does this. + +To avoid interfering with other GUI tests, all GUI objects must be +destroyed and deleted by the end of the test. The Tk root created in a +setUpX function should be destroyed in the corresponding tearDownX and +the module or class attribute deleted. Others widgets should descend +from the single root and the attributes deleted BEFORE root is +destroyed. See https://bugs.python.org/issue20567. + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.text = tk.Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + +The update_idletasks call is sometimes needed to prevent the following +warning either when running a test alone or as part of the test suite +(#27196). It should not hurt if not needed. + + can't invoke "event" command: application has been destroyed + ... + "ttk::ThemeChanged" + +If a test creates instance 'e' of EditorWindow, call 'e._close()' before +or as the first part of teardown. The effect of omitting this depends +on the later shutdown. Then enable the after_cancel loop in the +template. This prevents messages like the following. + +bgerror failed to handle background error. + Original error: invalid command name "106096696timer_event" + Error in bgerror: can't invoke "tk" command: application has been destroyed + +Requires('gui') causes the test(s) it guards to be skipped if any of +these conditions are met: + + - The tests are being run by regrtest.py, and it was started without + enabling the "gui" resource with the "-u" command line option. + + - The tests are being run on Windows by a service that is not allowed + to interact with the graphical environment. + + - The tests are being run on Linux and X Windows is not available. + + - The tests are being run on Mac OSX in a process that cannot make a + window manager connection. + + - tkinter.Tk cannot be successfully instantiated for some reason. + + - test.support.use_resources has been set by something other than + regrtest.py and does not contain "gui". + +Tests of non-GUI operations should avoid creating tk widgets. Incidental +uses of tk variables and messageboxes can be replaced by the mock +classes in idle_test/mock_tk.py. The mock text handles some uses of the +tk Text widget. + + +3. Running Unit Tests + +Assume that xyz.py and test_xyz.py both end with a unittest.main() call. +Running either from an Idle editor runs all tests in the test_xyz file +with the version of Python running Idle. Test output appears in the +Shell window. The 'verbosity=2' option lists all test methods in the +file, which is appropriate when developing tests. The 'exit=False' +option is needed in xyx.py files when an htest follows. + +The following command lines also run all test methods, including +GUI tests, in test_xyz.py. (Both '-m idlelib' and '-m idlelib.idle' +start Idle and so cannot run tests.) + +python -m idlelib.xyz +python -m idlelib.idle_test.test_xyz + +The following runs all idle_test/test_*.py tests interactively. + +>>> import unittest +>>> unittest.main('idlelib.idle_test', verbosity=2) + +The following run all Idle tests at a command line. Option '-v' is the +same as 'verbosity=2'. + +python -m unittest -v idlelib.idle_test +python -m test -v -ugui test_idle +python -m test.test_idle + +IDLE tests are 'discovered' by idlelib.idle_test.__init__.load_tests +when this is imported into test.test_idle. Normally, neither file +should be changed when working on individual test modules. The third +command runs unittest indirectly through regrtest. The same happens when +the entire test suite is run with 'python -m test'. So that command must +work for buildbots to stay green. IDLE tests must not disturb the +environment in a way that makes other tests fail (GH-62281). + +To test subsets of modules, see idlelib.idle_test.__init__. This +can be used to find refleaks or possible sources of "Theme changed" +tcl messages (GH-71383). + +To run an individual Testcase or test method, extend the dotted name +given to unittest on the command line or use the test -m option. The +latter allows use of other regrtest options. When using the latter, +all components of the pattern must be present, but any can be replaced +by '*'. + +python -m unittest -v idlelib.idle_test.test_xyz.Test_case.test_meth +python -m test -m idlelib.idle_test.text_xyz.Test_case.test_meth test_idle + +The test suite can be run in an IDLE user process from Shell. +>>> import test.autotest # Issue 25588, 2017/10/13, 3.6.4, 3.7.0a2. +There are currently failures not usually present, and this does not +work when run from the editor. + + +4. Human-mediated Tests + +Human-mediated tests are widget tests that cannot be automated but need +human verification. They are contained in idlelib/idle_test/htest.py, +which has instructions. (Some modules need an auxiliary function, +identified with "# htest # on the header line.) The set is about +complete, though some tests need improvement. To run all htests, run the +htest file from an editor or from the command line with: + +python -m idlelib.idle_test.htest + + +5. Test Coverage + +Install the coverage package into your Python 3.6 site-packages +directory. (Its exact location depends on the OS). +> python3 -m pip install coverage +(On Windows, replace 'python3 with 'py -3.6' or perhaps just 'python'.) + +The problem with running coverage with repository python is that +coverage uses absolute imports for its submodules, hence it needs to be +in a directory in sys.path. One solution: copy the package to the +directory containing the cpython repository. Call it 'dev'. Then run +coverage either directly or from a script in that directory so that +'dev' is prepended to sys.path. + +Either edit or add dev/.coveragerc so it looks something like this. +--- +# .coveragerc sets coverage options. +[run] +branch = True + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + + .*# htest # + if not _utest: + if _htest: +--- +The additions for IDLE are 'branch = True', to test coverage both ways, +and the last three exclude lines, to exclude things peculiar to IDLE +that are not executed during tests. + +A script like the following cover.bat (for Windows) is very handy. +--- +@echo off +rem Usage: cover filename [test_ suffix] # proper case required by coverage +rem filename without .py, 2nd parameter if test is not test_filename +setlocal +set py=f:\dev\3x\pcbuild\win32\python_d.exe +set src=idlelib.%1 +if "%2" EQU "" set tst=f:/dev/3x/Lib/idlelib/idle_test/test_%1.py +if "%2" NEQ "" set tst=f:/dev/ex/Lib/idlelib/idle_test/test_%2.py + +%py% -m coverage run --pylib --source=%src% %tst% +%py% -m coverage report --show-missing +%py% -m coverage html +start htmlcov\3x_Lib_idlelib_%1_py.html +rem Above opens new report; htmlcov\index.html displays report index +--- +The second parameter was added for tests of module x not named test_x. +(There were several before modules were renamed, now only one is left.) diff --git a/Lib/idlelib/idle_test/__init__.py b/Lib/idlelib/idle_test/__init__.py new file mode 100644 index 00000000000..8eed2699c41 --- /dev/null +++ b/Lib/idlelib/idle_test/__init__.py @@ -0,0 +1,23 @@ +"""idlelib.idle_test implements test.test_idle, which tests the IDLE +application as part of the stdlib test suite. +Run IDLE tests alone with "python -m test.test_idle (-v)". + +This package and its contained modules are subject to change and +any direct use is at your own risk. +""" +from os.path import dirname + +# test_idle imports load_tests for test discovery (default all). +# To run subsets of idlelib module tests, insert '[]' after '_'. +# Example: insert '[ac]' for modules beginning with 'a' or 'c'. +# Additional .discover/.addTest pairs with separate inserts work. +# Example: pairs with 'c' and 'g' test c* files and grep. + +def load_tests(loader, standard_tests, pattern): + this_dir = dirname(__file__) + top_dir = dirname(dirname(this_dir)) + module_tests = loader.discover(start_dir=this_dir, + pattern='test_*.py', # Insert here. + top_level_dir=top_dir) + standard_tests.addTests(module_tests) + return standard_tests diff --git a/Lib/idlelib/idle_test/example_noext b/Lib/idlelib/idle_test/example_noext new file mode 100644 index 00000000000..7d2510e30bc --- /dev/null +++ b/Lib/idlelib/idle_test/example_noext @@ -0,0 +1,4 @@ +#!usr/bin/env python + +def example_function(some_argument): + pass diff --git a/Lib/idlelib/idle_test/example_stub.pyi b/Lib/idlelib/idle_test/example_stub.pyi new file mode 100644 index 00000000000..abcdbc17529 --- /dev/null +++ b/Lib/idlelib/idle_test/example_stub.pyi @@ -0,0 +1,4 @@ +# An example file to test recognition of a .pyi file as Python source code. + +class Example: + def method(self, argument1: str, argument2: list[int]) -> None: ... diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py new file mode 100644 index 00000000000..b63ff9ec287 --- /dev/null +++ b/Lib/idlelib/idle_test/htest.py @@ -0,0 +1,442 @@ +"""Run human tests of Idle's window, dialog, and popup widgets. + +run(*tests) Create a master Tk() htest window. Within that, run each +callable in tests after finding the matching test spec in this file. If +tests is empty, run an htest for each spec dict in this file after +finding the matching callable in the module named in the spec. Close +the master window to end testing. + +In a tested module, let X be a global name bound to a callable (class or +function) whose .__name__ attribute is also X (the usual situation). The +first parameter of X must be 'parent' or 'master'. When called, the +first argument will be the root window. X must create a child +Toplevel(parent/master) (or subclass thereof). The Toplevel may be a +test widget or dialog, in which case the callable is the corresponding +class. Or the Toplevel may contain the widget to be tested or set up a +context in which a test widget is invoked. In this latter case, the +callable is a wrapper function that sets up the Toplevel and other +objects. Wrapper function names, such as _editor_window', should start +with '_' and be lowercase. + + +End the module with + +if __name__ == '__main__': + + from idlelib.idle_test.htest import run + run(callable) # There could be multiple comma-separated callables. + +To have wrapper functions ignored by coverage reports, tag the def +header like so: "def _wrapper(parent): # htest #". Use the same tag +for htest lines in widget code. Make sure that the 'if __name__' line +matches the above. Then have make sure that .coveragerc includes the +following: + +[report] +exclude_lines = + .*# htest # + if __name__ == .__main__.: + +(The "." instead of "'" is intentional and necessary.) + + +To run any X, this file must contain a matching instance of the +following template, with X.__name__ prepended to '_spec'. +When all tests are run, the prefix is use to get X. + +callable_spec = { + 'file': '', + 'kwds': {'title': ''}, + 'msg': "" + } + +file (no .py): run() imports file.py. +kwds: augmented with {'parent':root} and passed to X as **kwds. +title: an example kwd; some widgets need this, delete line if not. +msg: master window hints about testing the widget. + + +TODO test these modules and classes: + autocomplete_w.AutoCompleteWindow + debugger.Debugger + outwin.OutputWindow (indirectly being tested with grep test) + pyshell.PyShellEditorWindow +""" + +import idlelib.pyshell # Set Windows DPI awareness before Tk(). +from importlib import import_module +import textwrap +import tkinter as tk +from tkinter.ttk import Scrollbar +tk.NoDefaultRoot() + +AboutDialog_spec = { + 'file': 'help_about', + 'kwds': {'title': 'help_about test', + '_htest': True, + }, + 'msg': "Click on URL to open in default browser.\n" + "Verify x.y.z versions and test each button, including Close.\n " + } + +# TODO implement ^\; adding '' to function does not work. +_calltip_window_spec = { + 'file': 'calltip_w', + 'kwds': {}, + 'msg': "Typing '(' should display a calltip.\n" + "Typing ') should hide the calltip.\n" + "So should moving cursor out of argument area.\n" + "Force-open-calltip does not work here.\n" + } + +_color_delegator_spec = { + 'file': 'colorizer', + 'kwds': {}, + 'msg': "The text is sample Python code.\n" + "Ensure components like comments, keywords, builtins,\n" + "string, definitions, and break are correctly colored.\n" + "The default color scheme is in idlelib/config-highlight.def" + } + +ConfigDialog_spec = { + 'file': 'configdialog', + 'kwds': {'title': 'ConfigDialogTest', + '_htest': True,}, + 'msg': "IDLE preferences dialog.\n" + "In the 'Fonts/Tabs' tab, changing font face, should update the " + "font face of the text in the area below it.\nIn the " + "'Highlighting' tab, try different color schemes. Clicking " + "items in the sample program should update the choices above it." + "\nIn the 'Keys', 'General' and 'Extensions' tabs, test settings " + "of interest." + "\n[Ok] to close the dialog.[Apply] to apply the settings and " + "and [Cancel] to revert all changes.\nRe-run the test to ensure " + "changes made have persisted." + } + +CustomRun_spec = { + 'file': 'query', + 'kwds': {'title': 'Customize query.py Run', + '_htest': True}, + 'msg': "Enter with or [OK]. Print valid entry to Shell\n" + "Arguments are parsed into a list\n" + "Mode is currently restart True or False\n" + "Close dialog with valid entry, , [Cancel], [X]" + } + +_debug_object_browser_spec = { + 'file': 'debugobj', + 'kwds': {}, + 'msg': "Double click on items up to the lowest level.\n" + "Attributes of the objects and related information " + "will be displayed side-by-side at each level." + } + +# TODO Improve message +_dyn_option_menu_spec = { + 'file': 'dynoption', + 'kwds': {}, + 'msg': "Select one of the many options in the 'old option set'.\n" + "Click the button to change the option set.\n" + "Select one of the many options in the 'new option set'." + } + +# TODO edit wrapper +_editor_window_spec = { + 'file': 'editor', + 'kwds': {}, + 'msg': "Test editor functions of interest.\n" + "Best to close editor first." + } + +GetKeysWindow_spec = { + 'file': 'config_key', + 'kwds': {'title': 'Test keybindings', + 'action': 'find-again', + 'current_key_sequences': [['', '', '']], + '_htest': True, + }, + 'msg': "Test for different key modifier sequences.\n" + " is invalid.\n" + "No modifier key is invalid.\n" + "Shift key with [a-z],[0-9], function key, move key, tab, space " + "is invalid.\nNo validity checking if advanced key binding " + "entry is used." + } + +_grep_dialog_spec = { + 'file': 'grep', + 'kwds': {}, + 'msg': "Click the 'Show GrepDialog' button.\n" + "Test the various 'Find-in-files' functions.\n" + "The results should be displayed in a new '*Output*' window.\n" + "'Right-click'->'Go to file/line' in the search results\n " + "should open that file in a new EditorWindow." + } + +HelpSource_spec = { + 'file': 'query', + 'kwds': {'title': 'Help name and source', + 'menuitem': 'test', + 'filepath': __file__, + 'used_names': {'abc'}, + '_htest': True}, + 'msg': "Enter menu item name and help file path\n" + "'', > than 30 chars, and 'abc' are invalid menu item names.\n" + "'' and file does not exist are invalid path items.\n" + "Any url ('www...', 'http...') is accepted.\n" + "Test Browse with and without path, as cannot unittest.\n" + "[Ok] or prints valid entry to shell\n" + ", [Cancel], or [X] prints None to shell" + } + +_io_binding_spec = { + 'file': 'iomenu', + 'kwds': {}, + 'msg': "Test the following bindings.\n" + " to open file from dialog.\n" + "Edit the file.\n" + " to print the file.\n" + " to save the file.\n" + " to save-as another file.\n" + " to save-copy-as another file.\n" + "Check that changes were saved by opening the file elsewhere." + } + +_multi_call_spec = { + 'file': 'multicall', + 'kwds': {}, + 'msg': "The following should trigger a print to console or IDLE Shell.\n" + "Entering and leaving the text area, key entry, ,\n" + ", , , \n" + ", and focusing elsewhere." + } + +_module_browser_spec = { + 'file': 'browser', + 'kwds': {}, + 'msg': textwrap.dedent(""" + "Inspect names of module, class(with superclass if applicable), + "methods and functions. Toggle nested items. Double clicking + "on items prints a traceback for an exception that is ignored.""") + } + +_multistatus_bar_spec = { + 'file': 'statusbar', + 'kwds': {}, + 'msg': "Ensure presence of multi-status bar below text area.\n" + "Click 'Update Status' to change the status text" + } + +PathBrowser_spec = { + 'file': 'pathbrowser', + 'kwds': {'_htest': True}, + 'msg': "Test for correct display of all paths in sys.path.\n" + "Toggle nested items out to the lowest level.\n" + "Double clicking on an item prints a traceback\n" + "for an exception that is ignored." + } + +_percolator_spec = { + 'file': 'percolator', + 'kwds': {}, + 'msg': "There are two tracers which can be toggled using a checkbox.\n" + "Toggling a tracer 'on' by checking it should print tracer " + "output to the console or to the IDLE shell.\n" + "If both the tracers are 'on', the output from the tracer which " + "was switched 'on' later, should be printed first\n" + "Test for actions like text entry, and removal." + } + +Query_spec = { + 'file': 'query', + 'kwds': {'title': 'Query', + 'message': 'Enter something', + 'text0': 'Go', + '_htest': True}, + 'msg': "Enter with or [Ok]. Print valid entry to Shell\n" + "Blank line, after stripping, is ignored\n" + "Close dialog with valid entry, , [Cancel], [X]" + } + + +_replace_dialog_spec = { + 'file': 'replace', + 'kwds': {}, + 'msg': "Click the 'Replace' button.\n" + "Test various replace options in the 'Replace dialog'.\n" + "Click [Close] or [X] to close the 'Replace Dialog'." + } + +_scrolled_list_spec = { + 'file': 'scrolledlist', + 'kwds': {}, + 'msg': "You should see a scrollable list of items\n" + "Selecting (clicking) or double clicking an item " + "prints the name to the console or Idle shell.\n" + "Right clicking an item will display a popup." + } + +_search_dialog_spec = { + 'file': 'search', + 'kwds': {}, + 'msg': "Click the 'Search' button.\n" + "Test various search options in the 'Search dialog'.\n" + "Click [Close] or [X] to close the 'Search Dialog'." + } + +_searchbase_spec = { + 'file': 'searchbase', + 'kwds': {}, + 'msg': "Check the appearance of the base search dialog\n" + "Its only action is to close." + } + +show_idlehelp_spec = { + 'file': 'help', + 'kwds': {}, + 'msg': "If the help text displays, this works.\n" + "Text is selectable. Window is scrollable." + } + +_sidebar_number_scrolling_spec = { + 'file': 'sidebar', + 'kwds': {}, + 'msg': textwrap.dedent("""\ + 1. Click on the line numbers and drag down below the edge of the + window, moving the mouse a bit and then leaving it there for a + while. The text and line numbers should gradually scroll down, + with the selection updated continuously. + + 2. With the lines still selected, click on a line number above + or below the selected lines. Only the line whose number was + clicked should be selected. + + 3. Repeat step #1, dragging to above the window. The text and + line numbers should gradually scroll up, with the selection + updated continuously. + + 4. Repeat step #2, clicking a line number below the selection."""), + } + +_stackbrowser_spec = { + 'file': 'stackviewer', + 'kwds': {}, + 'msg': "A stacktrace for a NameError exception.\n" + "Should have NameError and 1 traceback line." + } + +_tooltip_spec = { + 'file': 'tooltip', + 'kwds': {}, + 'msg': "Place mouse cursor over both the buttons\n" + "A tooltip should appear with some text." + } + +_tree_widget_spec = { + 'file': 'tree', + 'kwds': {}, + 'msg': "The canvas is scrollable.\n" + "Click on folders up to the lowest level." + } + +_undo_delegator_spec = { + 'file': 'undo', + 'kwds': {}, + 'msg': "Click [Undo] to undo any action.\n" + "Click [Redo] to redo any action.\n" + "Click [Dump] to dump the current state " + "by printing to the console or the IDLE shell.\n" + } + +ViewWindow_spec = { + 'file': 'textview', + 'kwds': {'title': 'Test textview', + 'contents': 'The quick brown fox jumps over the lazy dog.\n'*35, + '_htest': True}, + 'msg': "Test for read-only property of text.\n" + "Select text, scroll window, close" + } + +_widget_redirector_spec = { + 'file': 'redirector', + 'kwds': {}, + 'msg': "Every text insert should be printed to the console " + "or the IDLE shell." + } + +def run(*tests): + "Run callables in tests." + root = tk.Tk() + root.title('IDLE htest') + root.resizable(0, 0) + + # A scrollable Label-like constant width text widget. + frameLabel = tk.Frame(root, padx=10) + frameLabel.pack() + text = tk.Text(frameLabel, wrap='word') + text.configure(bg=root.cget('bg'), relief='flat', height=4, width=70) + scrollbar = Scrollbar(frameLabel, command=text.yview) + text.config(yscrollcommand=scrollbar.set) + scrollbar.pack(side='right', fill='y', expand=False) + text.pack(side='left', fill='both', expand=True) + + test_list = [] # Make list of (spec, callable) tuples. + if tests: + for test in tests: + test_spec = globals()[test.__name__ + '_spec'] + test_spec['name'] = test.__name__ + test_list.append((test_spec, test)) + else: + for key, dic in globals().items(): + if key.endswith('_spec'): + test_name = key[:-5] + test_spec = dic + test_spec['name'] = test_name + mod = import_module('idlelib.' + test_spec['file']) + test = getattr(mod, test_name) + test_list.append((test_spec, test)) + test_list.reverse() # So can pop in proper order in next_test. + + test_name = tk.StringVar(root) + callable_object = None + test_kwds = None + + def next_test(): + nonlocal test_name, callable_object, test_kwds + if len(test_list) == 1: + next_button.pack_forget() + test_spec, callable_object = test_list.pop() + test_kwds = test_spec['kwds'] + test_name.set('Test ' + test_spec['name']) + + text['state'] = 'normal' # Enable text replacement. + text.delete('1.0', 'end') + text.insert("1.0", test_spec['msg']) + text['state'] = 'disabled' # Restore read-only property. + + def run_test(_=None): + widget = callable_object(root, **test_kwds) + try: + print(widget.result) # Only true for query classes(?). + except AttributeError: + pass + + def close(_=None): + root.destroy() + + button = tk.Button(root, textvariable=test_name, + default='active', command=run_test) + next_button = tk.Button(root, text="Next", command=next_test) + button.pack() + next_button.pack() + next_button.focus_set() + root.bind('', run_test) + root.bind('', close) + + next_test() + root.mainloop() + + +if __name__ == '__main__': + run() diff --git a/Lib/idlelib/idle_test/mock_idle.py b/Lib/idlelib/idle_test/mock_idle.py new file mode 100644 index 00000000000..71fa480ce4d --- /dev/null +++ b/Lib/idlelib/idle_test/mock_idle.py @@ -0,0 +1,61 @@ +'''Mock classes that imitate idlelib modules or classes. + +Attributes and methods will be added as needed for tests. +''' + +from idlelib.idle_test.mock_tk import Text + +class Func: + '''Record call, capture args, return/raise result set by test. + + When mock function is called, set or use attributes: + self.called - increment call number even if no args, kwds passed. + self.args - capture positional arguments. + self.kwds - capture keyword arguments. + self.result - return or raise value set in __init__. + self.return_self - return self instead, to mock query class return. + + Most common use will probably be to mock instance methods. + Given class instance, can set and delete as instance attribute. + Mock_tk.Var and Mbox_func are special variants of this. + ''' + def __init__(self, result=None, return_self=False): + self.called = 0 + self.result = result + self.return_self = return_self + self.args = None + self.kwds = None + def __call__(self, *args, **kwds): + self.called += 1 + self.args = args + self.kwds = kwds + if isinstance(self.result, BaseException): + raise self.result + elif self.return_self: + return self + else: + return self.result + + +class Editor: + '''Minimally imitate editor.EditorWindow class. + ''' + def __init__(self, flist=None, filename=None, key=None, root=None, + text=None): # Allow real Text with mock Editor. + self.text = text or Text() + self.undo = UndoDelegator() + + def get_selection_indices(self): + first = self.text.index('1.0') + last = self.text.index('end') + return first, last + + +class UndoDelegator: + '''Minimally imitate undo.UndoDelegator class. + ''' + # A real undo block is only needed for user interaction. + def undo_block_start(*args): + pass + def undo_block_stop(*args): + pass diff --git a/Lib/idlelib/idle_test/mock_tk.py b/Lib/idlelib/idle_test/mock_tk.py new file mode 100644 index 00000000000..8304734b847 --- /dev/null +++ b/Lib/idlelib/idle_test/mock_tk.py @@ -0,0 +1,307 @@ +"""Classes that replace tkinter gui objects used by an object being tested. + +A gui object is anything with a master or parent parameter, which is +typically required in spite of what the doc strings say. +""" +import re +from _tkinter import TclError + + +class Event: + '''Minimal mock with attributes for testing event handlers. + + This is not a gui object, but is used as an argument for callbacks + that access attributes of the event passed. If a callback ignores + the event, other than the fact that is happened, pass 'event'. + + Keyboard, mouse, window, and other sources generate Event instances. + Event instances have the following attributes: serial (number of + event), time (of event), type (of event as number), widget (in which + event occurred), and x,y (position of mouse). There are other + attributes for specific events, such as keycode for key events. + tkinter.Event.__doc__ has more but is still not complete. + ''' + def __init__(self, **kwds): + "Create event with attributes needed for test" + self.__dict__.update(kwds) + + +class Var: + "Use for String/Int/BooleanVar: incomplete" + def __init__(self, master=None, value=None, name=None): + self.master = master + self.value = value + self.name = name + def set(self, value): + self.value = value + def get(self): + return self.value + + +class Mbox_func: + """Generic mock for messagebox functions, which all have the same signature. + + Instead of displaying a message box, the mock's call method saves the + arguments as instance attributes, which test functions can then examine. + The test can set the result returned to ask function + """ + def __init__(self, result=None): + self.result = result # Return None for all show funcs + def __call__(self, title, message, *args, **kwds): + # Save all args for possible examination by tester + self.title = title + self.message = message + self.args = args + self.kwds = kwds + return self.result # Set by tester for ask functions + + +class Mbox: + """Mock for tkinter.messagebox with an Mbox_func for each function. + + Example usage in test_module.py for testing functions in module.py: + --- +from idlelib.idle_test.mock_tk import Mbox +import module + +orig_mbox = module.messagebox +showerror = Mbox.showerror # example, for attribute access in test methods + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + module.messagebox = Mbox + + @classmethod + def tearDownClass(cls): + module.messagebox = orig_mbox + --- + For 'ask' functions, set func.result return value before calling the method + that uses the message function. When messagebox functions are the + only GUI calls in a method, this replacement makes the method GUI-free, + """ + askokcancel = Mbox_func() # True or False + askquestion = Mbox_func() # 'yes' or 'no' + askretrycancel = Mbox_func() # True or False + askyesno = Mbox_func() # True or False + askyesnocancel = Mbox_func() # True, False, or None + showerror = Mbox_func() # None + showinfo = Mbox_func() # None + showwarning = Mbox_func() # None + + +class Text: + """A semi-functional non-gui replacement for tkinter.Text text editors. + + The mock's data model is that a text is a list of \n-terminated lines. + The mock adds an empty string at the beginning of the list so that the + index of actual lines start at 1, as with Tk. The methods never see this. + Tk initializes files with a terminal \n that cannot be deleted. It is + invisible in the sense that one cannot move the cursor beyond it. + + This class is only tested (and valid) with strings of ascii chars. + For testing, we are not concerned with Tk Text's treatment of, + for instance, 0-width characters or character + accent. + """ + def __init__(self, master=None, cnf={}, **kw): + '''Initialize mock, non-gui, text-only Text widget. + + At present, all args are ignored. Almost all affect visual behavior. + There are just a few Text-only options that affect text behavior. + ''' + self.data = ['', '\n'] + + def index(self, index): + "Return string version of index decoded according to current text." + return "%s.%s" % self._decode(index, endflag=1) + + def _decode(self, index, endflag=0): + """Return a (line, char) tuple of int indexes into self.data. + + This implements .index without converting the result back to a string. + The result is constrained by the number of lines and linelengths of + self.data. For many indexes, the result is initially (1, 0). + + The input index may have any of several possible forms: + * line.char float: converted to 'line.char' string; + * 'line.char' string, where line and char are decimal integers; + * 'line.char lineend', where lineend='lineend' (and char is ignored); + * 'line.end', where end='end' (same as above); + * 'insert', the positions before terminal \n; + * 'end', whose meaning depends on the endflag passed to ._endex. + * 'sel.first' or 'sel.last', where sel is a tag -- not implemented. + """ + if isinstance(index, (float, bytes)): + index = str(index) + try: + index=index.lower() + except AttributeError: + raise TclError('bad text index "%s"' % index) from None + + lastline = len(self.data) - 1 # same as number of text lines + if index == 'insert': + return lastline, len(self.data[lastline]) - 1 + elif index == 'end': + return self._endex(endflag) + + line, char = index.split('.') + line = int(line) + + # Out of bounds line becomes first or last ('end') index + if line < 1: + return 1, 0 + elif line > lastline: + return self._endex(endflag) + + linelength = len(self.data[line]) -1 # position before/at \n + if char.endswith(' lineend') or char == 'end': + return line, linelength + # Tk requires that ignored chars before ' lineend' be valid int + if m := re.fullmatch(r'end-(\d*)c', char, re.A): # Used by hyperparser. + return line, linelength - int(m.group(1)) + + # Out of bounds char becomes first or last index of line + char = int(char) + if char < 0: + char = 0 + elif char > linelength: + char = linelength + return line, char + + def _endex(self, endflag): + '''Return position for 'end' or line overflow corresponding to endflag. + + -1: position before terminal \n; for .insert(), .delete + 0: position after terminal \n; for .get, .delete index 1 + 1: same viewed as beginning of non-existent next line (for .index) + ''' + n = len(self.data) + if endflag == 1: + return n, 0 + else: + n -= 1 + return n, len(self.data[n]) + endflag + + def insert(self, index, chars): + "Insert chars before the character at index." + + if not chars: # ''.splitlines() is [], not [''] + return + chars = chars.splitlines(True) + if chars[-1][-1] == '\n': + chars.append('') + line, char = self._decode(index, -1) + before = self.data[line][:char] + after = self.data[line][char:] + self.data[line] = before + chars[0] + self.data[line+1:line+1] = chars[1:] + self.data[line+len(chars)-1] += after + + def get(self, index1, index2=None): + "Return slice from index1 to index2 (default is 'index1+1')." + + startline, startchar = self._decode(index1) + if index2 is None: + endline, endchar = startline, startchar+1 + else: + endline, endchar = self._decode(index2) + + if startline == endline: + return self.data[startline][startchar:endchar] + else: + lines = [self.data[startline][startchar:]] + for i in range(startline+1, endline): + lines.append(self.data[i]) + lines.append(self.data[endline][:endchar]) + return ''.join(lines) + + def delete(self, index1, index2=None): + '''Delete slice from index1 to index2 (default is 'index1+1'). + + Adjust default index2 ('index+1) for line ends. + Do not delete the terminal \n at the very end of self.data ([-1][-1]). + ''' + startline, startchar = self._decode(index1, -1) + if index2 is None: + if startchar < len(self.data[startline])-1: + # not deleting \n + endline, endchar = startline, startchar+1 + elif startline < len(self.data) - 1: + # deleting non-terminal \n, convert 'index1+1 to start of next line + endline, endchar = startline+1, 0 + else: + # do not delete terminal \n if index1 == 'insert' + return + else: + endline, endchar = self._decode(index2, -1) + # restricting end position to insert position excludes terminal \n + + if startline == endline and startchar < endchar: + self.data[startline] = self.data[startline][:startchar] + \ + self.data[startline][endchar:] + elif startline < endline: + self.data[startline] = self.data[startline][:startchar] + \ + self.data[endline][endchar:] + startline += 1 + for i in range(startline, endline+1): + del self.data[startline] + + def compare(self, index1, op, index2): + line1, char1 = self._decode(index1) + line2, char2 = self._decode(index2) + if op == '<': + return line1 < line2 or line1 == line2 and char1 < char2 + elif op == '<=': + return line1 < line2 or line1 == line2 and char1 <= char2 + elif op == '>': + return line1 > line2 or line1 == line2 and char1 > char2 + elif op == '>=': + return line1 > line2 or line1 == line2 and char1 >= char2 + elif op == '==': + return line1 == line2 and char1 == char2 + elif op == '!=': + return line1 != line2 or char1 != char2 + else: + raise TclError('''bad comparison operator "%s": ''' + '''must be <, <=, ==, >=, >, or !=''' % op) + + # The following Text methods normally do something and return None. + # Whether doing nothing is sufficient for a test will depend on the test. + + def mark_set(self, name, index): + "Set mark *name* before the character at index." + pass + + def mark_unset(self, *markNames): + "Delete all marks in markNames." + + def tag_remove(self, tagName, index1, index2=None): + "Remove tag tagName from all characters between index1 and index2." + pass + + # The following Text methods affect the graphics screen and return None. + # Doing nothing should always be sufficient for tests. + + def scan_dragto(self, x, y): + "Adjust the view of the text according to scan_mark" + + def scan_mark(self, x, y): + "Remember the current X, Y coordinates." + + def see(self, index): + "Scroll screen to make the character at INDEX is visible." + pass + + # The following is a Misc method inherited by Text. + # It should properly go in a Misc mock, but is included here for now. + + def bind(sequence=None, func=None, add=None): + "Bind to this widget at event sequence a call to function func." + pass + + +class Entry: + "Mock for tkinter.Entry." + def focus_set(self): + pass diff --git a/Lib/idlelib/idle_test/template.py b/Lib/idlelib/idle_test/template.py new file mode 100644 index 00000000000..725a55b9c47 --- /dev/null +++ b/Lib/idlelib/idle_test/template.py @@ -0,0 +1,30 @@ +"Test , coverage %." + +from idlelib import zzdummy +import unittest +from test.support import requires +from tkinter import Tk + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertTrue(True) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autocomplete.py b/Lib/idlelib/idle_test/test_autocomplete.py new file mode 100644 index 00000000000..a811363c18d --- /dev/null +++ b/Lib/idlelib/idle_test/test_autocomplete.py @@ -0,0 +1,305 @@ +"Test autocomplete, coverage 93%." + +import unittest +from unittest.mock import Mock, patch +from test.support import requires +from tkinter import Tk, Text +import os +import __main__ + +import idlelib.autocomplete as ac +import idlelib.autocomplete_w as acw +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Event + + +class DummyEditwin: + def __init__(self, root, text): + self.root = root + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' # Currently not used by autocomplete. + + +class AutoCompleteTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editor = DummyEditwin(cls.root, cls.text) + + @classmethod + def tearDownClass(cls): + del cls.editor, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.delete('1.0', 'end') + self.autocomplete = ac.AutoComplete(self.editor) + + def test_init(self): + self.assertEqual(self.autocomplete.editwin, self.editor) + self.assertEqual(self.autocomplete.text, self.text) + + def test_make_autocomplete_window(self): + testwin = self.autocomplete._make_autocomplete_window() + self.assertIsInstance(testwin, acw.AutoCompleteWindow) + + def test_remove_autocomplete_window(self): + acp = self.autocomplete + acp.autocompletewindow = m = Mock() + acp._remove_autocomplete_window() + m.hide_window.assert_called_once() + self.assertIsNone(acp.autocompletewindow) + + def test_force_open_completions_event(self): + # Call _open_completions and break. + acp = self.autocomplete + open_c = Func() + acp.open_completions = open_c + self.assertEqual(acp.force_open_completions_event('event'), 'break') + self.assertEqual(open_c.args[0], ac.FORCE) + + def test_autocomplete_event(self): + Equal = self.assertEqual + acp = self.autocomplete + + # Result of autocomplete event: If modified tab, None. + ev = Event(mc_state=True) + self.assertIsNone(acp.autocomplete_event(ev)) + del ev.mc_state + + # If tab after whitespace, None. + self.text.insert('1.0', ' """Docstring.\n ') + self.assertIsNone(acp.autocomplete_event(ev)) + self.text.delete('1.0', 'end') + + # If active autocomplete window, complete() and 'break'. + self.text.insert('1.0', 're.') + acp.autocompletewindow = mock = Mock() + mock.is_active = Mock(return_value=True) + Equal(acp.autocomplete_event(ev), 'break') + mock.complete.assert_called_once() + acp.autocompletewindow = None + + # If no active autocomplete window, open_completions(), None/break. + open_c = Func(result=False) + acp.open_completions = open_c + Equal(acp.autocomplete_event(ev), None) + Equal(open_c.args[0], ac.TAB) + open_c.result = True + Equal(acp.autocomplete_event(ev), 'break') + Equal(open_c.args[0], ac.TAB) + + def test_try_open_completions_event(self): + Equal = self.assertEqual + text = self.text + acp = self.autocomplete + trycompletions = acp.try_open_completions_event + after = Func(result='after1') + acp.text.after = after + + # If no text or trigger, after not called. + trycompletions() + Equal(after.called, 0) + text.insert('1.0', 're') + trycompletions() + Equal(after.called, 0) + + # Attribute needed, no existing callback. + text.insert('insert', ' re.') + acp._delayed_completion_id = None + trycompletions() + Equal(acp._delayed_completion_index, text.index('insert')) + Equal(after.args, + (acp.popupwait, acp._delayed_open_completions, ac.TRY_A)) + cb1 = acp._delayed_completion_id + Equal(cb1, 'after1') + + # File needed, existing callback cancelled. + text.insert('insert', ' "./Lib/') + after.result = 'after2' + cancel = Func() + acp.text.after_cancel = cancel + trycompletions() + Equal(acp._delayed_completion_index, text.index('insert')) + Equal(cancel.args, (cb1,)) + Equal(after.args, + (acp.popupwait, acp._delayed_open_completions, ac.TRY_F)) + Equal(acp._delayed_completion_id, 'after2') + + def test_delayed_open_completions(self): + Equal = self.assertEqual + acp = self.autocomplete + open_c = Func() + acp.open_completions = open_c + self.text.insert('1.0', '"dict.') + + # Set autocomplete._delayed_completion_id to None. + # Text index changed, don't call open_completions. + acp._delayed_completion_id = 'after' + acp._delayed_completion_index = self.text.index('insert+1c') + acp._delayed_open_completions('dummy') + self.assertIsNone(acp._delayed_completion_id) + Equal(open_c.called, 0) + + # Text index unchanged, call open_completions. + acp._delayed_completion_index = self.text.index('insert') + acp._delayed_open_completions((1, 2, 3, ac.FILES)) + self.assertEqual(open_c.args[0], (1, 2, 3, ac.FILES)) + + def test_oc_cancel_comment(self): + none = self.assertIsNone + acp = self.autocomplete + + # Comment is in neither code or string. + acp._delayed_completion_id = 'after' + after = Func(result='after') + acp.text.after_cancel = after + self.text.insert(1.0, '# comment') + none(acp.open_completions(ac.TAB)) # From 'else' after 'elif'. + none(acp._delayed_completion_id) + + def test_oc_no_list(self): + acp = self.autocomplete + fetch = Func(result=([],[])) + acp.fetch_completions = fetch + self.text.insert('1.0', 'object') + self.assertIsNone(acp.open_completions(ac.TAB)) + self.text.insert('insert', '.') + self.assertIsNone(acp.open_completions(ac.TAB)) + self.assertEqual(fetch.called, 2) + + + def test_open_completions_none(self): + # Test other two None returns. + none = self.assertIsNone + acp = self.autocomplete + + # No object for attributes or need call not allowed. + self.text.insert(1.0, '.') + none(acp.open_completions(ac.TAB)) + self.text.insert('insert', ' int().') + none(acp.open_completions(ac.TAB)) + + # Blank or quote trigger 'if complete ...'. + self.text.delete(1.0, 'end') + self.assertFalse(acp.open_completions(ac.TAB)) + self.text.insert('1.0', '"') + self.assertFalse(acp.open_completions(ac.TAB)) + self.text.delete('1.0', 'end') + + class dummy_acw: + __init__ = Func() + show_window = Func(result=False) + hide_window = Func() + + def test_open_completions(self): + # Test completions of files and attributes. + acp = self.autocomplete + fetch = Func(result=(['tem'],['tem', '_tem'])) + acp.fetch_completions = fetch + def make_acw(): return self.dummy_acw() + acp._make_autocomplete_window = make_acw + + self.text.insert('1.0', 'int.') + acp.open_completions(ac.TAB) + self.assertIsInstance(acp.autocompletewindow, self.dummy_acw) + self.text.delete('1.0', 'end') + + # Test files. + self.text.insert('1.0', '"t') + self.assertTrue(acp.open_completions(ac.TAB)) + self.text.delete('1.0', 'end') + + def test_completion_kwds(self): + self.assertIn('and', ac.completion_kwds) + self.assertIn('case', ac.completion_kwds) + self.assertNotIn('None', ac.completion_kwds) + + def test_fetch_completions(self): + # Test that fetch_completions returns 2 lists: + # For attribute completion, a large list containing all variables, and + # a small list containing non-private variables. + # For file completion, a large list containing all files in the path, + # and a small list containing files that do not start with '.'. + acp = self.autocomplete + small, large = acp.fetch_completions( + '', ac.ATTRS) + if hasattr(__main__, '__file__') and __main__.__file__ != ac.__file__: + self.assertNotIn('AutoComplete', small) # See issue 36405. + + # Test attributes + s, b = acp.fetch_completions('', ac.ATTRS) + self.assertLess(len(small), len(large)) + self.assertTrue(all(filter(lambda x: x.startswith('_'), s))) + self.assertTrue(any(filter(lambda x: x.startswith('_'), b))) + + # Test smalll should respect to __all__. + with patch.dict('__main__.__dict__', {'__all__': ['a', 'b']}): + s, b = acp.fetch_completions('', ac.ATTRS) + self.assertEqual(s, ['a', 'b']) + self.assertIn('__name__', b) # From __main__.__dict__. + self.assertIn('sum', b) # From __main__.__builtins__.__dict__. + self.assertIn('nonlocal', b) # From keyword.kwlist. + pos = b.index('False') # Test False not included twice. + self.assertNotEqual(b[pos+1], 'False') + + # Test attributes with name entity. + mock = Mock() + mock._private = Mock() + with patch.dict('__main__.__dict__', {'foo': mock}): + s, b = acp.fetch_completions('foo', ac.ATTRS) + self.assertNotIn('_private', s) + self.assertIn('_private', b) + self.assertEqual(s, [i for i in sorted(dir(mock)) if i[:1] != '_']) + self.assertEqual(b, sorted(dir(mock))) + + # Test files + def _listdir(path): + # This will be patch and used in fetch_completions. + if path == '.': + return ['foo', 'bar', '.hidden'] + return ['monty', 'python', '.hidden'] + + with patch.object(os, 'listdir', _listdir): + s, b = acp.fetch_completions('', ac.FILES) + self.assertEqual(s, ['bar', 'foo']) + self.assertEqual(b, ['.hidden', 'bar', 'foo']) + + s, b = acp.fetch_completions('~', ac.FILES) + self.assertEqual(s, ['monty', 'python']) + self.assertEqual(b, ['.hidden', 'monty', 'python']) + + def test_get_entity(self): + # Test that a name is in the namespace of sys.modules and + # __main__.__dict__. + acp = self.autocomplete + Equal = self.assertEqual + + Equal(acp.get_entity('int'), int) + + # Test name from sys.modules. + mock = Mock() + with patch.dict('sys.modules', {'tempfile': mock}): + Equal(acp.get_entity('tempfile'), mock) + + # Test name from __main__.__dict__. + di = {'foo': 10, 'bar': 20} + with patch.dict('__main__.__dict__', {'d': di}): + Equal(acp.get_entity('d'), di) + + # Test name not in namespace. + with patch.dict('__main__.__dict__', {}): + with self.assertRaises(NameError): + acp.get_entity('not_exist') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autocomplete_w.py b/Lib/idlelib/idle_test/test_autocomplete_w.py new file mode 100644 index 00000000000..a59a375c90f --- /dev/null +++ b/Lib/idlelib/idle_test/test_autocomplete_w.py @@ -0,0 +1,32 @@ +"Test autocomplete_w, coverage 11%." + +import unittest +from test.support import requires +from tkinter import Tk, Text + +import idlelib.autocomplete_w as acw + + +class AutoCompleteWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.acw = acw.AutoCompleteWindow(cls.text, tags=None) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.acw + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertEqual(self.acw.widget, self.text) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autoexpand.py b/Lib/idlelib/idle_test/test_autoexpand.py new file mode 100644 index 00000000000..e734a8be714 --- /dev/null +++ b/Lib/idlelib/idle_test/test_autoexpand.py @@ -0,0 +1,155 @@ +"Test autoexpand, coverage 100%." + +from idlelib.autoexpand import AutoExpand +import unittest +from test.support import requires +from tkinter import Text, Tk + + +class DummyEditwin: + # AutoExpand.__init__ only needs .text + def __init__(self, text): + self.text = text + +class AutoExpandTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.tk = Tk() + cls.text = Text(cls.tk) + cls.auto_expand = AutoExpand(DummyEditwin(cls.text)) + cls.auto_expand.bell = lambda: None + +# If mock_tk.Text._decode understood indexes 'insert' with suffixed 'linestart', +# 'wordstart', and 'lineend', used by autoexpand, we could use the following +# to run these test on non-gui machines (but check bell). +## try: +## requires('gui') +## #raise ResourceDenied() # Uncomment to test mock. +## except ResourceDenied: +## from idlelib.idle_test.mock_tk import Text +## cls.text = Text() +## cls.text.bell = lambda: None +## else: +## from tkinter import Tk, Text +## cls.tk = Tk() +## cls.text = Text(cls.tk) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.auto_expand + if hasattr(cls, 'tk'): + cls.tk.destroy() + del cls.tk + + def tearDown(self): + self.text.delete('1.0', 'end') + + def test_get_prevword(self): + text = self.text + previous = self.auto_expand.getprevword + equal = self.assertEqual + + equal(previous(), '') + + text.insert('insert', 't') + equal(previous(), 't') + + text.insert('insert', 'his') + equal(previous(), 'this') + + text.insert('insert', ' ') + equal(previous(), '') + + text.insert('insert', 'is') + equal(previous(), 'is') + + text.insert('insert', '\nsample\nstring') + equal(previous(), 'string') + + text.delete('3.0', 'insert') + equal(previous(), '') + + text.delete('1.0', 'end') + equal(previous(), '') + + def test_before_only(self): + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + self.text.insert('insert', 'ab ac bx ad ab a') + equal(self.auto_expand.getwords(), ['ab', 'ad', 'ac', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ad') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'a') + + def test_after_only(self): + # Also add punctuation 'noise' that should be ignored. + text = self.text + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + text.insert('insert', 'a, [ab] ac: () bx"" cd ac= ad ya') + text.mark_set('insert', '1.1') + equal(self.auto_expand.getwords(), ['ab', 'ac', 'ad', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'ad') + expand('event') + equal(previous(), 'a') + + def test_both_before_after(self): + text = self.text + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + text.insert('insert', 'ab xy yz\n') + text.insert('insert', 'a ac by ac') + + text.mark_set('insert', '2.1') + equal(self.auto_expand.getwords(), ['ab', 'ac', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'a') + + def test_other_expand_cases(self): + text = self.text + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + # no expansion candidate found + equal(self.auto_expand.getwords(), []) + equal(expand('event'), 'break') + + text.insert('insert', 'bx cy dz a') + equal(self.auto_expand.getwords(), []) + + # reset state by successfully expanding once + # move cursor to another position and expand again + text.insert('insert', 'ac xy a ac ad a') + text.mark_set('insert', '1.7') + expand('event') + initial_state = self.auto_expand.state + text.mark_set('insert', '1.end') + expand('event') + new_state = self.auto_expand.state + self.assertNotEqual(initial_state, new_state) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_browser.py b/Lib/idlelib/idle_test/test_browser.py new file mode 100644 index 00000000000..6cfea3888cd --- /dev/null +++ b/Lib/idlelib/idle_test/test_browser.py @@ -0,0 +1,257 @@ +"Test browser, coverage 90%." + +from idlelib import browser +from test.support import requires +import unittest +from unittest import mock +from idlelib.idle_test.mock_idle import Func +from idlelib.util import py_extensions + +from collections import deque +import os.path +import pyclbr +from tkinter import Tk + +from idlelib.tree import TreeNode + + +class ModuleBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.mb = browser.ModuleBrowser(cls.root, __file__, _utest=True) + + @classmethod + def tearDownClass(cls): + cls.mb.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root, cls.mb + + def test_init(self): + mb = self.mb + eq = self.assertEqual + eq(mb.path, __file__) + eq(pyclbr._modules, {}) + self.assertIsInstance(mb.node, TreeNode) + self.assertIsNotNone(browser.file_open) + + def test_settitle(self): + mb = self.mb + self.assertIn(os.path.basename(__file__), mb.top.title()) + self.assertEqual(mb.top.iconname(), 'Module Browser') + + def test_rootnode(self): + mb = self.mb + rn = mb.rootnode() + self.assertIsInstance(rn, browser.ModuleBrowserTreeItem) + + def test_close(self): + mb = self.mb + mb.top.destroy = Func() + mb.node.destroy = Func() + mb.close() + self.assertTrue(mb.top.destroy.called) + self.assertTrue(mb.node.destroy.called) + del mb.top.destroy, mb.node.destroy + + def test_is_browseable_extension(self): + path = "/path/to/file" + for ext in py_extensions: + with self.subTest(ext=ext): + filename = f'{path}{ext}' + actual = browser.is_browseable_extension(filename) + expected = ext not in browser.browseable_extension_blocklist + self.assertEqual(actual, expected) + + +# Nested tree same as in test_pyclbr.py except for supers on C0. C1. +mb = pyclbr +module, fname = 'test', 'test.py' +C0 = mb.Class(module, 'C0', ['base'], fname, 1, end_lineno=9) +F1 = mb._nest_function(C0, 'F1', 3, 5) +C1 = mb._nest_class(C0, 'C1', 6, 9, ['']) +C2 = mb._nest_class(C1, 'C2', 7, 9) +F3 = mb._nest_function(C2, 'F3', 9, 9) +f0 = mb.Function(module, 'f0', fname, 11, end_lineno=15) +f1 = mb._nest_function(f0, 'f1', 12, 14) +f2 = mb._nest_function(f1, 'f2', 13, 13) +c1 = mb._nest_class(f0, 'c1', 15, 15) +mock_pyclbr_tree = {'C0': C0, 'f0': f0} + +# Adjust C0.name, C1.name so tests do not depend on order. +browser.transform_children(mock_pyclbr_tree, 'test') # C0(base) +browser.transform_children(C0.children) # C1() + +# The class below checks that the calls above are correct +# and that duplicate calls have no effect. + + +class TransformChildrenTest(unittest.TestCase): + + def test_transform_module_children(self): + eq = self.assertEqual + transform = browser.transform_children + # Parameter matches tree module. + tcl = list(transform(mock_pyclbr_tree, 'test')) + eq(tcl, [C0, f0]) + eq(tcl[0].name, 'C0(base)') + eq(tcl[1].name, 'f0') + # Check that second call does not change suffix. + tcl = list(transform(mock_pyclbr_tree, 'test')) + eq(tcl[0].name, 'C0(base)') + # Nothing to traverse if parameter name isn't same as tree module. + tcl = list(transform(mock_pyclbr_tree, 'different name')) + eq(tcl, []) + + def test_transform_node_children(self): + eq = self.assertEqual + transform = browser.transform_children + # Class with two children, one name altered. + tcl = list(transform(C0.children)) + eq(tcl, [F1, C1]) + eq(tcl[0].name, 'F1') + eq(tcl[1].name, 'C1()') + tcl = list(transform(C0.children)) + eq(tcl[1].name, 'C1()') + # Function with two children. + eq(list(transform(f0.children)), [f1, c1]) + + +class ModuleBrowserTreeItemTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.mbt = browser.ModuleBrowserTreeItem(fname) + + def test_init(self): + self.assertEqual(self.mbt.file, fname) + + def test_gettext(self): + self.assertEqual(self.mbt.GetText(), fname) + + def test_geticonname(self): + self.assertEqual(self.mbt.GetIconName(), 'python') + + def test_isexpandable(self): + self.assertTrue(self.mbt.IsExpandable()) + + def test_listchildren(self): + save_rex = browser.pyclbr.readmodule_ex + save_tc = browser.transform_children + browser.pyclbr.readmodule_ex = Func(result=mock_pyclbr_tree) + browser.transform_children = Func(result=[f0, C0]) + try: + self.assertEqual(self.mbt.listchildren(), [f0, C0]) + finally: + browser.pyclbr.readmodule_ex = save_rex + browser.transform_children = save_tc + + def test_getsublist(self): + mbt = self.mbt + mbt.listchildren = Func(result=[f0, C0]) + sub0, sub1 = mbt.GetSubList() + del mbt.listchildren + self.assertIsInstance(sub0, browser.ChildBrowserTreeItem) + self.assertIsInstance(sub1, browser.ChildBrowserTreeItem) + self.assertEqual(sub0.name, 'f0') + self.assertEqual(sub1.name, 'C0(base)') + + @mock.patch('idlelib.browser.file_open') + def test_ondoubleclick(self, fopen): + mbt = self.mbt + + with mock.patch('os.path.exists', return_value=False): + mbt.OnDoubleClick() + fopen.assert_not_called() + + with mock.patch('os.path.exists', return_value=True): + mbt.OnDoubleClick() + fopen.assert_called_once_with(fname) + + +class ChildBrowserTreeItemTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + CBT = browser.ChildBrowserTreeItem + cls.cbt_f1 = CBT(f1) + cls.cbt_C1 = CBT(C1) + cls.cbt_F1 = CBT(F1) + + @classmethod + def tearDownClass(cls): + del cls.cbt_C1, cls.cbt_f1, cls.cbt_F1 + + def test_init(self): + eq = self.assertEqual + eq(self.cbt_C1.name, 'C1()') + self.assertFalse(self.cbt_C1.isfunction) + eq(self.cbt_f1.name, 'f1') + self.assertTrue(self.cbt_f1.isfunction) + + def test_gettext(self): + self.assertEqual(self.cbt_C1.GetText(), 'class C1()') + self.assertEqual(self.cbt_f1.GetText(), 'def f1(...)') + + def test_geticonname(self): + self.assertEqual(self.cbt_C1.GetIconName(), 'folder') + self.assertEqual(self.cbt_f1.GetIconName(), 'python') + + def test_isexpandable(self): + self.assertTrue(self.cbt_C1.IsExpandable()) + self.assertTrue(self.cbt_f1.IsExpandable()) + self.assertFalse(self.cbt_F1.IsExpandable()) + + def test_getsublist(self): + eq = self.assertEqual + CBT = browser.ChildBrowserTreeItem + + f1sublist = self.cbt_f1.GetSubList() + self.assertIsInstance(f1sublist[0], CBT) + eq(len(f1sublist), 1) + eq(f1sublist[0].name, 'f2') + + eq(self.cbt_F1.GetSubList(), []) + + @mock.patch('idlelib.browser.file_open') + def test_ondoubleclick(self, fopen): + goto = fopen.return_value.gotoline = mock.Mock() + self.cbt_F1.OnDoubleClick() + fopen.assert_called() + goto.assert_called() + goto.assert_called_with(self.cbt_F1.obj.lineno) + # Failure test would have to raise OSError or AttributeError. + + +class NestedChildrenTest(unittest.TestCase): + "Test that all the nodes in a nested tree are added to the BrowserTree." + + def test_nested(self): + queue = deque() + actual_names = [] + # The tree items are processed in breadth first order. + # Verify that processing each sublist hits every node and + # in the right order. + expected_names = ['f0', 'C0(base)', + 'f1', 'c1', 'F1', 'C1()', + 'f2', 'C2', + 'F3'] + CBT = browser.ChildBrowserTreeItem + queue.extend((CBT(f0), CBT(C0))) + while queue: + cb = queue.popleft() + sublist = cb.GetSubList() + queue.extend(sublist) + self.assertIn(cb.name, cb.GetText()) + self.assertIn(cb.GetIconName(), ('python', 'folder')) + self.assertIs(cb.IsExpandable(), sublist != []) + actual_names.append(cb.name) + self.assertEqual(actual_names, expected_names) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_calltip.py b/Lib/idlelib/idle_test/test_calltip.py new file mode 100644 index 00000000000..28c196a4267 --- /dev/null +++ b/Lib/idlelib/idle_test/test_calltip.py @@ -0,0 +1,372 @@ +"Test calltip, coverage 76%" + +from idlelib import calltip +import unittest +from unittest.mock import Mock +import textwrap +import types +import re +from idlelib.idle_test.mock_tk import Text +from test.support import MISSING_C_DOCSTRINGS + + +# Test Class TC is used in multiple get_argspec test methods +class TC: + 'doc' + tip = "(ai=None, *b)" + def __init__(self, ai=None, *b): 'doc' + __init__.tip = "(self, ai=None, *b)" + def t1(self): 'doc' + t1.tip = "(self)" + def t2(self, ai, b=None): 'doc' + t2.tip = "(self, ai, b=None)" + def t3(self, ai, *args): 'doc' + t3.tip = "(self, ai, *args)" + def t4(self, *args): 'doc' + t4.tip = "(self, *args)" + def t5(self, ai, b=None, *args, **kw): 'doc' + t5.tip = "(self, ai, b=None, *args, **kw)" + def t6(no, self): 'doc' + t6.tip = "(no, self)" + def __call__(self, ci): 'doc' + __call__.tip = "(self, ci)" + def nd(self): pass # No doc. + # attaching .tip to wrapped methods does not work + @classmethod + def cm(cls, a): 'doc' + @staticmethod + def sm(b): 'doc' + + +tc = TC() +default_tip = calltip._default_callable_argspec +get_spec = calltip.get_argspec + + +class Get_argspecTest(unittest.TestCase): + # The get_spec function must return a string, even if blank. + # Test a variety of objects to be sure that none cause it to raise + # (quite aside from getting as correct an answer as possible). + # The tests of builtins may break if inspect or the docstrings change, + # but a red buildbot is better than a user crash (as has happened). + # For a simple mismatch, change the expected output to the actual. + + @unittest.skipIf(MISSING_C_DOCSTRINGS, + "Signature information for builtins requires docstrings") + def test_builtins(self): + + def tiptest(obj, out): + self.assertEqual(get_spec(obj), out) + + # Python class that inherits builtin methods + class List(list): "List() doc" + + # Simulate builtin with no docstring for default tip test + class SB: __call__ = None + + if List.__doc__ is not None: + tiptest(List, + f'(iterable=(), /)' + f'\n{List.__doc__}') + tiptest(list.__new__, + '(*args, **kwargs)\n' + 'Create and return a new object. ' + 'See help(type) for accurate signature.') + tiptest(list.__init__, + '(self, /, *args, **kwargs)\n' + 'Initialize self. See help(type(self)) for accurate signature.') + append_doc = "\nAppend object to the end of the list." + tiptest(list.append, '(self, object, /)' + append_doc) + tiptest(List.append, '(self, object, /)' + append_doc) + tiptest([].append, '(object, /)' + append_doc) + # The use of 'object' above matches the signature text. + + tiptest(types.MethodType, + '(function, instance, /)\n' + 'Create a bound instance method object.') + tiptest(SB(), default_tip) + + p = re.compile('') + tiptest(re.sub, '''\ +(pattern, repl, string, count=0, flags=0) +Return the string obtained by replacing the leftmost +non-overlapping occurrences of the pattern in string by the +replacement repl. repl can be either a string or a callable; +if a string, backslash escapes in it are processed. If it is +a callable, it's passed the Match object and must return''') + tiptest(p.sub, '''\ +(repl, string, count=0) +Return the string obtained by replacing the leftmost \ +non-overlapping occurrences o...''') + + def test_signature_wrap(self): + if textwrap.TextWrapper.__doc__ is not None: + self.assertEqual(get_spec(textwrap.TextWrapper), '''\ +(width=70, initial_indent='', subsequent_indent='', expand_tabs=True, + replace_whitespace=True, fix_sentence_endings=False, break_long_words=True, + drop_whitespace=True, break_on_hyphens=True, tabsize=8, *, max_lines=None, + placeholder=' [...]') +Object for wrapping/filling text. The public interface consists of +the wrap() and fill() methods; the other methods are just there for +subclasses to override in order to tweak the default behaviour. +If you want to completely replace the main wrapping algorithm, +you\'ll probably have to override _wrap_chunks().''') + + def test_properly_formatted(self): + + def foo(s='a'*100): + pass + + def bar(s='a'*100): + """Hello Guido""" + pass + + def baz(s='a'*100, z='b'*100): + pass + + indent = calltip._INDENT + + sfoo = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa')" + sbar = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa')\nHello Guido" + sbaz = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa', z='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\ + "bbbbbbbbbbbbbbbbb\n" + indent + "bbbbbbbbbbbbbbbbbbbbbb"\ + "bbbbbbbbbbbbbbbbbbbbbb')" + + for func,doc in [(foo, sfoo), (bar, sbar), (baz, sbaz)]: + with self.subTest(func=func, doc=doc): + self.assertEqual(get_spec(func), doc) + + def test_docline_truncation(self): + def f(): pass + f.__doc__ = 'a'*300 + self.assertEqual(get_spec(f), f"()\n{'a'*(calltip._MAX_COLS-3) + '...'}") + + @unittest.skipIf(MISSING_C_DOCSTRINGS, + "Signature information for builtins requires docstrings") + def test_multiline_docstring(self): + # Test fewer lines than max. + self.assertEqual(get_spec(range), + "range(stop) -> range object\n" + "range(start, stop[, step]) -> range object") + + # Test max lines + self.assertEqual(get_spec(bytes), '''\ +bytes(iterable_of_ints) -> bytes +bytes(string, encoding[, errors]) -> bytes +bytes(bytes_or_buffer) -> immutable copy of bytes_or_buffer +bytes(int) -> bytes object of size given by the parameter initialized with null bytes +bytes() -> empty bytes object''') + + def test_multiline_docstring_2(self): + # Test more than max lines + def f(): pass + f.__doc__ = 'a\n' * 15 + self.assertEqual(get_spec(f), '()' + '\na' * calltip._MAX_LINES) + + def test_functions(self): + def t1(): 'doc' + t1.tip = "()" + def t2(a, b=None): 'doc' + t2.tip = "(a, b=None)" + def t3(a, *args): 'doc' + t3.tip = "(a, *args)" + def t4(*args): 'doc' + t4.tip = "(*args)" + def t5(a, b=None, *args, **kw): 'doc' + t5.tip = "(a, b=None, *args, **kw)" + + doc = '\ndoc' if t1.__doc__ is not None else '' + for func in (t1, t2, t3, t4, t5, TC): + with self.subTest(func=func): + self.assertEqual(get_spec(func), func.tip + doc) + + def test_methods(self): + doc = '\ndoc' if TC.__doc__ is not None else '' + for meth in (TC.t1, TC.t2, TC.t3, TC.t4, TC.t5, TC.t6, TC.__call__): + with self.subTest(meth=meth): + self.assertEqual(get_spec(meth), meth.tip + doc) + self.assertEqual(get_spec(TC.cm), "(a)" + doc) + self.assertEqual(get_spec(TC.sm), "(b)" + doc) + + def test_bound_methods(self): + # test that first parameter is correctly removed from argspec + doc = '\ndoc' if TC.__doc__ is not None else '' + for meth, mtip in ((tc.t1, "()"), (tc.t4, "(*args)"), + (tc.t6, "(self)"), (tc.__call__, '(ci)'), + (tc, '(ci)'), (TC.cm, "(a)"),): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip + doc) + + def test_starred_parameter(self): + # test that starred first parameter is *not* removed from argspec + class C: + def m1(*args): pass + c = C() + for meth, mtip in ((C.m1, '(*args)'), (c.m1, "(*args)"),): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_invalid_method_get_spec(self): + class C: + def m2(**kwargs): pass + class Test: + def __call__(*, a): pass + + mtip = calltip._invalid_method + self.assertEqual(get_spec(C().m2), mtip) + self.assertEqual(get_spec(Test()), mtip) + + def test_non_ascii_name(self): + # test that re works to delete a first parameter name that + # includes non-ascii chars, such as various forms of A. + uni = "(A\u0391\u0410\u05d0\u0627\u0905\u1e00\u3042, a)" + assert calltip._first_param.sub('', uni) == '(a)' + + def test_no_docstring(self): + for meth, mtip in ((TC.nd, "(self)"), (tc.nd, "()")): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_buggy_getattr_class(self): + class NoCall: + def __getattr__(self, name): # Not invoked for class attribute. + raise IndexError # Bug. + class CallA(NoCall): + def __call__(self, ci): # Bug does not matter. + pass + class CallB(NoCall): + def __call__(oui, a, b, c): # Non-standard 'self'. + pass + + for meth, mtip in ((NoCall, default_tip), (CallA, default_tip), + (NoCall(), ''), (CallA(), '(ci)'), + (CallB(), '(a, b, c)')): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_metaclass_class(self): # Failure case for issue 38689. + class Type(type): # Type() requires 3 type args, returns class. + __class__ = property({}.__getitem__, {}.__setitem__) + class Object(metaclass=Type): + __slots__ = '__class__' + for meth, mtip in ((Type, get_spec(type)), (Object, default_tip), + (Object(), '')): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_non_callables(self): + for obj in (0, 0.0, '0', b'0', [], {}): + with self.subTest(obj=obj): + self.assertEqual(get_spec(obj), '') + + +class Get_entityTest(unittest.TestCase): + def test_bad_entity(self): + self.assertIsNone(calltip.get_entity('1/0')) + def test_good_entity(self): + self.assertIs(calltip.get_entity('int'), int) + + +# Test the 9 Calltip methods. +# open_calltip is about half the code; the others are fairly trivial. +# The default mocks are what are needed for open_calltip. + +class mock_Shell: + "Return mock sufficient to pass to hyperparser." + def __init__(self, text): + text.tag_prevrange = Mock(return_value=None) + self.text = text + self.prompt_last_line = ">>> " + self.indentwidth = 4 + self.tabwidth = 8 + + +class mock_TipWindow: + def __init__(self): + pass + + def showtip(self, text, parenleft, parenright): + self.args = parenleft, parenright + self.parenline, self.parencol = map(int, parenleft.split('.')) + + +class WrappedCalltip(calltip.Calltip): + def _make_tk_calltip_window(self): + return mock_TipWindow() + + def remove_calltip_window(self, event=None): + if self.active_calltip: # Setup to None. + self.active_calltip = None + self.tips_removed += 1 # Setup to 0. + + def fetch_tip(self, expression): + return 'tip' + + +class CalltipTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.text = Text() + cls.ct = WrappedCalltip(mock_Shell(cls.text)) + + def setUp(self): + self.text.delete('1.0', 'end') # Insert and call + self.ct.active_calltip = None + # Test .active_calltip, +args + self.ct.tips_removed = 0 + + def open_close(self, testfunc): + # Open-close template with testfunc called in between. + opentip = self.ct.open_calltip + self.text.insert(1.0, 'f(') + opentip(False) + self.tip = self.ct.active_calltip + testfunc(self) ### + self.text.insert('insert', ')') + opentip(False) + self.assertIsNone(self.ct.active_calltip, None) + + def test_open_close(self): + def args(self): + self.assertEqual(self.tip.args, ('1.1', '1.end')) + self.open_close(args) + + def test_repeated_force(self): + def force(self): + for char in 'abc': + self.text.insert('insert', 'a') + self.ct.open_calltip(True) + self.ct.open_calltip(True) + self.assertIs(self.ct.active_calltip, self.tip) + self.open_close(force) + + def test_repeated_parens(self): + def parens(self): + for context in "a", "'": + with self.subTest(context=context): + self.text.insert('insert', context) + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "'") + self.open_close(parens) + + def test_comment_parens(self): + def comment(self): + self.text.insert('insert', "# ") + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "\n") + self.open_close(comment) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_calltip_w.py b/Lib/idlelib/idle_test/test_calltip_w.py new file mode 100644 index 00000000000..a5ec76e15ff --- /dev/null +++ b/Lib/idlelib/idle_test/test_calltip_w.py @@ -0,0 +1,29 @@ +"Test calltip_w, coverage 18%." + +from idlelib import calltip_w +import unittest +from test.support import requires +from tkinter import Tk, Text + + +class CallTipWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.calltip = calltip_w.CalltipWindow(cls.text) + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.text, cls.root + + def test_init(self): + self.assertEqual(self.calltip.anchor_widget, self.text) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py new file mode 100644 index 00000000000..6969ad73b01 --- /dev/null +++ b/Lib/idlelib/idle_test/test_codecontext.py @@ -0,0 +1,455 @@ +"Test codecontext, coverage 100%" + +from idlelib import codecontext +import unittest +import unittest.mock +from test.support import requires +from tkinter import NSEW, Tk, Frame, Text, TclError + +from unittest import mock +import re +from idlelib import config + + +usercfg = codecontext.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +code_sample = """\ + +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b + def compare(self): + if a > b: + return a + elif a < b: + return b + else: + return None +""" + + +class DummyEditwin: + def __init__(self, root, frame, text): + self.root = root + self.top = root + self.text_frame = frame + self.text = text + self.label = '' + + def getlineno(self, index): + return int(float(self.text.index(index))) + + def update_menu_label(self, **kwargs): + self.label = kwargs['label'] + + +class CodeContextTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + frame = cls.frame = Frame(root) + text = cls.text = Text(frame) + text.insert('1.0', code_sample) + # Need to pack for creation of code context text widget. + frame.pack(side='left', fill='both', expand=1) + text.grid(row=1, column=1, sticky=NSEW) + cls.editor = DummyEditwin(root, frame, text) + codecontext.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + codecontext.idleConf.userCfg = usercfg + cls.editor.text.delete('1.0', 'end') + del cls.editor, cls.frame, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.yview(0) + self.text['font'] = 'TkFixedFont' + self.cc = codecontext.CodeContext(self.editor) + + self.highlight_cfg = {"background": '#abcdef', + "foreground": '#123456'} + orig_idleConf_GetHighlight = codecontext.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element == 'context': + return self.highlight_cfg + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + self.addCleanup(GetHighlight_patcher.stop) + + self.font_override = 'TkFixedFont' + def mock_idleconf_GetFont(root, configType, section): + return self.font_override + GetFont_patcher = unittest.mock.patch.object( + codecontext.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + self.addCleanup(GetFont_patcher.stop) + + def tearDown(self): + if self.cc.context: + self.cc.context.destroy() + # Explicitly call __del__ to remove scheduled scripts. + self.cc.__del__() + del self.cc.context, self.cc + + def test_init(self): + eq = self.assertEqual + ed = self.editor + cc = self.cc + + eq(cc.editwin, ed) + eq(cc.text, ed.text) + eq(cc.text['font'], ed.text['font']) + self.assertIsNone(cc.context) + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 1) + self.assertIsNone(self.cc.t1) + + def test_del(self): + self.cc.__del__() + + def test_del_with_timer(self): + timer = self.cc.t1 = self.text.after(10000, lambda: None) + self.cc.__del__() + with self.assertRaises(TclError) as cm: + self.root.tk.call('after', 'info', timer) + self.assertIn("doesn't exist", str(cm.exception)) + + def test_reload(self): + codecontext.CodeContext.reload() + self.assertEqual(self.cc.context_depth, 15) + + def test_toggle_code_context_event(self): + eq = self.assertEqual + cc = self.cc + toggle = cc.toggle_code_context_event + + # Make sure code context is off. + if cc.context: + toggle() + + # Toggle on. + toggle() + self.assertIsNotNone(cc.context) + eq(cc.context['font'], self.text['font']) + eq(cc.context['fg'], self.highlight_cfg['foreground']) + eq(cc.context['bg'], self.highlight_cfg['background']) + eq(cc.context.get('1.0', 'end-1c'), '') + eq(cc.editwin.label, 'Hide Code Context') + eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer') + + # Toggle off. + toggle() + self.assertIsNone(cc.context) + eq(cc.editwin.label, 'Show Code Context') + self.assertIsNone(self.cc.t1) + + # Scroll down and toggle back on. + line11_context = '\n'.join(x[2] for x in cc.get_context(11)[0]) + cc.text.yview(11) + toggle() + eq(cc.context.get('1.0', 'end-1c'), line11_context) + + # Toggle off and on again. + toggle() + toggle() + eq(cc.context.get('1.0', 'end-1c'), line11_context) + + def test_get_context(self): + eq = self.assertEqual + gc = self.cc.get_context + + # stopline must be greater than 0. + with self.assertRaises(AssertionError): + gc(1, stopline=0) + + eq(gc(3), ([(2, 0, 'class C1:', 'class')], 0)) + + # Don't return comment. + eq(gc(4), ([(2, 0, 'class C1:', 'class')], 0)) + + # Two indentation levels and no comment. + eq(gc(5), ([(2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')], 0)) + + # Only one 'def' is returned, not both at the same indent level. + eq(gc(10), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if')], 0)) + + # With 'elif', also show the 'if' even though it's at the same level. + eq(gc(11), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + + # Set stop_line to not go back to first line in source code. + # Return includes stop_line. + eq(gc(11, stopline=2), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + eq(gc(11, stopline=3), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + eq(gc(11, stopline=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + # Set stop_indent to test indent level to stop at. + eq(gc(11, stopindent=4), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + # Check that the 'if' is included. + eq(gc(11, stopindent=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + def test_update_code_context(self): + eq = self.assertEqual + cc = self.cc + # Ensure code context is active. + if not cc.context: + cc.toggle_code_context_event() + + # Invoke update_code_context without scrolling - nothing happens. + self.assertIsNone(cc.update_code_context()) + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 1) + + # Scroll down to line 1. + cc.text.yview(1) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 2) + eq(cc.context.get('1.0', 'end-1c'), '') + + # Scroll down to line 2. + cc.text.yview(2) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1:', 'class')]) + eq(cc.topvisible, 3) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:') + + # Scroll down to line 3. Since it's a comment, nothing changes. + cc.text.yview(3) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1:', 'class')]) + eq(cc.topvisible, 4) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:') + + # Scroll down to line 4. + cc.text.yview(4) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 5) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def __init__(self, a, b):') + + # Scroll down to line 11. Last 'def' is removed. + cc.text.yview(11) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # No scroll. No update, even though context_depth changed. + cc.update_code_context() + cc.context_depth = 1 + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # Scroll up. + cc.text.yview(5) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 6) + # context_depth is 1. + eq(cc.context.get('1.0', 'end-1c'), ' def __init__(self, a, b):') + + def test_jumptoline(self): + eq = self.assertEqual + cc = self.cc + jump = cc.jumptoline + + if not cc.context: + cc.toggle_code_context_event() + + # Empty context. + cc.text.yview('2.0') + cc.update_code_context() + eq(cc.topvisible, 2) + cc.context.mark_set('insert', '1.5') + jump() + eq(cc.topvisible, 1) + + # 4 lines of context showing. + cc.text.yview('12.0') + cc.update_code_context() + eq(cc.topvisible, 12) + cc.context.mark_set('insert', '3.0') + jump() + eq(cc.topvisible, 8) + + # More context lines than limit. + cc.context_depth = 2 + cc.text.yview('12.0') + cc.update_code_context() + eq(cc.topvisible, 12) + cc.context.mark_set('insert', '1.0') + jump() + eq(cc.topvisible, 8) + + # Context selection stops jump. + cc.text.yview('5.0') + cc.update_code_context() + cc.context.tag_add('sel', '1.0', '2.0') + cc.context.mark_set('insert', '1.0') + jump() # Without selection, to line 2. + eq(cc.topvisible, 5) + + @mock.patch.object(codecontext.CodeContext, 'update_code_context') + def test_timer_event(self, mock_update): + # Ensure code context is not active. + if self.cc.context: + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_not_called() + + # Activate code context. + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_called() + + def test_font(self): + eq = self.assertEqual + cc = self.cc + + orig_font = cc.text['font'] + test_font = 'TkTextFont' + self.assertNotEqual(orig_font, test_font) + + # Ensure code context is not active. + if cc.context is not None: + cc.toggle_code_context_event() + + self.font_override = test_font + # Nothing breaks or changes with inactive code context. + cc.update_font() + + # Activate code context, previous font change is immediately effective. + cc.toggle_code_context_event() + eq(cc.context['font'], test_font) + + # Call the font update, change is picked up. + self.font_override = orig_font + cc.update_font() + eq(cc.context['font'], orig_font) + + def test_highlight_colors(self): + eq = self.assertEqual + cc = self.cc + + orig_colors = dict(self.highlight_cfg) + test_colors = {'background': '#222222', 'foreground': '#ffff00'} + + def assert_colors_are_equal(colors): + eq(cc.context['background'], colors['background']) + eq(cc.context['foreground'], colors['foreground']) + + # Ensure code context is not active. + if cc.context: + cc.toggle_code_context_event() + + self.highlight_cfg = test_colors + # Nothing breaks with inactive code context. + cc.update_highlight_colors() + + # Activate code context, previous colors change is immediately effective. + cc.toggle_code_context_event() + assert_colors_are_equal(test_colors) + + # Call colors update with no change to the configured colors. + cc.update_highlight_colors() + assert_colors_are_equal(test_colors) + + # Call the colors update with code context active, change is picked up. + self.highlight_cfg = orig_colors + cc.update_highlight_colors() + assert_colors_are_equal(orig_colors) + + +class HelperFunctionText(unittest.TestCase): + + def test_get_spaces_firstword(self): + get = codecontext.get_spaces_firstword + test_lines = ( + (' first word', (' ', 'first')), + ('\tfirst word', ('\t', 'first')), + (' \u19D4\u19D2: ', (' ', '\u19D4\u19D2')), + ('no spaces', ('', 'no')), + ('', ('', '')), + ('# TEST COMMENT', ('', '')), + (' (continuation)', (' ', '')) + ) + for line, expected_output in test_lines: + self.assertEqual(get(line), expected_output) + + # Send the pattern in the call. + self.assertEqual(get(' (continuation)', + c=re.compile(r'^(\s*)([^\s]*)')), + (' ', '(continuation)')) + + def test_get_line_info(self): + eq = self.assertEqual + gli = codecontext.get_line_info + lines = code_sample.splitlines() + + # Line 1 is not a BLOCKOPENER. + eq(gli(lines[0]), (codecontext.INFINITY, '', False)) + # Line 2 is a BLOCKOPENER without an indent. + eq(gli(lines[1]), (0, 'class C1:', 'class')) + # Line 3 is not a BLOCKOPENER and does not return the indent level. + eq(gli(lines[2]), (codecontext.INFINITY, ' # Class comment.', False)) + # Line 4 is a BLOCKOPENER and is indented. + eq(gli(lines[3]), (4, ' def __init__(self, a, b):', 'def')) + # Line 8 is a different BLOCKOPENER and is indented. + eq(gli(lines[7]), (8, ' if a > b:', 'if')) + # Test tab. + eq(gli('\tif a == b:'), (1, '\tif a == b:', 'if')) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_colorizer.py b/Lib/idlelib/idle_test/test_colorizer.py new file mode 100644 index 00000000000..40800df97b0 --- /dev/null +++ b/Lib/idlelib/idle_test/test_colorizer.py @@ -0,0 +1,623 @@ +"Test colorizer, coverage 99%." +from idlelib import colorizer +from test.support import requires +import unittest +from unittest import mock +from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop + +from functools import partial +import textwrap +from tkinter import Tk, Text +from idlelib import config +from idlelib.percolator import Percolator + + +usercfg = colorizer.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} + +source = textwrap.dedent("""\ + if True: int ('1') # keyword, builtin, string, comment + elif False: print(0) # 'string' in comment + else: float(None) # if in comment + if iF + If + IF: 'keyword matching must respect case' + if'': x or'' # valid keyword-string no-space combinations + async def f(): await g() + # Strings should be entirely colored, including quotes. + 'x', '''x''', "x", \"""x\""" + 'abc\\ + def' + '''abc\\ + def''' + # All valid prefixes for unicode and byte strings should be colored. + r'x', u'x', R'x', U'x', f'x', F'x' + fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x' + tr'x', Tr'x', tR'x', TR'x', rt'x', rT'x', Rt'x', RT'x' + b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x' + # Invalid combinations of legal characters should be half colored. + ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x' + match point: + case (x, 0) as _: + print(f"X={x}") + case [_, [_], "_", + _]: + pass + case _ if ("a" if _ else set()): pass + case _: + raise ValueError("Not a point _") + ''' + case _:''' + "match x:" + """) + + +def setUpModule(): + colorizer.idleConf.userCfg = testcfg + + +def tearDownModule(): + colorizer.idleConf.userCfg = usercfg + + +class FunctionTest(unittest.TestCase): + + def test_any(self): + self.assertEqual(colorizer.any('test', ('a', 'b', 'cd')), + '(?Pa|b|cd)') + + def test_make_pat(self): + # Tested in more detail by testing prog. + self.assertTrue(colorizer.make_pat()) + + def test_prog(self): + prog = colorizer.prog + eq = self.assertEqual + line = 'def f():\n print("hello")\n' + m = prog.search(line) + eq(m.groupdict()['KEYWORD'], 'def') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + m = prog.search(line, m.end()) + eq(m.groupdict()['BUILTIN'], 'print') + m = prog.search(line, m.end()) + eq(m.groupdict()['STRING'], '"hello"') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + + def test_idprog(self): + idprog = colorizer.idprog + m = idprog.match('nospace') + self.assertIsNone(m) + m = idprog.match(' space') + self.assertEqual(m.group(0), ' space') + + +class ColorConfigTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + cls.text = Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_color_config(self): + text = self.text + eq = self.assertEqual + colorizer.color_config(text) + # Uses IDLE Classic theme as default. + eq(text['background'], '#ffffff') + eq(text['foreground'], '#000000') + eq(text['selectbackground'], 'gray') + eq(text['selectforeground'], '#000000') + eq(text['insertbackground'], 'black') + eq(text['inactiveselectbackground'], 'gray') + + +class ColorDelegatorInstantiationTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + cls.text = Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.color = colorizer.ColorDelegator() + + def tearDown(self): + self.color.close() + self.text.delete('1.0', 'end') + self.color.resetcache() + del self.color + + def test_init(self): + color = self.color + self.assertIsInstance(color, colorizer.ColorDelegator) + + def test_init_state(self): + # init_state() is called during the instantiation of + # ColorDelegator in setUp(). + color = self.color + self.assertIsNone(color.after_id) + self.assertTrue(color.allow_colorizing) + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + + +class ColorDelegatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(root) + cls.percolator = Percolator(text) + # Delegator stack = [Delegator(text)] + + @classmethod + def tearDownClass(cls): + cls.percolator.close() + del cls.percolator, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.color = colorizer.ColorDelegator() + self.percolator.insertfilter(self.color) + # Calls color.setdelegate(Delegator(text)). + + def tearDown(self): + self.color.close() + self.percolator.removefilter(self.color) + self.text.delete('1.0', 'end') + self.color.resetcache() + del self.color + + def test_setdelegate(self): + # Called in setUp when filter is attached to percolator. + color = self.color + self.assertIsInstance(color.delegate, colorizer.Delegator) + # It is too late to mock notify_range, so test side effect. + self.assertEqual(self.root.tk.call( + 'after', 'info', color.after_id)[1], 'timer') + + def test_LoadTagDefs(self): + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag, colors in self.color.tagdefs.items(): + with self.subTest(tag=tag): + self.assertIn('background', colors) + self.assertIn('foreground', colors) + if tag not in ('SYNC', 'TODO'): + self.assertEqual(colors, highlight(element=tag.lower())) + + def test_config_colors(self): + text = self.text + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag in self.color.tagdefs: + for plane in ('background', 'foreground'): + with self.subTest(tag=tag, plane=plane): + if tag in ('SYNC', 'TODO'): + self.assertEqual(text.tag_cget(tag, plane), '') + else: + self.assertEqual(text.tag_cget(tag, plane), + highlight(element=tag.lower())[plane]) + # 'sel' is marked as the highest priority. + self.assertEqual(text.tag_names()[-1], 'sel') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_insert(self, mock_notify): + text = self.text + # Initial text. + text.insert('insert', 'foo') + self.assertEqual(text.get('1.0', 'end'), 'foo\n') + mock_notify.assert_called_with('1.0', '1.0+3c') + # Additional text. + text.insert('insert', 'barbaz') + self.assertEqual(text.get('1.0', 'end'), 'foobarbaz\n') + mock_notify.assert_called_with('1.3', '1.3+6c') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_delete(self, mock_notify): + text = self.text + # Initialize text. + text.insert('insert', 'abcdefghi') + self.assertEqual(text.get('1.0', 'end'), 'abcdefghi\n') + # Delete single character. + text.delete('1.7') + self.assertEqual(text.get('1.0', 'end'), 'abcdefgi\n') + mock_notify.assert_called_with('1.7') + # Delete multiple characters. + text.delete('1.3', '1.6') + self.assertEqual(text.get('1.0', 'end'), 'abcgi\n') + mock_notify.assert_called_with('1.3') + + def test_notify_range(self): + text = self.text + color = self.color + eq = self.assertEqual + + # Colorizing already scheduled. + save_id = color.after_id + eq(self.root.tk.call('after', 'info', save_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Coloring scheduled and colorizing in progress. + color.colorizing = True + color.notify_range('1.0', 'end') + self.assertFalse(color.stop_colorizing) + eq(color.after_id, save_id) + + # No colorizing scheduled and colorizing in progress. + text.after_cancel(save_id) + color.after_id = None + color.notify_range('1.0', '1.0+3c') + self.assertTrue(color.stop_colorizing) + self.assertIsNotNone(color.after_id) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + # New event scheduled. + self.assertNotEqual(color.after_id, save_id) + + # No colorizing scheduled and colorizing off. + text.after_cancel(color.after_id) + color.after_id = None + color.allow_colorizing = False + color.notify_range('1.4', '1.4+10c') + # Nothing scheduled when colorizing is off. + self.assertIsNone(color.after_id) + + def test_toggle_colorize_event(self): + color = self.color + eq = self.assertEqual + + # Starts with colorizing allowed and scheduled. + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # Toggle colorizing off. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing in progress (doesn't add timer). + color.colorizing = True + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Toggle off while colorizing in progress. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing not in progress. + color.colorizing = False + color.toggle_colorize_event() + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize_main') + def test_recolorize(self, mock_recmain): + text = self.text + color = self.color + eq = self.assertEqual + # Call recolorize manually and not scheduled. + text.after_cancel(color.after_id) + + # No delegate. + save_delegate = color.delegate + color.delegate = None + color.recolorize() + mock_recmain.assert_not_called() + color.delegate = save_delegate + + # Toggle off colorizing. + color.allow_colorizing = False + color.recolorize() + mock_recmain.assert_not_called() + color.allow_colorizing = True + + # Colorizing in progress. + color.colorizing = True + color.recolorize() + mock_recmain.assert_not_called() + color.colorizing = False + + # Colorizing is done, but not completed, so rescheduled. + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 1) + # Rescheduled when TODO tag still exists. + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # No changes to text, so no scheduling added. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 2) + self.assertIsNone(color.after_id) + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_recolorize_main(self, mock_notify): + text = self.text + color = self.color + eq = self.assertEqual + + text.insert('insert', source) + expected = (('1.0', ('KEYWORD',)), ('1.2', ()), ('1.3', ('KEYWORD',)), + ('1.7', ()), ('1.9', ('BUILTIN',)), ('1.14', ('STRING',)), + ('1.19', ('COMMENT',)), + ('2.1', ('KEYWORD',)), ('2.18', ()), ('2.25', ('COMMENT',)), + ('3.6', ('BUILTIN',)), ('3.12', ('KEYWORD',)), ('3.21', ('COMMENT',)), + ('4.0', ('KEYWORD',)), ('4.3', ()), ('4.6', ()), + ('5.2', ('STRING',)), ('5.8', ('KEYWORD',)), ('5.10', ('STRING',)), + ('6.0', ('KEYWORD',)), ('6.10', ('DEFINITION',)), ('6.11', ()), + ('8.0', ('STRING',)), ('8.4', ()), ('8.5', ('STRING',)), + ('8.12', ()), ('8.14', ('STRING',)), + ('20.0', ('KEYWORD',)), + ('21.4', ('KEYWORD',)), ('21.16', ('KEYWORD',)),# ('21.19', ('KEYWORD',)), + #('23.4', ('KEYWORD',)), ('23.10', ('KEYWORD',)), ('23.14', ('KEYWORD',)), ('23.19', ('STRING',)), + #('24.12', ('KEYWORD',)), + ('25.8', ('KEYWORD',)), + ('26.4', ('KEYWORD',)), ('26.9', ('KEYWORD',)), + ('26.11', ('KEYWORD',)), ('26.15', ('STRING',)), + ('26.19', ('KEYWORD',)), ('26.22', ()), + ('26.24', ('KEYWORD',)), ('26.29', ('BUILTIN',)), ('26.37', ('KEYWORD',)), + ('27.4', ('KEYWORD',)), ('27.9', ('KEYWORD',)),# ('27.11', ('KEYWORD',)), ('27.14', (),), + ('28.25', ('STRING',)), ('28.38', ('STRING',)), + ('30.0', ('STRING',)), + ('31.1', ('STRING',)), + # SYNC at the end of every line. + ('1.55', ('SYNC',)), ('2.50', ('SYNC',)), ('3.34', ('SYNC',)), + ) + + # Nothing marked to do therefore no tags in text. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize_main() + for tag in text.tag_names(): + with self.subTest(tag=tag): + eq(text.tag_ranges(tag), ()) + + # Source marked for processing. + text.tag_add('TODO', '1.0', 'end') + # Check some indexes. + color.recolorize_main() + for index, expected_tags in expected: + with self.subTest(index=index): + eq(text.tag_names(index), expected_tags) + + # Check for some tags for ranges. + eq(text.tag_nextrange('TODO', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + eq(text.tag_nextrange('COMMENT', '2.0'), ('2.22', '2.43')) + eq(text.tag_nextrange('SYNC', '2.0'), ('2.43', '3.0')) + eq(text.tag_nextrange('STRING', '2.0'), ('4.17', '4.53')) + eq(text.tag_nextrange('STRING', '8.0'), ('8.0', '8.3')) + eq(text.tag_nextrange('STRING', '8.3'), ('8.5', '8.12')) + eq(text.tag_nextrange('STRING', '8.12'), ('8.14', '8.17')) + eq(text.tag_nextrange('STRING', '8.17'), ('8.19', '8.26')) + eq(text.tag_nextrange('SYNC', '8.0'), ('8.26', '9.0')) + eq(text.tag_nextrange('SYNC', '31.0'), ('31.10', '33.0')) + + def _assert_highlighting(self, source, tag_ranges): + """Check highlighting of a given piece of code. + + This inserts just this code into the Text widget. It will then + check that the resulting highlighting tag ranges exactly match + those described in the given `tag_ranges` dict. + + Note that the irrelevant tags 'sel', 'TODO' and 'SYNC' are + ignored. + """ + text = self.text + + with mock.patch.object(colorizer.ColorDelegator, 'notify_range'): + text.delete('1.0', 'end-1c') + text.insert('insert', source) + text.tag_add('TODO', '1.0', 'end-1c') + self.color.recolorize_main() + + # Make a dict with highlighting tag ranges in the Text widget. + text_tag_ranges = {} + for tag in set(text.tag_names()) - {'sel', 'TODO', 'SYNC'}: + indexes = [rng.string for rng in text.tag_ranges(tag)] + for index_pair in zip(indexes[::2], indexes[1::2]): + text_tag_ranges.setdefault(tag, []).append(index_pair) + + self.assertEqual(text_tag_ranges, tag_ranges) + + with mock.patch.object(colorizer.ColorDelegator, 'notify_range'): + text.delete('1.0', 'end-1c') + + def test_def_statement(self): + # empty def + self._assert_highlighting('def', {'KEYWORD': [('1.0', '1.3')]}) + + # def followed by identifier + self._assert_highlighting('def foo:', {'KEYWORD': [('1.0', '1.3')], + 'DEFINITION': [('1.4', '1.7')]}) + + # def followed by partial identifier + self._assert_highlighting('def fo', {'KEYWORD': [('1.0', '1.3')], + 'DEFINITION': [('1.4', '1.6')]}) + + # def followed by non-keyword + self._assert_highlighting('def ++', {'KEYWORD': [('1.0', '1.3')]}) + + def test_match_soft_keyword(self): + # empty match + self._assert_highlighting('match', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by partial identifier + self._assert_highlighting('match fo', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by identifier and colon + self._assert_highlighting('match foo:', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by keyword + self._assert_highlighting('match and', {'KEYWORD': [('1.6', '1.9')]}) + + # match followed by builtin with keyword prefix + self._assert_highlighting('match int:', {'KEYWORD': [('1.0', '1.5')], + 'BUILTIN': [('1.6', '1.9')]}) + + # match followed by non-text operator + self._assert_highlighting('match^', {}) + self._assert_highlighting('match @', {}) + + # match followed by colon + self._assert_highlighting('match :', {}) + + # match followed by comma + self._assert_highlighting('match\t,', {}) + + # match followed by a lone underscore + self._assert_highlighting('match _:', {'KEYWORD': [('1.0', '1.5')]}) + + def test_case_soft_keyword(self): + # empty case + self._assert_highlighting('case', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by partial identifier + self._assert_highlighting('case fo', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by identifier and colon + self._assert_highlighting('case foo:', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by keyword + self._assert_highlighting('case and', {'KEYWORD': [('1.5', '1.8')]}) + + # case followed by builtin with keyword prefix + self._assert_highlighting('case int:', {'KEYWORD': [('1.0', '1.4')], + 'BUILTIN': [('1.5', '1.8')]}) + + # case followed by non-text operator + self._assert_highlighting('case^', {}) + self._assert_highlighting('case @', {}) + + # case followed by colon + self._assert_highlighting('case :', {}) + + # case followed by comma + self._assert_highlighting('case\t,', {}) + + # case followed by a lone underscore + self._assert_highlighting('case _:', {'KEYWORD': [('1.0', '1.4'), + ('1.5', '1.6')]}) + + def test_long_multiline_string(self): + source = textwrap.dedent('''\ + """a + b + c + d + e""" + ''') + self._assert_highlighting(source, {'STRING': [('1.0', '5.4')]}) + + @run_in_tk_mainloop(delay=50) + def test_incremental_editing(self): + text = self.text + eq = self.assertEqual + + # Simulate typing 'inte'. During this, the highlighting should + # change from normal to keyword to builtin to normal. + text.insert('insert', 'i') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.insert('insert', 'n') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + + text.insert('insert', 't') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3')) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.insert('insert', 'e') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + # Simulate deleting three characters from the end of 'inte'. + # During this, the highlighting should change from normal to + # builtin to keyword to normal. + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3')) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize') + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_removecolors(self, mock_notify, mock_recolorize): + text = self.text + color = self.color + text.insert('insert', source) + + color.recolorize_main() + # recolorize_main doesn't add these tags. + text.tag_add("ERROR", "1.0") + text.tag_add("TODO", "1.0") + text.tag_add("hit", "1.0") + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertNotEqual(text.tag_ranges(tag), ()) + + color.removecolors() + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertEqual(text.tag_ranges(tag), ()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py new file mode 100644 index 00000000000..6d75cf7aa67 --- /dev/null +++ b/Lib/idlelib/idle_test/test_config.py @@ -0,0 +1,805 @@ +"""Test config, coverage 93%. +(100% for IdleConfParser, IdleUserConfParser*, ConfigChanges). +* Exception is OSError clause in Save method. +Much of IdleConf is also exercised by ConfigDialog and test_configdialog. +""" +from idlelib import config +import sys +import os +import tempfile +from test.support import captured_stderr, findfile +import unittest +from unittest import mock +import idlelib +from idlelib.idle_test.mock_idle import Func + +# Tests should not depend on fortuitous user configurations. +# They must not affect actual user .cfg files. +# Replace user parsers with empty parsers that cannot be saved +# due to getting '' as the filename when created. + +idleConf = config.idleConf +usercfg = idleConf.userCfg +testcfg = {} +usermain = testcfg['main'] = config.IdleUserConfParser('') +userhigh = testcfg['highlight'] = config.IdleUserConfParser('') +userkeys = testcfg['keys'] = config.IdleUserConfParser('') +userextn = testcfg['extensions'] = config.IdleUserConfParser('') + +def setUpModule(): + idleConf.userCfg = testcfg + idlelib.testing = True + +def tearDownModule(): + idleConf.userCfg = usercfg + idlelib.testing = False + + +class IdleConfParserTest(unittest.TestCase): + """Test that IdleConfParser works""" + + config = """ + [one] + one = false + two = true + three = 10 + + [two] + one = a string + two = true + three = false + """ + + def test_get(self): + parser = config.IdleConfParser('') + parser.read_string(self.config) + eq = self.assertEqual + + # Test with type argument. + self.assertIs(parser.Get('one', 'one', type='bool'), False) + self.assertIs(parser.Get('one', 'two', type='bool'), True) + eq(parser.Get('one', 'three', type='int'), 10) + eq(parser.Get('two', 'one'), 'a string') + self.assertIs(parser.Get('two', 'two', type='bool'), True) + self.assertIs(parser.Get('two', 'three', type='bool'), False) + + # Test without type should fallback to string. + eq(parser.Get('two', 'two'), 'true') + eq(parser.Get('two', 'three'), 'false') + + # If option not exist, should return None, or default. + self.assertIsNone(parser.Get('not', 'exist')) + eq(parser.Get('not', 'exist', default='DEFAULT'), 'DEFAULT') + + def test_get_option_list(self): + parser = config.IdleConfParser('') + parser.read_string(self.config) + get_list = parser.GetOptionList + self.assertCountEqual(get_list('one'), ['one', 'two', 'three']) + self.assertCountEqual(get_list('two'), ['one', 'two', 'three']) + self.assertEqual(get_list('not exist'), []) + + def test_load_nothing(self): + parser = config.IdleConfParser('') + parser.Load() + self.assertEqual(parser.sections(), []) + + def test_load_file(self): + # Borrow test/configdata/cfgparser.1 from test_configparser. + config_path = findfile('cfgparser.1', subdir='configdata') + parser = config.IdleConfParser(config_path) + parser.Load() + + self.assertEqual(parser.Get('Foo Bar', 'foo'), 'newbar') + self.assertEqual(parser.GetOptionList('Foo Bar'), ['foo']) + + +class IdleUserConfParserTest(unittest.TestCase): + """Test that IdleUserConfParser works""" + + def new_parser(self, path=''): + return config.IdleUserConfParser(path) + + def test_set_option(self): + parser = self.new_parser() + parser.add_section('Foo') + # Setting new option in existing section should return True. + self.assertTrue(parser.SetOption('Foo', 'bar', 'true')) + # Setting existing option with same value should return False. + self.assertFalse(parser.SetOption('Foo', 'bar', 'true')) + # Setting exiting option with new value should return True. + self.assertTrue(parser.SetOption('Foo', 'bar', 'false')) + self.assertEqual(parser.Get('Foo', 'bar'), 'false') + + # Setting option in new section should create section and return True. + self.assertTrue(parser.SetOption('Bar', 'bar', 'true')) + self.assertCountEqual(parser.sections(), ['Bar', 'Foo']) + self.assertEqual(parser.Get('Bar', 'bar'), 'true') + + def test_remove_option(self): + parser = self.new_parser() + parser.AddSection('Foo') + parser.SetOption('Foo', 'bar', 'true') + + self.assertTrue(parser.RemoveOption('Foo', 'bar')) + self.assertFalse(parser.RemoveOption('Foo', 'bar')) + self.assertFalse(parser.RemoveOption('Not', 'Exist')) + + def test_add_section(self): + parser = self.new_parser() + self.assertEqual(parser.sections(), []) + + # Should not add duplicate section. + # Configparser raises DuplicateError, IdleParser not. + parser.AddSection('Foo') + parser.AddSection('Foo') + parser.AddSection('Bar') + self.assertCountEqual(parser.sections(), ['Bar', 'Foo']) + + def test_remove_empty_sections(self): + parser = self.new_parser() + + parser.AddSection('Foo') + parser.AddSection('Bar') + parser.SetOption('Idle', 'name', 'val') + self.assertCountEqual(parser.sections(), ['Bar', 'Foo', 'Idle']) + parser.RemoveEmptySections() + self.assertEqual(parser.sections(), ['Idle']) + + def test_is_empty(self): + parser = self.new_parser() + + parser.AddSection('Foo') + parser.AddSection('Bar') + self.assertTrue(parser.IsEmpty()) + self.assertEqual(parser.sections(), []) + + parser.SetOption('Foo', 'bar', 'false') + parser.AddSection('Bar') + self.assertFalse(parser.IsEmpty()) + self.assertCountEqual(parser.sections(), ['Foo']) + + def test_save(self): + with tempfile.TemporaryDirectory() as tdir: + path = os.path.join(tdir, 'test.cfg') + parser = self.new_parser(path) + parser.AddSection('Foo') + parser.SetOption('Foo', 'bar', 'true') + + # Should save to path when config is not empty. + self.assertFalse(os.path.exists(path)) + parser.Save() + self.assertTrue(os.path.exists(path)) + + # Should remove the file from disk when config is empty. + parser.remove_section('Foo') + parser.Save() + self.assertFalse(os.path.exists(path)) + + +class IdleConfTest(unittest.TestCase): + """Test for idleConf""" + + @classmethod + def setUpClass(cls): + cls.config_string = {} + + conf = config.IdleConf(_utest=True) + if __name__ != '__main__': + idle_dir = os.path.dirname(__file__) + else: + idle_dir = os.path.abspath(sys.path[0]) + for ctype in conf.config_types: + config_path = os.path.join(idle_dir, '../config-%s.def' % ctype) + with open(config_path) as f: + cls.config_string[ctype] = f.read() + + cls.orig_warn = config._warn + config._warn = Func() + + @classmethod + def tearDownClass(cls): + config._warn = cls.orig_warn + + def new_config(self, _utest=False): + return config.IdleConf(_utest=_utest) + + def mock_config(self): + """Return a mocked idleConf + + Both default and user config used the same config-*.def + """ + conf = config.IdleConf(_utest=True) + for ctype in conf.config_types: + conf.defaultCfg[ctype] = config.IdleConfParser('') + conf.defaultCfg[ctype].read_string(self.config_string[ctype]) + conf.userCfg[ctype] = config.IdleUserConfParser('') + conf.userCfg[ctype].read_string(self.config_string[ctype]) + + return conf + + @unittest.skipIf(sys.platform.startswith('win'), 'this is test for unix system') + def test_get_user_cfg_dir_unix(self): + # Test to get user config directory under unix. + conf = self.new_config(_utest=True) + + # Check normal way should success + with mock.patch('os.path.expanduser', return_value='/home/foo'): + with mock.patch('os.path.exists', return_value=True): + self.assertEqual(conf.GetUserCfgDir(), '/home/foo/.idlerc') + + # Check os.getcwd should success + with mock.patch('os.path.expanduser', return_value='~'): + with mock.patch('os.getcwd', return_value='/home/foo/cpython'): + with mock.patch('os.mkdir'): + self.assertEqual(conf.GetUserCfgDir(), + '/home/foo/cpython/.idlerc') + + # Check user dir not exists and created failed should raise SystemExit + with mock.patch('os.path.join', return_value='/path/not/exists'): + with self.assertRaises(SystemExit): + with self.assertRaises(FileNotFoundError): + conf.GetUserCfgDir() + + @unittest.skipIf(not sys.platform.startswith('win'), 'this is test for Windows system') + def test_get_user_cfg_dir_windows(self): + # Test to get user config directory under Windows. + conf = self.new_config(_utest=True) + + # Check normal way should success + with mock.patch('os.path.expanduser', return_value='C:\\foo'): + with mock.patch('os.path.exists', return_value=True): + self.assertEqual(conf.GetUserCfgDir(), 'C:\\foo\\.idlerc') + + # Check os.getcwd should success + with mock.patch('os.path.expanduser', return_value='~'): + with mock.patch('os.getcwd', return_value='C:\\foo\\cpython'): + with mock.patch('os.mkdir'): + self.assertEqual(conf.GetUserCfgDir(), + 'C:\\foo\\cpython\\.idlerc') + + # Check user dir not exists and created failed should raise SystemExit + with mock.patch('os.path.join', return_value='/path/not/exists'): + with self.assertRaises(SystemExit): + with self.assertRaises(FileNotFoundError): + conf.GetUserCfgDir() + + def test_create_config_handlers(self): + conf = self.new_config(_utest=True) + + # Mock out idle_dir + idle_dir = '/home/foo' + with mock.patch.dict({'__name__': '__foo__'}): + with mock.patch('os.path.dirname', return_value=idle_dir): + conf.CreateConfigHandlers() + + # Check keys are equal + self.assertCountEqual(conf.defaultCfg, conf.config_types) + self.assertCountEqual(conf.userCfg, conf.config_types) + + # Check conf parser are correct type + for default_parser in conf.defaultCfg.values(): + self.assertIsInstance(default_parser, config.IdleConfParser) + for user_parser in conf.userCfg.values(): + self.assertIsInstance(user_parser, config.IdleUserConfParser) + + # Check config path are correct + for cfg_type, parser in conf.defaultCfg.items(): + self.assertEqual(parser.file, + os.path.join(idle_dir, f'config-{cfg_type}.def')) + for cfg_type, parser in conf.userCfg.items(): + self.assertEqual(parser.file, + os.path.join(conf.userdir or '#', f'config-{cfg_type}.cfg')) + + def test_load_cfg_files(self): + conf = self.new_config(_utest=True) + + # Borrow test/configdata/cfgparser.1 from test_configparser. + config_path = findfile('cfgparser.1', subdir='configdata') + conf.defaultCfg['foo'] = config.IdleConfParser(config_path) + conf.userCfg['foo'] = config.IdleUserConfParser(config_path) + + # Load all config from path + conf.LoadCfgFiles() + + eq = self.assertEqual + + # Check defaultCfg is loaded + eq(conf.defaultCfg['foo'].Get('Foo Bar', 'foo'), 'newbar') + eq(conf.defaultCfg['foo'].GetOptionList('Foo Bar'), ['foo']) + + # Check userCfg is loaded + eq(conf.userCfg['foo'].Get('Foo Bar', 'foo'), 'newbar') + eq(conf.userCfg['foo'].GetOptionList('Foo Bar'), ['foo']) + + def test_save_user_cfg_files(self): + conf = self.mock_config() + + with mock.patch('idlelib.config.IdleUserConfParser.Save') as m: + conf.SaveUserCfgFiles() + self.assertEqual(m.call_count, len(conf.userCfg)) + + def test_get_option(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetOption('main', 'EditorWindow', 'width'), '80') + eq(conf.GetOption('main', 'EditorWindow', 'width', type='int'), 80) + with mock.patch('idlelib.config._warn') as _warn: + eq(conf.GetOption('main', 'EditorWindow', 'font', type='int'), None) + eq(conf.GetOption('main', 'EditorWindow', 'NotExists'), None) + eq(conf.GetOption('main', 'EditorWindow', 'NotExists', default='NE'), 'NE') + eq(_warn.call_count, 4) + + def test_set_option(self): + conf = self.mock_config() + + conf.SetOption('main', 'Foo', 'bar', 'newbar') + self.assertEqual(conf.GetOption('main', 'Foo', 'bar'), 'newbar') + + def test_get_section_list(self): + conf = self.mock_config() + + self.assertCountEqual( + conf.GetSectionList('default', 'main'), + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', + 'Keys', 'History', 'HelpFiles']) + self.assertCountEqual( + conf.GetSectionList('user', 'main'), + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', + 'Keys', 'History', 'HelpFiles']) + + with self.assertRaises(config.InvalidConfigSet): + conf.GetSectionList('foobar', 'main') + with self.assertRaises(config.InvalidConfigType): + conf.GetSectionList('default', 'notexists') + + def test_get_highlight(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetHighlight('IDLE Classic', 'normal'), {'foreground': '#000000', + 'background': '#ffffff'}) + + # Test cursor (this background should be normal-background) + eq(conf.GetHighlight('IDLE Classic', 'cursor'), {'foreground': 'black', + 'background': '#ffffff'}) + + # Test get user themes + conf.SetOption('highlight', 'Foobar', 'normal-foreground', '#747474') + conf.SetOption('highlight', 'Foobar', 'normal-background', '#171717') + with mock.patch('idlelib.config._warn'): + eq(conf.GetHighlight('Foobar', 'normal'), {'foreground': '#747474', + 'background': '#171717'}) + + def test_get_theme_dict(self): + # TODO: finish. + conf = self.mock_config() + + # These two should be the same + self.assertEqual( + conf.GetThemeDict('default', 'IDLE Classic'), + conf.GetThemeDict('user', 'IDLE Classic')) + + with self.assertRaises(config.InvalidTheme): + conf.GetThemeDict('bad', 'IDLE Classic') + + def test_get_current_theme_and_keys(self): + conf = self.mock_config() + + self.assertEqual(conf.CurrentTheme(), conf.current_colors_and_keys('Theme')) + self.assertEqual(conf.CurrentKeys(), conf.current_colors_and_keys('Keys')) + + def test_current_colors_and_keys(self): + conf = self.mock_config() + + self.assertEqual(conf.current_colors_and_keys('Theme'), 'IDLE Classic') + + def test_default_keys(self): + current_platform = sys.platform + conf = self.new_config(_utest=True) + + sys.platform = 'win32' + self.assertEqual(conf.default_keys(), 'IDLE Classic Windows') + + sys.platform = 'darwin' + self.assertEqual(conf.default_keys(), 'IDLE Classic OSX') + + sys.platform = 'some-linux' + self.assertEqual(conf.default_keys(), 'IDLE Modern Unix') + + # Restore platform + sys.platform = current_platform + + def test_get_extensions(self): + userextn.read_string(''' + [ZzDummy] + enable = True + [DISABLE] + enable = False + ''') + eq = self.assertEqual + iGE = idleConf.GetExtensions + eq(iGE(shell_only=True), []) + eq(iGE(), ['ZzDummy']) + eq(iGE(editor_only=True), ['ZzDummy']) + eq(iGE(active_only=False), ['ZzDummy', 'DISABLE']) + eq(iGE(active_only=False, editor_only=True), ['ZzDummy', 'DISABLE']) + userextn.remove_section('ZzDummy') + userextn.remove_section('DISABLE') + + + def test_remove_key_bind_names(self): + conf = self.mock_config() + + self.assertCountEqual( + conf.RemoveKeyBindNames(conf.GetSectionList('default', 'extensions')), + ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', 'ZzDummy']) + + def test_get_extn_name_for_event(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + eq = self.assertEqual + eq(idleConf.GetExtnNameForEvent('z-in'), 'ZzDummy') + eq(idleConf.GetExtnNameForEvent('z-out'), None) + userextn.remove_section('ZzDummy') + + def test_get_extension_keys(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + self.assertEqual(idleConf.GetExtensionKeys('ZzDummy'), + {'<>': ['']}) + userextn.remove_section('ZzDummy') +# need option key test +## key = [''] if sys.platform == 'darwin' else [''] +## eq(conf.GetExtensionKeys('ZoomHeight'), {'<>': key}) + + def test_get_extension_bindings(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + eq = self.assertEqual + iGEB = idleConf.GetExtensionBindings + eq(iGEB('NotExists'), {}) + expect = {'<>': [''], + '<>': ['']} + eq(iGEB('ZzDummy'), expect) + userextn.remove_section('ZzDummy') + + def test_get_keybinding(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetKeyBinding('IDLE Modern Unix', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Unix', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Windows', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Mac', '<>'), ['']) + eq(conf.GetKeyBinding('IDLE Classic OSX', '<>'), ['']) + + # Test keybinding not exists + eq(conf.GetKeyBinding('NOT EXISTS', '<>'), []) + eq(conf.GetKeyBinding('IDLE Modern Unix', 'NOT EXISTS'), []) + + def test_get_current_keyset(self): + current_platform = sys.platform + conf = self.mock_config() + + # Ensure that platform isn't darwin + sys.platform = 'some-linux' + self.assertEqual(conf.GetCurrentKeySet(), conf.GetKeySet(conf.CurrentKeys())) + + # This should not be the same, since replace ') + self.assertEqual(conf.GetKeySet('IDLE Modern Unix')['<>'], '') + + def test_is_core_binding(self): + # XXX: Should move out the core keys to config file or other place + conf = self.mock_config() + + self.assertTrue(conf.IsCoreBinding('copy')) + self.assertTrue(conf.IsCoreBinding('cut')) + self.assertTrue(conf.IsCoreBinding('del-word-right')) + self.assertFalse(conf.IsCoreBinding('not-exists')) + + def test_extra_help_source_list(self): + # Test GetExtraHelpSourceList and GetAllExtraHelpSourcesList in same + # place to prevent prepare input data twice. + conf = self.mock_config() + + # Test default with no extra help source + self.assertEqual(conf.GetExtraHelpSourceList('default'), []) + self.assertEqual(conf.GetExtraHelpSourceList('user'), []) + with self.assertRaises(config.InvalidConfigSet): + self.assertEqual(conf.GetExtraHelpSourceList('bad'), []) + self.assertCountEqual( + conf.GetAllExtraHelpSourcesList(), + conf.GetExtraHelpSourceList('default') + conf.GetExtraHelpSourceList('user')) + + # Add help source to user config + conf.userCfg['main'].SetOption('HelpFiles', '4', 'Python;https://python.org') # This is bad input + conf.userCfg['main'].SetOption('HelpFiles', '3', 'Python:https://python.org') # This is bad input + conf.userCfg['main'].SetOption('HelpFiles', '2', 'Pillow;https://pillow.readthedocs.io/en/latest/') + conf.userCfg['main'].SetOption('HelpFiles', '1', 'IDLE;C:/Programs/Python36/Lib/idlelib/help.html') + self.assertEqual(conf.GetExtraHelpSourceList('user'), + [('IDLE', 'C:/Programs/Python36/Lib/idlelib/help.html', '1'), + ('Pillow', 'https://pillow.readthedocs.io/en/latest/', '2'), + ('Python', 'https://python.org', '4')]) + self.assertCountEqual( + conf.GetAllExtraHelpSourcesList(), + conf.GetExtraHelpSourceList('default') + conf.GetExtraHelpSourceList('user')) + + def test_get_font(self): + from test.support import requires + from tkinter import Tk + from tkinter.font import Font + conf = self.mock_config() + + requires('gui') + root = Tk() + root.withdraw() + + f = Font.actual(Font(name='TkFixedFont', exists=True, root=root)) + self.assertEqual( + conf.GetFont(root, 'main', 'EditorWindow'), + (f['family'], 10 if f['size'] <= 0 else f['size'], f['weight'])) + + # Cleanup root + root.destroy() + del root + + def test_get_core_keys(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetCoreKeys()['<>'], ['']) + eq(conf.GetCoreKeys()['<>'], ['', '']) + eq(conf.GetCoreKeys()['<>'], ['']) + eq(conf.GetCoreKeys('IDLE Classic Windows')['<>'], + ['', '']) + eq(conf.GetCoreKeys('IDLE Classic OSX')['<>'], ['']) + eq(conf.GetCoreKeys('IDLE Classic Unix')['<>'], + ['', '']) + eq(conf.GetCoreKeys('IDLE Modern Unix')['<>'], + ['', '']) + + +class CurrentColorKeysTest(unittest.TestCase): + """ Test colorkeys function with user config [Theme] and [Keys] patterns. + + colorkeys = config.IdleConf.current_colors_and_keys + Test all patterns written by IDLE and some errors + Item 'default' should really be 'builtin' (versus 'custom). + """ + colorkeys = idleConf.current_colors_and_keys + default_theme = 'IDLE Classic' + default_keys = idleConf.default_keys() + + def test_old_builtin_theme(self): + # On initial installation, user main is blank. + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # For old default, name2 must be blank. + usermain.read_string(''' + [Theme] + default = True + ''') + # IDLE omits 'name' for default old builtin theme. + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # IDLE adds 'name' for non-default old builtin theme. + usermain['Theme']['name'] = 'IDLE New' + self.assertEqual(self.colorkeys('Theme'), 'IDLE New') + # Erroneous non-default old builtin reverts to default. + usermain['Theme']['name'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + usermain.remove_section('Theme') + + def test_new_builtin_theme(self): + # IDLE writes name2 for new builtins. + usermain.read_string(''' + [Theme] + default = True + name2 = IDLE Dark + ''') + self.assertEqual(self.colorkeys('Theme'), 'IDLE Dark') + # Leftover 'name', not removed, is ignored. + usermain['Theme']['name'] = 'IDLE New' + self.assertEqual(self.colorkeys('Theme'), 'IDLE Dark') + # Erroneous non-default new builtin reverts to default. + usermain['Theme']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + usermain.remove_section('Theme') + + def test_user_override_theme(self): + # Erroneous custom name (no definition) reverts to default. + usermain.read_string(''' + [Theme] + default = False + name = Custom Dark + ''') + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # Custom name is valid with matching Section name. + userhigh.read_string('[Custom Dark]\na=b') + self.assertEqual(self.colorkeys('Theme'), 'Custom Dark') + # Name2 is ignored. + usermain['Theme']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), 'Custom Dark') + usermain.remove_section('Theme') + userhigh.remove_section('Custom Dark') + + def test_old_builtin_keys(self): + # On initial installation, user main is blank. + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + # For old default, name2 must be blank, name is always used. + usermain.read_string(''' + [Keys] + default = True + name = IDLE Classic Unix + ''') + self.assertEqual(self.colorkeys('Keys'), 'IDLE Classic Unix') + # Erroneous non-default old builtin reverts to default. + usermain['Keys']['name'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + usermain.remove_section('Keys') + + def test_new_builtin_keys(self): + # IDLE writes name2 for new builtins. + usermain.read_string(''' + [Keys] + default = True + name2 = IDLE Modern Unix + ''') + self.assertEqual(self.colorkeys('Keys'), 'IDLE Modern Unix') + # Leftover 'name', not removed, is ignored. + usermain['Keys']['name'] = 'IDLE Classic Unix' + self.assertEqual(self.colorkeys('Keys'), 'IDLE Modern Unix') + # Erroneous non-default new builtin reverts to default. + usermain['Keys']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + usermain.remove_section('Keys') + + def test_user_override_keys(self): + # Erroneous custom name (no definition) reverts to default. + usermain.read_string(''' + [Keys] + default = False + name = Custom Keys + ''') + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + # Custom name is valid with matching Section name. + userkeys.read_string('[Custom Keys]\na=b') + self.assertEqual(self.colorkeys('Keys'), 'Custom Keys') + # Name2 is ignored. + usermain['Keys']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), 'Custom Keys') + usermain.remove_section('Keys') + userkeys.remove_section('Custom Keys') + + +class ChangesTest(unittest.TestCase): + + empty = {'main':{}, 'highlight':{}, 'keys':{}, 'extensions':{}} + + def load(self): # Test_add_option verifies that this works. + changes = self.changes + changes.add_option('main', 'Msec', 'mitem', 'mval') + changes.add_option('highlight', 'Hsec', 'hitem', 'hval') + changes.add_option('keys', 'Ksec', 'kitem', 'kval') + return changes + + loaded = {'main': {'Msec': {'mitem': 'mval'}}, + 'highlight': {'Hsec': {'hitem': 'hval'}}, + 'keys': {'Ksec': {'kitem':'kval'}}, + 'extensions': {}} + + def setUp(self): + self.changes = config.ConfigChanges() + + def test_init(self): + self.assertEqual(self.changes, self.empty) + + def test_add_option(self): + changes = self.load() + self.assertEqual(changes, self.loaded) + changes.add_option('main', 'Msec', 'mitem', 'mval') + self.assertEqual(changes, self.loaded) + + def test_save_option(self): # Static function does not touch changes. + save_option = self.changes.save_option + self.assertTrue(save_option('main', 'Indent', 'what', '0')) + self.assertFalse(save_option('main', 'Indent', 'what', '0')) + self.assertEqual(usermain['Indent']['what'], '0') + + self.assertTrue(save_option('main', 'Indent', 'use-spaces', '0')) + self.assertEqual(usermain['Indent']['use-spaces'], '0') + self.assertTrue(save_option('main', 'Indent', 'use-spaces', '1')) + self.assertFalse(usermain.has_option('Indent', 'use-spaces')) + usermain.remove_section('Indent') + + def test_save_added(self): + changes = self.load() + self.assertTrue(changes.save_all()) + self.assertEqual(usermain['Msec']['mitem'], 'mval') + self.assertEqual(userhigh['Hsec']['hitem'], 'hval') + self.assertEqual(userkeys['Ksec']['kitem'], 'kval') + changes.add_option('main', 'Msec', 'mitem', 'mval') + self.assertFalse(changes.save_all()) + usermain.remove_section('Msec') + userhigh.remove_section('Hsec') + userkeys.remove_section('Ksec') + + def test_save_help(self): + # Any change to HelpFiles overwrites entire section. + changes = self.changes + changes.save_option('main', 'HelpFiles', 'IDLE', 'idledoc') + changes.add_option('main', 'HelpFiles', 'ELDI', 'codeldi') + changes.save_all() + self.assertFalse(usermain.has_option('HelpFiles', 'IDLE')) + self.assertTrue(usermain.has_option('HelpFiles', 'ELDI')) + + def test_save_default(self): # Cover 2nd and 3rd false branches. + changes = self.changes + changes.add_option('main', 'Indent', 'use-spaces', '1') + # save_option returns False; cfg_type_changed remains False. + + # TODO: test that save_all calls usercfg Saves. + + def test_delete_section(self): + changes = self.load() + changes.delete_section('main', 'fake') # Test no exception. + self.assertEqual(changes, self.loaded) # Test nothing deleted. + for cfgtype, section in (('main', 'Msec'), ('keys', 'Ksec')): + testcfg[cfgtype].SetOption(section, 'name', 'value') + changes.delete_section(cfgtype, section) + with self.assertRaises(KeyError): + changes[cfgtype][section] # Test section gone from changes + testcfg[cfgtype][section] # and from mock userCfg. + # TODO test for save call. + + def test_clear(self): + changes = self.load() + changes.clear() + self.assertEqual(changes, self.empty) + + +class WarningTest(unittest.TestCase): + + def test_warn(self): + Equal = self.assertEqual + config._warned = set() + with captured_stderr() as stderr: + config._warn('warning', 'key') + Equal(config._warned, {('warning','key')}) + Equal(stderr.getvalue(), 'warning'+'\n') + with captured_stderr() as stderr: + config._warn('warning', 'key') + Equal(stderr.getvalue(), '') + with captured_stderr() as stderr: + config._warn('warn2', 'yek') + Equal(config._warned, {('warning','key'), ('warn2','yek')}) + Equal(stderr.getvalue(), 'warn2'+'\n') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_config_key.py b/Lib/idlelib/idle_test/test_config_key.py new file mode 100644 index 00000000000..32f878b842b --- /dev/null +++ b/Lib/idlelib/idle_test/test_config_key.py @@ -0,0 +1,356 @@ +"""Test config_key, coverage 98%. + +Coverage is effectively 100%. Tkinter dialog is mocked, Mac-only line +may be skipped, and dummy function in bind test should not be called. +Not tested: exit with 'self.advanced or self.keys_ok(keys) ...' False. +""" + +from idlelib import config_key +from test.support import requires +import unittest +from unittest import mock +from tkinter import Tk, TclError +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func + + +class ValidationTest(unittest.TestCase): + "Test validation methods: ok, keys_ok, bind_ok." + + class Validator(config_key.GetKeysFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + class list_keys_final: + get = Func() + self.list_keys_final = list_keys_final + get_modifiers = Func() + showerror = Mbox_func() + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + keylist = [[''], ['', '']] + cls.dialog = cls.Validator(cls.root, '<>', keylist) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.showerror.message = '' + # A test that needs a particular final key value should set it. + # A test that sets a non-blank modifier list should reset it to []. + + def test_ok_empty(self): + self.dialog.key_string.set(' ') + self.dialog.ok() + self.assertEqual(self.dialog.result, '') + self.assertEqual(self.dialog.showerror.message, 'No key specified.') + + def test_ok_good(self): + self.dialog.key_string.set('') + self.dialog.list_keys_final.get.result = 'F11' + self.dialog.ok() + self.assertEqual(self.dialog.result, '') + self.assertEqual(self.dialog.showerror.message, '') + + def test_keys_no_ending(self): + self.assertFalse(self.dialog.keys_ok('')) + self.assertIn('No modifier', self.dialog.showerror.message) + + def test_keys_no_modifier_ok(self): + self.dialog.list_keys_final.get.result = 'F11' + self.assertTrue(self.dialog.keys_ok('')) + self.assertEqual(self.dialog.showerror.message, '') + + def test_keys_shift_bad(self): + self.dialog.list_keys_final.get.result = 'a' + self.dialog.get_modifiers.result = ['Shift'] + self.assertFalse(self.dialog.keys_ok('')) + self.assertIn('shift modifier', self.dialog.showerror.message) + self.dialog.get_modifiers.result = [] + + def test_keys_dup(self): + for mods, final, seq in (([], 'F12', ''), + (['Control'], 'x', ''), + (['Control'], 'X', '')): + with self.subTest(m=mods, f=final, s=seq): + self.dialog.list_keys_final.get.result = final + self.dialog.get_modifiers.result = mods + self.assertFalse(self.dialog.keys_ok(seq)) + self.assertIn('already in use', self.dialog.showerror.message) + self.dialog.get_modifiers.result = [] + + def test_bind_ok(self): + self.assertTrue(self.dialog.bind_ok('')) + self.assertEqual(self.dialog.showerror.message, '') + + def test_bind_not_ok(self): + self.assertFalse(self.dialog.bind_ok('')) + self.assertIn('not accepted', self.dialog.showerror.message) + + +class ToggleLevelTest(unittest.TestCase): + "Test toggle between Basic and Advanced frames." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysFrame(cls.root, '<>', []) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_toggle_level(self): + dialog = self.dialog + + def stackorder(): + """Get the stack order of the children of the frame. + + winfo_children() stores the children in stack order, so + this can be used to check whether a frame is above or + below another one. + """ + for index, child in enumerate(dialog.winfo_children()): + if child._name == 'keyseq_basic': + basic = index + if child._name == 'keyseq_advanced': + advanced = index + return basic, advanced + + # New window starts at basic level. + self.assertFalse(dialog.advanced) + self.assertIn('Advanced', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(basic, advanced) + + # Toggle to advanced. + dialog.toggle_level() + self.assertTrue(dialog.advanced) + self.assertIn('Basic', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(advanced, basic) + + # Toggle to basic. + dialog.button_level.invoke() + self.assertFalse(dialog.advanced) + self.assertIn('Advanced', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(basic, advanced) + + +class KeySelectionTest(unittest.TestCase): + "Test selecting key on Basic frames." + + class Basic(config_key.GetKeysFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + class list_keys_final: + get = Func() + select_clear = Func() + yview = Func() + self.list_keys_final = list_keys_final + def set_modifiers_for_platform(self): + self.modifiers = ['foo', 'bar', 'BAZ'] + self.modifier_label = {'BAZ': 'ZZZ'} + showerror = Mbox_func() + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = cls.Basic(cls.root, '<>', []) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.clear_key_seq() + + def test_get_modifiers(self): + dialog = self.dialog + gm = dialog.get_modifiers + eq = self.assertEqual + + # Modifiers are set on/off by invoking the checkbutton. + dialog.modifier_checkbuttons['foo'].invoke() + eq(gm(), ['foo']) + + dialog.modifier_checkbuttons['BAZ'].invoke() + eq(gm(), ['foo', 'BAZ']) + + dialog.modifier_checkbuttons['foo'].invoke() + eq(gm(), ['BAZ']) + + @mock.patch.object(config_key.GetKeysFrame, 'get_modifiers') + def test_build_key_string(self, mock_modifiers): + dialog = self.dialog + key = dialog.list_keys_final + string = dialog.key_string.get + eq = self.assertEqual + + key.get.result = 'a' + mock_modifiers.return_value = [] + dialog.build_key_string() + eq(string(), '') + + mock_modifiers.return_value = ['mymod'] + dialog.build_key_string() + eq(string(), '') + + key.get.result = '' + mock_modifiers.return_value = ['mymod', 'test'] + dialog.build_key_string() + eq(string(), '') + + @mock.patch.object(config_key.GetKeysFrame, 'get_modifiers') + def test_final_key_selected(self, mock_modifiers): + dialog = self.dialog + key = dialog.list_keys_final + string = dialog.key_string.get + eq = self.assertEqual + + mock_modifiers.return_value = ['Shift'] + key.get.result = '{' + dialog.final_key_selected() + eq(string(), '') + + +class CancelWindowTest(unittest.TestCase): + "Simulate user clicking [Cancel] button." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch.object(config_key.GetKeysFrame, 'ok') + def test_cancel(self, mock_frame_ok): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_cancel.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + self.assertEqual(self.dialog.result, '') + mock_frame_ok.assert_not_called() + + +class OKWindowTest(unittest.TestCase): + "Simulate user clicking [OK] button." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch.object(config_key.GetKeysFrame, 'ok') + def test_ok(self, mock_frame_ok): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_ok.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + mock_frame_ok.assert_called() + + +class WindowResultTest(unittest.TestCase): + "Test window result get and set." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_result(self): + dialog = self.dialog + eq = self.assertEqual + + dialog.result = '' + eq(dialog.result, '') + eq(dialog.frame.result,'') + + dialog.result = 'bar' + eq(dialog.result,'bar') + eq(dialog.frame.result,'bar') + + dialog.frame.result = 'foo' + eq(dialog.result, 'foo') + eq(dialog.frame.result,'foo') + + +class HelperTest(unittest.TestCase): + "Test module level helper functions." + + def test_translate_key(self): + tr = config_key.translate_key + eq = self.assertEqual + + # Letters return unchanged with no 'Shift'. + eq(tr('q', []), 'Key-q') + eq(tr('q', ['Control', 'Alt']), 'Key-q') + + # 'Shift' uppercases single lowercase letters. + eq(tr('q', ['Shift']), 'Key-Q') + eq(tr('q', ['Control', 'Shift']), 'Key-Q') + eq(tr('q', ['Control', 'Alt', 'Shift']), 'Key-Q') + + # Convert key name to keysym. + eq(tr('Page Up', []), 'Key-Prior') + # 'Shift' doesn't change case when it's not a single char. + eq(tr('*', ['Shift']), 'Key-asterisk') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py new file mode 100644 index 00000000000..2773ed7ce61 --- /dev/null +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -0,0 +1,1580 @@ +"""Test configdialog, coverage 94%. + +Half the class creates dialog, half works with user customizations. +""" +from idlelib import configdialog +from test.support import requires +requires('gui') +import unittest +from unittest import mock +from idlelib.idle_test.mock_idle import Func +from tkinter import (Tk, StringVar, IntVar, BooleanVar, DISABLED, NORMAL) +from idlelib import config +from idlelib.configdialog import idleConf, changes, tracers + +# Tests should not depend on fortuitous user configurations. +# They must not affect actual user .cfg files. +# Use solution from test_config: empty parsers with no filename. +usercfg = idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} + +root = None +dialog = None +mainpage = changes['main'] +highpage = changes['highlight'] +keyspage = changes['keys'] +extpage = changes['extensions'] + + +def setUpModule(): + global root, dialog + idleConf.userCfg = testcfg + root = Tk() + # root.withdraw() # Comment out, see issue 30870 + dialog = configdialog.ConfigDialog(root, 'Test', _utest=True) + + +def tearDownModule(): + global root, dialog + idleConf.userCfg = usercfg + tracers.detach() + tracers.clear() + changes.clear() + root.update_idletasks() + root.destroy() + root = dialog = None + + +class ConfigDialogTest(unittest.TestCase): + + def test_deactivate_current_config(self): + pass + + def activate_config_changes(self): + pass + + +class ButtonTest(unittest.TestCase): + + def test_click_ok(self): + d = dialog + apply = d.apply = mock.Mock() + destroy = d.destroy = mock.Mock() + d.buttons['Ok'].invoke() + apply.assert_called_once() + destroy.assert_called_once() + del d.destroy, d.apply + + def test_click_apply(self): + d = dialog + deactivate = d.deactivate_current_config = mock.Mock() + save_ext = d.extpage.save_all_changed_extensions = mock.Mock() + activate = d.activate_config_changes = mock.Mock() + d.buttons['Apply'].invoke() + deactivate.assert_called_once() + save_ext.assert_called_once() + activate.assert_called_once() + del d.extpage.save_all_changed_extensions + del d.activate_config_changes, d.deactivate_current_config + + def test_click_cancel(self): + d = dialog + d.destroy = Func() + changes['main']['something'] = 1 + d.buttons['Cancel'].invoke() + self.assertEqual(changes['main'], {}) + self.assertEqual(d.destroy.called, 1) + del d.destroy + + def test_click_help(self): + dialog.note.select(dialog.keyspage) + with mock.patch.object(configdialog, 'view_text', + new_callable=Func) as view: + dialog.buttons['Help'].invoke() + title, contents = view.kwds['title'], view.kwds['contents'] + self.assertEqual(title, 'Help for IDLE preferences') + self.assertStartsWith(contents, 'When you click') + self.assertEndsWith(contents,'a different name.\n') + + +class FontPageTest(unittest.TestCase): + """Test that font widgets enable users to make font changes. + + Test that widget actions set vars, that var changes add three + options to changes and call set_samples, and that set_samples + changes the font of both sample boxes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.fontpage + dialog.note.select(page) + page.set_samples = Func() # Mask instance method. + page.update() + + @classmethod + def tearDownClass(cls): + del cls.page.set_samples # Unmask instance method. + + def setUp(self): + changes.clear() + + def test_load_font_cfg(self): + # Leave widget load test to human visual check. + # TODO Improve checks when add IdleConf.get_font_values. + tracers.detach() + d = self.page + d.font_name.set('Fake') + d.font_size.set('1') + d.font_bold.set(True) + d.set_samples.called = 0 + d.load_font_cfg() + self.assertNotEqual(d.font_name.get(), 'Fake') + self.assertNotEqual(d.font_size.get(), '1') + self.assertFalse(d.font_bold.get()) + self.assertEqual(d.set_samples.called, 1) + tracers.attach() + + def test_fontlist_key(self): + # Up and Down keys should select a new font. + d = self.page + if d.fontlist.size() < 2: + self.skipTest('need at least 2 fonts') + fontlist = d.fontlist + fontlist.activate(0) + font = d.fontlist.get('active') + + # Test Down key. + fontlist.focus_force() + fontlist.update() + fontlist.event_generate('') + fontlist.event_generate('') + + down_font = fontlist.get('active') + self.assertNotEqual(down_font, font) + self.assertIn(d.font_name.get(), down_font.lower()) + + # Test Up key. + fontlist.focus_force() + fontlist.update() + fontlist.event_generate('') + fontlist.event_generate('') + + up_font = fontlist.get('active') + self.assertEqual(up_font, font) + self.assertIn(d.font_name.get(), up_font.lower()) + + def test_fontlist_mouse(self): + # Click on item should select that item. + d = self.page + if d.fontlist.size() < 2: + self.skipTest('need at least 2 fonts') + fontlist = d.fontlist + fontlist.activate(0) + + # Select next item in listbox + fontlist.focus_force() + fontlist.see(1) + fontlist.update() + x, y, dx, dy = fontlist.bbox(1) + x += dx // 2 + y += dy // 2 + fontlist.event_generate('', x=x, y=y) + fontlist.event_generate('', x=x, y=y) + + font1 = fontlist.get(1) + select_font = fontlist.get('anchor') + self.assertEqual(select_font, font1) + self.assertIn(d.font_name.get(), font1.lower()) + + def test_sizelist(self): + # Click on number should select that number + d = self.page + d.sizelist.variable.set(40) + self.assertEqual(d.font_size.get(), '40') + + def test_bold_toggle(self): + # Click on checkbutton should invert it. + d = self.page + d.font_bold.set(False) + d.bold_toggle.invoke() + self.assertTrue(d.font_bold.get()) + d.bold_toggle.invoke() + self.assertFalse(d.font_bold.get()) + + def test_font_set(self): + # Test that setting a font Variable results in 3 provisional + # change entries and a call to set_samples. Use values sure to + # not be defaults. + + default_font = idleConf.GetFont(root, 'main', 'EditorWindow') + default_size = str(default_font[1]) + default_bold = default_font[2] == 'bold' + d = self.page + d.font_size.set(default_size) + d.font_bold.set(default_bold) + d.set_samples.called = 0 + + d.font_name.set('Test Font') + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': default_size, + 'font-bold': str(default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 1) + changes.clear() + + d.font_size.set('20') + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': '20', + 'font-bold': str(default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 2) + changes.clear() + + d.font_bold.set(not default_bold) + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': '20', + 'font-bold': str(not default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 3) + + def test_set_samples(self): + d = self.page + del d.set_samples # Unmask method for test + orig_samples = d.font_sample, d.highlight_sample + d.font_sample, d.highlight_sample = {}, {} + d.font_name.set('test') + d.font_size.set('5') + d.font_bold.set(1) + expected = {'font': ('test', '5', 'bold')} + + # Test set_samples. + d.set_samples() + self.assertTrue(d.font_sample == d.highlight_sample == expected) + + d.font_sample, d.highlight_sample = orig_samples + d.set_samples = Func() # Re-mask for other tests. + + +class HighPageTest(unittest.TestCase): + """Test that highlight tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes and that themes work correctly. + """ + + @classmethod + def setUpClass(cls): + page = cls.page = dialog.highpage + dialog.note.select(page) + page.set_theme_type = Func() + page.paint_theme_sample = Func() + page.set_highlight_target = Func() + page.set_color_sample = Func() + page.update() + + @classmethod + def tearDownClass(cls): + d = cls.page + del d.set_theme_type, d.paint_theme_sample + del d.set_highlight_target, d.set_color_sample + + def setUp(self): + d = self.page + # The following is needed for test_load_key_cfg, _delete_custom_keys. + # This may indicate a defect in some test or function. + for section in idleConf.GetSectionList('user', 'highlight'): + idleConf.userCfg['highlight'].remove_section(section) + changes.clear() + d.set_theme_type.called = 0 + d.paint_theme_sample.called = 0 + d.set_highlight_target.called = 0 + d.set_color_sample.called = 0 + + def test_load_theme_cfg(self): + tracers.detach() + d = self.page + eq = self.assertEqual + + # Use builtin theme with no user themes created. + idleConf.CurrentTheme = mock.Mock(return_value='IDLE Classic') + d.load_theme_cfg() + self.assertTrue(d.theme_source.get()) + # builtinlist sets variable builtin_name to the CurrentTheme default. + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), '- no custom themes -') + eq(d.custom_theme_on.state(), ('disabled',)) + eq(d.set_theme_type.called, 1) + eq(d.paint_theme_sample.called, 1) + eq(d.set_highlight_target.called, 1) + + # Builtin theme with non-empty user theme list. + idleConf.SetOption('highlight', 'test1', 'option', 'value') + idleConf.SetOption('highlight', 'test2', 'option2', 'value2') + d.load_theme_cfg() + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), 'test1') + eq(d.set_theme_type.called, 2) + eq(d.paint_theme_sample.called, 2) + eq(d.set_highlight_target.called, 2) + + # Use custom theme. + idleConf.CurrentTheme = mock.Mock(return_value='test2') + idleConf.SetOption('main', 'Theme', 'default', '0') + d.load_theme_cfg() + self.assertFalse(d.theme_source.get()) + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), 'test2') + eq(d.set_theme_type.called, 3) + eq(d.paint_theme_sample.called, 3) + eq(d.set_highlight_target.called, 3) + + del idleConf.CurrentTheme + tracers.attach() + + def test_theme_source(self): + eq = self.assertEqual + d = self.page + # Test these separately. + d.var_changed_builtin_name = Func() + d.var_changed_custom_name = Func() + # Builtin selected. + d.builtin_theme_on.invoke() + eq(mainpage, {'Theme': {'default': 'True'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 0) + changes.clear() + + # Custom selected. + d.custom_theme_on.state(('!disabled',)) + d.custom_theme_on.invoke() + self.assertEqual(mainpage, {'Theme': {'default': 'False'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 1) + del d.var_changed_builtin_name, d.var_changed_custom_name + + def test_builtin_name(self): + eq = self.assertEqual + d = self.page + item_list = ['IDLE Classic', 'IDLE Dark', 'IDLE New'] + + # Not in old_themes, defaults name to first item. + idleConf.SetOption('main', 'Theme', 'name', 'spam') + d.builtinlist.SetMenu(item_list, 'IDLE Dark') + eq(mainpage, {'Theme': {'name': 'IDLE Classic', + 'name2': 'IDLE Dark'}}) + eq(d.theme_message['text'], 'New theme, see Help') + eq(d.paint_theme_sample.called, 1) + + # Not in old themes - uses name2. + changes.clear() + idleConf.SetOption('main', 'Theme', 'name', 'IDLE New') + d.builtinlist.SetMenu(item_list, 'IDLE Dark') + eq(mainpage, {'Theme': {'name2': 'IDLE Dark'}}) + eq(d.theme_message['text'], 'New theme, see Help') + eq(d.paint_theme_sample.called, 2) + + # Builtin name in old_themes. + changes.clear() + d.builtinlist.SetMenu(item_list, 'IDLE Classic') + eq(mainpage, {'Theme': {'name': 'IDLE Classic', 'name2': ''}}) + eq(d.theme_message['text'], '') + eq(d.paint_theme_sample.called, 3) + + def test_custom_name(self): + d = self.page + + # If no selections, doesn't get added. + d.customlist.SetMenu([], '- no custom themes -') + self.assertNotIn('Theme', mainpage) + self.assertEqual(d.paint_theme_sample.called, 0) + + # Custom name selected. + changes.clear() + d.customlist.SetMenu(['a', 'b', 'c'], 'c') + self.assertEqual(mainpage, {'Theme': {'name': 'c'}}) + self.assertEqual(d.paint_theme_sample.called, 1) + + def test_color(self): + d = self.page + d.on_new_color_set = Func() + # self.color is only set in get_color through colorchooser. + d.color.set('green') + self.assertEqual(d.on_new_color_set.called, 1) + del d.on_new_color_set + + def test_highlight_target_list_mouse(self): + # Set highlight_target through targetlist. + eq = self.assertEqual + d = self.page + + d.targetlist.SetMenu(['a', 'b', 'c'], 'c') + eq(d.highlight_target.get(), 'c') + eq(d.set_highlight_target.called, 1) + + def test_highlight_target_text_mouse(self): + # Set highlight_target through clicking highlight_sample. + eq = self.assertEqual + d = self.page + hs = d.highlight_sample + hs.focus_force() + + def click_char(index): + "Simulate click on character at *index*." + hs.see(index) + hs.update_idletasks() + x, y, dx, dy = hs.bbox(index) + x += dx // 2 + y += dy // 2 + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + + # Reverse theme_elements to make the tag the key. + elem = {tag: element for element, tag in d.theme_elements.items()} + + # If highlight_sample has a tag that isn't in theme_elements, there + # will be a KeyError in the test run. + count = 0 + for tag in hs.tag_names(): + try: + click_char(hs.tag_nextrange(tag, "1.0")[0]) + eq(d.highlight_target.get(), elem[tag]) + count += 1 + eq(d.set_highlight_target.called, count) + except IndexError: + pass # Skip unused theme_elements tag, like 'sel'. + + def test_highlight_sample_double_click(self): + # Test double click on highlight_sample. + eq = self.assertEqual + d = self.page + + hs = d.highlight_sample + hs.focus_force() + hs.see(1.0) + hs.update_idletasks() + + # Test binding from configdialog. + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=0, y=0) + # Double click is a sequence of two clicks in a row. + for _ in range(2): + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=0, y=0) + + eq(hs.tag_ranges('sel'), ()) + + def test_highlight_sample_b1_motion(self): + # Test button motion on highlight_sample. + eq = self.assertEqual + d = self.page + + hs = d.highlight_sample + hs.focus_force() + hs.see(1.0) + hs.update_idletasks() + + x, y, dx, dy, offset = hs.dlineinfo('1.0') + + # Test binding from configdialog. + hs.event_generate('') + hs.event_generate('') + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=dx, y=dy) + hs.event_generate('', x=dx, y=dy) + + eq(hs.tag_ranges('sel'), ()) + + def test_set_theme_type(self): + eq = self.assertEqual + d = self.page + del d.set_theme_type + + # Builtin theme selected. + d.theme_source.set(True) + d.set_theme_type() + eq(d.builtinlist['state'], NORMAL) + eq(d.customlist['state'], DISABLED) + eq(d.button_delete_custom.state(), ('disabled',)) + + # Custom theme selected. + d.theme_source.set(False) + d.set_theme_type() + eq(d.builtinlist['state'], DISABLED) + eq(d.custom_theme_on.state(), ('selected',)) + eq(d.customlist['state'], NORMAL) + eq(d.button_delete_custom.state(), ()) + d.set_theme_type = Func() + + def test_get_color(self): + eq = self.assertEqual + d = self.page + orig_chooser = configdialog.colorchooser.askcolor + chooser = configdialog.colorchooser.askcolor = Func() + gntn = d.get_new_theme_name = Func() + + d.highlight_target.set('Editor Breakpoint') + d.color.set('#ffffff') + + # Nothing selected. + chooser.result = (None, None) + d.button_set_color.invoke() + eq(d.color.get(), '#ffffff') + + # Selection same as previous color. + chooser.result = ('', d.style.lookup(d.frame_color_set['style'], 'background')) + d.button_set_color.invoke() + eq(d.color.get(), '#ffffff') + + # Select different color. + chooser.result = ((222.8671875, 0.0, 0.0), '#de0000') + + # Default theme. + d.color.set('#ffffff') + d.theme_source.set(True) + + # No theme name selected therefore color not saved. + gntn.result = '' + d.button_set_color.invoke() + eq(gntn.called, 1) + eq(d.color.get(), '#ffffff') + # Theme name selected. + gntn.result = 'My New Theme' + d.button_set_color.invoke() + eq(d.custom_name.get(), gntn.result) + eq(d.color.get(), '#de0000') + + # Custom theme. + d.color.set('#ffffff') + d.theme_source.set(False) + d.button_set_color.invoke() + eq(d.color.get(), '#de0000') + + del d.get_new_theme_name + configdialog.colorchooser.askcolor = orig_chooser + + def test_on_new_color_set(self): + d = self.page + color = '#3f7cae' + d.custom_name.set('Python') + d.highlight_target.set('Selected Text') + d.fg_bg_toggle.set(True) + + d.color.set(color) + self.assertEqual(d.style.lookup(d.frame_color_set['style'], 'background'), color) + self.assertEqual(d.highlight_sample.tag_cget('hilite', 'foreground'), color) + self.assertEqual(highpage, + {'Python': {'hilite-foreground': color}}) + + def test_get_new_theme_name(self): + orig_sectionname = configdialog.SectionName + sn = configdialog.SectionName = Func(return_self=True) + d = self.page + + sn.result = 'New Theme' + self.assertEqual(d.get_new_theme_name(''), 'New Theme') + + configdialog.SectionName = orig_sectionname + + def test_save_as_new_theme(self): + d = self.page + gntn = d.get_new_theme_name = Func() + d.theme_source.set(True) + + # No name entered. + gntn.result = '' + d.button_save_custom.invoke() + self.assertNotIn(gntn.result, idleConf.userCfg['highlight']) + + # Name entered. + gntn.result = 'my new theme' + gntn.called = 0 + self.assertNotIn(gntn.result, idleConf.userCfg['highlight']) + d.button_save_custom.invoke() + self.assertIn(gntn.result, idleConf.userCfg['highlight']) + + del d.get_new_theme_name + + def test_create_new_and_save_new(self): + eq = self.assertEqual + d = self.page + + # Use default as previously active theme. + d.theme_source.set(True) + d.builtin_name.set('IDLE Classic') + first_new = 'my new custom theme' + second_new = 'my second custom theme' + + # No changes, so themes are an exact copy. + self.assertNotIn(first_new, idleConf.userCfg) + d.create_new(first_new) + eq(idleConf.GetSectionList('user', 'highlight'), [first_new]) + eq(idleConf.GetThemeDict('default', 'IDLE Classic'), + idleConf.GetThemeDict('user', first_new)) + eq(d.custom_name.get(), first_new) + self.assertFalse(d.theme_source.get()) # Use custom set. + eq(d.set_theme_type.called, 1) + + # Test that changed targets are in new theme. + changes.add_option('highlight', first_new, 'hit-background', 'yellow') + self.assertNotIn(second_new, idleConf.userCfg) + d.create_new(second_new) + eq(idleConf.GetSectionList('user', 'highlight'), [first_new, second_new]) + self.assertNotEqual(idleConf.GetThemeDict('user', first_new), + idleConf.GetThemeDict('user', second_new)) + # Check that difference in themes was in `hit-background` from `changes`. + idleConf.SetOption('highlight', first_new, 'hit-background', 'yellow') + eq(idleConf.GetThemeDict('user', first_new), + idleConf.GetThemeDict('user', second_new)) + + def test_set_highlight_target(self): + eq = self.assertEqual + d = self.page + del d.set_highlight_target + + # Target is cursor. + d.highlight_target.set('Cursor') + eq(d.fg_on.state(), ('disabled', 'selected')) + eq(d.bg_on.state(), ('disabled',)) + self.assertTrue(d.fg_bg_toggle) + eq(d.set_color_sample.called, 1) + + # Target is not cursor. + d.highlight_target.set('Comment') + eq(d.fg_on.state(), ('selected',)) + eq(d.bg_on.state(), ()) + self.assertTrue(d.fg_bg_toggle) + eq(d.set_color_sample.called, 2) + + d.set_highlight_target = Func() + + def test_set_color_sample_binding(self): + d = self.page + scs = d.set_color_sample + + d.fg_on.invoke() + self.assertEqual(scs.called, 1) + + d.bg_on.invoke() + self.assertEqual(scs.called, 2) + + def test_set_color_sample(self): + d = self.page + del d.set_color_sample + d.highlight_target.set('Selected Text') + d.fg_bg_toggle.set(True) + d.set_color_sample() + self.assertEqual( + d.style.lookup(d.frame_color_set['style'], 'background'), + d.highlight_sample.tag_cget('hilite', 'foreground')) + d.set_color_sample = Func() + + def test_paint_theme_sample(self): + eq = self.assertEqual + page = self.page + del page.paint_theme_sample # Delete masking mock. + hs_tag = page.highlight_sample.tag_cget + gh = idleConf.GetHighlight + + # Create custom theme based on IDLE Dark. + page.theme_source.set(True) + page.builtin_name.set('IDLE Dark') + theme = 'IDLE Test' + page.create_new(theme) + page.set_color_sample.called = 0 + + # Base theme with nothing in `changes`. + page.paint_theme_sample() + new_console = {'foreground': 'blue', + 'background': 'yellow',} + for key, value in new_console.items(): + self.assertNotEqual(hs_tag('console', key), value) + eq(page.set_color_sample.called, 1) + + # Apply changes. + for key, value in new_console.items(): + changes.add_option('highlight', theme, 'console-'+key, value) + page.paint_theme_sample() + for key, value in new_console.items(): + eq(hs_tag('console', key), value) + eq(page.set_color_sample.called, 2) + + page.paint_theme_sample = Func() + + def test_delete_custom(self): + eq = self.assertEqual + d = self.page + d.button_delete_custom.state(('!disabled',)) + yesno = d.askyesno = Func() + dialog.deactivate_current_config = Func() + dialog.activate_config_changes = Func() + + theme_name = 'spam theme' + idleConf.userCfg['highlight'].SetOption(theme_name, 'name', 'value') + highpage[theme_name] = {'option': 'True'} + + theme_name2 = 'other theme' + idleConf.userCfg['highlight'].SetOption(theme_name2, 'name', 'value') + highpage[theme_name2] = {'option': 'False'} + + # Force custom theme. + d.custom_theme_on.state(('!disabled',)) + d.custom_theme_on.invoke() + d.custom_name.set(theme_name) + + # Cancel deletion. + yesno.result = False + d.button_delete_custom.invoke() + eq(yesno.called, 1) + eq(highpage[theme_name], {'option': 'True'}) + eq(idleConf.GetSectionList('user', 'highlight'), [theme_name, theme_name2]) + eq(dialog.deactivate_current_config.called, 0) + eq(dialog.activate_config_changes.called, 0) + eq(d.set_theme_type.called, 0) + + # Confirm deletion. + yesno.result = True + d.button_delete_custom.invoke() + eq(yesno.called, 2) + self.assertNotIn(theme_name, highpage) + eq(idleConf.GetSectionList('user', 'highlight'), [theme_name2]) + eq(d.custom_theme_on.state(), ()) + eq(d.custom_name.get(), theme_name2) + eq(dialog.deactivate_current_config.called, 1) + eq(dialog.activate_config_changes.called, 1) + eq(d.set_theme_type.called, 1) + + # Confirm deletion of second theme - empties list. + d.custom_name.set(theme_name2) + yesno.result = True + d.button_delete_custom.invoke() + eq(yesno.called, 3) + self.assertNotIn(theme_name, highpage) + eq(idleConf.GetSectionList('user', 'highlight'), []) + eq(d.custom_theme_on.state(), ('disabled',)) + eq(d.custom_name.get(), '- no custom themes -') + eq(dialog.deactivate_current_config.called, 2) + eq(dialog.activate_config_changes.called, 2) + eq(d.set_theme_type.called, 2) + + del dialog.activate_config_changes, dialog.deactivate_current_config + del d.askyesno + + +class KeysPageTest(unittest.TestCase): + """Test that keys tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes and that key sets works correctly. + """ + + @classmethod + def setUpClass(cls): + page = cls.page = dialog.keyspage + dialog.note.select(page) + page.set_keys_type = Func() + page.load_keys_list = Func() + + @classmethod + def tearDownClass(cls): + page = cls.page + del page.set_keys_type, page.load_keys_list + + def setUp(self): + d = self.page + # The following is needed for test_load_key_cfg, _delete_custom_keys. + # This may indicate a defect in some test or function. + for section in idleConf.GetSectionList('user', 'keys'): + idleConf.userCfg['keys'].remove_section(section) + changes.clear() + d.set_keys_type.called = 0 + d.load_keys_list.called = 0 + + def test_load_key_cfg(self): + tracers.detach() + d = self.page + eq = self.assertEqual + + # Use builtin keyset with no user keysets created. + idleConf.CurrentKeys = mock.Mock(return_value='IDLE Classic OSX') + d.load_key_cfg() + self.assertTrue(d.keyset_source.get()) + # builtinlist sets variable builtin_name to the CurrentKeys default. + eq(d.builtin_name.get(), 'IDLE Classic OSX') + eq(d.custom_name.get(), '- no custom keys -') + eq(d.custom_keyset_on.state(), ('disabled',)) + eq(d.set_keys_type.called, 1) + eq(d.load_keys_list.called, 1) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + # Builtin keyset with non-empty user keyset list. + idleConf.SetOption('keys', 'test1', 'option', 'value') + idleConf.SetOption('keys', 'test2', 'option2', 'value2') + d.load_key_cfg() + eq(d.builtin_name.get(), 'IDLE Classic OSX') + eq(d.custom_name.get(), 'test1') + eq(d.set_keys_type.called, 2) + eq(d.load_keys_list.called, 2) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + # Use custom keyset. + idleConf.CurrentKeys = mock.Mock(return_value='test2') + idleConf.default_keys = mock.Mock(return_value='IDLE Modern Unix') + idleConf.SetOption('main', 'Keys', 'default', '0') + d.load_key_cfg() + self.assertFalse(d.keyset_source.get()) + eq(d.builtin_name.get(), 'IDLE Modern Unix') + eq(d.custom_name.get(), 'test2') + eq(d.set_keys_type.called, 3) + eq(d.load_keys_list.called, 3) + eq(d.load_keys_list.args, ('test2', )) + + del idleConf.CurrentKeys, idleConf.default_keys + tracers.attach() + + def test_keyset_source(self): + eq = self.assertEqual + d = self.page + # Test these separately. + d.var_changed_builtin_name = Func() + d.var_changed_custom_name = Func() + # Builtin selected. + d.builtin_keyset_on.invoke() + eq(mainpage, {'Keys': {'default': 'True'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 0) + changes.clear() + + # Custom selected. + d.custom_keyset_on.state(('!disabled',)) + d.custom_keyset_on.invoke() + self.assertEqual(mainpage, {'Keys': {'default': 'False'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 1) + del d.var_changed_builtin_name, d.var_changed_custom_name + + def test_builtin_name(self): + eq = self.assertEqual + d = self.page + idleConf.userCfg['main'].remove_section('Keys') + item_list = ['IDLE Classic Windows', 'IDLE Classic OSX', + 'IDLE Modern UNIX'] + + # Not in old_keys, defaults name to first item. + d.builtinlist.SetMenu(item_list, 'IDLE Modern UNIX') + eq(mainpage, {'Keys': {'name': 'IDLE Classic Windows', + 'name2': 'IDLE Modern UNIX'}}) + eq(d.keys_message['text'], 'New key set, see Help') + eq(d.load_keys_list.called, 1) + eq(d.load_keys_list.args, ('IDLE Modern UNIX', )) + + # Not in old keys - uses name2. + changes.clear() + idleConf.SetOption('main', 'Keys', 'name', 'IDLE Classic Unix') + d.builtinlist.SetMenu(item_list, 'IDLE Modern UNIX') + eq(mainpage, {'Keys': {'name2': 'IDLE Modern UNIX'}}) + eq(d.keys_message['text'], 'New key set, see Help') + eq(d.load_keys_list.called, 2) + eq(d.load_keys_list.args, ('IDLE Modern UNIX', )) + + # Builtin name in old_keys. + changes.clear() + d.builtinlist.SetMenu(item_list, 'IDLE Classic OSX') + eq(mainpage, {'Keys': {'name': 'IDLE Classic OSX', 'name2': ''}}) + eq(d.keys_message['text'], '') + eq(d.load_keys_list.called, 3) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + def test_custom_name(self): + d = self.page + + # If no selections, doesn't get added. + d.customlist.SetMenu([], '- no custom keys -') + self.assertNotIn('Keys', mainpage) + self.assertEqual(d.load_keys_list.called, 0) + + # Custom name selected. + changes.clear() + d.customlist.SetMenu(['a', 'b', 'c'], 'c') + self.assertEqual(mainpage, {'Keys': {'name': 'c'}}) + self.assertEqual(d.load_keys_list.called, 1) + + def test_keybinding(self): + idleConf.SetOption('extensions', 'ZzDummy', 'enable', 'True') + d = self.page + d.custom_name.set('my custom keys') + d.bindingslist.delete(0, 'end') + d.bindingslist.insert(0, 'copy') + d.bindingslist.insert(1, 'z-in') + d.bindingslist.selection_set(0) + d.bindingslist.selection_anchor(0) + # Core binding - adds to keys. + d.keybinding.set('') + self.assertEqual(keyspage, + {'my custom keys': {'copy': ''}}) + + # Not a core binding - adds to extensions. + d.bindingslist.selection_set(1) + d.bindingslist.selection_anchor(1) + d.keybinding.set('') + self.assertEqual(extpage, + {'ZzDummy_cfgBindings': {'z-in': ''}}) + + def test_set_keys_type(self): + eq = self.assertEqual + d = self.page + del d.set_keys_type + + # Builtin keyset selected. + d.keyset_source.set(True) + d.set_keys_type() + eq(d.builtinlist['state'], NORMAL) + eq(d.customlist['state'], DISABLED) + eq(d.button_delete_custom_keys.state(), ('disabled',)) + + # Custom keyset selected. + d.keyset_source.set(False) + d.set_keys_type() + eq(d.builtinlist['state'], DISABLED) + eq(d.custom_keyset_on.state(), ('selected',)) + eq(d.customlist['state'], NORMAL) + eq(d.button_delete_custom_keys.state(), ()) + d.set_keys_type = Func() + + def test_get_new_keys(self): + eq = self.assertEqual + d = self.page + orig_getkeysdialog = configdialog.GetKeysWindow + gkd = configdialog.GetKeysWindow = Func(return_self=True) + gnkn = d.get_new_keys_name = Func() + + d.button_new_keys.state(('!disabled',)) + d.bindingslist.delete(0, 'end') + d.bindingslist.insert(0, 'copy - ') + d.bindingslist.selection_set(0) + d.bindingslist.selection_anchor(0) + d.keybinding.set('Key-a') + d.keyset_source.set(True) # Default keyset. + + # Default keyset; no change to binding. + gkd.result = '' + d.button_new_keys.invoke() + eq(d.bindingslist.get('anchor'), 'copy - ') + # Keybinding isn't changed when there isn't a change entered. + eq(d.keybinding.get(), 'Key-a') + + # Default keyset; binding changed. + gkd.result = '' + # No keyset name selected therefore binding not saved. + gnkn.result = '' + d.button_new_keys.invoke() + eq(gnkn.called, 1) + eq(d.bindingslist.get('anchor'), 'copy - ') + # Keyset name selected. + gnkn.result = 'My New Key Set' + d.button_new_keys.invoke() + eq(d.custom_name.get(), gnkn.result) + eq(d.bindingslist.get('anchor'), 'copy - ') + eq(d.keybinding.get(), '') + + # User keyset; binding changed. + d.keyset_source.set(False) # Custom keyset. + gnkn.called = 0 + gkd.result = '' + d.button_new_keys.invoke() + eq(gnkn.called, 0) + eq(d.bindingslist.get('anchor'), 'copy - ') + eq(d.keybinding.get(), '') + + del d.get_new_keys_name + configdialog.GetKeysWindow = orig_getkeysdialog + + def test_get_new_keys_name(self): + orig_sectionname = configdialog.SectionName + sn = configdialog.SectionName = Func(return_self=True) + d = self.page + + sn.result = 'New Keys' + self.assertEqual(d.get_new_keys_name(''), 'New Keys') + + configdialog.SectionName = orig_sectionname + + def test_save_as_new_key_set(self): + d = self.page + gnkn = d.get_new_keys_name = Func() + d.keyset_source.set(True) + + # No name entered. + gnkn.result = '' + d.button_save_custom_keys.invoke() + + # Name entered. + gnkn.result = 'my new key set' + gnkn.called = 0 + self.assertNotIn(gnkn.result, idleConf.userCfg['keys']) + d.button_save_custom_keys.invoke() + self.assertIn(gnkn.result, idleConf.userCfg['keys']) + + del d.get_new_keys_name + + def test_on_bindingslist_select(self): + d = self.page + b = d.bindingslist + b.delete(0, 'end') + b.insert(0, 'copy') + b.insert(1, 'find') + b.activate(0) + + b.focus_force() + b.see(1) + b.update() + x, y, dx, dy = b.bbox(1) + x += dx // 2 + y += dy // 2 + b.event_generate('', x=0, y=0) + b.event_generate('', x=x, y=y) + b.event_generate('', x=x, y=y) + b.event_generate('', x=x, y=y) + self.assertEqual(b.get('anchor'), 'find') + self.assertEqual(d.button_new_keys.state(), ()) + + def test_create_new_key_set_and_save_new_key_set(self): + eq = self.assertEqual + d = self.page + + # Use default as previously active keyset. + d.keyset_source.set(True) + d.builtin_name.set('IDLE Classic Windows') + first_new = 'my new custom key set' + second_new = 'my second custom keyset' + + # No changes, so keysets are an exact copy. + self.assertNotIn(first_new, idleConf.userCfg) + d.create_new_key_set(first_new) + eq(idleConf.GetSectionList('user', 'keys'), [first_new]) + eq(idleConf.GetKeySet('IDLE Classic Windows'), + idleConf.GetKeySet(first_new)) + eq(d.custom_name.get(), first_new) + self.assertFalse(d.keyset_source.get()) # Use custom set. + eq(d.set_keys_type.called, 1) + + # Test that changed keybindings are in new keyset. + changes.add_option('keys', first_new, 'copy', '') + self.assertNotIn(second_new, idleConf.userCfg) + d.create_new_key_set(second_new) + eq(idleConf.GetSectionList('user', 'keys'), [first_new, second_new]) + self.assertNotEqual(idleConf.GetKeySet(first_new), + idleConf.GetKeySet(second_new)) + # Check that difference in keysets was in option `copy` from `changes`. + idleConf.SetOption('keys', first_new, 'copy', '') + eq(idleConf.GetKeySet(first_new), idleConf.GetKeySet(second_new)) + + def test_load_keys_list(self): + eq = self.assertEqual + d = self.page + gks = idleConf.GetKeySet = Func() + del d.load_keys_list + b = d.bindingslist + + b.delete(0, 'end') + b.insert(0, '<>') + b.insert(1, '<>') + gks.result = {'<>': ['', ''], + '<>': [''], + '<>': ['']} + changes.add_option('keys', 'my keys', 'spam', '') + expected = ('copy - ', + 'force-open-completions - ', + 'spam - ') + + # No current selection. + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), '') + eq(b.curselection(), ()) + + # Check selection. + b.selection_set(1) + b.selection_anchor(1) + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), 'force-open-completions - ') + eq(b.curselection(), (1, )) + + # Change selection. + b.selection_set(2) + b.selection_anchor(2) + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), 'spam - ') + eq(b.curselection(), (2, )) + d.load_keys_list = Func() + + del idleConf.GetKeySet + + def test_delete_custom_keys(self): + eq = self.assertEqual + d = self.page + d.button_delete_custom_keys.state(('!disabled',)) + yesno = d.askyesno = Func() + dialog.deactivate_current_config = Func() + dialog.activate_config_changes = Func() + + keyset_name = 'spam key set' + idleConf.userCfg['keys'].SetOption(keyset_name, 'name', 'value') + keyspage[keyset_name] = {'option': 'True'} + + keyset_name2 = 'other key set' + idleConf.userCfg['keys'].SetOption(keyset_name2, 'name', 'value') + keyspage[keyset_name2] = {'option': 'False'} + + # Force custom keyset. + d.custom_keyset_on.state(('!disabled',)) + d.custom_keyset_on.invoke() + d.custom_name.set(keyset_name) + + # Cancel deletion. + yesno.result = False + d.button_delete_custom_keys.invoke() + eq(yesno.called, 1) + eq(keyspage[keyset_name], {'option': 'True'}) + eq(idleConf.GetSectionList('user', 'keys'), [keyset_name, keyset_name2]) + eq(dialog.deactivate_current_config.called, 0) + eq(dialog.activate_config_changes.called, 0) + eq(d.set_keys_type.called, 0) + + # Confirm deletion. + yesno.result = True + d.button_delete_custom_keys.invoke() + eq(yesno.called, 2) + self.assertNotIn(keyset_name, keyspage) + eq(idleConf.GetSectionList('user', 'keys'), [keyset_name2]) + eq(d.custom_keyset_on.state(), ()) + eq(d.custom_name.get(), keyset_name2) + eq(dialog.deactivate_current_config.called, 1) + eq(dialog.activate_config_changes.called, 1) + eq(d.set_keys_type.called, 1) + + # Confirm deletion of second keyset - empties list. + d.custom_name.set(keyset_name2) + yesno.result = True + d.button_delete_custom_keys.invoke() + eq(yesno.called, 3) + self.assertNotIn(keyset_name, keyspage) + eq(idleConf.GetSectionList('user', 'keys'), []) + eq(d.custom_keyset_on.state(), ('disabled',)) + eq(d.custom_name.get(), '- no custom keys -') + eq(dialog.deactivate_current_config.called, 2) + eq(dialog.activate_config_changes.called, 2) + eq(d.set_keys_type.called, 2) + + del dialog.activate_config_changes, dialog.deactivate_current_config + del d.askyesno + + +class WinPageTest(unittest.TestCase): + """Test that general tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.winpage + dialog.note.select(page) + page.update() + + def setUp(self): + changes.clear() + + def test_load_windows_cfg(self): + # Set to wrong values, load, check right values. + eq = self.assertEqual + d = self.page + d.startup_edit.set(1) + d.win_width.set(1) + d.win_height.set(1) + d.load_windows_cfg() + eq(d.startup_edit.get(), 0) + eq(d.win_width.get(), '80') + eq(d.win_height.get(), '40') + + def test_startup(self): + d = self.page + d.startup_editor_on.invoke() + self.assertEqual(mainpage, + {'General': {'editor-on-startup': '1'}}) + changes.clear() + d.startup_shell_on.invoke() + self.assertEqual(mainpage, + {'General': {'editor-on-startup': '0'}}) + + def test_editor_size(self): + d = self.page + d.win_height_int.delete(0, 'end') + d.win_height_int.insert(0, '11') + self.assertEqual(mainpage, {'EditorWindow': {'height': '11'}}) + changes.clear() + d.win_width_int.delete(0, 'end') + d.win_width_int.insert(0, '11') + self.assertEqual(mainpage, {'EditorWindow': {'width': '11'}}) + + def test_indent_spaces(self): + d = self.page + d.indent_chooser.set(6) + self.assertEqual(d.indent_spaces.get(), '6') + self.assertEqual(mainpage, {'Indent': {'num-spaces': '6'}}) + + def test_cursor_blink(self): + self.page.cursor_blink_bool.invoke() + self.assertEqual(mainpage, {'EditorWindow': {'cursor-blink': 'False'}}) + + def test_autocomplete_wait(self): + self.page.auto_wait_int.delete(0, 'end') + self.page.auto_wait_int.insert(0, '11') + self.assertEqual(extpage, {'AutoComplete': {'popupwait': '11'}}) + + def test_parenmatch(self): + d = self.page + eq = self.assertEqual + d.paren_style_type['menu'].invoke(0) + eq(extpage, {'ParenMatch': {'style': 'opener'}}) + changes.clear() + d.paren_flash_time.delete(0, 'end') + d.paren_flash_time.insert(0, '11') + eq(extpage, {'ParenMatch': {'flash-delay': '11'}}) + changes.clear() + d.bell_on.invoke() + eq(extpage, {'ParenMatch': {'bell': 'False'}}) + + def test_paragraph(self): + self.page.format_width_int.delete(0, 'end') + self.page.format_width_int.insert(0, '11') + self.assertEqual(extpage, {'FormatParagraph': {'max-width': '11'}}) + + +class ShedPageTest(unittest.TestCase): + """Test that shed tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.shedpage + dialog.note.select(page) + page.update() + + def setUp(self): + changes.clear() + + def test_load_shelled_cfg(self): + # Set to wrong values, load, check right values. + eq = self.assertEqual + d = self.page + d.autosave.set(1) + d.load_shelled_cfg() + eq(d.autosave.get(), 0) + + def test_autosave(self): + d = self.page + d.save_auto_on.invoke() + self.assertEqual(mainpage, {'General': {'autosave': '1'}}) + d.save_ask_on.invoke() + self.assertEqual(mainpage, {'General': {'autosave': '0'}}) + + def test_context(self): + self.page.context_int.delete(0, 'end') + self.page.context_int.insert(0, '1') + self.assertEqual(extpage, {'CodeContext': {'maxlines': '1'}}) + + +#unittest.skip("Nothing here yet TODO") +class ExtPageTest(unittest.TestCase): + """Test that the help source list works correctly.""" + @classmethod + def setUpClass(cls): + page = dialog.extpage + dialog.note.select(page) + + +class HelpSourceTest(unittest.TestCase): + """Test that the help source list works correctly.""" + @classmethod + def setUpClass(cls): + page = dialog.extpage + dialog.note.select(page) + frame = cls.frame = page.frame_help + frame.set = frame.set_add_delete_state = Func() + frame.upc = frame.update_help_changes = Func() + frame.update() + + @classmethod + def tearDownClass(cls): + frame = cls.frame + del frame.set, frame.set_add_delete_state + del frame.upc, frame.update_help_changes + frame.helplist.delete(0, 'end') + frame.user_helplist.clear() + + def setUp(self): + changes.clear() + + def test_load_helplist(self): + eq = self.assertEqual + fr = self.frame + fr.helplist.insert('end', 'bad') + fr.user_helplist = ['bad', 'worse'] + idleConf.SetOption('main', 'HelpFiles', '1', 'name;file') + fr.load_helplist() + eq(fr.helplist.get(0, 'end'), ('name',)) + eq(fr.user_helplist, [('name', 'file', '1')]) + + def test_source_selected(self): + fr = self.frame + fr.set = fr.set_add_delete_state + fr.upc = fr.update_help_changes + helplist = fr.helplist + dex = 'end' + helplist.insert(dex, 'source') + helplist.activate(dex) + + helplist.focus_force() + helplist.see(dex) + helplist.update() + x, y, dx, dy = helplist.bbox(dex) + x += dx // 2 + y += dy // 2 + fr.set.called = fr.upc.called = 0 + helplist.event_generate('', x=0, y=0) + helplist.event_generate('', x=x, y=y) + helplist.event_generate('', x=x, y=y) + helplist.event_generate('', x=x, y=y) + self.assertEqual(helplist.get('anchor'), 'source') + self.assertTrue(fr.set.called) + self.assertFalse(fr.upc.called) + + def test_set_add_delete_state(self): + # Call with 0 items, 1 unselected item, 1 selected item. + eq = self.assertEqual + fr = self.frame + del fr.set_add_delete_state # Unmask method. + sad = fr.set_add_delete_state + h = fr.helplist + + h.delete(0, 'end') + sad() + eq(fr.button_helplist_edit.state(), ('disabled',)) + eq(fr.button_helplist_remove.state(), ('disabled',)) + + h.insert(0, 'source') + sad() + eq(fr.button_helplist_edit.state(), ('disabled',)) + eq(fr.button_helplist_remove.state(), ('disabled',)) + + h.selection_set(0) + sad() + eq(fr.button_helplist_edit.state(), ()) + eq(fr.button_helplist_remove.state(), ()) + fr.set_add_delete_state = Func() # Mask method. + + def test_helplist_item_add(self): + # Call without and twice with HelpSource result. + # Double call enables check on order. + eq = self.assertEqual + orig_helpsource = configdialog.HelpSource + hs = configdialog.HelpSource = Func(return_self=True) + fr = self.frame + fr.helplist.delete(0, 'end') + fr.user_helplist.clear() + fr.set.called = fr.upc.called = 0 + + hs.result = '' + fr.helplist_item_add() + self.assertTrue(list(fr.helplist.get(0, 'end')) == + fr.user_helplist == []) + self.assertFalse(fr.upc.called) + + hs.result = ('name1', 'file1') + fr.helplist_item_add() + hs.result = ('name2', 'file2') + fr.helplist_item_add() + eq(fr.helplist.get(0, 'end'), ('name1', 'name2')) + eq(fr.user_helplist, [('name1', 'file1'), ('name2', 'file2')]) + eq(fr.upc.called, 2) + self.assertFalse(fr.set.called) + + configdialog.HelpSource = orig_helpsource + + def test_helplist_item_edit(self): + # Call without and with HelpSource change. + eq = self.assertEqual + orig_helpsource = configdialog.HelpSource + hs = configdialog.HelpSource = Func(return_self=True) + fr = self.frame + fr.helplist.delete(0, 'end') + fr.helplist.insert(0, 'name1') + fr.helplist.selection_set(0) + fr.helplist.selection_anchor(0) + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.set.called = fr.upc.called = 0 + + hs.result = '' + fr.helplist_item_edit() + hs.result = ('name1', 'file1') + fr.helplist_item_edit() + eq(fr.helplist.get(0, 'end'), ('name1',)) + eq(fr.user_helplist, [('name1', 'file1')]) + self.assertFalse(fr.upc.called) + + hs.result = ('name2', 'file2') + fr.helplist_item_edit() + eq(fr.helplist.get(0, 'end'), ('name2',)) + eq(fr.user_helplist, [('name2', 'file2')]) + self.assertTrue(fr.upc.called == fr.set.called == 1) + + configdialog.HelpSource = orig_helpsource + + def test_helplist_item_remove(self): + eq = self.assertEqual + fr = self.frame + fr.helplist.delete(0, 'end') + fr.helplist.insert(0, 'name1') + fr.helplist.selection_set(0) + fr.helplist.selection_anchor(0) + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.set.called = fr.upc.called = 0 + + fr.helplist_item_remove() + eq(fr.helplist.get(0, 'end'), ()) + eq(fr.user_helplist, []) + self.assertTrue(fr.upc.called == fr.set.called == 1) + + def test_update_help_changes(self): + fr = self.frame + del fr.update_help_changes + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.user_helplist.append(('name2', 'file2')) + + fr.update_help_changes() + self.assertEqual(mainpage['HelpFiles'], + {'1': 'name1;file1', '2': 'name2;file2'}) + fr.update_help_changes = Func() + + +class VarTraceTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.tracers = configdialog.VarTrace() + cls.iv = IntVar(root) + cls.bv = BooleanVar(root) + + @classmethod + def tearDownClass(cls): + del cls.tracers, cls.iv, cls.bv + + def setUp(self): + self.tracers.clear() + self.called = 0 + + def var_changed_increment(self, *params): + self.called += 13 + + def var_changed_boolean(self, *params): + pass + + def test_init(self): + tr = self.tracers + tr.__init__() + self.assertEqual(tr.untraced, []) + self.assertEqual(tr.traced, []) + + def test_clear(self): + tr = self.tracers + tr.untraced.append(0) + tr.traced.append(1) + tr.clear() + self.assertEqual(tr.untraced, []) + self.assertEqual(tr.traced, []) + + def test_add(self): + tr = self.tracers + func = Func() + cb = tr.make_callback = mock.Mock(return_value=func) + + iv = tr.add(self.iv, self.var_changed_increment) + self.assertIs(iv, self.iv) + bv = tr.add(self.bv, self.var_changed_boolean) + self.assertIs(bv, self.bv) + + sv = StringVar(root) + sv2 = tr.add(sv, ('main', 'section', 'option')) + self.assertIs(sv2, sv) + cb.assert_called_once() + cb.assert_called_with(sv, ('main', 'section', 'option')) + + expected = [(iv, self.var_changed_increment), + (bv, self.var_changed_boolean), + (sv, func)] + self.assertEqual(tr.traced, []) + self.assertEqual(tr.untraced, expected) + + del tr.make_callback + + def test_make_callback(self): + cb = self.tracers.make_callback(self.iv, ('main', 'section', 'option')) + self.assertTrue(callable(cb)) + self.iv.set(42) + # Not attached, so set didn't invoke the callback. + self.assertNotIn('section', changes['main']) + # Invoke callback manually. + cb() + self.assertIn('section', changes['main']) + self.assertEqual(changes['main']['section']['option'], '42') + changes.clear() + + def test_attach_detach(self): + tr = self.tracers + iv = tr.add(self.iv, self.var_changed_increment) + bv = tr.add(self.bv, self.var_changed_boolean) + expected = [(iv, self.var_changed_increment), + (bv, self.var_changed_boolean)] + + # Attach callbacks and test call increment. + tr.attach() + self.assertEqual(tr.untraced, []) + self.assertCountEqual(tr.traced, expected) + iv.set(1) + self.assertEqual(iv.get(), 1) + self.assertEqual(self.called, 13) + + # Check that only one callback is attached to a variable. + # If more than one callback were attached, then var_changed_increment + # would be called twice and the counter would be 2. + self.called = 0 + tr.attach() + iv.set(1) + self.assertEqual(self.called, 13) + + # Detach callbacks. + self.called = 0 + tr.detach() + self.assertEqual(tr.traced, []) + self.assertCountEqual(tr.untraced, expected) + iv.set(1) + self.assertEqual(self.called, 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugger.py b/Lib/idlelib/idle_test/test_debugger.py new file mode 100644 index 00000000000..9ca3b332648 --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugger.py @@ -0,0 +1,297 @@ +"""Test debugger, coverage 66% + +Try to make tests pass with draft bdbx, which may replace bdb in 3.13+. +""" + +from idlelib import debugger +from collections import namedtuple +from textwrap import dedent +from tkinter import Tk + +from test.support import requires +import unittest +from unittest import mock +from unittest.mock import Mock, patch + +"""A test python script for the debug tests.""" +TEST_CODE = dedent(""" + i = 1 + i += 2 + if i == 3: + print(i) + """) + + +class MockFrame: + "Minimal mock frame." + + def __init__(self, code, lineno): + self.f_code = code + self.f_lineno = lineno + + +class IdbTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.gui = Mock() + cls.idb = debugger.Idb(cls.gui) + + # Create test and code objects to simulate a debug session. + code_obj = compile(TEST_CODE, 'idlelib/file.py', mode='exec') + frame1 = MockFrame(code_obj, 1) + frame1.f_back = None + frame2 = MockFrame(code_obj, 2) + frame2.f_back = frame1 + cls.frame = frame2 + cls.msg = 'file.py:2: ()' + + def test_init(self): + self.assertIs(self.idb.gui, self.gui) + # Won't test super call since two Bdbs are very different. + + def test_user_line(self): + # Test that .user_line() creates a string message for a frame. + self.gui.interaction = Mock() + self.idb.user_line(self.frame) + self.gui.interaction.assert_called_once_with(self.msg, self.frame) + + def test_user_exception(self): + # Test that .user_exception() creates a string message for a frame. + exc_info = (type(ValueError), ValueError(), None) + self.gui.interaction = Mock() + self.idb.user_exception(self.frame, exc_info) + self.gui.interaction.assert_called_once_with( + self.msg, self.frame, exc_info) + + +class FunctionTest(unittest.TestCase): + # Test module functions together. + + def test_functions(self): + rpc_obj = compile(TEST_CODE,'rpc.py', mode='exec') + rpc_frame = MockFrame(rpc_obj, 2) + rpc_frame.f_back = rpc_frame + self.assertTrue(debugger._in_rpc_code(rpc_frame)) + self.assertEqual(debugger._frame2message(rpc_frame), + 'rpc.py:2: ()') + + code_obj = compile(TEST_CODE, 'idlelib/debugger.py', mode='exec') + code_frame = MockFrame(code_obj, 1) + code_frame.f_back = None + self.assertFalse(debugger._in_rpc_code(code_frame)) + self.assertEqual(debugger._frame2message(code_frame), + 'debugger.py:1: ()') + + code_frame.f_back = code_frame + self.assertFalse(debugger._in_rpc_code(code_frame)) + code_frame.f_back = rpc_frame + self.assertTrue(debugger._in_rpc_code(code_frame)) + + +class DebuggerTest(unittest.TestCase): + "Tests for Debugger that do not need a real root." + + @classmethod + def setUpClass(cls): + cls.pyshell = Mock() + cls.pyshell.root = Mock() + cls.idb = Mock() + with patch.object(debugger.Debugger, 'make_gui'): + cls.debugger = debugger.Debugger(cls.pyshell, cls.idb) + cls.debugger.root = Mock() + + def test_cont(self): + self.debugger.cont() + self.idb.set_continue.assert_called_once() + + def test_step(self): + self.debugger.step() + self.idb.set_step.assert_called_once() + + def test_quit(self): + self.debugger.quit() + self.idb.set_quit.assert_called_once() + + def test_next(self): + with patch.object(self.debugger, 'frame') as frame: + self.debugger.next() + self.idb.set_next.assert_called_once_with(frame) + + def test_ret(self): + with patch.object(self.debugger, 'frame') as frame: + self.debugger.ret() + self.idb.set_return.assert_called_once_with(frame) + + def test_clear_breakpoint(self): + self.debugger.clear_breakpoint('test.py', 4) + self.idb.clear_break.assert_called_once_with('test.py', 4) + + def test_clear_file_breaks(self): + self.debugger.clear_file_breaks('test.py') + self.idb.clear_all_file_breaks.assert_called_once_with('test.py') + + def test_set_load_breakpoints(self): + # Test the .load_breakpoints() method calls idb. + FileIO = namedtuple('FileIO', 'filename') + + class MockEditWindow(object): + def __init__(self, fn, breakpoints): + self.io = FileIO(fn) + self.breakpoints = breakpoints + + self.pyshell.flist = Mock() + self.pyshell.flist.inversedict = ( + MockEditWindow('test1.py', [4, 4]), + MockEditWindow('test2.py', [13, 44, 45]), + ) + self.debugger.set_breakpoint('test0.py', 1) + self.idb.set_break.assert_called_once_with('test0.py', 1) + self.debugger.load_breakpoints() # Call set_breakpoint 5 times. + self.idb.set_break.assert_has_calls( + [mock.call('test0.py', 1), + mock.call('test1.py', 4), + mock.call('test1.py', 4), + mock.call('test2.py', 13), + mock.call('test2.py', 44), + mock.call('test2.py', 45)]) + + def test_sync_source_line(self): + # Test that .sync_source_line() will set the flist.gotofileline with fixed frame. + test_code = compile(TEST_CODE, 'test_sync.py', 'exec') + test_frame = MockFrame(test_code, 1) + self.debugger.frame = test_frame + + self.debugger.flist = Mock() + with patch('idlelib.debugger.os.path.exists', return_value=True): + self.debugger.sync_source_line() + self.debugger.flist.gotofileline.assert_called_once_with('test_sync.py', 1) + + +class DebuggerGuiTest(unittest.TestCase): + """Tests for debugger.Debugger that need tk root. + + close needs debugger.top set in make_gui. + """ + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = root = Tk() + root.withdraw() + cls.pyshell = Mock() + cls.pyshell.root = root + cls.idb = Mock() +# stack tests fail with debugger here. +## cls.debugger = debugger.Debugger(cls.pyshell, cls.idb) +## cls.debugger.root = root +## # real root needed for real make_gui +## # run, interacting, abort_loop + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.debugger = debugger.Debugger(self.pyshell, self.idb) + self.debugger.root = self.root + # real root needed for real make_gui + # run, interacting, abort_loop + + def test_run_debugger(self): + self.debugger.run(1, 'two') + self.idb.run.assert_called_once_with(1, 'two') + self.assertEqual(self.debugger.interacting, 0) + + def test_close(self): + # Test closing the window in an idle state. + self.debugger.close() + self.pyshell.close_debugger.assert_called_once() + + def test_show_stack(self): + self.debugger.show_stack() + self.assertEqual(self.debugger.stackviewer.gui, self.debugger) + + def test_show_stack_with_frame(self): + test_frame = MockFrame(None, None) + self.debugger.frame = test_frame + + # Reset the stackviewer to force it to be recreated. + self.debugger.stackviewer = None + self.idb.get_stack.return_value = ([], 0) + self.debugger.show_stack() + + # Check that the newly created stackviewer has the test gui as a field. + self.assertEqual(self.debugger.stackviewer.gui, self.debugger) + self.idb.get_stack.assert_called_once_with(test_frame, None) + + +class StackViewerTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.code = compile(TEST_CODE, 'test_stackviewer.py', 'exec') + self.stack = [ + (MockFrame(self.code, 1), 1), + (MockFrame(self.code, 2), 2) + ] + # Create a stackviewer and load the test stack. + self.sv = debugger.StackViewer(self.root, None, None) + self.sv.load_stack(self.stack) + + def test_init(self): + # Test creation of StackViewer. + gui = None + flist = None + master_window = self.root + sv = debugger.StackViewer(master_window, flist, gui) + self.assertHasAttr(sv, 'stack') + + def test_load_stack(self): + # Test the .load_stack() method against a fixed test stack. + # Check the test stack is assigned and the list contains the repr of them. + self.assertEqual(self.sv.stack, self.stack) + self.assertTrue('?.(), line 1:' in self.sv.get(0)) + self.assertEqual(self.sv.get(1), '?.(), line 2: ') + + def test_show_source(self): + # Test the .show_source() method against a fixed test stack. + # Patch out the file list to monitor it + self.sv.flist = Mock() + # Patch out isfile to pretend file exists. + with patch('idlelib.debugger.os.path.isfile', return_value=True) as isfile: + self.sv.show_source(1) + isfile.assert_called_once_with('test_stackviewer.py') + self.sv.flist.open.assert_called_once_with('test_stackviewer.py') + + +class NameSpaceTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_init(self): + debugger.NamespaceViewer(self.root, 'Test') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugger_r.py b/Lib/idlelib/idle_test/test_debugger_r.py new file mode 100644 index 00000000000..cf8af05fe27 --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugger_r.py @@ -0,0 +1,36 @@ +"Test debugger_r, coverage 30%." + +from idlelib import debugger_r +import unittest + +# Boilerplate likely to be needed for future test classes. +##from test.support import requires +##from tkinter import Tk +##class Test(unittest.TestCase): +## @classmethod +## def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() + +# GUIProxy, IdbAdapter, FrameProxy, CodeProxy, DictProxy, +# GUIAdapter, IdbProxy, and 7 functions still need tests. + +class IdbAdapterTest(unittest.TestCase): + + def test_dict_item_noattr(self): # Issue 33065. + + class BinData: + def __repr__(self): + return self.length + + debugger_r.dicttable[0] = {'BinData': BinData()} + idb = debugger_r.IdbAdapter(None) + self.assertTrue(idb.dict_item(0, 'BinData')) + debugger_r.dicttable.clear() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugobj.py b/Lib/idlelib/idle_test/test_debugobj.py new file mode 100644 index 00000000000..90ace4e1bc4 --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugobj.py @@ -0,0 +1,57 @@ +"Test debugobj, coverage 40%." + +from idlelib import debugobj +import unittest + + +class ObjectTreeItemTest(unittest.TestCase): + + def test_init(self): + ti = debugobj.ObjectTreeItem('label', 22) + self.assertEqual(ti.labeltext, 'label') + self.assertEqual(ti.object, 22) + self.assertEqual(ti.setfunction, None) + + +class ClassTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.ClassTreeItem('label', 0) + self.assertTrue(ti.IsExpandable()) + + +class AtomicObjectTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.AtomicObjectTreeItem('label', 0) + self.assertFalse(ti.IsExpandable()) + + +class SequenceTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.SequenceTreeItem('label', ()) + self.assertFalse(ti.IsExpandable()) + ti = debugobj.SequenceTreeItem('label', (1,)) + self.assertTrue(ti.IsExpandable()) + + def test_keys(self): + ti = debugobj.SequenceTreeItem('label', 'abc') + self.assertEqual(list(ti.keys()), [0, 1, 2]) # keys() is a range. + + +class DictTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.DictTreeItem('label', {}) + self.assertFalse(ti.IsExpandable()) + ti = debugobj.DictTreeItem('label', {1:1}) + self.assertTrue(ti.IsExpandable()) + + def test_keys(self): + ti = debugobj.DictTreeItem('label', {1:1, 0:0, 2:2}) + self.assertEqual(ti.keys(), [0, 1, 2]) # keys() is a sorted list. + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugobj_r.py b/Lib/idlelib/idle_test/test_debugobj_r.py new file mode 100644 index 00000000000..86e51b6cb2c --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugobj_r.py @@ -0,0 +1,22 @@ +"Test debugobj_r, coverage 56%." + +from idlelib import debugobj_r +import unittest + + +class WrappedObjectTreeItemTest(unittest.TestCase): + + def test_getattr(self): + ti = debugobj_r.WrappedObjectTreeItem(list) + self.assertEqual(ti.append, list.append) + +class StubObjectTreeItemTest(unittest.TestCase): + + def test_init(self): + ti = debugobj_r.StubObjectTreeItem('socket', 1111) + self.assertEqual(ti.sockio, 'socket') + self.assertEqual(ti.oid, 1111) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_delegator.py b/Lib/idlelib/idle_test/test_delegator.py new file mode 100644 index 00000000000..922416297a4 --- /dev/null +++ b/Lib/idlelib/idle_test/test_delegator.py @@ -0,0 +1,44 @@ +"Test delegator, coverage 100%." + +from idlelib.delegator import Delegator +import unittest + + +class DelegatorTest(unittest.TestCase): + + def test_mydel(self): + # Test a simple use scenario. + + # Initialize an int delegator. + mydel = Delegator(int) + self.assertIs(mydel.delegate, int) + self.assertEqual(mydel._Delegator__cache, set()) + # Trying to access a non-attribute of int fails. + self.assertRaises(AttributeError, mydel.__getattr__, 'xyz') + + # Add real int attribute 'bit_length' by accessing it. + bl = mydel.bit_length + self.assertIs(bl, int.bit_length) + self.assertIs(mydel.__dict__['bit_length'], int.bit_length) + self.assertEqual(mydel._Delegator__cache, {'bit_length'}) + + # Add attribute 'numerator'. + mydel.numerator + self.assertEqual(mydel._Delegator__cache, {'bit_length', 'numerator'}) + + # Delete 'numerator'. + del mydel.numerator + self.assertNotIn('numerator', mydel.__dict__) + # The current implementation leaves it in the name cache. + # self.assertIn('numerator', mydel._Delegator__cache) + # However, this is not required and not part of the specification + + # Change delegate to float, first resetting the attributes. + mydel.setdelegate(float) # calls resetcache + self.assertNotIn('bit_length', mydel.__dict__) + self.assertEqual(mydel._Delegator__cache, set()) + self.assertIs(mydel.delegate, float) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_editmenu.py b/Lib/idlelib/idle_test/test_editmenu.py new file mode 100644 index 00000000000..17478473a3d --- /dev/null +++ b/Lib/idlelib/idle_test/test_editmenu.py @@ -0,0 +1,74 @@ +'''Test (selected) IDLE Edit menu items. + +Edit modules have their own test files +''' +from test.support import requires +requires('gui') +import tkinter as tk +from tkinter import ttk +import unittest +from idlelib import pyshell + +class PasteTest(unittest.TestCase): + '''Test pasting into widgets that allow pasting. + + On X11, replacing selections requires tk fix. + ''' + @classmethod + def setUpClass(cls): + cls.root = root = tk.Tk() + cls.root.withdraw() + pyshell.fix_x11_paste(root) + cls.text = tk.Text(root) + cls.entry = tk.Entry(root) + cls.tentry = ttk.Entry(root) + cls.spin = tk.Spinbox(root) + root.clipboard_clear() + root.clipboard_append('two') + + @classmethod + def tearDownClass(cls): + del cls.text, cls.entry, cls.tentry + cls.root.clipboard_clear() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_paste_text(self): + "Test pasting into text with and without a selection." + text = self.text + for tag, ans in ('', 'onetwo\n'), ('sel', 'two\n'): + with self.subTest(tag=tag, ans=ans): + text.delete('1.0', 'end') + text.insert('1.0', 'one', tag) + text.event_generate('<>') + self.assertEqual(text.get('1.0', 'end'), ans) + + def test_paste_entry(self): + "Test pasting into an entry with and without a selection." + # Generated <> fails for tk entry without empty select + # range for 'no selection'. Live widget works fine. + for entry in self.entry, self.tentry: + for end, ans in (0, 'onetwo'), ('end', 'two'): + with self.subTest(entry=entry, end=end, ans=ans): + entry.delete(0, 'end') + entry.insert(0, 'one') + entry.select_range(0, end) + entry.event_generate('<>') + self.assertEqual(entry.get(), ans) + + def test_paste_spin(self): + "Test pasting into a spinbox with and without a selection." + # See note above for entry. + spin = self.spin + for end, ans in (0, 'onetwo'), ('end', 'two'): + with self.subTest(end=end, ans=ans): + spin.delete(0, 'end') + spin.insert(0, 'one') + spin.selection('range', 0, end) # see note + spin.event_generate('<>') + self.assertEqual(spin.get(), ans) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py new file mode 100644 index 00000000000..0dfe2f3c58b --- /dev/null +++ b/Lib/idlelib/idle_test/test_editor.py @@ -0,0 +1,241 @@ +"Test editor, coverage 53%." + +from idlelib import editor +import unittest +from collections import namedtuple +from test.support import requires +from tkinter import Tk, Text + +Editor = editor.EditorWindow + + +class EditorWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_init(self): + e = Editor(root=self.root) + self.assertEqual(e.root, self.root) + e._close() + + +class GetLineIndentTest(unittest.TestCase): + def test_empty_lines(self): + for tabwidth in [1, 2, 4, 6, 8]: + for line in ['', '\n']: + with self.subTest(line=line, tabwidth=tabwidth): + self.assertEqual( + editor.get_line_indent(line, tabwidth=tabwidth), + (0, 0), + ) + + def test_tabwidth_4(self): + # (line, (raw, effective)) + tests = (('no spaces', (0, 0)), + # Internal space isn't counted. + (' space test', (4, 4)), + ('\ttab test', (1, 4)), + ('\t\tdouble tabs test', (2, 8)), + # Different results when mixing tabs and spaces. + (' \tmixed test', (5, 8)), + (' \t mixed test', (5, 6)), + ('\t mixed test', (5, 8)), + # Spaces not divisible by tabwidth. + (' \tmixed test', (3, 4)), + (' \t mixed test', (3, 5)), + ('\t mixed test', (3, 6)), + # Only checks spaces and tabs. + ('\nnewline test', (0, 0))) + + for line, expected in tests: + with self.subTest(line=line): + self.assertEqual( + editor.get_line_indent(line, tabwidth=4), + expected, + ) + + def test_tabwidth_8(self): + # (line, (raw, effective)) + tests = (('no spaces', (0, 0)), + # Internal space isn't counted. + (' space test', (8, 8)), + ('\ttab test', (1, 8)), + ('\t\tdouble tabs test', (2, 16)), + # Different results when mixing tabs and spaces. + (' \tmixed test', (9, 16)), + (' \t mixed test', (9, 10)), + ('\t mixed test', (9, 16)), + # Spaces not divisible by tabwidth. + (' \tmixed test', (3, 8)), + (' \t mixed test', (3, 9)), + ('\t mixed test', (3, 10)), + # Only checks spaces and tabs. + ('\nnewline test', (0, 0))) + + for line, expected in tests: + with self.subTest(line=line): + self.assertEqual( + editor.get_line_indent(line, tabwidth=8), + expected, + ) + + +def insert(text, string): + text.delete('1.0', 'end') + text.insert('end', string) + text.update_idletasks() # Force update for colorizer to finish. + + +class IndentAndNewlineTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.window = Editor(root=cls.root) + cls.window.indentwidth = 2 + cls.window.tabwidth = 2 + + @classmethod + def tearDownClass(cls): + cls.window._close() + del cls.window + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_indent_and_newline_event(self): + eq = self.assertEqual + w = self.window + text = w.text + get = text.get + nl = w.newline_and_indent_event + + TestInfo = namedtuple('Tests', ['label', 'text', 'expected', 'mark']) + + tests = (TestInfo('Empty line inserts with no indent.', + ' \n def __init__(self):', + '\n \n def __init__(self):\n', + '1.end'), + TestInfo('Inside bracket before space, deletes space.', + ' def f1(self, a, b):', + ' def f1(self,\n a, b):\n', + '1.14'), + TestInfo('Inside bracket after space, deletes space.', + ' def f1(self, a, b):', + ' def f1(self,\n a, b):\n', + '1.15'), + TestInfo('Inside string with one line - no indent.', + ' """Docstring."""', + ' """Docstring.\n"""\n', + '1.15'), + TestInfo('Inside string with more than one line.', + ' """Docstring.\n Docstring Line 2"""', + ' """Docstring.\n Docstring Line 2\n """\n', + '2.18'), + TestInfo('Backslash with one line.', + 'a =\\', + 'a =\\\n \n', + '1.end'), + TestInfo('Backslash with more than one line.', + 'a =\\\n multiline\\', + 'a =\\\n multiline\\\n \n', + '2.end'), + TestInfo('Block opener - indents +1 level.', + ' def f1(self):\n pass', + ' def f1(self):\n \n pass\n', + '1.end'), + TestInfo('Block closer - dedents -1 level.', + ' def f1(self):\n pass', + ' def f1(self):\n pass\n \n', + '2.end'), + ) + + for test in tests: + with self.subTest(label=test.label): + insert(text, test.text) + text.mark_set('insert', test.mark) + nl(event=None) + eq(get('1.0', 'end'), test.expected) + + # Selected text. + insert(text, ' def f1(self, a, b):\n return a + b') + text.tag_add('sel', '1.17', '1.end') + nl(None) + # Deletes selected text before adding new line. + eq(get('1.0', 'end'), ' def f1(self, a,\n \n return a + b\n') + + +class IndentSearcherTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_searcher(self): + text = self.text + searcher = (self.text) + test_info = (# text, (block, indent)) + ("", (None, None)), + ("[1,", (None, None)), # TokenError + ("if 1:\n", ('if 1:\n', None)), + ("if 1:\n 2\n 3\n", ('if 1:\n', ' 2\n')), + ) + for code, expected_pair in test_info: + with self.subTest(code=code): + insert(text, code) + actual_pair = editor.IndentSearcher(text).run() + self.assertEqual(actual_pair, expected_pair) + + +class RMenuTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.window = Editor(root=cls.root) + + @classmethod + def tearDownClass(cls): + cls.window._close() + del cls.window + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + class DummyRMenu: + def tk_popup(x, y): pass + + def test_rclick(self): + pass + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_filelist.py b/Lib/idlelib/idle_test/test_filelist.py new file mode 100644 index 00000000000..731f1975e50 --- /dev/null +++ b/Lib/idlelib/idle_test/test_filelist.py @@ -0,0 +1,33 @@ +"Test filelist, coverage 19%." + +from idlelib import filelist +import unittest +from test.support import requires +from tkinter import Tk + +class FileListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_new_empty(self): + flist = filelist.FileList(self.root) + self.assertEqual(flist.root, self.root) + e = flist.new() + self.assertEqual(type(e), flist.EditorWindow) + e._close() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_format.py b/Lib/idlelib/idle_test/test_format.py new file mode 100644 index 00000000000..e5e90368859 --- /dev/null +++ b/Lib/idlelib/idle_test/test_format.py @@ -0,0 +1,668 @@ +"Test format, coverage 99%." + +from idlelib import format as ft +import unittest +from unittest import mock +from test.support import requires +from tkinter import Tk, Text +from idlelib.editor import EditorWindow +from idlelib.idle_test.mock_idle import Editor as MockEditor + + +class Is_Get_Test(unittest.TestCase): + """Test the is_ and get_ functions""" + test_comment = '# This is a comment' + test_nocomment = 'This is not a comment' + trailingws_comment = '# This is a comment ' + leadingws_comment = ' # This is a comment' + leadingws_nocomment = ' This is not a comment' + + def test_is_all_white(self): + self.assertTrue(ft.is_all_white('')) + self.assertTrue(ft.is_all_white('\t\n\r\f\v')) + self.assertFalse(ft.is_all_white(self.test_comment)) + + def test_get_indent(self): + Equal = self.assertEqual + Equal(ft.get_indent(self.test_comment), '') + Equal(ft.get_indent(self.trailingws_comment), '') + Equal(ft.get_indent(self.leadingws_comment), ' ') + Equal(ft.get_indent(self.leadingws_nocomment), ' ') + + def test_get_comment_header(self): + Equal = self.assertEqual + # Test comment strings + Equal(ft.get_comment_header(self.test_comment), '#') + Equal(ft.get_comment_header(self.trailingws_comment), '#') + Equal(ft.get_comment_header(self.leadingws_comment), ' #') + # Test non-comment strings + Equal(ft.get_comment_header(self.leadingws_nocomment), ' ') + Equal(ft.get_comment_header(self.test_nocomment), '') + + +class FindTest(unittest.TestCase): + """Test the find_paragraph function in paragraph module. + + Using the runcase() function, find_paragraph() is called with 'mark' set at + multiple indexes before and inside the test paragraph. + + It appears that code with the same indentation as a quoted string is grouped + as part of the same paragraph, which is probably incorrect behavior. + """ + + @classmethod + def setUpClass(cls): + from idlelib.idle_test.mock_tk import Text + cls.text = Text() + + def runcase(self, inserttext, stopline, expected): + # Check that find_paragraph returns the expected paragraph when + # the mark index is set to beginning, middle, end of each line + # up to but not including the stop line + text = self.text + text.insert('1.0', inserttext) + for line in range(1, stopline): + linelength = int(text.index("%d.end" % line).split('.')[1]) + for col in (0, linelength//2, linelength): + tempindex = "%d.%d" % (line, col) + self.assertEqual(ft.find_paragraph(text, tempindex), expected) + text.delete('1.0', 'end') + + def test_find_comment(self): + comment = ( + "# Comment block with no blank lines before\n" + "# Comment line\n" + "\n") + self.runcase(comment, 3, ('1.0', '3.0', '#', comment[0:58])) + + comment = ( + "\n" + "# Comment block with whitespace line before and after\n" + "# Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', '#', comment[1:70])) + + comment = ( + "\n" + " # Indented comment block with whitespace before and after\n" + " # Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', ' #', comment[1:82])) + + comment = ( + "\n" + "# Single line comment\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:23])) + + comment = ( + "\n" + " # Single line comment with leading whitespace\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:51])) + + comment = ( + "\n" + "# Comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:40])) + + comment = ( + "\n" + " # Indented comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:53])) + + comment = ( + "\n" + "# Comment immediately followed by indented code\n" + " x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:49])) + + def test_find_paragraph(self): + teststring = ( + '"""String with no blank lines before\n' + 'String line\n' + '"""\n' + '\n') + self.runcase(teststring, 4, ('1.0', '4.0', '', teststring[0:53])) + + teststring = ( + "\n" + '"""String with whitespace line before and after\n' + 'String line.\n' + '"""\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', '', teststring[1:66])) + + teststring = ( + '\n' + ' """Indented string with whitespace before and after\n' + ' Comment string.\n' + ' """\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', ' ', teststring[1:85])) + + teststring = ( + '\n' + '"""Single line string."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', '', teststring[1:27])) + + teststring = ( + '\n' + ' """Single line string with leading whitespace."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', ' ', teststring[1:55])) + + +class ReformatFunctionTest(unittest.TestCase): + """Test the reformat_paragraph function without the editor window.""" + + def test_reformat_paragraph(self): + Equal = self.assertEqual + reform = ft.reformat_paragraph + hw = "O hello world" + Equal(reform(' ', 1), ' ') + Equal(reform("Hello world", 20), "Hello world") + + # Test without leading newline + Equal(reform(hw, 1), "O\nhello\nworld") + Equal(reform(hw, 6), "O\nhello\nworld") + Equal(reform(hw, 7), "O hello\nworld") + Equal(reform(hw, 12), "O hello\nworld") + Equal(reform(hw, 13), "O hello world") + + # Test with leading newline + hw = "\nO hello world" + Equal(reform(hw, 1), "\nO\nhello\nworld") + Equal(reform(hw, 6), "\nO\nhello\nworld") + Equal(reform(hw, 7), "\nO hello\nworld") + Equal(reform(hw, 12), "\nO hello\nworld") + Equal(reform(hw, 13), "\nO hello world") + + +class ReformatCommentTest(unittest.TestCase): + """Test the reformat_comment function without the editor window.""" + + def test_reformat_comment(self): + Equal = self.assertEqual + + # reformat_comment formats to a minimum of 20 characters + test_string = ( + " \"\"\"this is a test of a reformat for a triple quoted string" + " will it reformat to less than 70 characters for me?\"\"\"") + result = ft.reformat_comment(test_string, 70, " ") + expected = ( + " \"\"\"this is a test of a reformat for a triple quoted string will it\n" + " reformat to less than 70 characters for me?\"\"\"") + Equal(result, expected) + + test_comment = ( + "# this is a test of a reformat for a triple quoted string will " + "it reformat to less than 70 characters for me?") + result = ft.reformat_comment(test_comment, 70, "#") + expected = ( + "# this is a test of a reformat for a triple quoted string will it\n" + "# reformat to less than 70 characters for me?") + Equal(result, expected) + + +class FormatClassTest(unittest.TestCase): + def test_init_close(self): + instance = ft.FormatParagraph('editor') + self.assertEqual(instance.editwin, 'editor') + instance.close() + self.assertEqual(instance.editwin, None) + + +# For testing format_paragraph_event, Initialize FormatParagraph with +# a mock Editor with .text and .get_selection_indices. The text must +# be a Text wrapper that adds two methods + +# A real EditorWindow creates unneeded, time-consuming baggage and +# sometimes emits shutdown warnings like this: +# "warning: callback failed in WindowList +# : invalid command name ".55131368.windows". +# Calling EditorWindow._close in tearDownClass prevents this but causes +# other problems (windows left open). + +class TextWrapper: + def __init__(self, master): + self.text = Text(master=master) + def __getattr__(self, name): + return getattr(self.text, name) + def undo_block_start(self): pass + def undo_block_stop(self): pass + +class Editor: + def __init__(self, root): + self.text = TextWrapper(root) + get_selection_indices = EditorWindow. get_selection_indices + +class FormatEventTest(unittest.TestCase): + """Test the formatting of text inside a Text widget. + + This is done with FormatParagraph.format.paragraph_event, + which calls functions in the module as appropriate. + """ + test_string = ( + " '''this is a test of a reformat for a triple " + "quoted string will it reformat to less than 70 " + "characters for me?'''\n") + multiline_test_string = ( + " '''The first line is under the max width.\n" + " The second line's length is way over the max width. It goes " + "on and on until it is over 100 characters long.\n" + " Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + " '''\n") + multiline_test_comment = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and on until it is over 100 characters long.\n" + "# Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fourth line is short like the first line.") + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + editor = Editor(root=cls.root) + cls.text = editor.text.text # Test code does not need the wrapper. + cls.formatter = ft.FormatParagraph(editor).format_paragraph_event + # Sets the insert mark just after the re-wrapped and inserted text. + + @classmethod + def tearDownClass(cls): + del cls.text, cls.formatter + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_short_line(self): + self.text.insert('1.0', "Short line\n") + self.formatter("Dummy") + self.assertEqual(self.text.get('1.0', 'insert'), "Short line\n" ) + self.text.delete('1.0', 'end') + + def test_long_line(self): + text = self.text + + # Set cursor ('insert' mark) to '1.0', within text. + text.insert('1.0', self.test_string) + text.mark_set('insert', '1.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + # find function includes \n + expected = ( +" '''this is a test of a reformat for a triple quoted string will it\n" +" reformat to less than 70 characters for me?'''\n") # yes + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + # Select from 1.11 to line end. + text.insert('1.0', self.test_string) + text.tag_add('sel', '1.11', '1.end') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + # selection excludes \n + expected = ( +" '''this is a test of a reformat for a triple quoted string will it reformat\n" +" to less than 70 characters for me?'''") # no + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + def test_multiple_lines(self): + text = self.text + # Select 2 long lines. + text.insert('1.0', self.multiline_test_string) + text.tag_add('sel', '2.0', '4.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('2.0', 'insert') + expected = ( +" The second line's length is way over the max width. It goes on and\n" +" on until it is over 100 characters long. Same thing with the third\n" +" line. It is also way over the max width, but FormatParagraph will\n" +" fix it.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + def test_comment_block(self): + text = self.text + + # Set cursor ('insert') to '1.0', within block. + text.insert('1.0', self.multiline_test_comment) + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + expected = ( +"# The first line is under the max width. The second line's length is\n" +"# way over the max width. It goes on and on until it is over 100\n" +"# characters long. Same thing with the third line. It is also way over\n" +"# the max width, but FormatParagraph will fix it. The fourth line is\n" +"# short like the first line.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + # Select line 2, verify line 1 unaffected. + text.insert('1.0', self.multiline_test_comment) + text.tag_add('sel', '2.0', '3.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + expected = ( +"# The first line is under the max width.\n" +"# The second line's length is way over the max width. It goes on and\n" +"# on until it is over 100 characters long.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + +# The following block worked with EditorWindow but fails with the mock. +# Lines 2 and 3 get pasted together even though the previous block left +# the previous line alone. More investigation is needed. +## # Select lines 3 and 4 +## text.insert('1.0', self.multiline_test_comment) +## text.tag_add('sel', '3.0', '5.0') +## self.formatter('ParameterDoesNothing') +## result = text.get('3.0', 'insert') +## expected = ( +##"# Same thing with the third line. It is also way over the max width,\n" +##"# but FormatParagraph will fix it. The fourth line is short like the\n" +##"# first line.\n") +## self.assertEqual(result, expected) +## text.delete('1.0', 'end') + + +class DummyEditwin: + def __init__(self, root, text): + self.root = root + self.text = text + self.indentwidth = 4 + self.tabwidth = 4 + self.usetabs = False + self.context_use_ps1 = True + + _make_blanks = EditorWindow._make_blanks + get_selection_indices = EditorWindow.get_selection_indices + + +class FormatRegionTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.text.undo_block_start = mock.Mock() + cls.text.undo_block_stop = mock.Mock() + cls.editor = DummyEditwin(cls.root, cls.text) + cls.formatter = ft.FormatRegion(cls.editor) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.formatter, cls.editor + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('1.0', self.code_sample) + + def tearDown(self): + self.text.delete('1.0', 'end') + + code_sample = """\ +# WS line needed for test. +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b + + def compare(self): + if a > b: + return a + elif a < b: + return b + else: + return None +""" + + def test_get_region(self): + get = self.formatter.get_region + text = self.text + eq = self.assertEqual + + # Add selection. + text.tag_add('sel', '7.0', '10.0') + expected_lines = ['', + ' def compare(self):', + ' if a > b:', + ''] + eq(get(), ('7.0', '10.0', '\n'.join(expected_lines), expected_lines)) + + # Remove selection. + text.tag_remove('sel', '1.0', 'end') + eq(get(), ('15.0', '16.0', '\n', ['', ''])) + + def test_set_region(self): + set_ = self.formatter.set_region + text = self.text + eq = self.assertEqual + + save_bell = text.bell + text.bell = mock.Mock() + line6 = self.code_sample.splitlines()[5] + line10 = self.code_sample.splitlines()[9] + + text.tag_add('sel', '6.0', '11.0') + head, tail, chars, lines = self.formatter.get_region() + + # No changes. + set_(head, tail, chars, lines) + text.bell.assert_called_once() + eq(text.get('6.0', '11.0'), chars) + eq(text.get('sel.first', 'sel.last'), chars) + text.tag_remove('sel', '1.0', 'end') + + # Alter selected lines by changing lines and adding a newline. + newstring = 'added line 1\n\n\n\n' + newlines = newstring.split('\n') + set_('7.0', '10.0', chars, newlines) + # Selection changed. + eq(text.get('sel.first', 'sel.last'), newstring) + # Additional line added, so last index is changed. + eq(text.get('7.0', '11.0'), newstring) + # Before and after lines unchanged. + eq(text.get('6.0', '7.0-1c'), line6) + eq(text.get('11.0', '12.0-1c'), line10) + text.tag_remove('sel', '1.0', 'end') + + text.bell = save_bell + + def test_indent_region_event(self): + indent = self.formatter.indent_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + indent() + # Blank lines aren't affected by indent. + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + def test_dedent_region_event(self): + dedent = self.formatter.dedent_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + dedent() + # Blank lines aren't affected by dedent. + eq(text.get('7.0', '10.0'), ('\ndef compare(self):\n if a > b:\n')) + + def test_comment_region_event(self): + comment = self.formatter.comment_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + comment() + eq(text.get('7.0', '10.0'), ('##\n## def compare(self):\n## if a > b:\n')) + + def test_uncomment_region_event(self): + comment = self.formatter.comment_region_event + uncomment = self.formatter.uncomment_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + comment() + uncomment() + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + # Only remove comments at the beginning of a line. + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '3.0', '4.0') + uncomment() + eq(text.get('3.0', '3.end'), (' # Class comment.')) + + self.formatter.set_region('3.0', '4.0', '', ['# Class comment.', '']) + uncomment() + eq(text.get('3.0', '3.end'), (' Class comment.')) + + @mock.patch.object(ft.FormatRegion, "_asktabwidth") + def test_tabify_region_event(self, _asktabwidth): + tabify = self.formatter.tabify_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + # No tabwidth selected. + _asktabwidth.return_value = None + self.assertIsNone(tabify()) + + _asktabwidth.return_value = 3 + self.assertIsNotNone(tabify()) + eq(text.get('7.0', '10.0'), ('\n\t def compare(self):\n\t\t if a > b:\n')) + + @mock.patch.object(ft.FormatRegion, "_asktabwidth") + def test_untabify_region_event(self, _asktabwidth): + untabify = self.formatter.untabify_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + # No tabwidth selected. + _asktabwidth.return_value = None + self.assertIsNone(untabify()) + + _asktabwidth.return_value = 2 + self.formatter.tabify_region_event() + _asktabwidth.return_value = 3 + self.assertIsNotNone(untabify()) + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + @mock.patch.object(ft, "askinteger") + def test_ask_tabwidth(self, askinteger): + ask = self.formatter._asktabwidth + askinteger.return_value = 10 + self.assertEqual(ask(), 10) + + +class IndentsTest(unittest.TestCase): + + @mock.patch.object(ft, "askyesno") + def test_toggle_tabs(self, askyesno): + editor = DummyEditwin(None, None) # usetabs == False. + indents = ft.Indents(editor) + askyesno.return_value = True + + indents.toggle_tabs_event(None) + self.assertEqual(editor.usetabs, True) + self.assertEqual(editor.indentwidth, 8) + + indents.toggle_tabs_event(None) + self.assertEqual(editor.usetabs, False) + self.assertEqual(editor.indentwidth, 8) + + @mock.patch.object(ft, "askinteger") + def test_change_indentwidth(self, askinteger): + editor = DummyEditwin(None, None) # indentwidth == 4. + indents = ft.Indents(editor) + + askinteger.return_value = None + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 4) + + askinteger.return_value = 3 + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 3) + + askinteger.return_value = 5 + editor.usetabs = True + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 3) + + +class RstripTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editor = MockEditor(text=cls.text) + cls.do_rstrip = ft.Rstrip(cls.editor).do_rstrip + + @classmethod + def tearDownClass(cls): + del cls.text, cls.do_rstrip, cls.editor + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def tearDown(self): + self.text.delete('1.0', 'end-1c') + + def test_rstrip_lines(self): + original = ( + "Line with an ending tab \n" + "Line ending in 5 spaces \n" + "Linewithnospaces\n" + " indented line\n" + " indented line with trailing space \n" + " \n") + stripped = ( + "Line with an ending tab\n" + "Line ending in 5 spaces\n" + "Linewithnospaces\n" + " indented line\n" + " indented line with trailing space\n") + + self.text.insert('1.0', original) + self.do_rstrip() + self.assertEqual(self.text.get('1.0', 'insert'), stripped) + + def test_rstrip_end(self): + text = self.text + for code in ('', '\n', '\n\n\n'): + with self.subTest(code=code): + text.insert('1.0', code) + self.do_rstrip() + self.assertEqual(text.get('1.0','end-1c'), '') + for code in ('a\n', 'a\n\n', 'a\n\n\n'): + with self.subTest(code=code): + text.delete('1.0', 'end-1c') + text.insert('1.0', code) + self.do_rstrip() + self.assertEqual(text.get('1.0','end-1c'), 'a\n') + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_grep.py b/Lib/idlelib/idle_test/test_grep.py new file mode 100644 index 00000000000..d67dba76911 --- /dev/null +++ b/Lib/idlelib/idle_test/test_grep.py @@ -0,0 +1,156 @@ +""" !Changing this line will break Test_findfile.test_found! +Non-gui unit tests for grep.GrepDialog methods. +dummy_command calls grep_it calls findfiles. +An exception raised in one method will fail callers. +Otherwise, tests are mostly independent. +Currently only test grep_it, coverage 51%. +""" +from idlelib import grep +import unittest +from test.support import captured_stdout +from idlelib.idle_test.mock_tk import Var +import os +import re + + +class Dummy_searchengine: + '''GrepDialog.__init__ calls parent SearchDiabolBase which attaches the + passed in SearchEngine instance as attribute 'engine'. Only a few of the + many possible self.engine.x attributes are needed here. + ''' + def getpat(self): + return self._pat + +searchengine = Dummy_searchengine() + + +class Dummy_grep: + # Methods tested + #default_command = GrepDialog.default_command + grep_it = grep.GrepDialog.grep_it + # Other stuff needed + recvar = Var(False) + engine = searchengine + def close(self): # gui method + pass + +_grep = Dummy_grep() + + +class FindfilesTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.realpath = os.path.realpath(__file__) + cls.path = os.path.dirname(cls.realpath) + + @classmethod + def tearDownClass(cls): + del cls.realpath, cls.path + + def test_invaliddir(self): + with captured_stdout() as s: + filelist = list(grep.findfiles('invaliddir', '*.*', False)) + self.assertEqual(filelist, []) + self.assertIn('invalid', s.getvalue()) + + def test_curdir(self): + # Test os.curdir. + ff = grep.findfiles + save_cwd = os.getcwd() + os.chdir(self.path) + filename = 'test_grep.py' + filelist = list(ff(os.curdir, filename, False)) + self.assertIn(os.path.join(os.curdir, filename), filelist) + os.chdir(save_cwd) + + def test_base(self): + ff = grep.findfiles + readme = os.path.join(self.path, 'README.txt') + + # Check for Python files in path where this file lives. + filelist = list(ff(self.path, '*.py', False)) + # This directory has many Python files. + self.assertGreater(len(filelist), 10) + self.assertIn(self.realpath, filelist) + self.assertNotIn(readme, filelist) + + # Look for .txt files in path where this file lives. + filelist = list(ff(self.path, '*.txt', False)) + self.assertNotEqual(len(filelist), 0) + self.assertNotIn(self.realpath, filelist) + self.assertIn(readme, filelist) + + # Look for non-matching pattern. + filelist = list(ff(self.path, 'grep.*', False)) + self.assertEqual(len(filelist), 0) + self.assertNotIn(self.realpath, filelist) + + def test_recurse(self): + ff = grep.findfiles + parent = os.path.dirname(self.path) + grepfile = os.path.join(parent, 'grep.py') + pat = '*.py' + + # Get Python files only in parent directory. + filelist = list(ff(parent, pat, False)) + parent_size = len(filelist) + # Lots of Python files in idlelib. + self.assertGreater(parent_size, 20) + self.assertIn(grepfile, filelist) + # Without subdirectories, this file isn't returned. + self.assertNotIn(self.realpath, filelist) + + # Include subdirectories. + filelist = list(ff(parent, pat, True)) + # More files found now. + self.assertGreater(len(filelist), parent_size) + self.assertIn(grepfile, filelist) + # This file exists in list now. + self.assertIn(self.realpath, filelist) + + # Check another level up the tree. + parent = os.path.dirname(parent) + filelist = list(ff(parent, '*.py', True)) + self.assertIn(self.realpath, filelist) + + +class Grep_itTest(unittest.TestCase): + # Test captured reports with 0 and some hits. + # Should test file names, but Windows reports have mixed / and \ separators + # from incomplete replacement, so 'later'. + + def report(self, pat): + _grep.engine._pat = pat + with captured_stdout() as s: + _grep.grep_it(re.compile(pat), __file__) + lines = s.getvalue().split('\n') + lines.pop() # remove bogus '' after last \n + return lines + + def test_unfound(self): + pat = 'xyz*'*7 + lines = self.report(pat) + self.assertEqual(len(lines), 2) + self.assertIn(pat, lines[0]) + self.assertEqual(lines[1], 'No hits.') + + def test_found(self): + + pat = '""" !Changing this line will break Test_findfile.test_found!' + lines = self.report(pat) + self.assertEqual(len(lines), 5) + self.assertIn(pat, lines[0]) + self.assertIn('py: 1:', lines[1]) # line number 1 + self.assertIn('2', lines[3]) # hits found 2 + self.assertStartsWith(lines[4], '(Hint:') + + +class Default_commandTest(unittest.TestCase): + # To write this, move outwin import to top of GrepDialog + # so it can be replaced by captured_stdout in class setup/teardown. + pass + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_help.py b/Lib/idlelib/idle_test/test_help.py new file mode 100644 index 00000000000..ebb02b5c0d8 --- /dev/null +++ b/Lib/idlelib/idle_test/test_help.py @@ -0,0 +1,36 @@ +"Test help, coverage 94%." + +from idlelib import help +import unittest +from test.support import requires +requires('gui') +from os.path import abspath, dirname, join +from tkinter import Tk + + +class IdleDocTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + "By itself, this tests that file parsed without exception." + cls.root = root = Tk() + root.withdraw() + cls.window = help.show_idlehelp(root) + + @classmethod + def tearDownClass(cls): + del cls.window + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_1window(self): + self.assertIn('IDLE Doc', self.window.wm_title()) + + def test_4text(self): + text = self.window.frame.text + self.assertEqual(text.get('1.0', '1.end'), ' IDLE — Python editor and shell ') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_help_about.py b/Lib/idlelib/idle_test/test_help_about.py new file mode 100644 index 00000000000..7e16abdb7c9 --- /dev/null +++ b/Lib/idlelib/idle_test/test_help_about.py @@ -0,0 +1,182 @@ +"""Test help_about, coverage 100%. +help_about.build_bits branches on sys.platform='darwin'. +'100% combines coverage on Mac and others. +""" + +from idlelib import help_about +import unittest +from test.support import requires, findfile +from tkinter import Tk, TclError +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func +from idlelib import textview +import os.path +from platform import python_version + +About = help_about.AboutDialog + + +class LiveDialogTest(unittest.TestCase): + """Simulate user clicking buttons other than [Close]. + + Test that invoked textview has text from source. + """ + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, 'About IDLE', _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_build_bits(self): + self.assertIn(help_about.bits, ('32', '64')) + + def test_dialog_title(self): + """Test about dialog title""" + self.assertEqual(self.dialog.title(), 'About IDLE') + + def test_dialog_logo(self): + """Test about dialog logo.""" + path, file = os.path.split(self.dialog.icon_image['file']) + fn, ext = os.path.splitext(file) + self.assertEqual(fn, 'idle_48') + + def test_printer_buttons(self): + """Test buttons whose commands use printer function.""" + dialog = self.dialog + button_sources = [(dialog.py_license, license, 'license'), + (dialog.py_copyright, copyright, 'copyright'), + (dialog.py_credits, credits, 'credits')] + + for button, printer, name in button_sources: + with self.subTest(name=name): + printer._Printer__setup() + button.invoke() + get = dialog._current_textview.viewframe.textframe.text.get + lines = printer._Printer__lines + if len(lines) < 2: + self.fail(name + ' full text was not found') + self.assertEqual(lines[0], get('1.0', '1.end')) + self.assertEqual(lines[1], get('2.0', '2.end')) + dialog._current_textview.destroy() + + def test_file_buttons(self): + """Test buttons that display files.""" + dialog = self.dialog + button_sources = [(self.dialog.readme, 'README.txt', 'readme'), + (self.dialog.idle_news, 'News3.txt', 'news'), + (self.dialog.idle_credits, 'CREDITS.txt', 'credits')] + + for button, filename, name in button_sources: + with self.subTest(name=name): + button.invoke() + fn = findfile(filename, subdir='idlelib') + get = dialog._current_textview.viewframe.textframe.text.get + with open(fn, encoding='utf-8') as f: + self.assertEqual(f.readline().strip(), get('1.0', '1.end')) + f.readline() + self.assertEqual(f.readline().strip(), get('3.0', '3.end')) + dialog._current_textview.destroy() + + +class DefaultTitleTest(unittest.TestCase): + "Test default title." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_dialog_title(self): + """Test about dialog title""" + self.assertEqual(self.dialog.title(), + f'About IDLE {python_version()}' + f' ({help_about.bits} bit)') + + +class CloseTest(unittest.TestCase): + """Simulate user clicking [Close] button""" + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, 'About IDLE', _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_close(self): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_ok.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + + +class Dummy_about_dialog: + # Dummy class for testing file display functions. + idle_credits = About.show_idle_credits + idle_readme = About.show_readme + idle_news = About.show_idle_news + # Called by the above + display_file_text = About.display_file_text + _utest = True + + +class DisplayFileTest(unittest.TestCase): + """Test functions that display files. + + While somewhat redundant with gui-based test_file_dialog, + these unit tests run on all buildbots, not just a few. + """ + dialog = Dummy_about_dialog() + + @classmethod + def setUpClass(cls): + cls.orig_error = textview.showerror + cls.orig_view = textview.view_text + cls.error = Mbox_func() + cls.view = Func() + textview.showerror = cls.error + textview.view_text = cls.view + + @classmethod + def tearDownClass(cls): + textview.showerror = cls.orig_error + textview.view_text = cls.orig_view + + def test_file_display(self): + for handler in (self.dialog.idle_credits, + self.dialog.idle_readme, + self.dialog.idle_news): + self.error.message = '' + self.view.called = False + with self.subTest(handler=handler): + handler() + self.assertEqual(self.error.message, '') + self.assertEqual(self.view.called, True) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_history.py b/Lib/idlelib/idle_test/test_history.py new file mode 100644 index 00000000000..67539651444 --- /dev/null +++ b/Lib/idlelib/idle_test/test_history.py @@ -0,0 +1,172 @@ +" Test history, coverage 100%." + +from idlelib.history import History +import unittest +from test.support import requires + +import tkinter as tk +from tkinter import Text as tkText +from idlelib.idle_test.mock_tk import Text as mkText +from idlelib.config import idleConf + +line1 = 'a = 7' +line2 = 'b = a' + + +class StoreTest(unittest.TestCase): + '''Tests History.__init__ and History.store with mock Text''' + + @classmethod + def setUpClass(cls): + cls.text = mkText() + cls.history = History(cls.text) + + def tearDown(self): + self.text.delete('1.0', 'end') + self.history.history = [] + + def test_init(self): + self.assertIs(self.history.text, self.text) + self.assertEqual(self.history.history, []) + self.assertIsNone(self.history.prefix) + self.assertIsNone(self.history.pointer) + self.assertEqual(self.history.cyclic, + idleConf.GetOption("main", "History", "cyclic", 1, "bool")) + + def test_store_short(self): + self.history.store('a') + self.assertEqual(self.history.history, []) + self.history.store(' a ') + self.assertEqual(self.history.history, []) + + def test_store_dup(self): + self.history.store(line1) + self.assertEqual(self.history.history, [line1]) + self.history.store(line2) + self.assertEqual(self.history.history, [line1, line2]) + self.history.store(line1) + self.assertEqual(self.history.history, [line2, line1]) + + def test_store_reset(self): + self.history.prefix = line1 + self.history.pointer = 0 + self.history.store(line2) + self.assertIsNone(self.history.prefix) + self.assertIsNone(self.history.pointer) + + +class TextWrapper: + def __init__(self, master): + self.text = tkText(master=master) + self._bell = False + def __getattr__(self, name): + return getattr(self.text, name) + def bell(self): + self._bell = True + + +class FetchTest(unittest.TestCase): + '''Test History.fetch with wrapped tk.Text. + ''' + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + + def setUp(self): + self.text = text = TextWrapper(self.root) + text.insert('1.0', ">>> ") + text.mark_set('iomark', '1.4') + text.mark_gravity('iomark', 'left') + self.history = History(text) + self.history.history = [line1, line2] + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def fetch_test(self, reverse, line, prefix, index, *, bell=False): + # Perform one fetch as invoked by Alt-N or Alt-P + # Test the result. The line test is the most important. + # The last two are diagnostic of fetch internals. + History = self.history + History.fetch(reverse) + + Equal = self.assertEqual + Equal(self.text.get('iomark', 'end-1c'), line) + Equal(self.text._bell, bell) + if bell: + self.text._bell = False + Equal(History.prefix, prefix) + Equal(History.pointer, index) + Equal(self.text.compare("insert", '==', "end-1c"), 1) + + def test_fetch_prev_cyclic(self): + prefix = '' + test = self.fetch_test + test(True, line2, prefix, 1) + test(True, line1, prefix, 0) + test(True, prefix, None, None, bell=True) + + def test_fetch_next_cyclic(self): + prefix = '' + test = self.fetch_test + test(False, line1, prefix, 0) + test(False, line2, prefix, 1) + test(False, prefix, None, None, bell=True) + + # Prefix 'a' tests skip line2, which starts with 'b' + def test_fetch_prev_prefix(self): + prefix = 'a' + self.text.insert('iomark', prefix) + self.fetch_test(True, line1, prefix, 0) + self.fetch_test(True, prefix, None, None, bell=True) + + def test_fetch_next_prefix(self): + prefix = 'a' + self.text.insert('iomark', prefix) + self.fetch_test(False, line1, prefix, 0) + self.fetch_test(False, prefix, None, None, bell=True) + + def test_fetch_prev_noncyclic(self): + prefix = '' + self.history.cyclic = False + test = self.fetch_test + test(True, line2, prefix, 1) + test(True, line1, prefix, 0) + test(True, line1, prefix, 0, bell=True) + + def test_fetch_next_noncyclic(self): + prefix = '' + self.history.cyclic = False + test = self.fetch_test + test(False, prefix, None, None, bell=True) + test(True, line2, prefix, 1) + test(False, prefix, None, None, bell=True) + test(False, prefix, None, None, bell=True) + + def test_fetch_cursor_move(self): + # Move cursor after fetch + self.history.fetch(reverse=True) # initialization + self.text.mark_set('insert', 'iomark') + self.fetch_test(True, line2, None, None, bell=True) + + def test_fetch_edit(self): + # Edit after fetch + self.history.fetch(reverse=True) # initialization + self.text.delete('iomark', 'insert', ) + self.text.insert('iomark', 'a =') + self.fetch_test(True, line1, 'a =', 0) # prefix is reset + + def test_history_prev_next(self): + # Minimally test functions bound to events + self.history.history_prev('dummy event') + self.assertEqual(self.history.pointer, 1) + self.history.history_next('dummy event') + self.assertEqual(self.history.pointer, None) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_hyperparser.py b/Lib/idlelib/idle_test/test_hyperparser.py new file mode 100644 index 00000000000..343843c4166 --- /dev/null +++ b/Lib/idlelib/idle_test/test_hyperparser.py @@ -0,0 +1,276 @@ +"Test hyperparser, coverage 98%." + +from idlelib.hyperparser import HyperParser +import unittest +from test.support import requires +from tkinter import Tk, Text +from idlelib.editor import EditorWindow + +class DummyEditwin: + def __init__(self, text): + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' + self.num_context_lines = 50, 500, 1000 + + _build_char_in_string_func = EditorWindow._build_char_in_string_func + is_char_in_string = EditorWindow.is_char_in_string + + +class HyperParserTest(unittest.TestCase): + code = ( + '"""This is a module docstring"""\n' + '# this line is a comment\n' + 'x = "this is a string"\n' + "y = 'this is also a string'\n" + 'l = [i for i in range(10)]\n' + 'm = [py*py for # comment\n' + ' py in l]\n' + 'x.__len__\n' + "z = ((r'asdf')+('a')))\n" + '[x for x in\n' + 'for = False\n' + 'cliché = "this is a string with unicode, what a cliché"' + ) + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editwin = DummyEditwin(cls.text) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.editwin + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('insert', self.code) + + def tearDown(self): + self.text.delete('1.0', 'end') + self.editwin.prompt_last_line = '>>>' + + def get_parser(self, index): + """ + Return a parser object with index at 'index' + """ + return HyperParser(self.editwin, index) + + def test_init(self): + """ + test corner cases in the init method + """ + with self.assertRaises(ValueError) as ve: + self.text.tag_add('console', '1.0', '1.end') + p = self.get_parser('1.5') + self.assertIn('precedes', str(ve.exception)) + + # test without ps1 + self.editwin.prompt_last_line = '' + + # number of lines lesser than 50 + p = self.get_parser('end') + self.assertEqual(p.rawtext, self.text.get('1.0', 'end')) + + # number of lines greater than 50 + self.text.insert('end', self.text.get('1.0', 'end')*4) + p = self.get_parser('54.5') + + def test_is_in_string(self): + get = self.get_parser + + p = get('1.0') + self.assertFalse(p.is_in_string()) + p = get('1.4') + self.assertTrue(p.is_in_string()) + p = get('2.3') + self.assertFalse(p.is_in_string()) + p = get('3.3') + self.assertFalse(p.is_in_string()) + p = get('3.7') + self.assertTrue(p.is_in_string()) + p = get('4.6') + self.assertTrue(p.is_in_string()) + p = get('12.54') + self.assertTrue(p.is_in_string()) + + def test_is_in_code(self): + get = self.get_parser + + p = get('1.0') + self.assertTrue(p.is_in_code()) + p = get('1.1') + self.assertFalse(p.is_in_code()) + p = get('2.5') + self.assertFalse(p.is_in_code()) + p = get('3.4') + self.assertTrue(p.is_in_code()) + p = get('3.6') + self.assertFalse(p.is_in_code()) + p = get('4.14') + self.assertFalse(p.is_in_code()) + + def test_get_surrounding_bracket(self): + get = self.get_parser + + def without_mustclose(parser): + # a utility function to get surrounding bracket + # with mustclose=False + return parser.get_surrounding_brackets(mustclose=False) + + def with_mustclose(parser): + # a utility function to get surrounding bracket + # with mustclose=True + return parser.get_surrounding_brackets(mustclose=True) + + p = get('3.2') + self.assertIsNone(with_mustclose(p)) + self.assertIsNone(without_mustclose(p)) + + p = get('5.6') + self.assertTupleEqual(without_mustclose(p), ('5.4', '5.25')) + self.assertTupleEqual(without_mustclose(p), with_mustclose(p)) + + p = get('5.23') + self.assertTupleEqual(without_mustclose(p), ('5.21', '5.24')) + self.assertTupleEqual(without_mustclose(p), with_mustclose(p)) + + p = get('6.15') + self.assertTupleEqual(without_mustclose(p), ('6.4', '6.end')) + self.assertIsNone(with_mustclose(p)) + + p = get('9.end') + self.assertIsNone(with_mustclose(p)) + self.assertIsNone(without_mustclose(p)) + + def test_get_expression(self): + get = self.get_parser + + p = get('4.2') + self.assertEqual(p.get_expression(), 'y ') + + p = get('4.7') + with self.assertRaises(ValueError) as ve: + p.get_expression() + self.assertIn('is inside a code', str(ve.exception)) + + p = get('5.25') + self.assertEqual(p.get_expression(), 'range(10)') + + p = get('6.7') + self.assertEqual(p.get_expression(), 'py') + + p = get('6.8') + self.assertEqual(p.get_expression(), '') + + p = get('7.9') + self.assertEqual(p.get_expression(), 'py') + + p = get('8.end') + self.assertEqual(p.get_expression(), 'x.__len__') + + p = get('9.13') + self.assertEqual(p.get_expression(), "r'asdf'") + + p = get('9.17') + with self.assertRaises(ValueError) as ve: + p.get_expression() + self.assertIn('is inside a code', str(ve.exception)) + + p = get('10.0') + self.assertEqual(p.get_expression(), '') + + p = get('10.6') + self.assertEqual(p.get_expression(), '') + + p = get('10.11') + self.assertEqual(p.get_expression(), '') + + p = get('11.3') + self.assertEqual(p.get_expression(), '') + + p = get('11.11') + self.assertEqual(p.get_expression(), 'False') + + p = get('12.6') + self.assertEqual(p.get_expression(), 'cliché') + + def test_eat_identifier(self): + def is_valid_id(candidate): + result = HyperParser._eat_identifier(candidate, 0, len(candidate)) + if result == len(candidate): + return True + elif result == 0: + return False + else: + err_msg = "Unexpected result: {} (expected 0 or {}".format( + result, len(candidate) + ) + raise Exception(err_msg) + + # invalid first character which is valid elsewhere in an identifier + self.assertFalse(is_valid_id('2notid')) + + # ASCII-only valid identifiers + self.assertTrue(is_valid_id('valid_id')) + self.assertTrue(is_valid_id('_valid_id')) + self.assertTrue(is_valid_id('valid_id_')) + self.assertTrue(is_valid_id('_2valid_id')) + + # keywords which should be "eaten" + self.assertTrue(is_valid_id('True')) + self.assertTrue(is_valid_id('False')) + self.assertTrue(is_valid_id('None')) + + # keywords which should not be "eaten" + self.assertFalse(is_valid_id('for')) + self.assertFalse(is_valid_id('import')) + self.assertFalse(is_valid_id('return')) + + # valid unicode identifiers + self.assertTrue(is_valid_id('cliche')) + self.assertTrue(is_valid_id('cliché')) + self.assertTrue(is_valid_id('a٢')) + + # invalid unicode identifiers + self.assertFalse(is_valid_id('2a')) + self.assertFalse(is_valid_id('٢a')) + self.assertFalse(is_valid_id('a²')) + + # valid identifier after "punctuation" + self.assertEqual(HyperParser._eat_identifier('+ var', 0, 5), len('var')) + self.assertEqual(HyperParser._eat_identifier('+var', 0, 4), len('var')) + self.assertEqual(HyperParser._eat_identifier('.var', 0, 4), len('var')) + + # invalid identifiers + self.assertFalse(is_valid_id('+')) + self.assertFalse(is_valid_id(' ')) + self.assertFalse(is_valid_id(':')) + self.assertFalse(is_valid_id('?')) + self.assertFalse(is_valid_id('^')) + self.assertFalse(is_valid_id('\\')) + self.assertFalse(is_valid_id('"')) + self.assertFalse(is_valid_id('"a string"')) + + def test_eat_identifier_various_lengths(self): + eat_id = HyperParser._eat_identifier + + for length in range(1, 21): + self.assertEqual(eat_id('a' * length, 0, length), length) + self.assertEqual(eat_id('é' * length, 0, length), length) + self.assertEqual(eat_id('a' + '2' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' + '2' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' + 'a' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' * (length - 1) + 'a', 0, length), length) + self.assertEqual(eat_id('+' * length, 0, length), 0) + self.assertEqual(eat_id('2' + 'a' * (length - 1), 0, length), 0) + self.assertEqual(eat_id('2' + 'é' * (length - 1), 0, length), 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py new file mode 100644 index 00000000000..e0642cf0cab --- /dev/null +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -0,0 +1,84 @@ +"Test , coverage 17%." + +from idlelib import iomenu +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow +from idlelib import util +from idlelib.idle_test.mock_idle import Func + +# Fail if either tokenize.open and t.detect_encoding does not exist. +# These are used in loadfile and encode. +# Also used in pyshell.MI.execfile and runscript.tabnanny. +from tokenize import open, detect_encoding +# Remove when we have proper tests that use both. + + +class IOBindingTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.editwin = EditorWindow(root=cls.root) + cls.io = iomenu.IOBinding(cls.editwin) + + @classmethod + def tearDownClass(cls): + cls.io.close() + cls.editwin._close() + del cls.editwin + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertIs(self.io.editwin, self.editwin) + + def test_fixnewlines_end(self): + eq = self.assertEqual + io = self.io + fix = io.fixnewlines + text = io.editwin.text + + # Make the editor temporarily look like Shell. + self.editwin.interp = None + shelltext = '>>> if 1' + self.editwin.get_prompt_text = Func(result=shelltext) + eq(fix(), shelltext) # Get... call and '\n' not added. + del self.editwin.interp, self.editwin.get_prompt_text + + text.insert(1.0, 'a') + eq(fix(), 'a'+io.eol_convention) + eq(text.get('1.0', 'end-1c'), 'a\n') + eq(fix(), 'a'+io.eol_convention) + + +def _extension_in_filetypes(extension): + return any( + f'*{extension}' in filetype_tuple[1] + for filetype_tuple in iomenu.IOBinding.filetypes + ) + + +class FiletypesTest(unittest.TestCase): + def test_python_source_files(self): + for extension in util.py_extensions: + with self.subTest(extension=extension): + self.assertTrue( + _extension_in_filetypes(extension) + ) + + def test_text_files(self): + self.assertTrue(_extension_in_filetypes('.txt')) + + def test_all_files(self): + self.assertTrue(_extension_in_filetypes('')) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_macosx.py b/Lib/idlelib/idle_test/test_macosx.py new file mode 100644 index 00000000000..86da8849e5c --- /dev/null +++ b/Lib/idlelib/idle_test/test_macosx.py @@ -0,0 +1,113 @@ +"Test macosx, coverage 45% on Windows." + +from idlelib import macosx +import unittest +from test.support import requires +import tkinter as tk +import unittest.mock as mock +from idlelib.filelist import FileList + +mactypes = {'carbon', 'cocoa', 'xquartz'} +nontypes = {'other'} +alltypes = mactypes | nontypes + + +def setUpModule(): + global orig_tktype + orig_tktype = macosx._tk_type + + +def tearDownModule(): + macosx._tk_type = orig_tktype + + +class InitTktypeTest(unittest.TestCase): + "Test _init_tk_type." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + cls.orig_platform = macosx.platform + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + macosx.platform = cls.orig_platform + + def test_init_sets_tktype(self): + "Test that _init_tk_type sets _tk_type according to platform." + for platform, types in ('darwin', alltypes), ('other', nontypes): + with self.subTest(platform=platform): + macosx.platform = platform + macosx._tk_type = None + macosx._init_tk_type() + self.assertIn(macosx._tk_type, types) + + +class IsTypeTkTest(unittest.TestCase): + "Test each of the four isTypeTk predecates." + isfuncs = ((macosx.isAquaTk, ('carbon', 'cocoa')), + (macosx.isCarbonTk, ('carbon')), + (macosx.isCocoaTk, ('cocoa')), + (macosx.isXQuartz, ('xquartz')), + ) + + @mock.patch('idlelib.macosx._init_tk_type') + def test_is_calls_init(self, mockinit): + "Test that each isTypeTk calls _init_tk_type when _tk_type is None." + macosx._tk_type = None + for func, whentrue in self.isfuncs: + with self.subTest(func=func): + func() + self.assertTrue(mockinit.called) + mockinit.reset_mock() + + def test_isfuncs(self): + "Test that each isTypeTk return correct bool." + for func, whentrue in self.isfuncs: + for tktype in alltypes: + with self.subTest(func=func, whentrue=whentrue, tktype=tktype): + macosx._tk_type = tktype + (self.assertTrue if tktype in whentrue else self.assertFalse)\ + (func()) + + +class SetupTest(unittest.TestCase): + "Test setupApp." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + def cmd(tkpath, func): + assert isinstance(tkpath, str) + assert isinstance(func, type(cmd)) + cls.root.createcommand = cmd + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch('idlelib.macosx.overrideRootMenu') #27312 + def test_setupapp(self, overrideRootMenu): + "Call setupApp with each possible graphics type." + root = self.root + flist = FileList(root) + for tktype in alltypes: + with self.subTest(tktype=tktype): + macosx._tk_type = tktype + macosx.setupApp(root, flist) + if tktype in ('carbon', 'cocoa'): + self.assertTrue(overrideRootMenu.called) + overrideRootMenu.reset_mock() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_mainmenu.py b/Lib/idlelib/idle_test/test_mainmenu.py new file mode 100644 index 00000000000..51d2accfe48 --- /dev/null +++ b/Lib/idlelib/idle_test/test_mainmenu.py @@ -0,0 +1,42 @@ +"Test mainmenu, coverage 100%." +# Reported as 88%; mocking turtledemo absence would have no point. + +from idlelib import mainmenu +import re +import unittest + + +class MainMenuTest(unittest.TestCase): + + def test_menudefs(self): + actual = [item[0] for item in mainmenu.menudefs] + expect = ['file', 'edit', 'format', 'run', 'shell', + 'debug', 'options', 'window', 'help'] + self.assertEqual(actual, expect) + + def test_default_keydefs(self): + self.assertGreaterEqual(len(mainmenu.default_keydefs), 50) + + def test_tcl_indexes(self): + # Test tcl patterns used to find menuitem to alter. + # On failure, change pattern here and in function(s). + # Patterns here have '.*' for re instead of '*' for tcl. + for menu, pattern in ( + ('debug', '.*tack.*iewer'), # PyShell.debug_menu_postcommand + ('options', '.*ode.*ontext'), # EW.__init__, CodeContext.toggle... + ('options', '.*ine.*umbers'), # EW.__init__, EW.toggle...event. + ): + with self.subTest(menu=menu, pattern=pattern): + for menutup in mainmenu.menudefs: + if menutup[0] == menu: + break + else: + self.assertTrue(0, f"{menu} not in menudefs") + self.assertTrue(any(re.search(pattern, menuitem[0]) + for menuitem in menutup[1] + if menuitem is not None), # Separator. + f"{pattern} not in {menu}") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_multicall.py b/Lib/idlelib/idle_test/test_multicall.py new file mode 100644 index 00000000000..67f28db6b08 --- /dev/null +++ b/Lib/idlelib/idle_test/test_multicall.py @@ -0,0 +1,48 @@ +"Test multicall, coverage 33%." + +from idlelib import multicall +import unittest +from test.support import requires +from tkinter import Tk, Text + + +class MultiCallTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.mc = multicall.MultiCallCreator(Text) + + @classmethod + def tearDownClass(cls): + del cls.mc + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_creator(self): + mc = self.mc + self.assertIs(multicall._multicall_dict[Text], mc) + self.assertIsSubclass(mc, Text) + mc2 = multicall.MultiCallCreator(Text) + self.assertIs(mc, mc2) + + def test_init(self): + mctext = self.mc(self.root) + self.assertIsInstance(mctext._MultiCall__binders, list) + + def test_yview(self): + # Added for tree.wheel_event + # (it depends on yview to not be overridden) + mc = self.mc + self.assertIs(mc.yview, Text.yview) + mctext = self.mc(self.root) + self.assertIs(mctext.yview.__func__, Text.yview) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_outwin.py b/Lib/idlelib/idle_test/test_outwin.py new file mode 100644 index 00000000000..0f13363f84f --- /dev/null +++ b/Lib/idlelib/idle_test/test_outwin.py @@ -0,0 +1,172 @@ +"Test outwin, coverage 76%." + +from idlelib import outwin +import platform +import sys +import unittest +from test.support import requires +from tkinter import Tk, Text +from idlelib.idle_test.mock_tk import Mbox_func +from idlelib.idle_test.mock_idle import Func +from unittest import mock + + +class OutputWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + w = cls.window = outwin.OutputWindow(None, None, None, root) + cls.text = w.text = Text(root) + if sys.platform == 'darwin': # Issue 112938 + cls.text.update = cls.text.update_idletasks + # Without this, test write, writelines, and goto... fail. + # The reasons and why macOS-specific are unclear. + + @classmethod + def tearDownClass(cls): + cls.window.close() + del cls.text, cls.window + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.delete('1.0', 'end') + + def test_ispythonsource(self): + # OutputWindow overrides ispythonsource to always return False. + w = self.window + self.assertFalse(w.ispythonsource('test.txt')) + self.assertFalse(w.ispythonsource(__file__)) + + def test_window_title(self): + self.assertEqual(self.window.top.title(), 'Output' + ' (%s)' % platform.python_version()) + + def test_maybesave(self): + w = self.window + eq = self.assertEqual + w.get_saved = Func() + + w.get_saved.result = False + eq(w.maybesave(), 'no') + eq(w.get_saved.called, 1) + + w.get_saved.result = True + eq(w.maybesave(), 'yes') + eq(w.get_saved.called, 2) + del w.get_saved + + def test_write(self): + eq = self.assertEqual + delete = self.text.delete + get = self.text.get + write = self.window.write + + # No new line - insert stays on same line. + delete('1.0', 'end') + test_text = 'test text' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('insert linestart', 'insert lineend'), 'test text') + + # New line - insert moves to next line. + delete('1.0', 'end') + test_text = 'test text\n' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('insert linestart', 'insert lineend'), '') + + # Text after new line is tagged for second line of Text widget. + delete('1.0', 'end') + test_text = 'test text\nLine 2' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('2.0', '2.end'), 'Line 2') + eq(get('insert linestart', 'insert lineend'), 'Line 2') + + # Test tags. + delete('1.0', 'end') + test_text = 'test text\n' + test_text2 = 'Line 2\n' + eq(write(test_text, tags='mytag'), len(test_text)) + eq(write(test_text2, tags='secondtag'), len(test_text2)) + eq(get('mytag.first', 'mytag.last'), test_text) + eq(get('secondtag.first', 'secondtag.last'), test_text2) + eq(get('1.0', '1.end'), test_text.rstrip('\n')) + eq(get('2.0', '2.end'), test_text2.rstrip('\n')) + + def test_writelines(self): + eq = self.assertEqual + get = self.text.get + writelines = self.window.writelines + + writelines(('Line 1\n', 'Line 2\n', 'Line 3\n')) + eq(get('1.0', '1.end'), 'Line 1') + eq(get('2.0', '2.end'), 'Line 2') + eq(get('3.0', '3.end'), 'Line 3') + eq(get('insert linestart', 'insert lineend'), '') + + def test_goto_file_line(self): + eq = self.assertEqual + w = self.window + text = self.text + + w.flist = mock.Mock() + gfl = w.flist.gotofileline = Func() + showerror = w.showerror = Mbox_func() + + # No file/line number. + w.write('Not a file line') + self.assertIsNone(w.goto_file_line()) + eq(gfl.called, 0) + eq(showerror.title, 'No special line') + + # Current file/line number. + w.write(f'{str(__file__)}: 42: spam\n') + w.write(f'{str(__file__)}: 21: spam') + self.assertIsNone(w.goto_file_line()) + eq(gfl.args, (str(__file__), 21)) + + # Previous line has file/line number. + text.delete('1.0', 'end') + w.write(f'{str(__file__)}: 42: spam\n') + w.write('Not a file line') + self.assertIsNone(w.goto_file_line()) + eq(gfl.args, (str(__file__), 42)) + + del w.flist.gotofileline, w.showerror + + +class ModuleFunctionTest(unittest.TestCase): + + @classmethod + def setUp(cls): + outwin.file_line_progs = None + + def test_compile_progs(self): + outwin.compile_progs() + for pat, regex in zip(outwin.file_line_pats, outwin.file_line_progs): + self.assertEqual(regex.pattern, pat) + + @mock.patch('builtins.open') + def test_file_line_helper(self, mock_open): + flh = outwin.file_line_helper + test_lines = ( + (r'foo file "testfile1", line 42, bar', ('testfile1', 42)), + (r'foo testfile2(21) bar', ('testfile2', 21)), + (r' testfile3 : 42: foo bar\n', (' testfile3 ', 42)), + (r'foo testfile4.py :1: ', ('foo testfile4.py ', 1)), + ('testfile5: \u19D4\u19D2: ', ('testfile5', 42)), + (r'testfile6: 42', None), # only one `:` + (r'testfile7 42 text', None) # no separators + ) + for line, expected_output in test_lines: + self.assertEqual(flh(line), expected_output) + if expected_output: + mock_open.assert_called_with(expected_output[0]) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_parenmatch.py b/Lib/idlelib/idle_test/test_parenmatch.py new file mode 100644 index 00000000000..2e10d7cd367 --- /dev/null +++ b/Lib/idlelib/idle_test/test_parenmatch.py @@ -0,0 +1,112 @@ +"""Test parenmatch, coverage 91%. + +This must currently be a gui test because ParenMatch methods use +several text methods not defined on idlelib.idle_test.mock_tk.Text. +""" +from idlelib.parenmatch import ParenMatch +from test.support import requires +requires('gui') + +import unittest +from unittest.mock import Mock +from tkinter import Tk, Text + + +class DummyEditwin: + def __init__(self, text): + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' # Currently not used by parenmatch. + + +class ParenMatchTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editwin = DummyEditwin(cls.text) + cls.editwin.text_frame = Mock() + + @classmethod + def tearDownClass(cls): + del cls.text, cls.editwin + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def tearDown(self): + self.text.delete('1.0', 'end') + + def get_parenmatch(self): + pm = ParenMatch(self.editwin) + pm.bell = lambda: None + return pm + + def test_paren_styles(self): + """ + Test ParenMatch with each style. + """ + text = self.text + pm = self.get_parenmatch() + for style, range1, range2 in ( + ('opener', ('1.10', '1.11'), ('1.10', '1.11')), + ('default',('1.10', '1.11'),('1.10', '1.11')), + ('parens', ('1.14', '1.15'), ('1.15', '1.16')), + ('expression', ('1.10', '1.15'), ('1.10', '1.16'))): + with self.subTest(style=style): + text.delete('1.0', 'end') + pm.STYLE = style + text.insert('insert', 'def foobar(a, b') + + pm.flash_paren_event('event') + self.assertIn('<>', text.event_info()) + if style == 'parens': + self.assertTupleEqual(text.tag_nextrange('paren', '1.0'), + ('1.10', '1.11')) + self.assertTupleEqual( + text.tag_prevrange('paren', 'end'), range1) + + text.insert('insert', ')') + pm.restore_event() + self.assertNotIn('<>', + text.event_info()) + self.assertEqual(text.tag_prevrange('paren', 'end'), ()) + + pm.paren_closed_event('event') + self.assertTupleEqual( + text.tag_prevrange('paren', 'end'), range2) + + def test_paren_corner(self): + """ + Test corner cases in flash_paren_event and paren_closed_event. + + Force execution of conditional expressions and alternate paths. + """ + text = self.text + pm = self.get_parenmatch() + + text.insert('insert', '# Comment.)') + pm.paren_closed_event('event') + + text.insert('insert', '\ndef') + pm.flash_paren_event('event') + pm.paren_closed_event('event') + + text.insert('insert', ' a, *arg)') + pm.paren_closed_event('event') + + def test_handle_restore_timer(self): + pm = self.get_parenmatch() + pm.restore_event = Mock() + pm.handle_restore_timer(0) + self.assertTrue(pm.restore_event.called) + pm.restore_event.reset_mock() + pm.handle_restore_timer(1) + self.assertFalse(pm.restore_event.called) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pathbrowser.py b/Lib/idlelib/idle_test/test_pathbrowser.py new file mode 100644 index 00000000000..13d8b9e1ba9 --- /dev/null +++ b/Lib/idlelib/idle_test/test_pathbrowser.py @@ -0,0 +1,86 @@ +"Test pathbrowser, coverage 95%." + +from idlelib import pathbrowser +import unittest +from test.support import requires +from tkinter import Tk + +import os.path +import pyclbr # for _modules +import sys # for sys.path + +from idlelib.idle_test.mock_idle import Func +import idlelib # for __file__ +from idlelib import browser +from idlelib.tree import TreeNode + + +class PathBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.pb = pathbrowser.PathBrowser(cls.root, _utest=True) + + @classmethod + def tearDownClass(cls): + cls.pb.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root, cls.pb + + def test_init(self): + pb = self.pb + eq = self.assertEqual + eq(pb.master, self.root) + eq(pyclbr._modules, {}) + self.assertIsInstance(pb.node, TreeNode) + self.assertIsNotNone(browser.file_open) + + def test_settitle(self): + pb = self.pb + self.assertEqual(pb.top.title(), 'Path Browser') + self.assertEqual(pb.top.iconname(), 'Path Browser') + + def test_rootnode(self): + pb = self.pb + rn = pb.rootnode() + self.assertIsInstance(rn, pathbrowser.PathBrowserTreeItem) + + def test_close(self): + pb = self.pb + pb.top.destroy = Func() + pb.node.destroy = Func() + pb.close() + self.assertTrue(pb.top.destroy.called) + self.assertTrue(pb.node.destroy.called) + del pb.top.destroy, pb.node.destroy + + +class DirBrowserTreeItemTest(unittest.TestCase): + + def test_DirBrowserTreeItem(self): + # Issue16226 - make sure that getting a sublist works + d = pathbrowser.DirBrowserTreeItem('') + d.GetSubList() + self.assertEqual('', d.GetText()) + + dir = os.path.split(os.path.abspath(idlelib.__file__))[0] + self.assertEqual(d.ispackagedir(dir), True) + self.assertEqual(d.ispackagedir(dir + '/Icons'), False) + + +class PathBrowserTreeItemTest(unittest.TestCase): + + def test_PathBrowserTreeItem(self): + p = pathbrowser.PathBrowserTreeItem() + self.assertEqual(p.GetText(), 'sys.path') + sub = p.GetSubList() + self.assertEqual(len(sub), len(sys.path)) + self.assertEqual(type(sub[0]), pathbrowser.DirBrowserTreeItem) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_percolator.py b/Lib/idlelib/idle_test/test_percolator.py new file mode 100644 index 00000000000..17668ccd122 --- /dev/null +++ b/Lib/idlelib/idle_test/test_percolator.py @@ -0,0 +1,118 @@ +"Test percolator, coverage 100%." + +from idlelib.percolator import Percolator, Delegator +import unittest +from test.support import requires +requires('gui') +from tkinter import Text, Tk, END + + +class MyFilter(Delegator): + def __init__(self): + Delegator.__init__(self, None) + + def insert(self, *args): + self.insert_called_with = args + self.delegate.insert(*args) + + def delete(self, *args): + self.delete_called_with = args + self.delegate.delete(*args) + + def uppercase_insert(self, index, chars, tags=None): + chars = chars.upper() + self.delegate.insert(index, chars) + + def lowercase_insert(self, index, chars, tags=None): + chars = chars.lower() + self.delegate.insert(index, chars) + + def dont_insert(self, index, chars, tags=None): + pass + + +class PercolatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.destroy() + del cls.root + + def setUp(self): + self.percolator = Percolator(self.text) + self.filter_one = MyFilter() + self.filter_two = MyFilter() + self.percolator.insertfilter(self.filter_one) + self.percolator.insertfilter(self.filter_two) + + def tearDown(self): + self.percolator.close() + self.text.delete('1.0', END) + + def test_insertfilter(self): + self.assertIsNotNone(self.filter_one.delegate) + self.assertEqual(self.percolator.top, self.filter_two) + self.assertEqual(self.filter_two.delegate, self.filter_one) + self.assertEqual(self.filter_one.delegate, self.percolator.bottom) + + def test_removefilter(self): + filter_three = MyFilter() + self.percolator.removefilter(self.filter_two) + self.assertEqual(self.percolator.top, self.filter_one) + self.assertIsNone(self.filter_two.delegate) + + filter_three = MyFilter() + self.percolator.insertfilter(self.filter_two) + self.percolator.insertfilter(filter_three) + self.percolator.removefilter(self.filter_one) + self.assertEqual(self.percolator.top, filter_three) + self.assertEqual(filter_three.delegate, self.filter_two) + self.assertEqual(self.filter_two.delegate, self.percolator.bottom) + self.assertIsNone(self.filter_one.delegate) + + def test_insert(self): + self.text.insert('insert', 'foo') + self.assertEqual(self.text.get('1.0', END), 'foo\n') + self.assertTupleEqual(self.filter_one.insert_called_with, + ('insert', 'foo', None)) + + def test_modify_insert(self): + self.filter_one.insert = self.filter_one.uppercase_insert + self.text.insert('insert', 'bAr') + self.assertEqual(self.text.get('1.0', END), 'BAR\n') + + def test_modify_chain_insert(self): + filter_three = MyFilter() + self.percolator.insertfilter(filter_three) + self.filter_two.insert = self.filter_two.uppercase_insert + self.filter_one.insert = self.filter_one.lowercase_insert + self.text.insert('insert', 'BaR') + self.assertEqual(self.text.get('1.0', END), 'bar\n') + + def test_dont_insert(self): + self.filter_one.insert = self.filter_one.dont_insert + self.text.insert('insert', 'foo bar') + self.assertEqual(self.text.get('1.0', END), '\n') + self.filter_one.insert = self.filter_one.dont_insert + self.text.insert('insert', 'foo bar') + self.assertEqual(self.text.get('1.0', END), '\n') + + def test_without_filter(self): + self.text.insert('insert', 'hello') + self.assertEqual(self.text.get('1.0', 'end'), 'hello\n') + + def test_delete(self): + self.text.insert('insert', 'foo') + self.text.delete('1.0', '1.2') + self.assertEqual(self.text.get('1.0', END), 'o\n') + self.assertTupleEqual(self.filter_one.delete_called_with, + ('1.0', '1.2')) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pyparse.py b/Lib/idlelib/idle_test/test_pyparse.py new file mode 100644 index 00000000000..384db566ac7 --- /dev/null +++ b/Lib/idlelib/idle_test/test_pyparse.py @@ -0,0 +1,483 @@ +"Test pyparse, coverage 96%." + +from idlelib import pyparse +import unittest +from collections import namedtuple + + +class ParseMapTest(unittest.TestCase): + + def test_parsemap(self): + keepwhite = {ord(c): ord(c) for c in ' \t\n\r'} + mapping = pyparse.ParseMap(keepwhite) + self.assertEqual(mapping[ord('\t')], ord('\t')) + self.assertEqual(mapping[ord('a')], ord('x')) + self.assertEqual(mapping[1000], ord('x')) + + def test_trans(self): + # trans is the production instance of ParseMap, used in _study1 + parser = pyparse.Parser(4, 4) + self.assertEqual('\t a([{b}])b"c\'d\n'.translate(pyparse.trans), + 'xxx(((x)))x"x\'x\n') + + +class PyParseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.parser = pyparse.Parser(indentwidth=4, tabwidth=4) + + @classmethod + def tearDownClass(cls): + del cls.parser + + def test_init(self): + self.assertEqual(self.parser.indentwidth, 4) + self.assertEqual(self.parser.tabwidth, 4) + + def test_set_code(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + + # Not empty and doesn't end with newline. + with self.assertRaises(AssertionError): + setcode('a') + + tests = ('', + 'a\n') + + for string in tests: + with self.subTest(string=string): + setcode(string) + eq(p.code, string) + eq(p.study_level, 0) + + def test_find_good_parse_start(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + start = p.find_good_parse_start + def char_in_string_false(index): return False + + # First line starts with 'def' and ends with ':', then 0 is the pos. + setcode('def spam():\n') + eq(start(char_in_string_false), 0) + + # First line begins with a keyword in the list and ends + # with an open brace, then 0 is the pos. This is how + # hyperparser calls this function as the newline is not added + # in the editor, but rather on the call to setcode. + setcode('class spam( ' + ' \n') + eq(start(char_in_string_false), 0) + + # Split def across lines. + setcode('"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a,\n' + ' b=True):\n' + ' pass\n' + ) + pos0, pos = 33, 42 # Start of 'class...', ' def' lines. + + # Passing no value or non-callable should fail (issue 32989). + with self.assertRaises(TypeError): + start() + with self.assertRaises(TypeError): + start(False) + + # Make text look like a string. This returns pos as the start + # position, but it's set to None. + self.assertIsNone(start(is_char_in_string=lambda index: True)) + + # Make all text look like it's not in a string. This means that it + # found a good start position. + eq(start(char_in_string_false), pos) + + # If the beginning of the def line is not in a string, then it + # returns that as the index. + eq(start(is_char_in_string=lambda index: index > pos), pos) + # If the beginning of the def line is in a string, then it + # looks for a previous index. + eq(start(is_char_in_string=lambda index: index >= pos), pos0) + # If everything before the 'def' is in a string, then returns None. + # The non-continuation def line returns 44 (see below). + eq(start(is_char_in_string=lambda index: index < pos), None) + + # Code without extra line break in def line - mostly returns the same + # values. + setcode('"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a, b=True):\n' + ' pass\n' + ) # Does not affect class, def positions. + eq(start(char_in_string_false), pos) + eq(start(is_char_in_string=lambda index: index > pos), pos) + eq(start(is_char_in_string=lambda index: index >= pos), pos0) + # When the def line isn't split, this returns which doesn't match the + # split line test. + eq(start(is_char_in_string=lambda index: index < pos), pos) + + def test_set_lo(self): + code = ( + '"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a,\n' + ' b=True):\n' + ' pass\n' + ) + pos = 42 + p = self.parser + p.set_code(code) + + # Previous character is not a newline. + with self.assertRaises(AssertionError): + p.set_lo(5) + + # A value of 0 doesn't change self.code. + p.set_lo(0) + self.assertEqual(p.code, code) + + # An index that is preceded by a newline. + p.set_lo(pos) + self.assertEqual(p.code, code[pos:]) + + def test_study1(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + study = p._study1 + + (NONE, BACKSLASH, FIRST, NEXT, BRACKET) = range(5) + TestInfo = namedtuple('TestInfo', ['string', 'goodlines', + 'continuation']) + tests = ( + TestInfo('', [0], NONE), + # Docstrings. + TestInfo('"""This is a complete docstring."""\n', [0, 1], NONE), + TestInfo("'''This is a complete docstring.'''\n", [0, 1], NONE), + TestInfo('"""This is a continued docstring.\n', [0, 1], FIRST), + TestInfo("'''This is a continued docstring.\n", [0, 1], FIRST), + TestInfo('"""Closing quote does not match."\n', [0, 1], FIRST), + TestInfo('"""Bracket in docstring [\n', [0, 1], FIRST), + TestInfo("'''Incomplete two line docstring.\n\n", [0, 2], NEXT), + # Single-quoted strings. + TestInfo('"This is a complete string."\n', [0, 1], NONE), + TestInfo('"This is an incomplete string.\n', [0, 1], NONE), + TestInfo("'This is more incomplete.\n\n", [0, 1, 2], NONE), + # Comment (backslash does not continue comments). + TestInfo('# Comment\\\n', [0, 1], NONE), + # Brackets. + TestInfo('("""Complete string in bracket"""\n', [0, 1], BRACKET), + TestInfo('("""Open string in bracket\n', [0, 1], FIRST), + TestInfo('a = (1 + 2) - 5 *\\\n', [0, 1], BACKSLASH), # No bracket. + TestInfo('\n def function1(self, a,\n b):\n', + [0, 1, 3], NONE), + TestInfo('\n def function1(self, a,\\\n', [0, 1, 2], BRACKET), + TestInfo('\n def function1(self, a,\n', [0, 1, 2], BRACKET), + TestInfo('())\n', [0, 1], NONE), # Extra closer. + TestInfo(')(\n', [0, 1], BRACKET), # Extra closer. + # For the mismatched example, it doesn't look like continuation. + TestInfo('{)(]\n', [0, 1], NONE), # Mismatched. + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) # resets study_level + study() + eq(p.study_level, 1) + eq(p.goodlines, test.goodlines) + eq(p.continuation, test.continuation) + + # Called again, just returns without reprocessing. + self.assertIsNone(study()) + + def test_get_continuation_type(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + gettype = p.get_continuation_type + + (NONE, BACKSLASH, FIRST, NEXT, BRACKET) = range(5) + TestInfo = namedtuple('TestInfo', ['string', 'continuation']) + tests = ( + TestInfo('', NONE), + TestInfo('"""This is a continuation docstring.\n', FIRST), + TestInfo("'''This is a multiline-continued docstring.\n\n", NEXT), + TestInfo('a = (1 + 2) - 5 *\\\n', BACKSLASH), + TestInfo('\n def function1(self, a,\\\n', BRACKET) + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(gettype(), test.continuation) + + def test_study2(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + study = p._study2 + + TestInfo = namedtuple('TestInfo', ['string', 'start', 'end', 'lastch', + 'openbracket', 'bracketing']) + tests = ( + TestInfo('', 0, 0, '', None, ((0, 0),)), + TestInfo("'''This is a multiline continuation docstring.\n\n", + 0, 48, "'", None, ((0, 0), (0, 1), (48, 0))), + TestInfo(' # Comment\\\n', + 0, 12, '', None, ((0, 0), (1, 1), (12, 0))), + # A comment without a space is a special case + TestInfo(' #Comment\\\n', + 0, 0, '', None, ((0, 0),)), + # Backslash continuation. + TestInfo('a = (1 + 2) - 5 *\\\n', + 0, 19, '*', None, ((0, 0), (4, 1), (11, 0))), + # Bracket continuation with close. + TestInfo('\n def function1(self, a,\n b):\n', + 1, 48, ':', None, ((1, 0), (17, 1), (46, 0))), + # Bracket continuation with unneeded backslash. + TestInfo('\n def function1(self, a,\\\n', + 1, 28, ',', 17, ((1, 0), (17, 1))), + # Bracket continuation. + TestInfo('\n def function1(self, a,\n', + 1, 27, ',', 17, ((1, 0), (17, 1))), + # Bracket continuation with comment at end of line with text. + TestInfo('\n def function1(self, a, # End of line comment.\n', + 1, 51, ',', 17, ((1, 0), (17, 1), (28, 2), (51, 1))), + # Multi-line statement with comment line in between code lines. + TestInfo(' a = ["first item",\n # Comment line\n "next item",\n', + 0, 55, ',', 6, ((0, 0), (6, 1), (7, 2), (19, 1), + (23, 2), (38, 1), (42, 2), (53, 1))), + TestInfo('())\n', + 0, 4, ')', None, ((0, 0), (0, 1), (2, 0), (3, 0))), + TestInfo(')(\n', 0, 3, '(', 1, ((0, 0), (1, 0), (1, 1))), + # Wrong closers still decrement stack level. + TestInfo('{)(]\n', + 0, 5, ']', None, ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + # Character after backslash. + TestInfo(':\\a\n', 0, 4, '\\a', None, ((0, 0),)), + TestInfo('\n', 0, 0, '', None, ((0, 0),)), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + study() + eq(p.study_level, 2) + eq(p.stmt_start, test.start) + eq(p.stmt_end, test.end) + eq(p.lastch, test.lastch) + eq(p.lastopenbracketpos, test.openbracket) + eq(p.stmt_bracketing, test.bracketing) + + # Called again, just returns without reprocessing. + self.assertIsNone(study()) + + def test_get_num_lines_in_stmt(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + getlines = p.get_num_lines_in_stmt + + TestInfo = namedtuple('TestInfo', ['string', 'lines']) + tests = ( + TestInfo('[x for x in a]\n', 1), # Closed on one line. + TestInfo('[x\nfor x in a\n', 2), # Not closed. + TestInfo('[x\\\nfor x in a\\\n', 2), # "", unneeded backslashes. + TestInfo('[x\nfor x in a\n]\n', 3), # Closed on multi-line. + TestInfo('\n"""Docstring comment L1"""\nL2\nL3\nL4\n', 1), + TestInfo('\n"""Docstring comment L1\nL2"""\nL3\nL4\n', 1), + TestInfo('\n"""Docstring comment L1\\\nL2\\\nL3\\\nL4\\\n', 4), + TestInfo('\n\n"""Docstring comment L1\\\nL2\\\nL3\\\nL4\\\n"""\n', 5) + ) + + # Blank string doesn't have enough elements in goodlines. + setcode('') + with self.assertRaises(IndexError): + getlines() + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(getlines(), test.lines) + + def test_compute_bracket_indent(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + indent = p.compute_bracket_indent + + TestInfo = namedtuple('TestInfo', ['string', 'spaces']) + tests = ( + TestInfo('def function1(self, a,\n', 14), + # Characters after bracket. + TestInfo('\n def function1(self, a,\n', 18), + TestInfo('\n\tdef function1(self, a,\n', 18), + # No characters after bracket. + TestInfo('\n def function1(\n', 8), + TestInfo('\n\tdef function1(\n', 8), + TestInfo('\n def function1( \n', 8), # Ignore extra spaces. + TestInfo('[\n"first item",\n # Comment line\n "next item",\n', 0), + TestInfo('[\n "first item",\n # Comment line\n "next item",\n', 2), + TestInfo('["first item",\n # Comment line\n "next item",\n', 1), + TestInfo('(\n', 4), + TestInfo('(a\n', 1), + ) + + # Must be C_BRACKET continuation type. + setcode('def function1(self, a, b):\n') + with self.assertRaises(AssertionError): + indent() + + for test in tests: + setcode(test.string) + eq(indent(), test.spaces) + + def test_compute_backslash_indent(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + indent = p.compute_backslash_indent + + # Must be C_BACKSLASH continuation type. + errors = (('def function1(self, a, b\\\n'), # Bracket. + (' """ (\\\n'), # Docstring. + ('a = #\\\n'), # Inline comment. + ) + for string in errors: + with self.subTest(string=string): + setcode(string) + with self.assertRaises(AssertionError): + indent() + + TestInfo = namedtuple('TestInfo', ('string', 'spaces')) + tests = (TestInfo('a = (1 + 2) - 5 *\\\n', 4), + TestInfo('a = 1 + 2 - 5 *\\\n', 4), + TestInfo(' a = 1 + 2 - 5 *\\\n', 8), + TestInfo(' a = "spam"\\\n', 6), + TestInfo(' a = \\\n"a"\\\n', 4), + TestInfo(' a = #\\\n"a"\\\n', 5), + TestInfo('a == \\\n', 2), + TestInfo('a != \\\n', 2), + # Difference between containing = and those not. + TestInfo('\\\n', 2), + TestInfo(' \\\n', 6), + TestInfo('\t\\\n', 6), + TestInfo('a\\\n', 3), + TestInfo('{}\\\n', 4), + TestInfo('(1 + 2) - 5 *\\\n', 3), + ) + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(indent(), test.spaces) + + def test_get_base_indent_string(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + baseindent = p.get_base_indent_string + + TestInfo = namedtuple('TestInfo', ['string', 'indent']) + tests = (TestInfo('', ''), + TestInfo('def a():\n', ''), + TestInfo('\tdef a():\n', '\t'), + TestInfo(' def a():\n', ' '), + TestInfo(' def a(\n', ' '), + TestInfo('\t\n def a(\n', ' '), + TestInfo('\t\n # Comment.\n', ' '), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(baseindent(), test.indent) + + def test_is_block_opener(self): + yes = self.assertTrue + no = self.assertFalse + p = self.parser + setcode = p.set_code + opener = p.is_block_opener + + TestInfo = namedtuple('TestInfo', ['string', 'assert_']) + tests = ( + TestInfo('def a():\n', yes), + TestInfo('\n def function1(self, a,\n b):\n', yes), + TestInfo(':\n', yes), + TestInfo('a:\n', yes), + TestInfo('):\n', yes), + TestInfo('(:\n', yes), + TestInfo('":\n', no), + TestInfo('\n def function1(self, a,\n', no), + TestInfo('def function1(self, a):\n pass\n', no), + TestInfo('# A comment:\n', no), + TestInfo('"""A docstring:\n', no), + TestInfo('"""A docstring:\n', no), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + test.assert_(opener()) + + def test_is_block_closer(self): + yes = self.assertTrue + no = self.assertFalse + p = self.parser + setcode = p.set_code + closer = p.is_block_closer + + TestInfo = namedtuple('TestInfo', ['string', 'assert_']) + tests = ( + TestInfo('return\n', yes), + TestInfo('\tbreak\n', yes), + TestInfo(' continue\n', yes), + TestInfo(' raise\n', yes), + TestInfo('pass \n', yes), + TestInfo('pass\t\n', yes), + TestInfo('return #\n', yes), + TestInfo('raised\n', no), + TestInfo('returning\n', no), + TestInfo('# return\n', no), + TestInfo('"""break\n', no), + TestInfo('"continue\n', no), + TestInfo('def function1(self, a):\n pass\n', yes), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + test.assert_(closer()) + + def test_get_last_stmt_bracketing(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + bracketing = p.get_last_stmt_bracketing + + TestInfo = namedtuple('TestInfo', ['string', 'bracket']) + tests = ( + TestInfo('', ((0, 0),)), + TestInfo('a\n', ((0, 0),)), + TestInfo('()()\n', ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + TestInfo('(\n)()\n', ((0, 0), (0, 1), (3, 0), (3, 1), (5, 0))), + TestInfo('()\n()\n', ((3, 0), (3, 1), (5, 0))), + TestInfo('()(\n)\n', ((0, 0), (0, 1), (2, 0), (2, 1), (5, 0))), + TestInfo('(())\n', ((0, 0), (0, 1), (1, 2), (3, 1), (4, 0))), + TestInfo('(\n())\n', ((0, 0), (0, 1), (2, 2), (4, 1), (5, 0))), + # Same as matched test. + TestInfo('{)(]\n', ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + TestInfo('(((())\n', + ((0, 0), (0, 1), (1, 2), (2, 3), (3, 4), (5, 3), (6, 2))), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(bracketing(), test.bracket) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pyshell.py b/Lib/idlelib/idle_test/test_pyshell.py new file mode 100644 index 00000000000..706703965bf --- /dev/null +++ b/Lib/idlelib/idle_test/test_pyshell.py @@ -0,0 +1,148 @@ +"Test pyshell, coverage 12%." +# Plus coverage of test_warning. Was 20% with test_openshell. + +from idlelib import pyshell +import unittest +from test.support import requires +from tkinter import Tk + + +class FunctionTest(unittest.TestCase): + # Test stand-alone module level non-gui functions. + + def test_restart_line_wide(self): + eq = self.assertEqual + for file, mul, extra in (('', 22, ''), ('finame', 21, '=')): + width = 60 + bar = mul * '=' + with self.subTest(file=file, bar=bar): + file = file or 'Shell' + line = pyshell.restart_line(width, file) + eq(len(line), width) + eq(line, f"{bar+extra} RESTART: {file} {bar}") + + def test_restart_line_narrow(self): + expect, taglen = "= RESTART: Shell", 16 + for width in (taglen-1, taglen, taglen+1): + with self.subTest(width=width): + self.assertEqual(pyshell.restart_line(width, ''), expect) + self.assertEqual(pyshell.restart_line(taglen+2, ''), expect+' =') + + +class PyShellFileListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + #cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + psfl = pyshell.PyShellFileList(self.root) + self.assertEqual(psfl.EditorWindow, pyshell.PyShellEditorWindow) + self.assertIsNone(psfl.pyshell) + +# The following sometimes causes 'invalid command name "109734456recolorize"'. +# Uncommenting after_cancel above prevents this, but results in +# TclError: bad window path name ".!listedtoplevel.!frame.text" +# which is normally prevented by after_cancel. +## def test_openshell(self): +## pyshell.use_subprocess = False +## ps = pyshell.PyShellFileList(self.root).open_shell() +## self.assertIsInstance(ps, pyshell.PyShell) + + +class PyShellRemoveLastNewlineAndSurroundingWhitespaceTest(unittest.TestCase): + regexp = pyshell.PyShell._last_newline_re + + def all_removed(self, text): + self.assertEqual('', self.regexp.sub('', text)) + + def none_removed(self, text): + self.assertEqual(text, self.regexp.sub('', text)) + + def check_result(self, text, expected): + self.assertEqual(expected, self.regexp.sub('', text)) + + def test_empty(self): + self.all_removed('') + + def test_newline(self): + self.all_removed('\n') + + def test_whitespace_no_newline(self): + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ' * 20) + self.all_removed('\t') + self.all_removed('\t\t') + self.all_removed('\t\t\t') + self.all_removed('\t' * 20) + self.all_removed('\t ') + self.all_removed(' \t') + self.all_removed(' \t \t ') + self.all_removed('\t \t \t') + + def test_newline_with_whitespace(self): + self.all_removed(' \n') + self.all_removed('\t\n') + self.all_removed(' \t\n') + self.all_removed('\t \n') + self.all_removed('\n ') + self.all_removed('\n\t') + self.all_removed('\n \t') + self.all_removed('\n\t ') + self.all_removed(' \n ') + self.all_removed('\t\n ') + self.all_removed(' \n\t') + self.all_removed('\t\n\t') + self.all_removed('\t \t \t\n') + self.all_removed(' \t \t \n') + self.all_removed('\n\t \t \t') + self.all_removed('\n \t \t ') + + def test_multiple_newlines(self): + self.check_result('\n\n', '\n') + self.check_result('\n' * 5, '\n' * 4) + self.check_result('\n' * 5 + '\t', '\n' * 4) + self.check_result('\n' * 20, '\n' * 19) + self.check_result('\n' * 20 + ' ', '\n' * 19) + self.check_result(' \n \n ', ' \n') + self.check_result(' \n\n ', ' \n') + self.check_result(' \n\n', ' \n') + self.check_result('\t\n\n', '\t\n') + self.check_result('\n\n ', '\n') + self.check_result('\n\n\t', '\n') + self.check_result(' \n \n ', ' \n') + self.check_result('\t\n\t\n\t', '\t\n') + + def test_non_whitespace(self): + self.none_removed('a') + self.check_result('a\n', 'a') + self.check_result('a\n ', 'a') + self.check_result('a \n ', 'a') + self.check_result('a \n\t', 'a') + self.none_removed('-') + self.check_result('-\n', '-') + self.none_removed('.') + self.check_result('.\n', '.') + + def test_unsupported_whitespace(self): + self.none_removed('\v') + self.none_removed('\n\v') + self.check_result('\v\n', '\v') + self.none_removed(' \n\v') + self.check_result('\v\n ', '\v') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_query.py b/Lib/idlelib/idle_test/test_query.py new file mode 100644 index 00000000000..a6ef858a8c9 --- /dev/null +++ b/Lib/idlelib/idle_test/test_query.py @@ -0,0 +1,451 @@ +"""Test query, coverage 93%. + +Non-gui tests for Query, SectionName, ModuleName, and HelpSource use +dummy versions that extract the non-gui methods and add other needed +attributes. GUI tests create an instance of each class and simulate +entries and button clicks. Subclass tests only target the new code in +the subclass definition. + +The appearance of the widgets is checked by the Query and +HelpSource htests. These are run by running query.py. +""" +from idlelib import query +import unittest +from test.support import requires +from tkinter import Tk, END + +import sys +from unittest import mock +from idlelib.idle_test.mock_tk import Var + + +# NON-GUI TESTS + +class QueryTest(unittest.TestCase): + "Test Query base class." + + class Dummy_Query: + # Test the following Query methods. + entry_ok = query.Query.entry_ok + ok = query.Query.ok + cancel = query.Query.cancel + # Add attributes and initialization needed for tests. + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + self.result = None + self.destroyed = False + def showerror(self, message): + self.entry_error['text'] = message + def destroy(self): + self.destroyed = True + + def test_entry_ok_blank(self): + dialog = self.Dummy_Query(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertEqual((dialog.result, dialog.destroyed), (None, False)) + self.assertIn('blank line', dialog.entry_error['text']) + + def test_entry_ok_good(self): + dialog = self.Dummy_Query(' good ') + Equal = self.assertEqual + Equal(dialog.entry_ok(), 'good') + Equal((dialog.result, dialog.destroyed), (None, False)) + Equal(dialog.entry_error['text'], '') + + def test_ok_blank(self): + dialog = self.Dummy_Query('') + dialog.entry.focus_set = mock.Mock() + self.assertEqual(dialog.ok(), None) + self.assertTrue(dialog.entry.focus_set.called) + del dialog.entry.focus_set + self.assertEqual((dialog.result, dialog.destroyed), (None, False)) + + def test_ok_good(self): + dialog = self.Dummy_Query('good') + self.assertEqual(dialog.ok(), None) + self.assertEqual((dialog.result, dialog.destroyed), ('good', True)) + + def test_cancel(self): + dialog = self.Dummy_Query('does not matter') + self.assertEqual(dialog.cancel(), None) + self.assertEqual((dialog.result, dialog.destroyed), (None, True)) + + +class SectionNameTest(unittest.TestCase): + "Test SectionName subclass of Query." + + class Dummy_SectionName: + entry_ok = query.SectionName.entry_ok # Function being tested. + used_names = ['used'] + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_section_name(self): + dialog = self.Dummy_SectionName(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('no name', dialog.entry_error['text']) + + def test_used_section_name(self): + dialog = self.Dummy_SectionName('used') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('use', dialog.entry_error['text']) + + def test_long_section_name(self): + dialog = self.Dummy_SectionName('good'*8) + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('longer than 30', dialog.entry_error['text']) + + def test_good_section_name(self): + dialog = self.Dummy_SectionName(' good ') + self.assertEqual(dialog.entry_ok(), 'good') + self.assertEqual(dialog.entry_error['text'], '') + + +class ModuleNameTest(unittest.TestCase): + "Test ModuleName subclass of Query." + + class Dummy_ModuleName: + entry_ok = query.ModuleName.entry_ok # Function being tested. + text0 = '' + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_module_name(self): + dialog = self.Dummy_ModuleName(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('no name', dialog.entry_error['text']) + + def test_bogus_module_name(self): + dialog = self.Dummy_ModuleName('__name_xyz123_should_not_exist__') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not found', dialog.entry_error['text']) + + def test_c_source_name(self): + dialog = self.Dummy_ModuleName('itertools') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('source-based', dialog.entry_error['text']) + + def test_good_module_name(self): + dialog = self.Dummy_ModuleName('idlelib') + self.assertEndsWith(dialog.entry_ok(), '__init__.py') + self.assertEqual(dialog.entry_error['text'], '') + dialog = self.Dummy_ModuleName('idlelib.idle') + self.assertEndsWith(dialog.entry_ok(), 'idle.py') + self.assertEqual(dialog.entry_error['text'], '') + + +class GotoTest(unittest.TestCase): + "Test Goto subclass of Query." + + class Dummy_ModuleName: + entry_ok = query.Goto.entry_ok # Function being tested. + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_bogus_goto(self): + dialog = self.Dummy_ModuleName('a') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not a base 10 integer', dialog.entry_error['text']) + + def test_bad_goto(self): + dialog = self.Dummy_ModuleName('0') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not a positive integer', dialog.entry_error['text']) + + def test_good_goto(self): + dialog = self.Dummy_ModuleName('1') + self.assertEqual(dialog.entry_ok(), 1) + self.assertEqual(dialog.entry_error['text'], '') + + +# 3 HelpSource test classes each test one method. + +class HelpsourceBrowsefileTest(unittest.TestCase): + "Test browse_file method of ModuleName subclass of Query." + + class Dummy_HelpSource: + browse_file = query.HelpSource.browse_file + pathvar = Var() + + def test_file_replaces_path(self): + dialog = self.Dummy_HelpSource() + # Path is widget entry, either '' or something. + # Func return is file dialog return, either '' or something. + # Func return should override widget entry. + # We need all 4 combinations to test all (most) code paths. + for path, func, result in ( + ('', lambda a,b,c:'', ''), + ('', lambda a,b,c: __file__, __file__), + ('htest', lambda a,b,c:'', 'htest'), + ('htest', lambda a,b,c: __file__, __file__)): + with self.subTest(): + dialog.pathvar.set(path) + dialog.askfilename = func + dialog.browse_file() + self.assertEqual(dialog.pathvar.get(), result) + + +class HelpsourcePathokTest(unittest.TestCase): + "Test path_ok method of HelpSource subclass of Query." + + class Dummy_HelpSource: + path_ok = query.HelpSource.path_ok + def __init__(self, dummy_path): + self.path = Var(value=dummy_path) + self.path_error = {'text': ''} + def showerror(self, message, widget=None): + self.path_error['text'] = message + + orig_platform = query.platform # Set in test_path_ok_file. + @classmethod + def tearDownClass(cls): + query.platform = cls.orig_platform + + def test_path_ok_blank(self): + dialog = self.Dummy_HelpSource(' ') + self.assertEqual(dialog.path_ok(), None) + self.assertIn('no help file', dialog.path_error['text']) + + def test_path_ok_bad(self): + dialog = self.Dummy_HelpSource(__file__ + 'bad-bad-bad') + self.assertEqual(dialog.path_ok(), None) + self.assertIn('not exist', dialog.path_error['text']) + + def test_path_ok_web(self): + dialog = self.Dummy_HelpSource('') + Equal = self.assertEqual + for url in 'www.py.org', 'http://py.org': + with self.subTest(): + dialog.path.set(url) + self.assertEqual(dialog.path_ok(), url) + self.assertEqual(dialog.path_error['text'], '') + + def test_path_ok_file(self): + dialog = self.Dummy_HelpSource('') + for platform, prefix in ('darwin', 'file://'), ('other', ''): + with self.subTest(): + query.platform = platform + dialog.path.set(__file__) + self.assertEqual(dialog.path_ok(), prefix + __file__) + self.assertEqual(dialog.path_error['text'], '') + + +class HelpsourceEntryokTest(unittest.TestCase): + "Test entry_ok method of HelpSource subclass of Query." + + class Dummy_HelpSource: + entry_ok = query.HelpSource.entry_ok + entry_error = {} + path_error = {} + def item_ok(self): + return self.name + def path_ok(self): + return self.path + + def test_entry_ok_helpsource(self): + dialog = self.Dummy_HelpSource() + for name, path, result in ((None, None, None), + (None, 'doc.txt', None), + ('doc', None, None), + ('doc', 'doc.txt', ('doc', 'doc.txt'))): + with self.subTest(): + dialog.name, dialog.path = name, path + self.assertEqual(dialog.entry_ok(), result) + + +# 2 CustomRun test classes each test one method. + +class CustomRunCLIargsokTest(unittest.TestCase): + "Test cli_ok method of the CustomRun subclass of Query." + + class Dummy_CustomRun: + cli_args_ok = query.CustomRun.cli_args_ok + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_args(self): + dialog = self.Dummy_CustomRun(' ') + self.assertEqual(dialog.cli_args_ok(), []) + + def test_invalid_args(self): + dialog = self.Dummy_CustomRun("'no-closing-quote") + self.assertEqual(dialog.cli_args_ok(), None) + self.assertIn('No closing', dialog.entry_error['text']) + + def test_good_args(self): + args = ['-n', '10', '--verbose', '-p', '/path', '--name'] + dialog = self.Dummy_CustomRun(' '.join(args) + ' "my name"') + self.assertEqual(dialog.cli_args_ok(), args + ["my name"]) + self.assertEqual(dialog.entry_error['text'], '') + + +class CustomRunEntryokTest(unittest.TestCase): + "Test entry_ok method of the CustomRun subclass of Query." + + class Dummy_CustomRun: + entry_ok = query.CustomRun.entry_ok + entry_error = {} + restartvar = Var() + def cli_args_ok(self): + return self.cli_args + + def test_entry_ok_customrun(self): + dialog = self.Dummy_CustomRun() + for restart in {True, False}: + dialog.restartvar.set(restart) + for cli_args, result in ((None, None), + (['my arg'], (['my arg'], restart))): + with self.subTest(restart=restart, cli_args=cli_args): + dialog.cli_args = cli_args + self.assertEqual(dialog.entry_ok(), result) + + +# GUI TESTS + +class QueryGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = root = Tk() + cls.root.withdraw() + cls.dialog = query.Query(root, 'TEST', 'test', _utest=True) + cls.dialog.destroy = mock.Mock() + + @classmethod + def tearDownClass(cls): + del cls.dialog.destroy + del cls.dialog + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.entry.delete(0, 'end') + self.dialog.result = None + self.dialog.destroy.reset_mock() + + def test_click_ok(self): + dialog = self.dialog + dialog.entry.insert(0, 'abc') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 'abc') + self.assertTrue(dialog.destroy.called) + + def test_click_blank(self): + dialog = self.dialog + dialog.button_ok.invoke() + self.assertEqual(dialog.result, None) + self.assertFalse(dialog.destroy.called) + + def test_click_cancel(self): + dialog = self.dialog + dialog.entry.insert(0, 'abc') + dialog.button_cancel.invoke() + self.assertEqual(dialog.result, None) + self.assertTrue(dialog.destroy.called) + + +class SectionnameGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_section_name(self): + root = Tk() + root.withdraw() + dialog = query.SectionName(root, 'T', 't', {'abc'}, _utest=True) + Equal = self.assertEqual + self.assertEqual(dialog.used_names, {'abc'}) + dialog.entry.insert(0, 'okay') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 'okay') + root.destroy() + + +class ModulenameGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_module_name(self): + root = Tk() + root.withdraw() + dialog = query.ModuleName(root, 'T', 't', 'idlelib', _utest=True) + self.assertEqual(dialog.text0, 'idlelib') + self.assertEqual(dialog.entry.get(), 'idlelib') + dialog.button_ok.invoke() + self.assertEndsWith(dialog.result, '__init__.py') + root.destroy() + + +class GotoGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_module_name(self): + root = Tk() + root.withdraw() + dialog = query.Goto(root, 'T', 't', _utest=True) + dialog.entry.insert(0, '22') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 22) + root.destroy() + + +class HelpsourceGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_help_source(self): + root = Tk() + root.withdraw() + dialog = query.HelpSource(root, 'T', menuitem='__test__', + filepath=__file__, _utest=True) + Equal = self.assertEqual + Equal(dialog.entry.get(), '__test__') + Equal(dialog.path.get(), __file__) + dialog.button_ok.invoke() + prefix = "file://" if sys.platform == 'darwin' else '' + Equal(dialog.result, ('__test__', prefix + __file__)) + root.destroy() + + +class CustomRunGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_args(self): + root = Tk() + root.withdraw() + dialog = query.CustomRun(root, 'Title', + cli_args=['a', 'b=1'], _utest=True) + self.assertEqual(dialog.entry.get(), 'a b=1') + dialog.entry.insert(END, ' c') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, (['a', 'b=1', 'c'], True)) + root.destroy() + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_redirector.py b/Lib/idlelib/idle_test/test_redirector.py new file mode 100644 index 00000000000..bd486d7da66 --- /dev/null +++ b/Lib/idlelib/idle_test/test_redirector.py @@ -0,0 +1,122 @@ +"Test redirector, coverage 100%." + +from idlelib.redirector import WidgetRedirector +import unittest +from test.support import requires +from tkinter import Tk, Text, TclError +from idlelib.idle_test.mock_idle import Func + + +class InitCloseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.destroy() + del cls.root + + def test_init(self): + redir = WidgetRedirector(self.text) + self.assertEqual(redir.widget, self.text) + self.assertEqual(redir.tk, self.text.tk) + self.assertRaises(TclError, WidgetRedirector, self.text) + redir.close() # restore self.tk, self.text + + def test_close(self): + redir = WidgetRedirector(self.text) + redir.register('insert', Func) + redir.close() + self.assertEqual(redir._operations, {}) + self.assertNotHasAttr(self.text, 'widget') + + +class WidgetRedirectorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.redir = WidgetRedirector(self.text) + self.func = Func() + self.orig_insert = self.redir.register('insert', self.func) + self.text.insert('insert', 'asdf') # leaves self.text empty + + def tearDown(self): + self.text.delete('1.0', 'end') + self.redir.close() + + def test_repr(self): # partly for 100% coverage + self.assertIn('Redirector', repr(self.redir)) + self.assertIn('Original', repr(self.orig_insert)) + + def test_register(self): + self.assertEqual(self.text.get('1.0', 'end'), '\n') + self.assertEqual(self.func.args, ('insert', 'asdf')) + self.assertIn('insert', self.redir._operations) + self.assertIn('insert', self.text.__dict__) + self.assertEqual(self.text.insert, self.func) + + def test_original_command(self): + self.assertEqual(self.orig_insert.operation, 'insert') + self.assertEqual(self.orig_insert.tk_call, self.text.tk.call) + self.orig_insert('insert', 'asdf') + self.assertEqual(self.text.get('1.0', 'end'), 'asdf\n') + + def test_unregister(self): + self.assertIsNone(self.redir.unregister('invalid operation name')) + self.assertEqual(self.redir.unregister('insert'), self.func) + self.assertNotIn('insert', self.redir._operations) + self.assertNotIn('insert', self.text.__dict__) + + def test_unregister_no_attribute(self): + del self.text.insert + self.assertEqual(self.redir.unregister('insert'), self.func) + + def test_dispatch_intercept(self): + self.func.__init__(True) + self.assertTrue(self.redir.dispatch('insert', False)) + self.assertFalse(self.func.args[0]) + + def test_dispatch_bypass(self): + self.orig_insert('insert', 'asdf') + # tk.call returns '' where Python would return None + self.assertEqual(self.redir.dispatch('delete', '1.0', 'end'), '') + self.assertEqual(self.text.get('1.0', 'end'), '\n') + + def test_dispatch_error(self): + self.func.__init__(TclError()) + self.assertEqual(self.redir.dispatch('insert', False), '') + self.assertEqual(self.redir.dispatch('invalid'), '') + + def test_command_dispatch(self): + # Test that .__init__ causes redirection of tk calls + # through redir.dispatch + self.root.call(self.text._w, 'insert', 'hello') + self.assertEqual(self.func.args, ('hello',)) + self.assertEqual(self.text.get('1.0', 'end'), '\n') + # Ensure that called through redir .dispatch and not through + # self.text.insert by having mock raise TclError. + self.func.__init__(TclError()) + self.assertEqual(self.root.call(self.text._w, 'insert', 'boo'), '') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_replace.py b/Lib/idlelib/idle_test/test_replace.py new file mode 100644 index 00000000000..6c07389b29a --- /dev/null +++ b/Lib/idlelib/idle_test/test_replace.py @@ -0,0 +1,294 @@ +"Test replace, coverage 78%." + +from idlelib.replace import ReplaceDialog +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, Text + +from unittest.mock import Mock +from idlelib.idle_test.mock_tk import Mbox +import idlelib.searchengine as se + +orig_mbox = se.messagebox +showerror = Mbox.showerror + + +class ReplaceDialogTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + se.messagebox = Mbox + cls.engine = se.SearchEngine(cls.root) + cls.dialog = ReplaceDialog(cls.root, cls.engine) + cls.dialog.bell = lambda: None + cls.dialog.ok = Mock() + cls.text = Text(cls.root) + cls.text.undo_block_start = Mock() + cls.text.undo_block_stop = Mock() + cls.dialog.text = cls.text + + @classmethod + def tearDownClass(cls): + se.messagebox = orig_mbox + del cls.text, cls.dialog, cls.engine + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('insert', 'This is a sample sTring') + + def tearDown(self): + self.engine.patvar.set('') + self.dialog.replvar.set('') + self.engine.wordvar.set(False) + self.engine.casevar.set(False) + self.engine.revar.set(False) + self.engine.wrapvar.set(True) + self.engine.backvar.set(False) + showerror.title = '' + showerror.message = '' + self.text.delete('1.0', 'end') + + def test_replace_simple(self): + # Test replace function with all options at default setting. + # Wrap around - True + # Regular Expression - False + # Match case - False + # Match word - False + # Direction - Forwards + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + + # test accessor method + self.engine.setpat('asdf') + equal(self.engine.getpat(), pv.get()) + + # text found and replaced + pv.set('a') + rv.set('asdf') + replace() + equal(text.get('1.8', '1.12'), 'asdf') + + # don't "match word" case + text.mark_set('insert', '1.0') + pv.set('is') + rv.set('hello') + replace() + equal(text.get('1.2', '1.7'), 'hello') + + # don't "match case" case + pv.set('string') + rv.set('world') + replace() + equal(text.get('1.23', '1.28'), 'world') + + # without "regular expression" case + text.mark_set('insert', 'end') + text.insert('insert', '\nline42:') + before_text = text.get('1.0', 'end') + pv.set(r'[a-z][\d]+') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # test with wrap around selected and complete a cycle + text.mark_set('insert', '1.9') + pv.set('i') + rv.set('j') + replace() + equal(text.get('1.8'), 'i') + equal(text.get('2.1'), 'j') + replace() + equal(text.get('2.1'), 'j') + equal(text.get('1.8'), 'j') + before_text = text.get('1.0', 'end') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # text not found + before_text = text.get('1.0', 'end') + pv.set('foobar') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # test access method + self.dialog.find_it(0) + + def test_replace_wrap_around(self): + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.wrapvar.set(False) + + # replace candidate found both after and before 'insert' + text.mark_set('insert', '1.4') + pv.set('i') + rv.set('j') + replace() + equal(text.get('1.2'), 'i') + equal(text.get('1.5'), 'j') + replace() + equal(text.get('1.2'), 'i') + equal(text.get('1.20'), 'j') + replace() + equal(text.get('1.2'), 'i') + + # replace candidate found only before 'insert' + text.mark_set('insert', '1.8') + pv.set('is') + before_text = text.get('1.0', 'end') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + def test_replace_whole_word(self): + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.wordvar.set(True) + + pv.set('is') + rv.set('hello') + replace() + equal(text.get('1.0', '1.4'), 'This') + equal(text.get('1.5', '1.10'), 'hello') + + def test_replace_match_case(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.casevar.set(True) + + before_text = self.text.get('1.0', 'end') + pv.set('this') + rv.set('that') + replace() + after_text = self.text.get('1.0', 'end') + equal(before_text, after_text) + + pv.set('This') + replace() + equal(text.get('1.0', '1.4'), 'that') + + def test_replace_regex(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.revar.set(True) + + before_text = text.get('1.0', 'end') + pv.set(r'[a-z][\d]+') + rv.set('hello') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + text.insert('insert', '\nline42') + replace() + equal(text.get('2.0', '2.8'), 'linhello') + + pv.set('') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Empty', showerror.message) + + pv.set(r'[\d') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Pattern', showerror.message) + + showerror.title = '' + showerror.message = '' + pv.set('[a]') + rv.set('test\\') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Invalid Replace Expression', showerror.message) + + # test access method + self.engine.setcookedpat("?") + equal(pv.get(), "\\?") + + def test_replace_backwards(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.backvar.set(True) + + text.insert('insert', '\nis as ') + + pv.set('is') + rv.set('was') + replace() + equal(text.get('1.2', '1.4'), 'is') + equal(text.get('2.0', '2.3'), 'was') + replace() + equal(text.get('1.5', '1.8'), 'was') + replace() + equal(text.get('1.2', '1.5'), 'was') + + def test_replace_all(self): + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace_all = self.dialog.replace_all + + text.insert('insert', '\n') + text.insert('insert', text.get('1.0', 'end')*100) + pv.set('is') + rv.set('was') + replace_all() + self.assertNotIn('is', text.get('1.0', 'end')) + + self.engine.revar.set(True) + pv.set('') + replace_all() + self.assertIn('error', showerror.title) + self.assertIn('Empty', showerror.message) + + pv.set('[s][T]') + rv.set('\\') + replace_all() + + self.engine.revar.set(False) + pv.set('text which is not present') + rv.set('foobar') + replace_all() + + def test_default_command(self): + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace_find = self.dialog.default_command + equal = self.assertEqual + + pv.set('This') + rv.set('was') + replace_find() + equal(text.get('sel.first', 'sel.last'), 'was') + + self.engine.revar.set(True) + pv.set('') + replace_find() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_rpc.py b/Lib/idlelib/idle_test/test_rpc.py new file mode 100644 index 00000000000..81eff398c72 --- /dev/null +++ b/Lib/idlelib/idle_test/test_rpc.py @@ -0,0 +1,29 @@ +"Test rpc, coverage 20%." + +from idlelib import rpc +import unittest + + + +class CodePicklerTest(unittest.TestCase): + + def test_pickle_unpickle(self): + def f(): return a + b + c + func, (cbytes,) = rpc.pickle_code(f.__code__) + self.assertIs(func, rpc.unpickle_code) + self.assertIn(b'test_rpc.py', cbytes) + code = rpc.unpickle_code(cbytes) + self.assertEqual(code.co_names, ('a', 'b', 'c')) + + def test_code_pickler(self): + self.assertIn(type((lambda:None).__code__), + rpc.CodePickler.dispatch_table) + + def test_dumps(self): + def f(): pass + # The main test here is that pickling code does not raise. + self.assertIn(b'test_rpc.py', rpc.dumps(f.__code__)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_run.py b/Lib/idlelib/idle_test/test_run.py new file mode 100644 index 00000000000..83ecbffa2a1 --- /dev/null +++ b/Lib/idlelib/idle_test/test_run.py @@ -0,0 +1,433 @@ +"Test run, coverage 54%." + +from idlelib import run +import io +import sys +from test.support import captured_output, captured_stderr +import unittest +from unittest import mock +import idlelib +from idlelib.idle_test.mock_idle import Func +from test.support import force_not_colorized + +idlelib.testing = True # Use {} for executing test user code. + + +class ExceptionTest(unittest.TestCase): + + def test_print_exception_unhashable(self): + class UnhashableException(Exception): + def __eq__(self, other): + return True + + ex1 = UnhashableException('ex1') + ex2 = UnhashableException('ex2') + try: + raise ex2 from ex1 + except UnhashableException: + try: + raise ex1 + except UnhashableException: + with captured_stderr() as output: + with mock.patch.object(run, 'cleanup_traceback') as ct: + ct.side_effect = lambda t, e: t + run.print_exception() + + tb = output.getvalue().strip().splitlines() + self.assertEqual(11, len(tb)) + self.assertIn('UnhashableException: ex2', tb[3]) + self.assertIn('UnhashableException: ex1', tb[10]) + + data = (('1/0', ZeroDivisionError, "division by zero\n"), + ('abc', NameError, "name 'abc' is not defined. " + "Did you mean: 'abs'? " + "Or did you forget to import 'abc'?\n"), + ('int.reel', AttributeError, + "type object 'int' has no attribute 'reel'. " + "Did you mean: 'real'?\n"), + ) + + @force_not_colorized + def test_get_message(self): + for code, exc, msg in self.data: + with self.subTest(code=code): + try: + eval(compile(code, '', 'eval')) + except exc: + typ, val, tb = sys.exc_info() + actual = run.get_message_lines(typ, val, tb)[0] + expect = f'{exc.__name__}: {msg}' + self.assertEqual(actual, expect) + + @force_not_colorized + @mock.patch.object(run, 'cleanup_traceback', + new_callable=lambda: (lambda t, e: None)) + def test_get_multiple_message(self, mock): + d = self.data + data2 = ((d[0], d[1]), (d[1], d[2]), (d[2], d[0])) + subtests = 0 + for (code1, exc1, msg1), (code2, exc2, msg2) in data2: + with self.subTest(codes=(code1,code2)): + try: + eval(compile(code1, '', 'eval')) + except exc1: + try: + eval(compile(code2, '', 'eval')) + except exc2: + with captured_stderr() as output: + run.print_exception() + actual = output.getvalue() + self.assertIn(msg1, actual) + self.assertIn(msg2, actual) + subtests += 1 + self.assertEqual(subtests, len(data2)) # All subtests ran? + +# StdioFile tests. + +class S(str): + def __str__(self): + return '%s:str' % type(self).__name__ + def __unicode__(self): + return '%s:unicode' % type(self).__name__ + def __len__(self): + return 3 + def __iter__(self): + return iter('abc') + def __getitem__(self, *args): + return '%s:item' % type(self).__name__ + def __getslice__(self, *args): + return '%s:slice' % type(self).__name__ + + +class MockShell: + def __init__(self): + self.reset() + def write(self, *args): + self.written.append(args) + def readline(self): + return self.lines.pop() + def close(self): + pass + def reset(self): + self.written = [] + def push(self, lines): + self.lines = list(lines)[::-1] + + +class StdInputFilesTest(unittest.TestCase): + + def test_misc(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + self.assertIsInstance(f, io.TextIOBase) + self.assertEqual(f.encoding, 'utf-8') + self.assertEqual(f.errors, 'strict') + self.assertIsNone(f.newlines) + self.assertEqual(f.name, '') + self.assertFalse(f.closed) + self.assertTrue(f.isatty()) + self.assertTrue(f.readable()) + self.assertFalse(f.writable()) + self.assertFalse(f.seekable()) + + def test_unsupported(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + self.assertRaises(OSError, f.fileno) + self.assertRaises(OSError, f.tell) + self.assertRaises(OSError, f.seek, 0) + self.assertRaises(OSError, f.write, 'x') + self.assertRaises(OSError, f.writelines, ['x']) + + def test_read(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(), 'one\ntwo\n') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(-1), 'one\ntwo\n') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(None), 'one\ntwo\n') + shell.push(['one\n', 'two\n', 'three\n', '']) + self.assertEqual(f.read(2), 'on') + self.assertEqual(f.read(3), 'e\nt') + self.assertEqual(f.read(10), 'wo\nthree\n') + + shell.push(['one\n', 'two\n']) + self.assertEqual(f.read(0), '') + self.assertRaises(TypeError, f.read, 1.5) + self.assertRaises(TypeError, f.read, '1') + self.assertRaises(TypeError, f.read, 1, 1) + + def test_readline(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', 'three\n', 'four\n']) + self.assertEqual(f.readline(), 'one\n') + self.assertEqual(f.readline(-1), 'two\n') + self.assertEqual(f.readline(None), 'three\n') + shell.push(['one\ntwo\n']) + self.assertEqual(f.readline(), 'one\n') + self.assertEqual(f.readline(), 'two\n') + shell.push(['one', 'two', 'three']) + self.assertEqual(f.readline(), 'one') + self.assertEqual(f.readline(), 'two') + shell.push(['one\n', 'two\n', 'three\n']) + self.assertEqual(f.readline(2), 'on') + self.assertEqual(f.readline(1), 'e') + self.assertEqual(f.readline(1), '\n') + self.assertEqual(f.readline(10), 'two\n') + + shell.push(['one\n', 'two\n']) + self.assertEqual(f.readline(0), '') + self.assertRaises(TypeError, f.readlines, 1.5) + self.assertRaises(TypeError, f.readlines, '1') + self.assertRaises(TypeError, f.readlines, 1, 1) + + def test_readlines(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(-1), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(None), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(0), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(3), ['one\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(4), ['one\n', 'two\n']) + + shell.push(['one\n', 'two\n', '']) + self.assertRaises(TypeError, f.readlines, 1.5) + self.assertRaises(TypeError, f.readlines, '1') + self.assertRaises(TypeError, f.readlines, 1, 1) + + def test_close(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertFalse(f.closed) + self.assertEqual(f.readline(), 'one\n') + f.close() + self.assertFalse(f.closed) + self.assertEqual(f.readline(), 'two\n') + self.assertRaises(TypeError, f.close, 1) + + +class StdOutputFilesTest(unittest.TestCase): + + def test_misc(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertIsInstance(f, io.TextIOBase) + self.assertEqual(f.encoding, 'utf-8') + self.assertEqual(f.errors, 'strict') + self.assertIsNone(f.newlines) + self.assertEqual(f.name, '') + self.assertFalse(f.closed) + self.assertTrue(f.isatty()) + self.assertFalse(f.readable()) + self.assertTrue(f.writable()) + self.assertFalse(f.seekable()) + + def test_unsupported(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertRaises(OSError, f.fileno) + self.assertRaises(OSError, f.tell) + self.assertRaises(OSError, f.seek, 0) + self.assertRaises(OSError, f.read, 0) + self.assertRaises(OSError, f.readline, 0) + + def test_write(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + f.write('test') + self.assertEqual(shell.written, [('test', 'stdout')]) + shell.reset() + f.write('t\xe8\u015b\U0001d599') + self.assertEqual(shell.written, [('t\xe8\u015b\U0001d599', 'stdout')]) + shell.reset() + + f.write(S('t\xe8\u015b\U0001d599')) + self.assertEqual(shell.written, [('t\xe8\u015b\U0001d599', 'stdout')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.write) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, b'test') + self.assertRaises(TypeError, f.write, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, 'test', 'spam') + self.assertEqual(shell.written, []) + + def test_write_stderr_nonencodable(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stderr', 'iso-8859-15', 'backslashreplace') + f.write('t\xe8\u015b\U0001d599\xa4') + self.assertEqual(shell.written, [('t\xe8\\u015b\\U0001d599\\xa4', 'stderr')]) + shell.reset() + + f.write(S('t\xe8\u015b\U0001d599\xa4')) + self.assertEqual(shell.written, [('t\xe8\\u015b\\U0001d599\\xa4', 'stderr')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.write) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, b'test') + self.assertRaises(TypeError, f.write, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, 'test', 'spam') + self.assertEqual(shell.written, []) + + def test_writelines(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + f.writelines([]) + self.assertEqual(shell.written, []) + shell.reset() + f.writelines(['one\n', 'two']) + self.assertEqual(shell.written, + [('one\n', 'stdout'), ('two', 'stdout')]) + shell.reset() + f.writelines(['on\xe8\n', 'tw\xf2']) + self.assertEqual(shell.written, + [('on\xe8\n', 'stdout'), ('tw\xf2', 'stdout')]) + shell.reset() + + f.writelines([S('t\xe8st')]) + self.assertEqual(shell.written, [('t\xe8st', 'stdout')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.writelines) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, [b'test']) + self.assertRaises(TypeError, f.writelines, [123]) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, [], []) + self.assertEqual(shell.written, []) + + def test_close(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertFalse(f.closed) + f.write('test') + f.close() + self.assertTrue(f.closed) + self.assertRaises(ValueError, f.write, 'x') + self.assertEqual(shell.written, [('test', 'stdout')]) + f.close() + self.assertRaises(TypeError, f.close, 1) + + +class RecursionLimitTest(unittest.TestCase): + # Test (un)install_recursionlimit_wrappers and fixdoc. + + def test_bad_setrecursionlimit_calls(self): + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + f = sys.setrecursionlimit + self.assertRaises(TypeError, f, limit=100) + self.assertRaises(TypeError, f, 100, 1000) + self.assertRaises(ValueError, f, 0) + + def test_roundtrip(self): + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + + # Check that setting the recursion limit works. + orig_reclimit = sys.getrecursionlimit() + self.addCleanup(sys.setrecursionlimit, orig_reclimit) + sys.setrecursionlimit(orig_reclimit + 3) + + # Check that the new limit is returned by sys.getrecursionlimit(). + new_reclimit = sys.getrecursionlimit() + self.assertEqual(new_reclimit, orig_reclimit + 3) + + def test_default_recursion_limit_preserved(self): + orig_reclimit = sys.getrecursionlimit() + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + new_reclimit = sys.getrecursionlimit() + self.assertEqual(new_reclimit, orig_reclimit) + + def test_fixdoc(self): + # Put here until better place for miscellaneous test. + def func(): "docstring" + run.fixdoc(func, "more") + self.assertEqual(func.__doc__, "docstring\n\nmore") + func.__doc__ = None + run.fixdoc(func, "more") + self.assertEqual(func.__doc__, "more") + + +class HandleErrorTest(unittest.TestCase): + # Method of MyRPCServer + def test_fatal_error(self): + eq = self.assertEqual + with captured_output('__stderr__') as err,\ + mock.patch('idlelib.run.thread.interrupt_main', + new_callable=Func) as func: + try: + raise EOFError + except EOFError: + run.MyRPCServer.handle_error(None, 'abc', '123') + eq(run.exit_now, True) + run.exit_now = False + eq(err.getvalue(), '') + + try: + raise IndexError + except IndexError: + run.MyRPCServer.handle_error(None, 'abc', '123') + eq(run.quitting, True) + run.quitting = False + msg = err.getvalue() + self.assertIn('abc', msg) + self.assertIn('123', msg) + self.assertIn('IndexError', msg) + eq(func.called, 2) + + +class ExecRuncodeTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.addClassCleanup(setattr,run,'print_exception',run.print_exception) + cls.prt = Func() # Need reference. + run.print_exception = cls.prt + mockrpc = mock.Mock() + mockrpc.console.getvar = Func(result=False) + cls.ex = run.Executive(mockrpc) + + @classmethod + def tearDownClass(cls): + assert sys.excepthook == sys.__excepthook__ + + def test_exceptions(self): + ex = self.ex + ex.runcode('1/0') + self.assertIs(ex.user_exc_info[0], ZeroDivisionError) + + self.addCleanup(setattr, sys, 'excepthook', sys.__excepthook__) + sys.excepthook = lambda t, e, tb: run.print_exception(t) + ex.runcode('1/0') + self.assertIs(self.prt.args[0], ZeroDivisionError) + + sys.excepthook = lambda: None + ex.runcode('1/0') + t, e, tb = ex.user_exc_info + self.assertIs(t, TypeError) + self.assertTrue(isinstance(e.__context__, ZeroDivisionError)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_runscript.py b/Lib/idlelib/idle_test/test_runscript.py new file mode 100644 index 00000000000..5fc60185a66 --- /dev/null +++ b/Lib/idlelib/idle_test/test_runscript.py @@ -0,0 +1,33 @@ +"Test runscript, coverage 16%." + +from idlelib import runscript +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow + + +class ScriptBindingTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + ew = EditorWindow(root=self.root) + sb = runscript.ScriptBinding(ew) + ew._close() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_scrolledlist.py b/Lib/idlelib/idle_test/test_scrolledlist.py new file mode 100644 index 00000000000..2f819fda025 --- /dev/null +++ b/Lib/idlelib/idle_test/test_scrolledlist.py @@ -0,0 +1,27 @@ +"Test scrolledlist, coverage 38%." + +from idlelib.scrolledlist import ScrolledList +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk + + +class ScrolledListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + + def test_init(self): + ScrolledList(self.root) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_search.py b/Lib/idlelib/idle_test/test_search.py new file mode 100644 index 00000000000..de703c195cd --- /dev/null +++ b/Lib/idlelib/idle_test/test_search.py @@ -0,0 +1,80 @@ +"Test search, coverage 69%." + +from idlelib import search +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, Text, BooleanVar +from idlelib import searchengine + +# Does not currently test the event handler wrappers. +# A usage test should simulate clicks and check highlighting. +# Tests need to be coordinated with SearchDialogBase tests +# to avoid duplication. + + +class SearchDialogTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.engine = searchengine.SearchEngine(self.root) + self.dialog = search.SearchDialog(self.root, self.engine) + self.dialog.bell = lambda: None + self.text = Text(self.root) + self.text.insert('1.0', 'Hello World!') + + def test_find_again(self): + # Search for various expressions + text = self.text + + self.engine.setpat('') + self.assertFalse(self.dialog.find_again(text)) + self.dialog.bell = lambda: None + + self.engine.setpat('Hello') + self.assertTrue(self.dialog.find_again(text)) + + self.engine.setpat('Goodbye') + self.assertFalse(self.dialog.find_again(text)) + + self.engine.setpat('World!') + self.assertTrue(self.dialog.find_again(text)) + + self.engine.setpat('Hello World!') + self.assertTrue(self.dialog.find_again(text)) + + # Regular expression + self.engine.revar = BooleanVar(self.root, True) + self.engine.setpat('W[aeiouy]r') + self.assertTrue(self.dialog.find_again(text)) + + def test_find_selection(self): + # Select some text and make sure it's found + text = self.text + # Add additional line to find + self.text.insert('2.0', 'Hello World!') + + text.tag_add('sel', '1.0', '1.4') # Select 'Hello' + self.assertTrue(self.dialog.find_selection(text)) + + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '1.6', '1.11') # Select 'World!' + self.assertTrue(self.dialog.find_selection(text)) + + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '1.0', '1.11') # Select 'Hello World!' + self.assertTrue(self.dialog.find_selection(text)) + + # Remove additional line + text.delete('2.0', 'end') + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_searchbase.py b/Lib/idlelib/idle_test/test_searchbase.py new file mode 100644 index 00000000000..8c9c410ebaf --- /dev/null +++ b/Lib/idlelib/idle_test/test_searchbase.py @@ -0,0 +1,160 @@ +"Test searchbase, coverage 98%." +# The only thing not covered is inconsequential -- +# testing skipping of suite when self.needwrapbutton is false. + +import unittest +from test.support import requires +from tkinter import Text, Tk, Toplevel +from tkinter.ttk import Frame +from idlelib import searchengine as se +from idlelib import searchbase as sdb +from idlelib.idle_test.mock_idle import Func +## from idlelib.idle_test.mock_tk import Var + +# The ## imports above & following could help make some tests gui-free. +# However, they currently make radiobutton tests fail. +##def setUpModule(): +## # Replace tk objects used to initialize se.SearchEngine. +## se.BooleanVar = Var +## se.StringVar = Var +## +##def tearDownModule(): +## se.BooleanVar = BooleanVar +## se.StringVar = StringVar + + +class SearchDialogBaseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.engine = se.SearchEngine(self.root) # None also seems to work + self.dialog = sdb.SearchDialogBase(root=self.root, engine=self.engine) + + def tearDown(self): + self.dialog.close() + + def test_open_and_close(self): + # open calls create_widgets, which needs default_command + self.dialog.default_command = None + + toplevel = Toplevel(self.root) + text = Text(toplevel) + self.dialog.open(text) + self.assertEqual(self.dialog.top.state(), 'normal') + self.dialog.close() + self.assertEqual(self.dialog.top.state(), 'withdrawn') + + self.dialog.open(text, searchphrase="hello") + self.assertEqual(self.dialog.ent.get(), 'hello') + toplevel.update_idletasks() + toplevel.destroy() + + def test_create_widgets(self): + self.dialog.create_entries = Func() + self.dialog.create_option_buttons = Func() + self.dialog.create_other_buttons = Func() + self.dialog.create_command_buttons = Func() + + self.dialog.default_command = None + self.dialog.create_widgets() + + self.assertTrue(self.dialog.create_entries.called) + self.assertTrue(self.dialog.create_option_buttons.called) + self.assertTrue(self.dialog.create_other_buttons.called) + self.assertTrue(self.dialog.create_command_buttons.called) + + def test_make_entry(self): + equal = self.assertEqual + self.dialog.row = 0 + self.dialog.frame = Frame(self.root) + entry, label = self.dialog.make_entry("Test:", 'hello') + equal(label['text'], 'Test:') + + self.assertIn(entry.get(), 'hello') + egi = entry.grid_info() + equal(int(egi['row']), 0) + equal(int(egi['column']), 1) + equal(int(egi['rowspan']), 1) + equal(int(egi['columnspan']), 1) + equal(self.dialog.row, 1) + + def test_create_entries(self): + self.dialog.frame = Frame(self.root) + self.dialog.row = 0 + self.engine.setpat('hello') + self.dialog.create_entries() + self.assertIn(self.dialog.ent.get(), 'hello') + + def test_make_frame(self): + self.dialog.row = 0 + self.dialog.frame = Frame(self.root) + frame, label = self.dialog.make_frame() + self.assertEqual(label, '') + self.assertEqual(str(type(frame)), "") + # self.assertIsInstance(frame, Frame) fails when test is run by + # test_idle not run from IDLE editor. See issue 33987 PR. + + frame, label = self.dialog.make_frame('testlabel') + self.assertEqual(label['text'], 'testlabel') + + def btn_test_setup(self, meth): + self.dialog.frame = Frame(self.root) + self.dialog.row = 0 + return meth() + + def test_create_option_buttons(self): + e = self.engine + for state in (0, 1): + for var in (e.revar, e.casevar, e.wordvar, e.wrapvar): + var.set(state) + frame, options = self.btn_test_setup( + self.dialog.create_option_buttons) + for spec, button in zip (options, frame.pack_slaves()): + var, label = spec + self.assertEqual(button['text'], label) + self.assertEqual(var.get(), state) + + def test_create_other_buttons(self): + for state in (False, True): + var = self.engine.backvar + var.set(state) + frame, others = self.btn_test_setup( + self.dialog.create_other_buttons) + buttons = frame.pack_slaves() + for spec, button in zip(others, buttons): + val, label = spec + self.assertEqual(button['text'], label) + if val == state: + # hit other button, then this one + # indexes depend on button order + self.assertEqual(var.get(), state) + + def test_make_button(self): + self.dialog.frame = Frame(self.root) + self.dialog.buttonframe = Frame(self.dialog.frame) + btn = self.dialog.make_button('Test', self.dialog.close) + self.assertEqual(btn['text'], 'Test') + + def test_create_command_buttons(self): + self.dialog.frame = Frame(self.root) + self.dialog.create_command_buttons() + # Look for close button command in buttonframe + closebuttoncommand = '' + for child in self.dialog.buttonframe.winfo_children(): + if child['text'] == 'Close': + closebuttoncommand = child['command'] + self.assertIn('close', closebuttoncommand) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_searchengine.py b/Lib/idlelib/idle_test/test_searchengine.py new file mode 100644 index 00000000000..9d979839419 --- /dev/null +++ b/Lib/idlelib/idle_test/test_searchengine.py @@ -0,0 +1,332 @@ +"Test searchengine, coverage 99%." + +from idlelib import searchengine as se +import unittest +# from test.support import requires +from tkinter import BooleanVar, StringVar, TclError # ,Tk, Text +from tkinter import messagebox +from idlelib.idle_test.mock_tk import Var, Mbox +from idlelib.idle_test.mock_tk import Text as mockText +import re + +# With mock replacements, the module does not use any gui widgets. +# The use of tk.Text is avoided (for now, until mock Text is improved) +# by patching instances with an index function returning what is needed. +# This works because mock Text.get does not use .index. +# The tkinter imports are used to restore searchengine. + +def setUpModule(): + # Replace s-e module tkinter imports other than non-gui TclError. + se.BooleanVar = Var + se.StringVar = Var + se.messagebox = Mbox + +def tearDownModule(): + # Restore 'just in case', though other tests should also replace. + se.BooleanVar = BooleanVar + se.StringVar = StringVar + se.messagebox = messagebox + + +class Mock: + def __init__(self, *args, **kwargs): pass + +class GetTest(unittest.TestCase): + # SearchEngine.get returns singleton created & saved on first call. + def test_get(self): + saved_Engine = se.SearchEngine + se.SearchEngine = Mock # monkey-patch class + try: + root = Mock() + engine = se.get(root) + self.assertIsInstance(engine, se.SearchEngine) + self.assertIs(root._searchengine, engine) + self.assertIs(se.get(root), engine) + finally: + se.SearchEngine = saved_Engine # restore class to module + +class GetLineColTest(unittest.TestCase): + # Test simple text-independent helper function + def test_get_line_col(self): + self.assertEqual(se.get_line_col('1.0'), (1, 0)) + self.assertEqual(se.get_line_col('1.11'), (1, 11)) + + self.assertRaises(ValueError, se.get_line_col, ('1.0 lineend')) + self.assertRaises(ValueError, se.get_line_col, ('end')) + +class GetSelectionTest(unittest.TestCase): + # Test text-dependent helper function. +## # Need gui for text.index('sel.first/sel.last/insert'). +## @classmethod +## def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + def test_get_selection(self): + # text = Text(master=self.root) + text = mockText() + text.insert('1.0', 'Hello World!') + + # fix text.index result when called in get_selection + def sel(s): + # select entire text, cursor irrelevant + if s == 'sel.first': return '1.0' + if s == 'sel.last': return '1.12' + raise TclError + text.index = sel # replaces .tag_add('sel', '1.0, '1.12') + self.assertEqual(se.get_selection(text), ('1.0', '1.12')) + + def mark(s): + # no selection, cursor after 'Hello' + if s == 'insert': return '1.5' + raise TclError + text.index = mark # replaces .mark_set('insert', '1.5') + self.assertEqual(se.get_selection(text), ('1.5', '1.5')) + + +class ReverseSearchTest(unittest.TestCase): + # Test helper function that searches backwards within a line. + def test_search_reverse(self): + Equal = self.assertEqual + line = "Here is an 'is' test text." + prog = re.compile('is') + Equal(se.search_reverse(prog, line, len(line)).span(), (12, 14)) + Equal(se.search_reverse(prog, line, 14).span(), (12, 14)) + Equal(se.search_reverse(prog, line, 13).span(), (5, 7)) + Equal(se.search_reverse(prog, line, 7).span(), (5, 7)) + Equal(se.search_reverse(prog, line, 6), None) + + +class SearchEngineTest(unittest.TestCase): + # Test class methods that do not use Text widget. + + def setUp(self): + self.engine = se.SearchEngine(root=None) + # Engine.root is only used to create error message boxes. + # The mock replacement ignores the root argument. + + def test_is_get(self): + engine = self.engine + Equal = self.assertEqual + + Equal(engine.getpat(), '') + engine.setpat('hello') + Equal(engine.getpat(), 'hello') + + Equal(engine.isre(), False) + engine.revar.set(1) + Equal(engine.isre(), True) + + Equal(engine.iscase(), False) + engine.casevar.set(1) + Equal(engine.iscase(), True) + + Equal(engine.isword(), False) + engine.wordvar.set(1) + Equal(engine.isword(), True) + + Equal(engine.iswrap(), True) + engine.wrapvar.set(0) + Equal(engine.iswrap(), False) + + Equal(engine.isback(), False) + engine.backvar.set(1) + Equal(engine.isback(), True) + + def test_setcookedpat(self): + engine = self.engine + engine.setcookedpat(r'\s') + self.assertEqual(engine.getpat(), r'\s') + engine.revar.set(1) + engine.setcookedpat(r'\s') + self.assertEqual(engine.getpat(), r'\\s') + + def test_getcookedpat(self): + engine = self.engine + Equal = self.assertEqual + + Equal(engine.getcookedpat(), '') + engine.setpat('hello') + Equal(engine.getcookedpat(), 'hello') + engine.wordvar.set(True) + Equal(engine.getcookedpat(), r'\bhello\b') + engine.wordvar.set(False) + + engine.setpat(r'\s') + Equal(engine.getcookedpat(), r'\\s') + engine.revar.set(True) + Equal(engine.getcookedpat(), r'\s') + + def test_getprog(self): + engine = self.engine + Equal = self.assertEqual + + engine.setpat('Hello') + temppat = engine.getprog() + Equal(temppat.pattern, re.compile('Hello', re.IGNORECASE).pattern) + engine.casevar.set(1) + temppat = engine.getprog() + Equal(temppat.pattern, re.compile('Hello').pattern, 0) + + engine.setpat('') + Equal(engine.getprog(), None) + Equal(Mbox.showerror.message, + 'Error: Empty regular expression') + engine.setpat('+') + engine.revar.set(1) + Equal(engine.getprog(), None) + Equal(Mbox.showerror.message, + 'Error: nothing to repeat\nPattern: +\nOffset: 0') + + def test_report_error(self): + showerror = Mbox.showerror + Equal = self.assertEqual + pat = '[a-z' + msg = 'unexpected end of regular expression' + + Equal(self.engine.report_error(pat, msg), None) + Equal(showerror.title, 'Regular expression error') + expected_message = ("Error: " + msg + "\nPattern: [a-z") + Equal(showerror.message, expected_message) + + Equal(self.engine.report_error(pat, msg, 5), None) + Equal(showerror.title, 'Regular expression error') + expected_message += "\nOffset: 5" + Equal(showerror.message, expected_message) + + +class SearchTest(unittest.TestCase): + # Test that search_text makes right call to right method. + + @classmethod + def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## cls.text = Text(master=cls.root) + cls.text = mockText() + test_text = ( + 'First line\n' + 'Line with target\n' + 'Last line\n') + cls.text.insert('1.0', test_text) + cls.pat = re.compile('target') + + cls.engine = se.SearchEngine(None) + cls.engine.search_forward = lambda *args: ('f', args) + cls.engine.search_backward = lambda *args: ('b', args) + +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + def test_search(self): + Equal = self.assertEqual + engine = self.engine + search = engine.search_text + text = self.text + pat = self.pat + + engine.patvar.set(None) + #engine.revar.set(pat) + Equal(search(text), None) + + def mark(s): + # no selection, cursor after 'Hello' + if s == 'insert': return '1.5' + raise TclError + text.index = mark + Equal(search(text, pat), ('f', (text, pat, 1, 5, True, False))) + engine.wrapvar.set(False) + Equal(search(text, pat), ('f', (text, pat, 1, 5, False, False))) + engine.wrapvar.set(True) + engine.backvar.set(True) + Equal(search(text, pat), ('b', (text, pat, 1, 5, True, False))) + engine.backvar.set(False) + + def sel(s): + if s == 'sel.first': return '2.10' + if s == 'sel.last': return '2.16' + raise TclError + text.index = sel + Equal(search(text, pat), ('f', (text, pat, 2, 16, True, False))) + Equal(search(text, pat, True), ('f', (text, pat, 2, 10, True, True))) + engine.backvar.set(True) + Equal(search(text, pat), ('b', (text, pat, 2, 10, True, False))) + Equal(search(text, pat, True), ('b', (text, pat, 2, 16, True, True))) + + +class ForwardBackwardTest(unittest.TestCase): + # Test that search_forward method finds the target. +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + @classmethod + def setUpClass(cls): + cls.engine = se.SearchEngine(None) +## requires('gui') +## cls.root = Tk() +## cls.text = Text(master=cls.root) + cls.text = mockText() + # search_backward calls index('end-1c') + cls.text.index = lambda index: '4.0' + test_text = ( + 'First line\n' + 'Line with target\n' + 'Last line\n') + cls.text.insert('1.0', test_text) + cls.pat = re.compile('target') + cls.res = (2, (10, 16)) # line, slice indexes of 'target' + cls.failpat = re.compile('xyz') # not in text + cls.emptypat = re.compile(r'\w*') # empty match possible + + def make_search(self, func): + def search(pat, line, col, wrap, ok=0): + res = func(self.text, pat, line, col, wrap, ok) + # res is (line, matchobject) or None + return (res[0], res[1].span()) if res else res + return search + + def test_search_forward(self): + # search for non-empty match + Equal = self.assertEqual + forward = self.make_search(self.engine.search_forward) + pat = self.pat + Equal(forward(pat, 1, 0, True), self.res) + Equal(forward(pat, 3, 0, True), self.res) # wrap + Equal(forward(pat, 3, 0, False), None) # no wrap + Equal(forward(pat, 2, 10, False), self.res) + + Equal(forward(self.failpat, 1, 0, True), None) + Equal(forward(self.emptypat, 2, 9, True, ok=True), (2, (9, 9))) + #Equal(forward(self.emptypat, 2, 9, True), self.res) + # While the initial empty match is correctly ignored, skipping + # the rest of the line and returning (3, (0,4)) seems buggy - tjr. + Equal(forward(self.emptypat, 2, 10, True), self.res) + + def test_search_backward(self): + # search for non-empty match + Equal = self.assertEqual + backward = self.make_search(self.engine.search_backward) + pat = self.pat + Equal(backward(pat, 3, 5, True), self.res) + Equal(backward(pat, 2, 0, True), self.res) # wrap + Equal(backward(pat, 2, 0, False), None) # no wrap + Equal(backward(pat, 2, 16, False), self.res) + + Equal(backward(self.failpat, 3, 9, True), None) + Equal(backward(self.emptypat, 2, 10, True, ok=True), (2, (9,9))) + # Accepted because 9 < 10, not because ok=True. + # It is not clear that ok=True is useful going back - tjr + Equal(backward(self.emptypat, 2, 9, True), (2, (5, 9))) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py new file mode 100644 index 00000000000..4157a4b4dcd --- /dev/null +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -0,0 +1,775 @@ +"""Test sidebar, coverage 85%""" +from textwrap import dedent +import sys + +from itertools import chain +import unittest +import unittest.mock +from test.support import requires, swap_attr +from test import support +import tkinter as tk +from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop + +from idlelib.delegator import Delegator +from idlelib.editor import fixwordbreaks +from idlelib.percolator import Percolator +import idlelib.pyshell +from idlelib.pyshell import fix_x11_paste, PyShell, PyShellFileList +from idlelib.run import fix_scaling +import idlelib.sidebar +from idlelib.sidebar import get_end_linenumber, get_lineno + + +class Dummy_editwin: + def __init__(self, text): + self.text = text + self.text_frame = self.text.master + self.per = Percolator(text) + self.undo = Delegator() + self.per.insertfilter(self.undo) + + def setvar(self, name, value): + pass + + def getlineno(self, index): + return int(float(self.text.index(index))) + + +class LineNumbersTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + + cls.text_frame = tk.Frame(cls.root) + cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + cls.text_frame.rowconfigure(1, weight=1) + cls.text_frame.columnconfigure(1, weight=1) + + cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE) + cls.text.grid(row=1, column=1, sticky=tk.NSEW) + + cls.editwin = Dummy_editwin(cls.text) + cls.editwin.vbar = tk.Scrollbar(cls.text_frame) + + @classmethod + def tearDownClass(cls): + cls.editwin.per.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.text, cls.text_frame, cls.editwin, cls.root + + def setUp(self): + self.linenumber = idlelib.sidebar.LineNumbers(self.editwin) + + self.highlight_cfg = {"background": '#abcdef', + "foreground": '#123456'} + orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element == 'linenumber': + return self.highlight_cfg + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + self.addCleanup(GetHighlight_patcher.stop) + + self.font_override = 'TkFixedFont' + def mock_idleconf_GetFont(root, configType, section): + return self.font_override + GetFont_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + self.addCleanup(GetFont_patcher.stop) + + def tearDown(self): + self.text.delete('1.0', 'end') + + def get_selection(self): + return tuple(map(str, self.text.tag_ranges('sel'))) + + def get_line_screen_position(self, line): + bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c') + x = bbox[0] + 2 + y = bbox[1] + 2 + return x, y + + def assert_state_disabled(self): + state = self.linenumber.sidebar_text.config()['state'] + self.assertEqual(state[-1], tk.DISABLED) + + def get_sidebar_text_contents(self): + return self.linenumber.sidebar_text.get('1.0', tk.END) + + def assert_sidebar_n_lines(self, n_lines): + expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), [''])) + self.assertEqual(self.get_sidebar_text_contents(), expected) + + def assert_text_equals(self, expected): + return self.assertEqual(self.text.get('1.0', 'end'), expected) + + def test_init_empty(self): + self.assert_sidebar_n_lines(1) + + def test_init_not_empty(self): + self.text.insert('insert', 'foo bar\n'*3) + self.assert_text_equals('foo bar\n'*3 + '\n') + self.assert_sidebar_n_lines(4) + + def test_toggle_linenumbering(self): + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + self.linenumber.hide_sidebar() + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.hide_sidebar() + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + + def test_insert(self): + self.text.insert('insert', 'foobar') + self.assert_text_equals('foobar\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + self.text.insert('insert', '\nfoo') + self.assert_text_equals('foobar\nfoo\n') + self.assert_sidebar_n_lines(2) + self.assert_state_disabled() + + self.text.insert('insert', 'hello\n'*2) + self.assert_text_equals('foobar\nfoohello\nhello\n\n') + self.assert_sidebar_n_lines(4) + self.assert_state_disabled() + + self.text.insert('insert', '\nworld') + self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n') + self.assert_sidebar_n_lines(5) + self.assert_state_disabled() + + def test_delete(self): + self.text.insert('insert', 'foobar') + self.assert_text_equals('foobar\n') + self.text.delete('1.1', '1.3') + self.assert_text_equals('fbar\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + self.text.insert('insert', 'foo\n'*2) + self.assert_text_equals('fbarfoo\nfoo\n\n') + self.assert_sidebar_n_lines(3) + self.assert_state_disabled() + + # Deleting up to "2.end" doesn't delete the final newline. + self.text.delete('2.0', '2.end') + self.assert_text_equals('fbarfoo\n\n\n') + self.assert_sidebar_n_lines(3) + self.assert_state_disabled() + + self.text.delete('1.3', 'end') + self.assert_text_equals('fba\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + # Text widgets always keep a single '\n' character at the end. + self.text.delete('1.0', 'end') + self.assert_text_equals('\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + def test_sidebar_text_width(self): + """ + Test that linenumber text widget is always at the minimum + width + """ + def get_width(): + return self.linenumber.sidebar_text.config()['width'][-1] + + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo') + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n'*8) + self.assert_sidebar_n_lines(9) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(10) + self.assertEqual(get_width(), 2) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(11) + self.assertEqual(get_width(), 2) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(10) + self.assertEqual(get_width(), 2) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(9) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n'*90) + self.assert_sidebar_n_lines(99) + self.assertEqual(get_width(), 2) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(100) + self.assertEqual(get_width(), 3) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(101) + self.assertEqual(get_width(), 3) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(100) + self.assertEqual(get_width(), 3) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(99) + self.assertEqual(get_width(), 2) + + self.text.delete('50.0 -1c', 'end -1c') + self.assert_sidebar_n_lines(49) + self.assertEqual(get_width(), 2) + + self.text.delete('5.0 -1c', 'end -1c') + self.assert_sidebar_n_lines(4) + self.assertEqual(get_width(), 1) + + # Text widgets always keep a single '\n' character at the end. + self.text.delete('1.0', 'end -1c') + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + # The following tests are temporarily disabled due to relying on + # simulated user input and inspecting which text is selected, which + # are fragile and can fail when several GUI tests are run in parallel + # or when the windows created by the test lose focus. + # + # TODO: Re-work these tests or remove them from the test suite. + + @unittest.skip('test disabled') + def test_click_selection(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\n') + self.root.update() + + # Click on the second line. + x, y = self.get_line_screen_position(2) + self.linenumber.sidebar_text.event_generate('', x=x, y=y) + self.linenumber.sidebar_text.update() + self.root.update() + + self.assertEqual(self.get_selection(), ('2.0', '3.0')) + + def simulate_drag(self, start_line, end_line): + start_x, start_y = self.get_line_screen_position(start_line) + end_x, end_y = self.get_line_screen_position(end_line) + + self.linenumber.sidebar_text.event_generate('', + x=start_x, y=start_y) + self.root.update() + + def lerp(a, b, steps): + """linearly interpolate from a to b (inclusive) in equal steps""" + last_step = steps - 1 + for i in range(steps): + yield ((last_step - i) / last_step) * a + (i / last_step) * b + + for x, y in zip( + map(int, lerp(start_x, end_x, steps=11)), + map(int, lerp(start_y, end_y, steps=11)), + ): + self.linenumber.sidebar_text.event_generate('', x=x, y=y) + self.root.update() + + self.linenumber.sidebar_text.event_generate('', + x=end_x, y=end_y) + self.root.update() + + @unittest.skip('test disabled') + def test_drag_selection_down(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') + self.root.update() + + # Drag from the second line to the fourth line. + self.simulate_drag(2, 4) + self.assertEqual(self.get_selection(), ('2.0', '5.0')) + + @unittest.skip('test disabled') + def test_drag_selection_up(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') + self.root.update() + + # Drag from the fourth line to the second line. + self.simulate_drag(4, 2) + self.assertEqual(self.get_selection(), ('2.0', '5.0')) + + def test_scroll(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'line\n' * 100) + self.root.update() + + # Scroll down 10 lines. + self.text.yview_scroll(10, 'unit') + self.root.update() + self.assertEqual(self.text.index('@0,0'), '11.0') + self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') + + # Generate a mouse-wheel event and make sure it scrolled up or down. + # The meaning of the "delta" is OS-dependent, so this just checks for + # any change. + self.linenumber.sidebar_text.event_generate('', + x=0, y=0, + delta=10) + self.root.update() + self.assertNotEqual(self.text.index('@0,0'), '11.0') + self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') + + def test_font(self): + ln = self.linenumber + + orig_font = ln.sidebar_text['font'] + test_font = 'TkTextFont' + self.assertNotEqual(orig_font, test_font) + + # Ensure line numbers aren't shown. + ln.hide_sidebar() + + self.font_override = test_font + # Nothing breaks when line numbers aren't shown. + ln.update_font() + + # Activate line numbers, previous font change is immediately effective. + ln.show_sidebar() + self.assertEqual(ln.sidebar_text['font'], test_font) + + # Call the font update with line numbers shown, change is picked up. + self.font_override = orig_font + ln.update_font() + self.assertEqual(ln.sidebar_text['font'], orig_font) + + def test_highlight_colors(self): + ln = self.linenumber + + orig_colors = dict(self.highlight_cfg) + test_colors = {'background': '#222222', 'foreground': '#ffff00'} + + def assert_colors_are_equal(colors): + self.assertEqual(ln.sidebar_text['background'], colors['background']) + self.assertEqual(ln.sidebar_text['foreground'], colors['foreground']) + + # Ensure line numbers aren't shown. + ln.hide_sidebar() + + self.highlight_cfg = test_colors + # Nothing breaks with inactive line numbers. + ln.update_colors() + + # Show line numbers, previous colors change is immediately effective. + ln.show_sidebar() + assert_colors_are_equal(test_colors) + + # Call colors update with no change to the configured colors. + ln.update_colors() + assert_colors_are_equal(test_colors) + + # Call the colors update with line numbers shown, change is picked up. + self.highlight_cfg = orig_colors + ln.update_colors() + assert_colors_are_equal(orig_colors) + + +class ShellSidebarTest(unittest.TestCase): + root: tk.Tk = None + shell: PyShell = None + + @classmethod + def setUpClass(cls): + requires('gui') + + cls.root = root = tk.Tk() + root.withdraw() + + fix_scaling(root) + fixwordbreaks(root) + fix_x11_paste(root) + + cls.flist = flist = PyShellFileList(root) + # See #43981 about macosx.setupApp(root, flist) causing failure. + root.update_idletasks() + + cls.init_shell() + + @classmethod + def tearDownClass(cls): + if cls.shell is not None: + cls.shell.executing = False + cls.shell.close() + cls.shell = None + cls.flist = None + cls.root.update_idletasks() + cls.root.destroy() + cls.root = None + + @classmethod + def init_shell(cls): + cls.shell = cls.flist.open_shell() + cls.shell.pollinterval = 10 + cls.root.update() + cls.n_preface_lines = get_lineno(cls.shell.text, 'end-1c') - 1 + + @classmethod + def reset_shell(cls): + cls.shell.per.bottom.delete(f'{cls.n_preface_lines+1}.0', 'end-1c') + cls.shell.shell_sidebar.update_sidebar() + cls.root.update() + + def setUp(self): + # In some test environments, e.g. Azure Pipelines (as of + # Apr. 2021), sys.stdout is changed between tests. However, + # PyShell relies on overriding sys.stdout when run without a + # sub-process (as done here; see setUpClass). + self._saved_stdout = None + if sys.stdout != self.shell.stdout: + self._saved_stdout = sys.stdout + sys.stdout = self.shell.stdout + + self.reset_shell() + + def tearDown(self): + if self._saved_stdout is not None: + sys.stdout = self._saved_stdout + + def get_sidebar_lines(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts_by_y_coords = { + canvas.bbox(text)[1]: canvas.itemcget(text, 'text') + for text in texts + } + line_y_coords = self.get_shell_line_y_coords() + return [texts_by_y_coords.get(y, None) for y in line_y_coords] + + def assert_sidebar_lines_end_with(self, expected_lines): + self.shell.shell_sidebar.update_sidebar() + self.assertEqual( + self.get_sidebar_lines()[-len(expected_lines):], + expected_lines, + ) + + def get_shell_line_y_coords(self): + text = self.shell.text + y_coords = [] + index = text.index("@0,0") + if index.split('.', 1)[1] != '0': + index = text.index(f"{index} +1line linestart") + while (lineinfo := text.dlineinfo(index)) is not None: + y_coords.append(lineinfo[1]) + index = text.index(f"{index} +1line") + return y_coords + + def get_sidebar_line_y_coords(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts.sort(key=lambda text: canvas.bbox(text)[1]) + return [canvas.bbox(text)[1] for text in texts] + + def assert_sidebar_lines_synced(self): + self.assertLessEqual( + set(self.get_sidebar_line_y_coords()), + set(self.get_shell_line_y_coords()), + ) + + def do_input(self, input): + shell = self.shell + text = shell.text + for line_index, line in enumerate(input.split('\n')): + if line_index > 0: + text.event_generate('<>') + text.insert('insert', line, 'stdin') + + def test_initial_state(self): + sidebar_lines = self.get_sidebar_lines() + self.assertEqual( + sidebar_lines, + [None] * (len(sidebar_lines) - 1) + ['>>>'], + ) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + + @run_in_tk_mainloop() + def test_single_line_statement(self): + self.do_input('1\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + + @run_in_tk_mainloop() + def test_multi_line_statement(self): + # Block statements are not indented because IDLE auto-indents. + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + self.assert_sidebar_lines_end_with([ + '>>>', + '...', + '...', + '...', + None, + '>>>', + ]) + + @run_in_tk_mainloop() + def test_single_long_line_wraps(self): + self.do_input('1' * 200 + '\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_squeeze_multi_line_output(self): + shell = self.shell + text = shell.text + + self.do_input('print("a\\nb\\nc")\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + + text.mark_set('insert', f'insert -1line linestart') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + shell.squeezer.expandingbuttons[0].expand() + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_interrupt_recall_undo_redo(self): + text = self.shell.text + # Block statements are not indented because IDLE auto-indents. + initial_sidebar_lines = self.get_sidebar_lines() + + self.do_input(dedent('''\ + if True: + print(1) + ''')) + yield + self.assert_sidebar_lines_end_with(['>>>', '...', '...']) + with_block_sidebar_lines = self.get_sidebar_lines() + self.assertNotEqual(with_block_sidebar_lines, initial_sidebar_lines) + + # Control-C + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>']) + + # Recall previous via history + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>']) + + # Recall previous via recall + text.mark_set('insert', text.index('insert -2l')) + text.event_generate('<>') + yield + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>']) + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...']) + + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with( + ['>>>', '...', '...', '...', None, '>>>'] + ) + + @run_in_tk_mainloop() + def test_very_long_wrapped_line(self): + with support.adjust_int_max_str_digits(11_111), \ + swap_attr(self.shell, 'squeezer', None): + self.do_input('x = ' + '1'*10_000 + '\n') + yield + self.assertEqual(self.get_sidebar_lines(), ['>>>']) + + def test_font(self): + sidebar = self.shell.shell_sidebar + + test_font = 'TkTextFont' + + def mock_idleconf_GetFont(root, configType, section): + return test_font + GetFont_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + def cleanup(): + GetFont_patcher.stop() + sidebar.update_font() + self.addCleanup(cleanup) + + def get_sidebar_font(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fonts = {canvas.itemcget(text, 'font') for text in texts} + self.assertEqual(len(fonts), 1) + return next(iter(fonts)) + + self.assertNotEqual(get_sidebar_font(), test_font) + sidebar.update_font() + self.assertEqual(get_sidebar_font(), test_font) + + def test_highlight_colors(self): + sidebar = self.shell.shell_sidebar + + test_colors = {"background": '#abcdef', "foreground": '#123456'} + + orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element in ['linenumber', 'console']: + return test_colors + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetHighlight', + mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + def cleanup(): + GetHighlight_patcher.stop() + sidebar.update_colors() + self.addCleanup(cleanup) + + def get_sidebar_colors(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fgs = {canvas.itemcget(text, 'fill') for text in texts} + self.assertEqual(len(fgs), 1) + fg = next(iter(fgs)) + bg = canvas.cget('background') + return {"background": bg, "foreground": fg} + + self.assertNotEqual(get_sidebar_colors(), test_colors) + sidebar.update_colors() + self.assertEqual(get_sidebar_colors(), test_colors) + + @run_in_tk_mainloop() + def test_mousewheel(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + # Enter a 100-line string to scroll the shell screen down. + self.do_input('x = """' + '\n'*100 + '"""\n') + yield + self.assertGreater(get_lineno(text, '@0,0'), 1) + + last_lineno = get_end_linenumber(text) + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Delta for , whose meaning is platform-dependent. + delta = 1 if sidebar.canvas._windowingsystem == 'aqua' else 120 + + # Scroll up. + if sidebar.canvas._windowingsystem == 'x11': + sidebar.canvas.event_generate('', x=0, y=0) + else: + sidebar.canvas.event_generate('', x=0, y=0, delta=delta) + yield + self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Scroll back down. + if sidebar.canvas._windowingsystem == 'x11': + sidebar.canvas.event_generate('', x=0, y=0) + else: + sidebar.canvas.event_generate('', x=0, y=0, delta=-delta) + yield + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + @run_in_tk_mainloop() + def test_copy(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.0', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertStartsWith(selected_text, 'if True:\n') + self.assertIn('\n1\n', selected_text) + + text.event_generate('<>') + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text) + + @run_in_tk_mainloop() + def test_copy_with_prompts(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.3', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertStartsWith(selected_text, 'True:\n') + + selected_lines_text = text.get('sel.first linestart', 'sel.last') + selected_lines = selected_lines_text.split('\n') + selected_lines.pop() # Final '' is a split artifact, not a line. + # Expect a block of input and a single output line. + expected_prompts = \ + ['>>>'] + ['...'] * (len(selected_lines) - 2) + [None] + selected_text_with_prompts = '\n'.join( + line if prompt is None else prompt + ' ' + line + for prompt, line in zip(expected_prompts, + selected_lines, + strict=True) + ) + '\n' + + text.event_generate('<>') + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text_with_prompts) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py new file mode 100644 index 00000000000..86c21f00bb8 --- /dev/null +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -0,0 +1,467 @@ +"Test squeezer, coverage 95%" + +from textwrap import dedent +from tkinter import Text, Tk +import unittest +from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY +from test.support import requires + +from idlelib.config import idleConf +from idlelib.percolator import Percolator +from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ + Squeezer +from idlelib import macosx +from idlelib.textview import view_text +from idlelib.tooltip import Hovertip + +SENTINEL_VALUE = sentinel.SENTINEL_VALUE + + +def get_test_tk_root(test_instance): + """Helper for tests: Create a root Tk object.""" + requires('gui') + root = Tk() + root.withdraw() + + def cleanup_root(): + root.update_idletasks() + root.destroy() + test_instance.addCleanup(cleanup_root) + + return root + + +class CountLinesTest(unittest.TestCase): + """Tests for the count_lines_with_wrapping function.""" + def check(self, expected, text, linewidth): + return self.assertEqual( + expected, + count_lines_with_wrapping(text, linewidth), + ) + + def test_count_empty(self): + """Test with an empty string.""" + self.assertEqual(count_lines_with_wrapping(""), 0) + + def test_count_begins_with_empty_line(self): + """Test with a string which begins with a newline.""" + self.assertEqual(count_lines_with_wrapping("\ntext"), 2) + + def test_count_ends_with_empty_line(self): + """Test with a string which ends with a newline.""" + self.assertEqual(count_lines_with_wrapping("text\n"), 1) + + def test_count_several_lines(self): + """Test with several lines of text.""" + self.assertEqual(count_lines_with_wrapping("1\n2\n3\n"), 3) + + def test_empty_lines(self): + self.check(expected=1, text='\n', linewidth=80) + self.check(expected=2, text='\n\n', linewidth=80) + self.check(expected=10, text='\n' * 10, linewidth=80) + + def test_long_line(self): + self.check(expected=3, text='a' * 200, linewidth=80) + self.check(expected=3, text='a' * 200 + '\n', linewidth=80) + + def test_several_lines_different_lengths(self): + text = dedent("""\ + 13 characters + 43 is the number of characters on this line + + 7 chars + 13 characters""") + self.check(expected=5, text=text, linewidth=80) + self.check(expected=5, text=text + '\n', linewidth=80) + self.check(expected=6, text=text, linewidth=40) + self.check(expected=7, text=text, linewidth=20) + self.check(expected=11, text=text, linewidth=10) + + +class SqueezerTest(unittest.TestCase): + """Tests for the Squeezer class.""" + def make_mock_editor_window(self, with_text_widget=False): + """Create a mock EditorWindow instance.""" + editwin = NonCallableMagicMock() + editwin.width = 80 + + if with_text_widget: + editwin.root = get_test_tk_root(self) + text_widget = self.make_text_widget(root=editwin.root) + editwin.text = editwin.per.bottom = text_widget + + return editwin + + def make_squeezer_instance(self, editor_window=None): + """Create an actual Squeezer instance with a mock EditorWindow.""" + if editor_window is None: + editor_window = self.make_mock_editor_window() + squeezer = Squeezer(editor_window) + return squeezer + + def make_text_widget(self, root=None): + if root is None: + root = get_test_tk_root(self) + text_widget = Text(root) + text_widget["font"] = ('Courier', 10) + text_widget.mark_set("iomark", "1.0") + return text_widget + + def set_idleconf_option_with_cleanup(self, configType, section, option, value): + prev_val = idleConf.GetOption(configType, section, option) + idleConf.SetOption(configType, section, option, value) + self.addCleanup(idleConf.SetOption, + configType, section, option, prev_val) + + def test_count_lines(self): + """Test Squeezer.count_lines() with various inputs.""" + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + + for text_code, line_width, expected in [ + (r"'\n'", 80, 1), + (r"'\n' * 3", 80, 3), + (r"'a' * 40 + '\n'", 80, 1), + (r"'a' * 80 + '\n'", 80, 1), + (r"'a' * 200 + '\n'", 80, 3), + (r"'aa\t' * 20", 80, 2), + (r"'aa\t' * 21", 80, 3), + (r"'aa\t' * 20", 40, 4), + ]: + with self.subTest(text_code=text_code, + line_width=line_width, + expected=expected): + text = eval(text_code) + with patch.object(editwin, 'width', line_width): + self.assertEqual(squeezer.count_lines(text), expected) + + def test_init(self): + """Test the creation of Squeezer instances.""" + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + self.assertIs(squeezer.editwin, editwin) + self.assertEqual(squeezer.expandingbuttons, []) + + def test_write_no_tags(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + editwin = self.make_mock_editor_window() + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + + self.assertEqual(squeezer.editwin.write(text, ()), SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, ()) + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_write_not_stdout(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin = self.make_mock_editor_window() + editwin.write.return_value = SENTINEL_VALUE + orig_write = editwin.write + squeezer = self.make_squeezer_instance(editwin) + + self.assertEqual(squeezer.editwin.write(text, "stderr"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stderr") + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_write_stdout(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + requires('gui') + editwin = self.make_mock_editor_window() + + for text in ['', 'TEXT']: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 50 + + self.assertEqual(squeezer.editwin.write(text, "stdout"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stdout") + self.assertEqual(len(squeezer.expandingbuttons), 0) + + for text in ['LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 50 + + self.assertEqual(squeezer.editwin.write(text, "stdout"), None) + self.assertEqual(orig_write.call_count, 0) + self.assertEqual(len(squeezer.expandingbuttons), 1) + + def test_auto_squeeze(self): + """Test that the auto-squeezing creates an ExpandingButton properly.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 5 + squeezer.count_lines = Mock(return_value=6) + + editwin.write('TEXT\n'*6, "stdout") + self.assertEqual(text_widget.get('1.0', 'end'), '\n') + self.assertEqual(len(squeezer.expandingbuttons), 1) + + def test_squeeze_current_text(self): + """Test the squeeze_current_text method.""" + # Squeezing text should work for both stdout and stderr. + for tag_name in ["stdout", "stderr"]: + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget. + text_widget.insert("1.0", "SOME\nTEXT\n", tag_name) + text_widget.mark_set("insert", "1.0") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # Test squeezing the current text. + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 1) + self.assertEqual(squeezer.expandingbuttons[0].s, 'SOME\nTEXT') + + # Test that expanding the squeezed text works and afterwards + # the Text widget contains the original text. + squeezer.expandingbuttons[0].expand() + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_squeeze_current_text_no_allowed_tags(self): + """Test that the event doesn't squeeze text without a relevant tag.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget. + text_widget.insert("1.0", "SOME\nTEXT\n", "TAG") + text_widget.mark_set("insert", "1.0") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # Test squeezing the current text. + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_squeeze_text_before_existing_squeezed_text(self): + """Test squeezing text before existing squeezed text.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget and squeeze it. + text_widget.insert("1.0", "SOME\nTEXT\n", "stdout") + text_widget.mark_set("insert", "1.0") + squeezer.squeeze_current_text() + self.assertEqual(len(squeezer.expandingbuttons), 1) + + # Test squeezing the current text. + text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout") + text_widget.mark_set("insert", "1.0") + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 2) + self.assertTrue(text_widget.compare( + squeezer.expandingbuttons[0], + '<', + squeezer.expandingbuttons[1], + )) + + def test_reload(self): + """Test the reload() class-method.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + squeezer = self.make_squeezer_instance(editwin) + + orig_auto_squeeze_min_lines = squeezer.auto_squeeze_min_lines + + # Increase auto-squeeze-min-lines. + new_auto_squeeze_min_lines = orig_auto_squeeze_min_lines + 10 + self.set_idleconf_option_with_cleanup( + 'main', 'PyShell', 'auto-squeeze-min-lines', + str(new_auto_squeeze_min_lines)) + + Squeezer.reload() + self.assertEqual(squeezer.auto_squeeze_min_lines, + new_auto_squeeze_min_lines) + + def test_reload_no_squeezer_instances(self): + """Test that Squeezer.reload() runs without any instances existing.""" + Squeezer.reload() + + +class ExpandingButtonTest(unittest.TestCase): + """Tests for the ExpandingButton class.""" + # In these tests the squeezer instance is a mock, but actual tkinter + # Text and Button instances are created. + def make_mock_squeezer(self): + """Helper for tests: Create a mock Squeezer object.""" + root = get_test_tk_root(self) + squeezer = Mock() + squeezer.editwin.text = Text(root) + squeezer.editwin.per = Percolator(squeezer.editwin.text) + self.addCleanup(squeezer.editwin.per.close) + + # Set default values for the configuration settings. + squeezer.auto_squeeze_min_lines = 50 + return squeezer + + @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) + def test_init(self, MockHovertip): + """Test the simplest creation of an ExpandingButton.""" + squeezer = self.make_mock_squeezer() + text_widget = squeezer.editwin.text + + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + self.assertEqual(expandingbutton.s, 'TEXT') + + # Check that the underlying tkinter.Button is properly configured. + self.assertEqual(expandingbutton.master, text_widget) + self.assertTrue('50 lines' in expandingbutton.cget('text')) + + # Check that the text widget still contains no text. + self.assertEqual(text_widget.get('1.0', 'end'), '\n') + + # Check that the mouse events are bound. + self.assertIn('', expandingbutton.bind()) + right_button_code = '' % ('2' if macosx.isAquaTk() else '3') + self.assertIn(right_button_code, expandingbutton.bind()) + + # Check that ToolTip was called once, with appropriate values. + self.assertEqual(MockHovertip.call_count, 1) + MockHovertip.assert_called_with(expandingbutton, ANY, hover_delay=ANY) + + # Check that 'right-click' appears in the tooltip text. + tooltip_text = MockHovertip.call_args[0][1] + self.assertIn('right-click', tooltip_text.lower()) + + def test_expand(self): + """Test the expand event.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + + # Insert the button into the text widget + # (this is normally done by the Squeezer class). + text_widget = squeezer.editwin.text + text_widget.window_create("1.0", window=expandingbutton) + + # trigger the expand event + retval = expandingbutton.expand(event=Mock()) + self.assertEqual(retval, None) + + # Check that the text was inserted into the text widget. + self.assertEqual(text_widget.get('1.0', 'end'), 'TEXT\n') + + # Check that the 'TAGS' tag was set on the inserted text. + text_end_index = text_widget.index('end-1c') + self.assertEqual(text_widget.get('1.0', text_end_index), 'TEXT') + self.assertEqual(text_widget.tag_nextrange('TAGS', '1.0'), + ('1.0', text_end_index)) + + # Check that the button removed itself from squeezer.expandingbuttons. + self.assertEqual(squeezer.expandingbuttons.remove.call_count, 1) + squeezer.expandingbuttons.remove.assert_called_with(expandingbutton) + + def test_expand_dangerous_oupput(self): + """Test that expanding very long output asks user for confirmation.""" + squeezer = self.make_mock_squeezer() + text = 'a' * 10**5 + expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer) + expandingbutton.set_is_dangerous() + self.assertTrue(expandingbutton.is_dangerous) + + # Insert the button into the text widget + # (this is normally done by the Squeezer class). + text_widget = expandingbutton.text + text_widget.window_create("1.0", window=expandingbutton) + + # Patch the message box module to always return False. + with patch('idlelib.squeezer.messagebox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = False + mock_msgbox.askyesno.return_value = False + # Trigger the expand event. + retval = expandingbutton.expand(event=Mock()) + + # Check that the event chain was broken and no text was inserted. + self.assertEqual(retval, 'break') + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), '') + + # Patch the message box module to always return True. + with patch('idlelib.squeezer.messagebox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = True + mock_msgbox.askyesno.return_value = True + # Trigger the expand event. + retval = expandingbutton.expand(event=Mock()) + + # Check that the event chain wasn't broken and the text was inserted. + self.assertEqual(retval, None) + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text) + + def test_copy(self): + """Test the copy event.""" + # Testing with the actual clipboard proved problematic, so this + # test replaces the clipboard manipulation functions with mocks + # and checks that they are called appropriately. + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + expandingbutton.clipboard_clear = Mock() + expandingbutton.clipboard_append = Mock() + + # Trigger the copy event. + retval = expandingbutton.copy(event=Mock()) + self.assertEqual(retval, None) + + # Vheck that the expanding button called clipboard_clear() and + # clipboard_append('TEXT') once each. + self.assertEqual(expandingbutton.clipboard_clear.call_count, 1) + self.assertEqual(expandingbutton.clipboard_append.call_count, 1) + expandingbutton.clipboard_append.assert_called_with('TEXT') + + def test_view(self): + """Test the view event.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + expandingbutton.selection_own = Mock() + + with patch('idlelib.squeezer.view_text', autospec=view_text)\ + as mock_view_text: + # Trigger the view event. + expandingbutton.view(event=Mock()) + + # Check that the expanding button called view_text. + self.assertEqual(mock_view_text.call_count, 1) + + # Check that the proper text was passed. + self.assertEqual(mock_view_text.call_args[0][2], 'TEXT') + + def test_rmenu(self): + """Test the context menu.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + with patch('tkinter.Menu') as mock_Menu: + mock_menu = Mock() + mock_Menu.return_value = mock_menu + mock_event = Mock() + mock_event.x = 10 + mock_event.y = 10 + expandingbutton.context_menu_event(event=mock_event) + self.assertEqual(mock_menu.add_command.call_count, + len(expandingbutton.rmenu_specs)) + for label, *data in expandingbutton.rmenu_specs: + mock_menu.add_command.assert_any_call(label=label, command=ANY) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_stackviewer.py b/Lib/idlelib/idle_test/test_stackviewer.py new file mode 100644 index 00000000000..55f510382bf --- /dev/null +++ b/Lib/idlelib/idle_test/test_stackviewer.py @@ -0,0 +1,41 @@ +"Test stackviewer, coverage 63%." + +from idlelib import stackviewer +import unittest +from test.support import requires +from tkinter import Tk + +from idlelib.tree import TreeNode, ScrolledCanvas + + +class StackBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + try: + abc + except NameError as exc: + sb = stackviewer.StackBrowser(self.root, exc) + isi = self.assertIsInstance + isi(stackviewer.sc, ScrolledCanvas) + isi(stackviewer.item, stackviewer.StackTreeItem) + isi(stackviewer.node, TreeNode) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_statusbar.py b/Lib/idlelib/idle_test/test_statusbar.py new file mode 100644 index 00000000000..203a57db89c --- /dev/null +++ b/Lib/idlelib/idle_test/test_statusbar.py @@ -0,0 +1,41 @@ +"Test statusbar, coverage 100%." + +from idlelib import statusbar +import unittest +from test.support import requires +from tkinter import Tk + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_init(self): + bar = statusbar.MultiStatusBar(self.root) + self.assertEqual(bar.labels, {}) + + def test_set_label(self): + bar = statusbar.MultiStatusBar(self.root) + bar.set_label('left', text='sometext', width=10) + self.assertIn('left', bar.labels) + left = bar.labels['left'] + self.assertEqual(left['text'], 'sometext') + self.assertEqual(left['width'], 10) + bar.set_label('left', text='revised text') + self.assertEqual(left['text'], 'revised text') + bar.set_label('right', text='correct text') + self.assertEqual(bar.labels['right']['text'], 'correct text') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_text.py b/Lib/idlelib/idle_test/test_text.py new file mode 100644 index 00000000000..43a9ba02c3d --- /dev/null +++ b/Lib/idlelib/idle_test/test_text.py @@ -0,0 +1,236 @@ +''' Test mock_tk.Text class against tkinter.Text class + +Run same tests with both by creating a mixin class. +''' +import unittest +from test.support import requires +from _tkinter import TclError + +class TextTest: + "Define items common to both sets of tests." + + hw = 'hello\nworld' # Several tests insert this after initialization. + hwn = hw+'\n' # \n present at initialization, before insert + + # setUpClass defines cls.Text and maybe cls.root. + # setUp defines self.text from Text and maybe root. + + def test_init(self): + self.assertEqual(self.text.get('1.0'), '\n') + self.assertEqual(self.text.get('end'), '') + + def test_index_empty(self): + index = self.text.index + + for dex in (-1.0, 0.3, '1.-1', '1.0', '1.0 lineend', '1.end', '1.33', + 'insert'): + self.assertEqual(index(dex), '1.0') + + for dex in 'end', 2.0, '2.1', '33.44': + self.assertEqual(index(dex), '2.0') + + def test_index_data(self): + index = self.text.index + self.text.insert('1.0', self.hw) + + for dex in -1.0, 0.3, '1.-1', '1.0': + self.assertEqual(index(dex), '1.0') + + for dex in '1.0 lineend', '1.end', '1.33': + self.assertEqual(index(dex), '1.5') + + for dex in 'end', '33.44': + self.assertEqual(index(dex), '3.0') + + def test_get(self): + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + Equal(get('end'), '') + Equal(get('end', 'end'), '') + Equal(get('1.0'), 'h') + Equal(get('1.0', '1.1'), 'h') + Equal(get('1.0', '1.3'), 'hel') + Equal(get('1.1', '1.3'), 'el') + Equal(get('1.0', '1.0 lineend'), 'hello') + Equal(get('1.0', '1.10'), 'hello') + Equal(get('1.0 lineend'), '\n') + Equal(get('1.1', '2.3'), 'ello\nwor') + Equal(get('1.0', '2.5'), self.hw) + Equal(get('1.0', 'end'), self.hwn) + Equal(get('0.0', '5.0'), self.hwn) + + def test_insert(self): + insert = self.text.insert + get = self.text.get + Equal = self.assertEqual + + insert('1.0', self.hw) + Equal(get('1.0', 'end'), self.hwn) + + insert('1.0', '') # nothing + Equal(get('1.0', 'end'), self.hwn) + + insert('1.0', '*') + Equal(get('1.0', 'end'), '*hello\nworld\n') + + insert('1.0 lineend', '*') + Equal(get('1.0', 'end'), '*hello*\nworld\n') + + insert('2.3', '*') + Equal(get('1.0', 'end'), '*hello*\nwor*ld\n') + + insert('end', 'x') + Equal(get('1.0', 'end'), '*hello*\nwor*ldx\n') + + insert('1.4', 'x\n') + Equal(get('1.0', 'end'), '*helx\nlo*\nwor*ldx\n') + + def test_no_delete(self): + # if index1 == 'insert' or 'end' or >= end, there is no deletion + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('insert') + Equal(get('1.0', 'end'), self.hwn) + + delete('end') + Equal(get('1.0', 'end'), self.hwn) + + delete('insert', 'end') + Equal(get('1.0', 'end'), self.hwn) + + delete('insert', '5.5') + Equal(get('1.0', 'end'), self.hwn) + + delete('1.4', '1.0') + Equal(get('1.0', 'end'), self.hwn) + + delete('1.4', '1.4') + Equal(get('1.0', 'end'), self.hwn) + + def test_delete_char(self): + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('1.0') + Equal(get('1.0', '1.end'), 'ello') + + delete('1.0', '1.1') + Equal(get('1.0', '1.end'), 'llo') + + # delete \n and combine 2 lines into 1 + delete('1.end') + Equal(get('1.0', '1.end'), 'lloworld') + + self.text.insert('1.3', '\n') + delete('1.10') + Equal(get('1.0', '1.end'), 'lloworld') + + self.text.insert('1.3', '\n') + delete('1.3', '2.0') + Equal(get('1.0', '1.end'), 'lloworld') + + def test_delete_slice(self): + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('1.0', '1.0 lineend') + Equal(get('1.0', 'end'), '\nworld\n') + + delete('1.0', 'end') + Equal(get('1.0', 'end'), '\n') + + self.text.insert('1.0', self.hw) + delete('1.0', '2.0') + Equal(get('1.0', 'end'), 'world\n') + + delete('1.0', 'end') + Equal(get('1.0', 'end'), '\n') + + self.text.insert('1.0', self.hw) + delete('1.2', '2.3') + Equal(get('1.0', 'end'), 'held\n') + + def test_multiple_lines(self): # insert and delete + self.text.insert('1.0', 'hello') + + self.text.insert('1.3', '1\n2\n3\n4\n5') + self.assertEqual(self.text.get('1.0', 'end'), 'hel1\n2\n3\n4\n5lo\n') + + self.text.delete('1.3', '5.1') + self.assertEqual(self.text.get('1.0', 'end'), 'hello\n') + + def test_compare(self): + compare = self.text.compare + Equal = self.assertEqual + # need data so indexes not squished to 1,0 + self.text.insert('1.0', 'First\nSecond\nThird\n') + + self.assertRaises(TclError, compare, '2.2', 'op', '2.2') + + for op, less1, less0, equal, greater0, greater1 in ( + ('<', True, True, False, False, False), + ('<=', True, True, True, False, False), + ('>', False, False, False, True, True), + ('>=', False, False, True, True, True), + ('==', False, False, True, False, False), + ('!=', True, True, False, True, True), + ): + Equal(compare('1.1', op, '2.2'), less1, op) + Equal(compare('2.1', op, '2.2'), less0, op) + Equal(compare('2.2', op, '2.2'), equal, op) + Equal(compare('2.3', op, '2.2'), greater0, op) + Equal(compare('3.3', op, '2.2'), greater1, op) + + +class MockTextTest(TextTest, unittest.TestCase): + + @classmethod + def setUpClass(cls): + from idlelib.idle_test.mock_tk import Text + cls.Text = Text + + def setUp(self): + self.text = self.Text() + + + def test_decode(self): + # test endflags (-1, 0) not tested by test_index (which uses +1) + decode = self.text._decode + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + Equal(decode('end', -1), (2, 5)) + Equal(decode('3.1', -1), (2, 5)) + Equal(decode('end', 0), (2, 6)) + Equal(decode('3.1', 0), (2, 6)) + + +class TkTextTest(TextTest, unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + from tkinter import Tk, Text + cls.Text = Text + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.text = self.Text(self.root) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_textview.py b/Lib/idlelib/idle_test/test_textview.py new file mode 100644 index 00000000000..7189378ab3d --- /dev/null +++ b/Lib/idlelib/idle_test/test_textview.py @@ -0,0 +1,233 @@ +"""Test textview, coverage 100%. + +Since all methods and functions create (or destroy) a ViewWindow, which +is a widget containing a widget, etcetera, all tests must be gui tests. +Using mock Text would not change this. Other mocks are used to retrieve +information about calls. +""" +from idlelib import textview as tv +from test.support import requires +requires('gui') + +import os +import unittest +from tkinter import Tk, TclError, CHAR, NONE, WORD +from tkinter.ttk import Button +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func + +def setUpModule(): + global root + root = Tk() + root.withdraw() + +def tearDownModule(): + global root + root.update_idletasks() + root.destroy() + del root + +# If we call ViewWindow or wrapper functions with defaults +# modal=True, _utest=False, test hangs on call to wait_window. +# Have also gotten tk error 'can't invoke "event" command'. + + +class VW(tv.ViewWindow): # Used in ViewWindowTest. + transient = Func() + grab_set = Func() + wait_window = Func() + + +# Call wrapper class VW with mock wait_window. +class ViewWindowTest(unittest.TestCase): + + def setUp(self): + VW.transient.__init__() + VW.grab_set.__init__() + VW.wait_window.__init__() + + def test_init_modal(self): + view = VW(root, 'Title', 'test text') + self.assertTrue(VW.transient.called) + self.assertTrue(VW.grab_set.called) + self.assertTrue(VW.wait_window.called) + view.ok() + + def test_init_nonmodal(self): + view = VW(root, 'Title', 'test text', modal=False) + self.assertFalse(VW.transient.called) + self.assertFalse(VW.grab_set.called) + self.assertFalse(VW.wait_window.called) + view.ok() + + def test_ok(self): + view = VW(root, 'Title', 'test text', modal=False) + view.destroy = Func() + view.ok() + self.assertTrue(view.destroy.called) + del view.destroy # Unmask real function. + view.destroy() + + +class AutoHideScrollbarTest(unittest.TestCase): + # Method set is tested in ScrollableTextFrameTest + def test_forbidden_geometry(self): + scroll = tv.AutoHideScrollbar(root) + self.assertRaises(TclError, scroll.pack) + self.assertRaises(TclError, scroll.place) + + +class ScrollableTextFrameTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = root = Tk() + root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def make_frame(self, wrap=NONE, **kwargs): + frame = tv.ScrollableTextFrame(self.root, wrap=wrap, **kwargs) + def cleanup_frame(): + frame.update_idletasks() + frame.destroy() + self.addCleanup(cleanup_frame) + return frame + + def test_line1(self): + frame = self.make_frame() + frame.text.insert('1.0', 'test text') + self.assertEqual(frame.text.get('1.0', '1.end'), 'test text') + + def test_horiz_scrollbar(self): + # The horizontal scrollbar should be shown/hidden according to + # the 'wrap' setting: It should only be shown when 'wrap' is + # set to NONE. + + # wrap = NONE -> with horizontal scrolling + frame = self.make_frame(wrap=NONE) + self.assertEqual(frame.text.cget('wrap'), NONE) + self.assertIsNotNone(frame.xscroll) + + # wrap != NONE -> no horizontal scrolling + for wrap in [CHAR, WORD]: + with self.subTest(wrap=wrap): + frame = self.make_frame(wrap=wrap) + self.assertEqual(frame.text.cget('wrap'), wrap) + self.assertIsNone(frame.xscroll) + + +class ViewFrameTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = root = Tk() + root.withdraw() + cls.frame = tv.ViewFrame(root, 'test text') + + @classmethod + def tearDownClass(cls): + del cls.frame + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_line1(self): + get = self.frame.text.get + self.assertEqual(get('1.0', '1.end'), 'test text') + + +# Call ViewWindow with modal=False. +class ViewFunctionTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.orig_error = tv.showerror + tv.showerror = Mbox_func() + + @classmethod + def tearDownClass(cls): + tv.showerror = cls.orig_error + del cls.orig_error + + def test_view_text(self): + view = tv.view_text(root, 'Title', 'test text', modal=False) + self.assertIsInstance(view, tv.ViewWindow) + self.assertIsInstance(view.viewframe, tv.ViewFrame) + view.viewframe.ok() + + def test_view_file(self): + view = tv.view_file(root, 'Title', __file__, 'ascii', modal=False) + self.assertIsInstance(view, tv.ViewWindow) + self.assertIsInstance(view.viewframe, tv.ViewFrame) + get = view.viewframe.textframe.text.get + self.assertIn('Test', get('1.0', '1.end')) + view.ok() + + def test_bad_file(self): + # Mock showerror will be used; view_file will return None. + view = tv.view_file(root, 'Title', 'abc.xyz', 'ascii', modal=False) + self.assertIsNone(view) + self.assertEqual(tv.showerror.title, 'File Load Error') + + def test_bad_encoding(self): + p = os.path + fn = p.abspath(p.join(p.dirname(__file__), '..', 'CREDITS.txt')) + view = tv.view_file(root, 'Title', fn, 'ascii', modal=False) + self.assertIsNone(view) + self.assertEqual(tv.showerror.title, 'Unicode Decode Error') + + def test_nowrap(self): + view = tv.view_text(root, 'Title', 'test', modal=False, wrap='none') + text_widget = view.viewframe.textframe.text + self.assertEqual(text_widget.cget('wrap'), 'none') + + +# Call ViewWindow with _utest=True. +class ButtonClickTest(unittest.TestCase): + + def setUp(self): + self.view = None + self.called = False + + def tearDown(self): + if self.view: + self.view.destroy() + + def test_view_text_bind_with_button(self): + def _command(): + self.called = True + self.view = tv.view_text(root, 'TITLE_TEXT', 'COMMAND', _utest=True) + button = Button(root, text='BUTTON', command=_command) + button.invoke() + self.addCleanup(button.destroy) + + self.assertEqual(self.called, True) + self.assertEqual(self.view.title(), 'TITLE_TEXT') + self.assertEqual(self.view.viewframe.textframe.text.get('1.0', '1.end'), + 'COMMAND') + + def test_view_file_bind_with_button(self): + def _command(): + self.called = True + self.view = tv.view_file(root, 'TITLE_FILE', __file__, + encoding='ascii', _utest=True) + button = Button(root, text='BUTTON', command=_command) + button.invoke() + self.addCleanup(button.destroy) + + self.assertEqual(self.called, True) + self.assertEqual(self.view.title(), 'TITLE_FILE') + get = self.view.viewframe.textframe.text.get + with open(__file__) as f: + self.assertEqual(get('1.0', '1.end'), f.readline().strip()) + f.readline() + self.assertEqual(get('3.0', '3.end'), f.readline().strip()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_tooltip.py b/Lib/idlelib/idle_test/test_tooltip.py new file mode 100644 index 00000000000..c616d4fde3b --- /dev/null +++ b/Lib/idlelib/idle_test/test_tooltip.py @@ -0,0 +1,161 @@ +"""Test tooltip, coverage 100%. + +Coverage is 100% after excluding 6 lines with "# pragma: no cover". +They involve TclErrors that either should or should not happen in a +particular situation, and which are 'pass'ed if they do. +""" + +from idlelib.tooltip import TooltipBase, Hovertip +from test.support import requires +requires('gui') + +from functools import wraps +import time +from tkinter import Button, Tk, Toplevel +import unittest + + +def setUpModule(): + global root + root = Tk() + +def tearDownModule(): + global root + root.update_idletasks() + root.destroy() + del root + + +def add_call_counting(func): + @wraps(func) + def wrapped_func(*args, **kwargs): + wrapped_func.call_args_list.append((args, kwargs)) + return func(*args, **kwargs) + wrapped_func.call_args_list = [] + return wrapped_func + + +def _make_top_and_button(testobj): + global root + top = Toplevel(root) + testobj.addCleanup(top.destroy) + top.title("Test tooltip") + button = Button(top, text='ToolTip test button') + button.pack() + testobj.addCleanup(button.destroy) + top.lift() + return top, button + + +class ToolTipBaseTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def test_base_class_is_unusable(self): + global root + top = Toplevel(root) + self.addCleanup(top.destroy) + + button = Button(top, text='ToolTip test button') + button.pack() + self.addCleanup(button.destroy) + + with self.assertRaises(NotImplementedError): + tooltip = TooltipBase(button) + tooltip.showtip() + + +class HovertipTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def is_tipwindow_shown(self, tooltip): + return tooltip.tipwindow and tooltip.tipwindow.winfo_viewable() + + def test_showtip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(self.is_tipwindow_shown(tooltip)) + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + + def test_showtip_twice(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(self.is_tipwindow_shown(tooltip)) + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + orig_tipwindow = tooltip.tipwindow + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + self.assertIs(tooltip.tipwindow, orig_tipwindow) + + def test_hidetip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + tooltip.showtip() + tooltip.hidetip() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + + def test_showtip_on_mouse_enter_no_delay(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + self.button.event_generate('', x=0, y=0) + root.update() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + def test_hover_with_delay(self): + # Run multiple tests requiring an actual delay simultaneously. + + # Test #1: A hover tip with a non-zero delay appears after the delay. + tooltip1 = Hovertip(self.button, 'ToolTip text', hover_delay=100) + self.addCleanup(tooltip1.hidetip) + tooltip1.showtip = add_call_counting(tooltip1.showtip) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip1)) + self.button.event_generate('', x=0, y=0) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip1)) + + # Test #2: A hover tip with a non-zero delay doesn't appear when + # the mouse stops hovering over the base widget before the delay + # expires. + tooltip2 = Hovertip(self.button, 'ToolTip text', hover_delay=100) + self.addCleanup(tooltip2.hidetip) + tooltip2.showtip = add_call_counting(tooltip2.showtip) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + + time.sleep(0.15) + root.update() + + # Test #1 assertions. + self.assertTrue(self.is_tipwindow_shown(tooltip1)) + self.assertGreater(len(tooltip1.showtip.call_args_list), 0) + + # Test #2 assertions. + self.assertFalse(self.is_tipwindow_shown(tooltip2)) + self.assertEqual(tooltip2.showtip.call_args_list, []) + + def test_hidetip_on_mouse_leave(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_tree.py b/Lib/idlelib/idle_test/test_tree.py new file mode 100644 index 00000000000..b3e4c10cf9e --- /dev/null +++ b/Lib/idlelib/idle_test/test_tree.py @@ -0,0 +1,60 @@ +"Test tree. coverage 56%." + +from idlelib import tree +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, EventType, SCROLL + + +class TreeTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_init(self): + # Start with code slightly adapted from htest. + sc = tree.ScrolledCanvas( + self.root, bg="white", highlightthickness=0, takefocus=1) + sc.frame.pack(expand=1, fill="both", side='left') + item = tree.FileTreeItem(tree.ICONDIR) + node = tree.TreeNode(sc.canvas, None, item) + node.expand() + + +class TestScrollEvent(unittest.TestCase): + + def test_wheel_event(self): + # Fake widget class containing `yview` only. + class _Widget: + def __init__(widget, *expected): + widget.expected = expected + def yview(widget, *args): + self.assertTupleEqual(widget.expected, args) + # Fake event class + class _Event: + pass + # (type, delta, num, amount) + tests = ((EventType.MouseWheel, 120, -1, -5), + (EventType.MouseWheel, -120, -1, 5), + (EventType.ButtonPress, -1, 4, -5), + (EventType.ButtonPress, -1, 5, 5)) + + event = _Event() + for ty, delta, num, amount in tests: + event.type = ty + event.delta = delta + event.num = num + res = tree.wheel_event(event, _Widget(SCROLL, amount, "units")) + self.assertEqual(res, "break") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_undo.py b/Lib/idlelib/idle_test/test_undo.py new file mode 100644 index 00000000000..beb5b582039 --- /dev/null +++ b/Lib/idlelib/idle_test/test_undo.py @@ -0,0 +1,135 @@ +"Test undo, coverage 77%." +# Only test UndoDelegator so far. + +from idlelib.undo import UndoDelegator +import unittest +from test.support import requires +requires('gui') + +from unittest.mock import Mock +from tkinter import Text, Tk +from idlelib.percolator import Percolator + + +class UndoDelegatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.text = Text(cls.root) + cls.percolator = Percolator(cls.text) + + @classmethod + def tearDownClass(cls): + cls.percolator.redir.close() + del cls.percolator, cls.text + cls.root.destroy() + del cls.root + + def setUp(self): + self.delegator = UndoDelegator() + self.delegator.bell = Mock() + self.percolator.insertfilter(self.delegator) + + def tearDown(self): + self.percolator.removefilter(self.delegator) + self.text.delete('1.0', 'end') + self.delegator.resetcache() + + def test_undo_event(self): + text = self.text + + text.insert('insert', 'foobar') + text.insert('insert', 'h') + text.event_generate('<>') + self.assertEqual(text.get('1.0', 'end'), '\n') + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.2', '1.4') + text.insert('insert', 'hello') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.4'), 'foar') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.6'), 'foobar') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.3'), 'foo') + text.event_generate('<>') + self.delegator.undo_event('event') + self.assertTrue(self.delegator.bell.called) + + def test_redo_event(self): + text = self.text + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.0', '1.3') + text.event_generate('<>') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.3'), 'bar') + text.event_generate('<>') + self.assertTrue(self.delegator.bell.called) + + def test_dump_event(self): + """ + Dump_event cannot be tested directly without changing + environment variables. So, test statements in dump_event + indirectly + """ + text = self.text + d = self.delegator + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.2', '1.4') + self.assertTupleEqual((d.pointer, d.can_merge), (3, True)) + text.event_generate('<>') + self.assertTupleEqual((d.pointer, d.can_merge), (2, False)) + + def test_get_set_saved(self): + # test the getter method get_saved + # test the setter method set_saved + # indirectly test check_saved + d = self.delegator + + self.assertTrue(d.get_saved()) + self.text.insert('insert', 'a') + self.assertFalse(d.get_saved()) + d.saved_change_hook = Mock() + + d.set_saved(True) + self.assertEqual(d.pointer, d.saved) + self.assertTrue(d.saved_change_hook.called) + + d.set_saved(False) + self.assertEqual(d.saved, -1) + self.assertTrue(d.saved_change_hook.called) + + def test_undo_start_stop(self): + # test the undo_block_start and undo_block_stop methods + text = self.text + + text.insert('insert', 'foo') + self.delegator.undo_block_start() + text.insert('insert', 'bar') + text.insert('insert', 'bar') + self.delegator.undo_block_stop() + self.assertEqual(text.get('1.0', '1.3'), 'foo') + + # test another code path + self.delegator.undo_block_start() + text.insert('insert', 'bar') + self.delegator.undo_block_stop() + self.assertEqual(text.get('1.0', '1.3'), 'foo') + + def test_addcmd(self): + text = self.text + # when number of undo operations exceeds max_undo + self.delegator.max_undo = max_undo = 10 + for i in range(max_undo + 10): + text.insert('insert', 'foo') + self.assertLessEqual(len(self.delegator.undolist), max_undo) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_util.py b/Lib/idlelib/idle_test/test_util.py new file mode 100644 index 00000000000..20721fe980c --- /dev/null +++ b/Lib/idlelib/idle_test/test_util.py @@ -0,0 +1,14 @@ +"""Test util, coverage 100%""" + +import unittest +from idlelib import util + + +class UtilTest(unittest.TestCase): + def test_extensions(self): + for extension in {'.pyi', '.py', '.pyw'}: + self.assertIn(extension, util.py_extensions) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_warning.py b/Lib/idlelib/idle_test/test_warning.py new file mode 100644 index 00000000000..221068c5885 --- /dev/null +++ b/Lib/idlelib/idle_test/test_warning.py @@ -0,0 +1,73 @@ +'''Test warnings replacement in pyshell.py and run.py. + +This file could be expanded to include traceback overrides +(in same two modules). If so, change name. +Revise if output destination changes (http://bugs.python.org/issue18318). +Make sure warnings module is left unaltered (http://bugs.python.org/issue18081). +''' +from idlelib import run +from idlelib import pyshell as shell +import unittest +from test.support import captured_stderr +import warnings + +# Try to capture default showwarning before Idle modules are imported. +showwarning = warnings.showwarning +# But if we run this file within idle, we are in the middle of the run.main loop +# and default showwarnings has already been replaced. +running_in_idle = 'idle' in showwarning.__name__ + +# The following was generated from pyshell.idle_formatwarning +# and checked as matching expectation. +idlemsg = ''' +Warning (from warnings module): + File "test_warning.py", line 99 + Line of code +UserWarning: Test +''' +shellmsg = idlemsg + ">>> " + + +class RunWarnTest(unittest.TestCase): + + @unittest.skipIf(running_in_idle, "Does not work when run within Idle.") + def test_showwarnings(self): + self.assertIs(warnings.showwarning, showwarning) + run.capture_warnings(True) + self.assertIs(warnings.showwarning, run.idle_showwarning_subproc) + run.capture_warnings(False) + self.assertIs(warnings.showwarning, showwarning) + + def test_run_show(self): + with captured_stderr() as f: + run.idle_showwarning_subproc( + 'Test', UserWarning, 'test_warning.py', 99, f, 'Line of code') + # The following uses .splitlines to erase line-ending differences + self.assertEqual(idlemsg.splitlines(), f.getvalue().splitlines()) + + +class ShellWarnTest(unittest.TestCase): + + @unittest.skipIf(running_in_idle, "Does not work when run within Idle.") + def test_showwarnings(self): + self.assertIs(warnings.showwarning, showwarning) + shell.capture_warnings(True) + self.assertIs(warnings.showwarning, shell.idle_showwarning) + shell.capture_warnings(False) + self.assertIs(warnings.showwarning, showwarning) + + def test_idle_formatter(self): + # Will fail if format changed without regenerating idlemsg + s = shell.idle_formatwarning( + 'Test', UserWarning, 'test_warning.py', 99, 'Line of code') + self.assertEqual(idlemsg, s) + + def test_shell_show(self): + with captured_stderr() as f: + shell.idle_showwarning( + 'Test', UserWarning, 'test_warning.py', 99, f, 'Line of code') + self.assertEqual(shellmsg.splitlines(), f.getvalue().splitlines()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_window.py b/Lib/idlelib/idle_test/test_window.py new file mode 100644 index 00000000000..5a2645b9cc2 --- /dev/null +++ b/Lib/idlelib/idle_test/test_window.py @@ -0,0 +1,45 @@ +"Test window, coverage 47%." + +from idlelib import window +import unittest +from test.support import requires +from tkinter import Tk + + +class WindowListTest(unittest.TestCase): + + def test_init(self): + wl = window.WindowList() + self.assertEqual(wl.dict, {}) + self.assertEqual(wl.callbacks, []) + + # Further tests need mock Window. + + +class ListedToplevelTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + window.registry = set() + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + window.registry = window.WindowList() + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + + win = window.ListedToplevel(self.root) + self.assertIn(win, window.registry) + self.assertEqual(win.focused_widget, win) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_zoomheight.py b/Lib/idlelib/idle_test/test_zoomheight.py new file mode 100644 index 00000000000..aa5bdfb4fbd --- /dev/null +++ b/Lib/idlelib/idle_test/test_zoomheight.py @@ -0,0 +1,39 @@ +"Test zoomheight, coverage 66%." +# Some code is system dependent. + +from idlelib import zoomheight +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.editwin = EditorWindow(root=cls.root) + + @classmethod + def tearDownClass(cls): + cls.editwin._close() + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + zoom = zoomheight.ZoomHeight(self.editwin) + self.assertIs(zoom.editwin, self.editwin) + + def test_zoom_height_event(self): + zoom = zoomheight.ZoomHeight(self.editwin) + zoom.zoom_height_event() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_zzdummy.py b/Lib/idlelib/idle_test/test_zzdummy.py new file mode 100644 index 00000000000..209d8564da0 --- /dev/null +++ b/Lib/idlelib/idle_test/test_zzdummy.py @@ -0,0 +1,152 @@ +"Test zzdummy, coverage 100%." + +from idlelib import zzdummy +import unittest +from test.support import requires +from tkinter import Tk, Text +from unittest import mock +from idlelib import config +from idlelib import editor +from idlelib import format + + +usercfg = zzdummy.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +code_sample = """\ + +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b +""" + + +class DummyEditwin: + get_selection_indices = editor.EditorWindow.get_selection_indices + def __init__(self, root, text): + self.root = root + self.top = root + self.text = text + self.fregion = format.FormatRegion(self) + self.text.undo_block_start = mock.Mock() + self.text.undo_block_stop = mock.Mock() + + +class ZZDummyTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(cls.root) + cls.editor = DummyEditwin(root, text) + zzdummy.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + zzdummy.idleConf.userCfg = usercfg + del cls.editor, cls.text + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def setUp(self): + text = self.text + text.insert('1.0', code_sample) + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + zz = self.zz = zzdummy.ZzDummy(self.editor) + zzdummy.ZzDummy.ztext = '# ignore #' + + def tearDown(self): + self.text.delete('1.0', 'end') + del self.zz + + def checklines(self, text, value): + # Verify that there are lines being checked. + end_line = int(float(text.index('end'))) + + # Check each line for the starting text. + actual = [] + for line in range(1, end_line): + txt = text.get(f'{line}.0', f'{line}.end') + actual.append(txt.startswith(value)) + return actual + + def test_init(self): + zz = self.zz + self.assertEqual(zz.editwin, self.editor) + self.assertEqual(zz.text, self.editor.text) + + def test_reload(self): + self.assertEqual(self.zz.ztext, '# ignore #') + testcfg['extensions'].SetOption('ZzDummy', 'z-text', 'spam') + zzdummy.ZzDummy.reload() + self.assertEqual(self.zz.ztext, 'spam') + + def test_z_in_event(self): + eq = self.assertEqual + zz = self.zz + text = zz.text + eq(self.zz.ztext, '# ignore #') + + # No lines have the leading text. + expected = [False, False, False, False, False, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.tag_add('sel', '2.0', '4.end') + eq(zz.z_in_event(), 'break') + expected = [False, True, True, True, False, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.undo_block_start.assert_called_once() + text.undo_block_stop.assert_called_once() + + def test_z_out_event(self): + eq = self.assertEqual + zz = self.zz + text = zz.text + eq(self.zz.ztext, '# ignore #') + + # Prepend text. + text.tag_add('sel', '2.0', '5.end') + zz.z_in_event() + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + + # Select a few lines to remove text. + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '3.0', '4.end') + eq(zz.z_out_event(), 'break') + expected = [False, True, False, False, True, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.undo_block_start.assert_called_once() + text.undo_block_stop.assert_called_once() + + def test_roundtrip(self): + # Insert and remove to all code should give back original text. + zz = self.zz + text = zz.text + + text.tag_add('sel', '1.0', 'end-1c') + zz.z_in_event() + zz.z_out_event() + + self.assertEqual(text.get('1.0', 'end-1c'), code_sample) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/tkinter_testing_utils.py b/Lib/idlelib/idle_test/tkinter_testing_utils.py new file mode 100644 index 00000000000..a89839bbe38 --- /dev/null +++ b/Lib/idlelib/idle_test/tkinter_testing_utils.py @@ -0,0 +1,62 @@ +"""Utilities for testing with Tkinter""" +import functools + + +def run_in_tk_mainloop(delay=1): + """Decorator for running a test method with a real Tk mainloop. + + This starts a Tk mainloop before running the test, and stops it + at the end. This is faster and more robust than the common + alternative method of calling .update() and/or .update_idletasks(). + + Test methods using this must be written as generator functions, + using "yield" to allow the mainloop to process events and "after" + callbacks, and then continue the test from that point. + + The delay argument is passed into root.after(...) calls as the number + of ms to wait before passing execution back to the generator function. + + This also assumes that the test class has a .root attribute, + which is a tkinter.Tk object. + + For example (from test_sidebar.py): + + @run_test_with_tk_mainloop() + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + """ + def decorator(test_method): + @functools.wraps(test_method) + def new_test_method(self): + test_generator = test_method(self) + root = self.root + # Exceptions raised by self.assert...() need to be raised + # outside of the after() callback in order for the test + # harness to capture them. + exception = None + def after_callback(): + nonlocal exception + try: + next(test_generator) + except StopIteration: + root.quit() + except Exception as exc: + exception = exc + root.quit() + else: + # Schedule the Tk mainloop to call this function again, + # using a robust method of ensuring that it gets a + # chance to process queued events before doing so. + # See: https://stackoverflow.com/q/18499082#comment65004099_38817470 + root.after(delay, root.after_idle, after_callback) + root.after(0, root.after_idle, after_callback) + root.mainloop() + + if exception: + raise exception + + return new_test_method + + return decorator diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py new file mode 100644 index 00000000000..fc502f7fde1 --- /dev/null +++ b/Lib/idlelib/iomenu.py @@ -0,0 +1,468 @@ +import io +import os +import shlex +import sys +import tempfile +import tokenize + +from tkinter import filedialog +from tkinter import messagebox +from tkinter.simpledialog import askstring # loadfile encoding. + +from idlelib.config import idleConf +from idlelib.util import py_extensions + +py_extensions = ' '.join("*"+ext for ext in py_extensions) +encoding = 'utf-8' +errors = 'surrogatepass' if sys.platform == 'win32' else 'surrogateescape' + + +class IOBinding: +# One instance per editor Window so methods know which to save, close. +# Open returns focus to self.editwin if aborted. +# EditorWindow.open_module, others, belong here. + + def __init__(self, editwin): + self.editwin = editwin + self.text = editwin.text + self.__id_open = self.text.bind("<>", self.open) + self.__id_save = self.text.bind("<>", self.save) + self.__id_saveas = self.text.bind("<>", + self.save_as) + self.__id_savecopy = self.text.bind("<>", + self.save_a_copy) + self.fileencoding = 'utf-8' + self.__id_print = self.text.bind("<>", self.print_window) + + def close(self): + # Undo command bindings + self.text.unbind("<>", self.__id_open) + self.text.unbind("<>", self.__id_save) + self.text.unbind("<>",self.__id_saveas) + self.text.unbind("<>", self.__id_savecopy) + self.text.unbind("<>", self.__id_print) + # Break cycles + self.editwin = None + self.text = None + self.filename_change_hook = None + + def get_saved(self): + return self.editwin.get_saved() + + def set_saved(self, flag): + self.editwin.set_saved(flag) + + def reset_undo(self): + self.editwin.reset_undo() + + filename_change_hook = None + + def set_filename_change_hook(self, hook): + self.filename_change_hook = hook + + filename = None + file_timestamp = None + dirname = None + + def set_filename(self, filename): + if filename and os.path.isdir(filename): + self.filename = None + self.dirname = filename + else: + self.filename = filename + self.dirname = None + self.set_saved(1) + if self.filename_change_hook: + self.filename_change_hook() + + def open(self, event=None, editFile=None): + flist = self.editwin.flist + # Save in case parent window is closed (ie, during askopenfile()). + if flist: + if not editFile: + filename = self.askopenfile() + else: + filename=editFile + if filename: + # If editFile is valid and already open, flist.open will + # shift focus to its existing window. + # If the current window exists and is a fresh unnamed, + # unmodified editor window (not an interpreter shell), + # pass self.loadfile to flist.open so it will load the file + # in the current window (if the file is not already open) + # instead of a new window. + if (self.editwin and + not getattr(self.editwin, 'interp', None) and + not self.filename and + self.get_saved()): + flist.open(filename, self.loadfile) + else: + flist.open(filename) + else: + if self.text: + self.text.focus_set() + return "break" + + # Code for use outside IDLE: + if self.get_saved(): + reply = self.maybesave() + if reply == "cancel": + self.text.focus_set() + return "break" + if not editFile: + filename = self.askopenfile() + else: + filename=editFile + if filename: + self.loadfile(filename) + else: + self.text.focus_set() + return "break" + + eol_convention = os.linesep # default + + def loadfile(self, filename): + try: + try: + with tokenize.open(filename) as f: + chars = f.read() + fileencoding = f.encoding + eol_convention = f.newlines + file_timestamp = self.getmtime(filename) + converted = False + except (UnicodeDecodeError, SyntaxError): + # Wait for the editor window to appear + self.editwin.text.update() + enc = askstring( + "Specify file encoding", + "The file's encoding is invalid for Python 3.x.\n" + "IDLE will convert it to UTF-8.\n" + "What is the current encoding of the file?", + initialvalue='utf-8', + parent=self.editwin.text) + with open(filename, encoding=enc) as f: + chars = f.read() + fileencoding = f.encoding + eol_convention = f.newlines + file_timestamp = self.getmtime(filename) + converted = True + except OSError as err: + messagebox.showerror("I/O Error", str(err), parent=self.text) + return False + except UnicodeDecodeError: + messagebox.showerror("Decoding Error", + "File %s\nFailed to Decode" % filename, + parent=self.text) + return False + + if not isinstance(eol_convention, str): + # If the file does not contain line separators, it is None. + # If the file contains mixed line separators, it is a tuple. + if eol_convention is not None: + messagebox.showwarning("Mixed Newlines", + "Mixed newlines detected.\n" + "The file will be changed on save.", + parent=self.text) + converted = True + eol_convention = os.linesep # default + + self.text.delete("1.0", "end") + self.set_filename(None) + self.fileencoding = fileencoding + self.eol_convention = eol_convention + self.text.insert("1.0", chars) + self.reset_undo() + self.set_filename(filename) + self.file_timestamp = file_timestamp + if converted: + # We need to save the conversion results first + # before being able to execute the code + self.set_saved(False) + self.text.mark_set("insert", "1.0") + self.text.yview("insert") + self.updaterecentfileslist(filename) + return True + + def maybesave(self): + """Return 'yes', 'no', 'cancel' as appropriate. + + Tkinter messagebox.askyesnocancel converts these tk responses + to True, False, None. Convert back, as now expected elsewhere. + """ + if self.get_saved(): + return "yes" + message = ("Do you want to save " + f"{self.filename or 'this untitled document'}" + " before closing?") + confirm = messagebox.askyesnocancel( + title="Save On Close", + message=message, + default=messagebox.YES, + parent=self.text) + if confirm: + self.save(None) + reply = "yes" if self.get_saved() else "cancel" + else: reply = "cancel" if confirm is None else "no" + self.text.focus_set() + return reply + + def save(self, event): + if not self.filename: + self.save_as(event) + else: + # Check the time of most recent content modification so the + # user doesn't accidentally overwrite a newer version of the file. + try: + file_timestamp = self.getmtime(self.filename) + except OSError: + pass + else: + if self.file_timestamp != file_timestamp: + confirm = messagebox.askokcancel( + title="File has changed", + message=( + "The file has changed on disk since reading it!\n\n" + "Do you really want to overwrite it?"), + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + return "break" + + if self.writefile(self.filename): + self.file_timestamp = self.getmtime(self.filename) + self.set_saved(True) + try: + self.editwin.store_file_breaks() + except AttributeError: # may be a PyShell + pass + self.text.focus_set() + return "break" + + def save_as(self, event): + filename = self.asksavefile() + if filename: + if self.writefile(filename): + self.file_timestamp = self.getmtime(filename) + self.set_filename(filename) + self.set_saved(1) + try: + self.editwin.store_file_breaks() + except AttributeError: + pass + self.text.focus_set() + self.updaterecentfileslist(filename) + return "break" + + def save_a_copy(self, event): + filename = self.asksavefile() + if filename: + self.writefile(filename) + self.text.focus_set() + self.updaterecentfileslist(filename) + return "break" + + def writefile(self, filename): + text = self.fixnewlines() + chars = self.encode(text) + try: + with open(filename, "wb") as f: + f.write(chars) + f.flush() + os.fsync(f.fileno()) + return True + except OSError as msg: + messagebox.showerror("I/O Error", str(msg), + parent=self.text) + return False + + def getmtime(self, filename): + return os.stat(filename).st_mtime + + def fixnewlines(self): + """Return text with os eols. + + Add prompts if shell else final \n if missing. + """ + + if hasattr(self.editwin, "interp"): # Saving shell. + text = self.editwin.get_prompt_text('1.0', self.text.index('end-1c')) + else: + if self.text.get("end-2c") != '\n': + self.text.insert("end-1c", "\n") # Changes 'end-1c' value. + text = self.text.get('1.0', "end-1c") + if self.eol_convention != "\n": + text = text.replace("\n", self.eol_convention) + return text + + def encode(self, chars): + if isinstance(chars, bytes): + # This is either plain ASCII, or Tk was returning mixed-encoding + # text to us. Don't try to guess further. + return chars + # Preserve a BOM that might have been present on opening + if self.fileencoding == 'utf-8-sig': + return chars.encode('utf-8-sig') + # See whether there is anything non-ASCII in it. + # If not, no need to figure out the encoding. + try: + return chars.encode('ascii') + except UnicodeEncodeError: + pass + # Check if there is an encoding declared + try: + encoded = chars.encode('ascii', 'replace') + enc, _ = tokenize.detect_encoding(io.BytesIO(encoded).readline) + return chars.encode(enc) + except SyntaxError as err: + failed = str(err) + except UnicodeEncodeError: + failed = "Invalid encoding '%s'" % enc + messagebox.showerror( + "I/O Error", + "%s.\nSaving as UTF-8" % failed, + parent=self.text) + # Fallback: save as UTF-8, with BOM - ignoring the incorrect + # declared encoding + return chars.encode('utf-8-sig') + + def print_window(self, event): + confirm = messagebox.askokcancel( + title="Print", + message="Print to Default Printer", + default=messagebox.OK, + parent=self.text) + if not confirm: + self.text.focus_set() + return "break" + tempfilename = None + saved = self.get_saved() + if saved: + filename = self.filename + # shell undo is reset after every prompt, looks saved, probably isn't + if not saved or filename is None: + (tfd, tempfilename) = tempfile.mkstemp(prefix='IDLE_tmp_') + filename = tempfilename + os.close(tfd) + if not self.writefile(tempfilename): + os.unlink(tempfilename) + return "break" + platform = os.name + printPlatform = True + if platform == 'posix': #posix platform + command = idleConf.GetOption('main','General', + 'print-command-posix') + command = command + " 2>&1" + elif platform == 'nt': #win32 platform + command = idleConf.GetOption('main','General','print-command-win') + else: #no printing for this platform + printPlatform = False + if printPlatform: #we can try to print for this platform + command = command % shlex.quote(filename) + pipe = os.popen(command, "r") + # things can get ugly on NT if there is no printer available. + output = pipe.read().strip() + status = pipe.close() + if status: + output = "Printing failed (exit status 0x%x)\n" % \ + status + output + if output: + output = "Printing command: %s\n" % repr(command) + output + messagebox.showerror("Print status", output, parent=self.text) + else: #no printing for this platform + message = "Printing is not enabled for this platform: %s" % platform + messagebox.showinfo("Print status", message, parent=self.text) + if tempfilename: + os.unlink(tempfilename) + return "break" + + opendialog = None + savedialog = None + + filetypes = ( + ("Python files", py_extensions, "TEXT"), + ("Text files", "*.txt", "TEXT"), + ("All files", "*"), + ) + + defaultextension = '.py' if sys.platform == 'darwin' else '' + + def askopenfile(self): + dir, base = self.defaultfilename("open") + if not self.opendialog: + self.opendialog = filedialog.Open(parent=self.text, + filetypes=self.filetypes) + filename = self.opendialog.show(initialdir=dir, initialfile=base) + return filename + + def defaultfilename(self, mode="open"): + if self.filename: + return os.path.split(self.filename) + elif self.dirname: + return self.dirname, "" + else: + try: + pwd = os.getcwd() + except OSError: + pwd = "" + return pwd, "" + + def asksavefile(self): + dir, base = self.defaultfilename("save") + if not self.savedialog: + self.savedialog = filedialog.SaveAs( + parent=self.text, + filetypes=self.filetypes, + defaultextension=self.defaultextension) + filename = self.savedialog.show(initialdir=dir, initialfile=base) + return filename + + def updaterecentfileslist(self,filename): + "Update recent file list on all editor windows" + if self.editwin.flist: + self.editwin.update_recent_files_list(filename) + + +def _io_binding(parent): # htest # + from tkinter import Toplevel, Text + + top = Toplevel(parent) + top.title("Test IOBinding") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + class MyEditWin: + def __init__(self, text): + self.text = text + self.flist = None + self.text.bind("", self.open) + self.text.bind('', self.print) + self.text.bind("", self.save) + self.text.bind("", self.saveas) + self.text.bind('', self.savecopy) + def get_saved(self): return 0 + def set_saved(self, flag): pass + def reset_undo(self): pass + def open(self, event): + self.text.event_generate("<>") + def print(self, event): + self.text.event_generate("<>") + def save(self, event): + self.text.event_generate("<>") + def saveas(self, event): + self.text.event_generate("<>") + def savecopy(self, event): + self.text.event_generate("<>") + + text = Text(top) + text.pack() + text.focus_set() + editwin = MyEditWin(text) + IOBinding(editwin) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_iomenu', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_io_binding) diff --git a/Lib/idlelib/macosx.py b/Lib/idlelib/macosx.py new file mode 100644 index 00000000000..332952f4572 --- /dev/null +++ b/Lib/idlelib/macosx.py @@ -0,0 +1,278 @@ +""" +A number of functions that enhance IDLE on macOS. +""" +from os.path import expanduser +import plistlib +from sys import platform # Used in _init_tk_type, changed by test. + +import tkinter + + +## Define functions that query the Mac graphics type. +## _tk_type and its initializer are private to this section. + +_tk_type = None + +def _init_tk_type(): + """ Initialize _tk_type for isXyzTk functions. + + This function is only called once, when _tk_type is still None. + """ + global _tk_type + if platform == 'darwin': + + # When running IDLE, GUI is present, test/* may not be. + # When running tests, test/* is present, GUI may not be. + # If not, guess most common. Does not matter for testing. + from idlelib.__init__ import testing + if testing: + from test.support import requires, ResourceDenied + try: + requires('gui') + except ResourceDenied: + _tk_type = "cocoa" + return + + root = tkinter.Tk() + ws = root.tk.call('tk', 'windowingsystem') + if 'x11' in ws: + _tk_type = "xquartz" + elif 'aqua' not in ws: + _tk_type = "other" + elif 'AppKit' in root.tk.call('winfo', 'server', '.'): + _tk_type = "cocoa" + else: + _tk_type = "carbon" + root.destroy() + else: + _tk_type = "other" + return + +def isAquaTk(): + """ + Returns True if IDLE is using a native OS X Tk (Cocoa or Carbon). + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "cocoa" or _tk_type == "carbon" + +def isCarbonTk(): + """ + Returns True if IDLE is using a Carbon Aqua Tk (instead of the + newer Cocoa Aqua Tk). + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "carbon" + +def isCocoaTk(): + """ + Returns True if IDLE is using a Cocoa Aqua Tk. + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "cocoa" + +def isXQuartz(): + """ + Returns True if IDLE is using an OS X X11 Tk. + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "xquartz" + + +def readSystemPreferences(): + """ + Fetch the macOS system preferences. + """ + if platform != 'darwin': + return None + + plist_path = expanduser('~/Library/Preferences/.GlobalPreferences.plist') + try: + with open(plist_path, 'rb') as plist_file: + return plistlib.load(plist_file) + except OSError: + return None + + +def preferTabsPreferenceWarning(): + """ + Warn if "Prefer tabs when opening documents" is set to "Always". + """ + if platform != 'darwin': + return None + + prefs = readSystemPreferences() + if prefs and prefs.get('AppleWindowTabbingMode') == 'always': + return ( + 'WARNING: The system preference "Prefer tabs when opening' + ' documents" is set to "Always". This will cause various problems' + ' with IDLE. For the best experience, change this setting when' + ' running IDLE (via System Preferences -> Dock).' + ) + return None + + +## Fix the menu and related functions. + +def addOpenEventSupport(root, flist): + """ + This ensures that the application will respond to open AppleEvents, which + makes is feasible to use IDLE as the default application for python files. + """ + def doOpenFile(*args): + for fn in args: + flist.open(fn) + + # The command below is a hook in aquatk that is called whenever the app + # receives a file open event. The callback can have multiple arguments, + # one for every file that should be opened. + root.createcommand("::tk::mac::OpenDocument", doOpenFile) + +def hideTkConsole(root): + try: + root.tk.call('console', 'hide') + except tkinter.TclError: + # Some versions of the Tk framework don't have a console object + pass + +def overrideRootMenu(root, flist): + """ + Replace the Tk root menu by something that is more appropriate for + IDLE with an Aqua Tk. + """ + # The menu that is attached to the Tk root (".") is also used by AquaTk for + # all windows that don't specify a menu of their own. The default menubar + # contains a number of menus, none of which are appropriate for IDLE. The + # Most annoying of those is an 'About Tck/Tk...' menu in the application + # menu. + # + # This function replaces the default menubar by a mostly empty one, it + # should only contain the correct application menu and the window menu. + # + # Due to a (mis-)feature of TkAqua the user will also see an empty Help + # menu. + from tkinter import Menu + from idlelib import mainmenu + from idlelib import window + + closeItem = mainmenu.menudefs[0][1][-2] + + # Remove the last 3 items of the file menu: a separator, close window and + # quit. Close window will be reinserted just above the save item, where + # it should be according to the HIG. Quit is in the application menu. + del mainmenu.menudefs[0][1][-3:] + mainmenu.menudefs[0][1].insert(6, closeItem) + + # Remove the 'About' entry from the help menu, it is in the application + # menu + del mainmenu.menudefs[-1][1][0:2] + # Remove the 'Configure Idle' entry from the options menu, it is in the + # application menu as 'Preferences' + del mainmenu.menudefs[-3][1][0:2] + menubar = Menu(root) + root.configure(menu=menubar) + + menu = Menu(menubar, name='window', tearoff=0) + menubar.add_cascade(label='Window', menu=menu, underline=0) + + def postwindowsmenu(menu=menu): + end = menu.index('end') + if end is None: + end = -1 + + if end > 0: + menu.delete(0, end) + window.add_windows_to_menu(menu) + window.register_callback(postwindowsmenu) + + def about_dialog(event=None): + "Handle Help 'About IDLE' event." + # Synchronize with editor.EditorWindow.about_dialog. + from idlelib import help_about + help_about.AboutDialog(root) + + def config_dialog(event=None): + "Handle Options 'Configure IDLE' event." + # Synchronize with editor.EditorWindow.config_dialog. + from idlelib import configdialog + + # Ensure that the root object has an instance_dict attribute, + # mirrors code in EditorWindow (although that sets the attribute + # on an EditorWindow instance that is then passed as the first + # argument to ConfigDialog) + root.instance_dict = flist.inversedict + configdialog.ConfigDialog(root, 'Settings') + + def help_dialog(event=None): + "Handle Help 'IDLE Help' event." + # Synchronize with editor.EditorWindow.help_dialog. + from idlelib import help + help.show_idlehelp(root) + + root.bind('<>', about_dialog) + root.bind('<>', config_dialog) + root.createcommand('::tk::mac::ShowPreferences', config_dialog) + if flist: + root.bind('<>', flist.close_all_callback) + + # The binding above doesn't reliably work on all versions of Tk + # on macOS. Adding command definition below does seem to do the + # right thing for now. + root.createcommand('::tk::mac::Quit', flist.close_all_callback) + + if isCarbonTk(): + # for Carbon AquaTk, replace the default Tk apple menu + menu = Menu(menubar, name='apple', tearoff=0) + menubar.add_cascade(label='IDLE', menu=menu) + mainmenu.menudefs.insert(0, + ('application', [ + ('About IDLE', '<>'), + None, + ])) + if isCocoaTk(): + # replace default About dialog with About IDLE one + root.createcommand('tkAboutDialog', about_dialog) + # replace default "Help" item in Help menu + root.createcommand('::tk::mac::ShowHelp', help_dialog) + # remove redundant "IDLE Help" from menu + del mainmenu.menudefs[-1][1][0] + +def fixb2context(root): + '''Removed bad AquaTk Button-2 (right) and Paste bindings. + + They prevent context menu access and seem to be gone in AquaTk8.6. + See issue #24801. + ''' + root.unbind_class('Text', '') + root.unbind_class('Text', '') + root.unbind_class('Text', '<>') + +def setupApp(root, flist): + """ + Perform initial OS X customizations if needed. + Called from pyshell.main() after initial calls to Tk() + + There are currently three major versions of Tk in use on OS X: + 1. Aqua Cocoa Tk (native default since OS X 10.6) + 2. Aqua Carbon Tk (original native, 32-bit only, deprecated) + 3. X11 (supported by some third-party distributors, deprecated) + There are various differences among the three that affect IDLE + behavior, primarily with menus, mouse key events, and accelerators. + Some one-time customizations are performed here. + Others are dynamically tested throughout idlelib by calls to the + isAquaTk(), isCarbonTk(), isCocoaTk(), isXQuartz() functions which + are initialized here as well. + """ + if isAquaTk(): + hideTkConsole(root) + overrideRootMenu(root, flist) + addOpenEventSupport(root, flist) + fixb2context(root) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_macosx', verbosity=2) diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py new file mode 100644 index 00000000000..91a32cebb51 --- /dev/null +++ b/Lib/idlelib/mainmenu.py @@ -0,0 +1,126 @@ +"""Define the menu contents, hotkeys, and event bindings. + +There is additional configuration information in the EditorWindow class (and +subclasses): the menus are created there based on the menu_specs (class) +variable, and menus not created are silently skipped in the code here. This +makes it possible, for example, to define a Debug menu which is only present in +the PythonShell window, and a Format menu which is only present in the Editor +windows. + +""" +from importlib.util import find_spec + +from idlelib.config import idleConf + +# Warning: menudefs is altered in macosx.overrideRootMenu() +# after it is determined that an OS X Aqua Tk is in use, +# which cannot be done until after Tk() is first called. +# Do not alter the 'file', 'options', or 'help' cascades here +# without altering overrideRootMenu() as well. +# TODO: Make this more robust + +menudefs = [ + # underscore prefixes character to underscore + ('file', [ + ('_New File', '<>'), + ('_Open...', '<>'), + ('Open _Module...', '<>'), + ('Module _Browser', '<>'), + ('_Path Browser', '<>'), + None, + ('_Save', '<>'), + ('Save _As...', '<>'), + ('Save Cop_y As...', '<>'), + None, + ('Prin_t Window', '<>'), + None, + ('_Close Window', '<>'), + ('E_xit IDLE', '<>'), + ]), + + ('edit', [ + ('_Undo', '<>'), + ('_Redo', '<>'), + None, + ('Select _All', '<>'), + ('Cu_t', '<>'), + ('_Copy', '<>'), + ('_Paste', '<>'), + None, + ('_Find...', '<>'), + ('Find A_gain', '<>'), + ('Find _Selection', '<>'), + ('Find in Files...', '<>'), + ('R_eplace...', '<>'), + None, + ('Go to _Line', '<>'), + ('S_how Completions', '<>'), + ('E_xpand Word', '<>'), + ('Show C_all Tip', '<>'), + ('Show Surrounding P_arens', '<>'), + ]), + + ('format', [ + ('F_ormat Paragraph', '<>'), + ('_Indent Region', '<>'), + ('_Dedent Region', '<>'), + ('Comment _Out Region', '<>'), + ('U_ncomment Region', '<>'), + ('Tabify Region', '<>'), + ('Untabify Region', '<>'), + ('Toggle Tabs', '<>'), + ('New Indent Width', '<>'), + ('S_trip Trailing Whitespace', '<>'), + ]), + + ('run', [ + ('R_un Module', '<>'), + ('Run... _Customized', '<>'), + ('C_heck Module', '<>'), + ('Python Shell', '<>'), + ]), + + ('shell', [ + ('_View Last Restart', '<>'), + ('_Restart Shell', '<>'), + None, + ('_Previous History', '<>'), + ('_Next History', '<>'), + None, + ('_Interrupt Execution', '<>'), + ]), + + ('debug', [ + ('_Go to File/Line', '<>'), + ('!_Debugger', '<>'), + ('_Stack Viewer', '<>'), + ('!_Auto-open Stack Viewer', '<>'), + ]), + + ('options', [ + ('Configure _IDLE', '<>'), + None, + ('Show _Code Context', '<>'), + ('Show _Line Numbers', '<>'), + ('_Zoom Height', '<>'), + ]), + + ('window', [ + ]), + + ('help', [ + ('_About IDLE', '<>'), + None, + ('_IDLE Doc', '<>'), + ('Python _Docs', '<>'), + ]), +] + +if find_spec('turtledemo'): + menudefs[-1][1].append(('Turtle Demo', '<>')) + +default_keydefs = idleConf.GetCurrentKeySet() + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_mainmenu', verbosity=2) diff --git a/Lib/idlelib/multicall.py b/Lib/idlelib/multicall.py new file mode 100644 index 00000000000..41f81813113 --- /dev/null +++ b/Lib/idlelib/multicall.py @@ -0,0 +1,451 @@ +""" +MultiCall - a class which inherits its methods from a Tkinter widget (Text, for +example), but enables multiple calls of functions per virtual event - all +matching events will be called, not only the most specific one. This is done +by wrapping the event functions - event_add, event_delete and event_info. +MultiCall recognizes only a subset of legal event sequences. Sequences which +are not recognized are treated by the original Tk handling mechanism. A +more-specific event will be called before a less-specific event. + +The recognized sequences are complete one-event sequences (no emacs-style +Ctrl-X Ctrl-C, no shortcuts like <3>), for all types of events. +Key/Button Press/Release events can have modifiers. +The recognized modifiers are Shift, Control, Option and Command for Mac, and +Control, Alt, Shift, Meta/M for other platforms. + +For all events which were handled by MultiCall, a new member is added to the +event instance passed to the binded functions - mc_type. This is one of the +event type constants defined in this module (such as MC_KEYPRESS). +For Key/Button events (which are handled by MultiCall and may receive +modifiers), another member is added - mc_state. This member gives the state +of the recognized modifiers, as a combination of the modifier constants +also defined in this module (for example, MC_SHIFT). +Using these members is absolutely portable. + +The order by which events are called is defined by these rules: +1. A more-specific event will be called before a less-specific event. +2. A recently-binded event will be called before a previously-binded event, + unless this conflicts with the first rule. +Each function will be called at most once for each event. +""" +import re +import sys + +import tkinter + +# the event type constants, which define the meaning of mc_type +MC_KEYPRESS=0; MC_KEYRELEASE=1; MC_BUTTONPRESS=2; MC_BUTTONRELEASE=3; +MC_ACTIVATE=4; MC_CIRCULATE=5; MC_COLORMAP=6; MC_CONFIGURE=7; +MC_DEACTIVATE=8; MC_DESTROY=9; MC_ENTER=10; MC_EXPOSE=11; MC_FOCUSIN=12; +MC_FOCUSOUT=13; MC_GRAVITY=14; MC_LEAVE=15; MC_MAP=16; MC_MOTION=17; +MC_MOUSEWHEEL=18; MC_PROPERTY=19; MC_REPARENT=20; MC_UNMAP=21; MC_VISIBILITY=22; +# the modifier state constants, which define the meaning of mc_state +MC_SHIFT = 1<<0; MC_CONTROL = 1<<2; MC_ALT = 1<<3; MC_META = 1<<5 +MC_OPTION = 1<<6; MC_COMMAND = 1<<7 + +# define the list of modifiers, to be used in complex event types. +if sys.platform == "darwin": + _modifiers = (("Shift",), ("Control",), ("Option",), ("Command",)) + _modifier_masks = (MC_SHIFT, MC_CONTROL, MC_OPTION, MC_COMMAND) +else: + _modifiers = (("Control",), ("Alt",), ("Shift",), ("Meta", "M")) + _modifier_masks = (MC_CONTROL, MC_ALT, MC_SHIFT, MC_META) + +# a dictionary to map a modifier name into its number +_modifier_names = {name: number + for number in range(len(_modifiers)) + for name in _modifiers[number]} + +# In 3.4, if no shell window is ever open, the underlying Tk widget is +# destroyed before .__del__ methods here are called. The following +# is used to selectively ignore shutdown exceptions to avoid +# 'Exception ignored' messages. See http://bugs.python.org/issue20167 +APPLICATION_GONE = "application has been destroyed" + +# A binder is a class which binds functions to one type of event. It has two +# methods: bind and unbind, which get a function and a parsed sequence, as +# returned by _parse_sequence(). There are two types of binders: +# _SimpleBinder handles event types with no modifiers and no detail. +# No Python functions are called when no events are binded. +# _ComplexBinder handles event types with modifiers and a detail. +# A Python function is called each time an event is generated. + +class _SimpleBinder: + def __init__(self, type, widget, widgetinst): + self.type = type + self.sequence = '<'+_types[type][0]+'>' + self.widget = widget + self.widgetinst = widgetinst + self.bindedfuncs = [] + self.handlerid = None + + def bind(self, triplet, func): + if not self.handlerid: + def handler(event, l = self.bindedfuncs, mc_type = self.type): + event.mc_type = mc_type + wascalled = {} + for i in range(len(l)-1, -1, -1): + func = l[i] + if func not in wascalled: + wascalled[func] = True + r = func(event) + if r: + return r + self.handlerid = self.widget.bind(self.widgetinst, + self.sequence, handler) + self.bindedfuncs.append(func) + + def unbind(self, triplet, func): + self.bindedfuncs.remove(func) + if not self.bindedfuncs: + self.widget.unbind(self.widgetinst, self.sequence, self.handlerid) + self.handlerid = None + + def __del__(self): + if self.handlerid: + try: + self.widget.unbind(self.widgetinst, self.sequence, + self.handlerid) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + +# An int in range(1 << len(_modifiers)) represents a combination of modifiers +# (if the least significant bit is on, _modifiers[0] is on, and so on). +# _state_subsets gives for each combination of modifiers, or *state*, +# a list of the states which are a subset of it. This list is ordered by the +# number of modifiers is the state - the most specific state comes first. +_states = range(1 << len(_modifiers)) +_state_names = [''.join(m[0]+'-' + for i, m in enumerate(_modifiers) + if (1 << i) & s) + for s in _states] + +def expand_substates(states): + '''For each item of states return a list containing all combinations of + that item with individual bits reset, sorted by the number of set bits. + ''' + def nbits(n): + "number of bits set in n base 2" + nb = 0 + while n: + n, rem = divmod(n, 2) + nb += rem + return nb + statelist = [] + for state in states: + substates = list({state & x for x in states}) + substates.sort(key=nbits, reverse=True) + statelist.append(substates) + return statelist + +_state_subsets = expand_substates(_states) + +# _state_codes gives for each state, the portable code to be passed as mc_state +_state_codes = [] +for s in _states: + r = 0 + for i in range(len(_modifiers)): + if (1 << i) & s: + r |= _modifier_masks[i] + _state_codes.append(r) + +class _ComplexBinder: + # This class binds many functions, and only unbinds them when it is deleted. + # self.handlerids is the list of seqs and ids of binded handler functions. + # The binded functions sit in a dictionary of lists of lists, which maps + # a detail (or None) and a state into a list of functions. + # When a new detail is discovered, handlers for all the possible states + # are binded. + + def __create_handler(self, lists, mc_type, mc_state): + def handler(event, lists = lists, + mc_type = mc_type, mc_state = mc_state, + ishandlerrunning = self.ishandlerrunning, + doafterhandler = self.doafterhandler): + ishandlerrunning[:] = [True] + event.mc_type = mc_type + event.mc_state = mc_state + wascalled = {} + r = None + for l in lists: + for i in range(len(l)-1, -1, -1): + func = l[i] + if func not in wascalled: + wascalled[func] = True + r = l[i](event) + if r: + break + if r: + break + ishandlerrunning[:] = [] + # Call all functions in doafterhandler and remove them from list + for f in doafterhandler: + f() + doafterhandler[:] = [] + if r: + return r + return handler + + def __init__(self, type, widget, widgetinst): + self.type = type + self.typename = _types[type][0] + self.widget = widget + self.widgetinst = widgetinst + self.bindedfuncs = {None: [[] for s in _states]} + self.handlerids = [] + # we don't want to change the lists of functions while a handler is + # running - it will mess up the loop and anyway, we usually want the + # change to happen from the next event. So we have a list of functions + # for the handler to run after it finishes calling the binded functions. + # It calls them only once. + # ishandlerrunning is a list. An empty one means no, otherwise - yes. + # this is done so that it would be mutable. + self.ishandlerrunning = [] + self.doafterhandler = [] + for s in _states: + lists = [self.bindedfuncs[None][i] for i in _state_subsets[s]] + handler = self.__create_handler(lists, type, _state_codes[s]) + seq = '<'+_state_names[s]+self.typename+'>' + self.handlerids.append((seq, self.widget.bind(self.widgetinst, + seq, handler))) + + def bind(self, triplet, func): + if triplet[2] not in self.bindedfuncs: + self.bindedfuncs[triplet[2]] = [[] for s in _states] + for s in _states: + lists = [ self.bindedfuncs[detail][i] + for detail in (triplet[2], None) + for i in _state_subsets[s] ] + handler = self.__create_handler(lists, self.type, + _state_codes[s]) + seq = "<%s%s-%s>"% (_state_names[s], self.typename, triplet[2]) + self.handlerids.append((seq, self.widget.bind(self.widgetinst, + seq, handler))) + doit = lambda: self.bindedfuncs[triplet[2]][triplet[0]].append(func) + if not self.ishandlerrunning: + doit() + else: + self.doafterhandler.append(doit) + + def unbind(self, triplet, func): + doit = lambda: self.bindedfuncs[triplet[2]][triplet[0]].remove(func) + if not self.ishandlerrunning: + doit() + else: + self.doafterhandler.append(doit) + + def __del__(self): + for seq, id in self.handlerids: + try: + self.widget.unbind(self.widgetinst, seq, id) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + +# define the list of event types to be handled by MultiEvent. the order is +# compatible with the definition of event type constants. +_types = ( + ("KeyPress", "Key"), ("KeyRelease",), ("ButtonPress", "Button"), + ("ButtonRelease",), ("Activate",), ("Circulate",), ("Colormap",), + ("Configure",), ("Deactivate",), ("Destroy",), ("Enter",), ("Expose",), + ("FocusIn",), ("FocusOut",), ("Gravity",), ("Leave",), ("Map",), + ("Motion",), ("MouseWheel",), ("Property",), ("Reparent",), ("Unmap",), + ("Visibility",), +) + +# which binder should be used for every event type? +_binder_classes = (_ComplexBinder,) * 4 + (_SimpleBinder,) * (len(_types)-4) + +# A dictionary to map a type name into its number +_type_names = {name: number + for number in range(len(_types)) + for name in _types[number]} + +_keysym_re = re.compile(r"^\w+$") +_button_re = re.compile(r"^[1-5]$") +def _parse_sequence(sequence): + """Get a string which should describe an event sequence. If it is + successfully parsed as one, return a tuple containing the state (as an int), + the event type (as an index of _types), and the detail - None if none, or a + string if there is one. If the parsing is unsuccessful, return None. + """ + if not sequence or sequence[0] != '<' or sequence[-1] != '>': + return None + words = sequence[1:-1].split('-') + modifiers = 0 + while words and words[0] in _modifier_names: + modifiers |= 1 << _modifier_names[words[0]] + del words[0] + if words and words[0] in _type_names: + type = _type_names[words[0]] + del words[0] + else: + return None + if _binder_classes[type] is _SimpleBinder: + if modifiers or words: + return None + else: + detail = None + else: + # _ComplexBinder + if type in [_type_names[s] for s in ("KeyPress", "KeyRelease")]: + type_re = _keysym_re + else: + type_re = _button_re + + if not words: + detail = None + elif len(words) == 1 and type_re.match(words[0]): + detail = words[0] + else: + return None + + return modifiers, type, detail + +def _triplet_to_sequence(triplet): + if triplet[2]: + return '<'+_state_names[triplet[0]]+_types[triplet[1]][0]+'-'+ \ + triplet[2]+'>' + else: + return '<'+_state_names[triplet[0]]+_types[triplet[1]][0]+'>' + +_multicall_dict = {} +def MultiCallCreator(widget): + """Return a MultiCall class which inherits its methods from the + given widget class (for example, Tkinter.Text). This is used + instead of a templating mechanism. + """ + if widget in _multicall_dict: + return _multicall_dict[widget] + + class MultiCall (widget): + assert issubclass(widget, tkinter.Misc) + + def __init__(self, *args, **kwargs): + widget.__init__(self, *args, **kwargs) + # a dictionary which maps a virtual event to a tuple with: + # 0. the function binded + # 1. a list of triplets - the sequences it is binded to + self.__eventinfo = {} + self.__binders = [_binder_classes[i](i, widget, self) + for i in range(len(_types))] + + def bind(self, sequence=None, func=None, add=None): + #print("bind(%s, %s, %s)" % (sequence, func, add), + # file=sys.__stderr__) + if type(sequence) is str and len(sequence) > 2 and \ + sequence[:2] == "<<" and sequence[-2:] == ">>": + if sequence in self.__eventinfo: + ei = self.__eventinfo[sequence] + if ei[0] is not None: + for triplet in ei[1]: + self.__binders[triplet[1]].unbind(triplet, ei[0]) + ei[0] = func + if ei[0] is not None: + for triplet in ei[1]: + self.__binders[triplet[1]].bind(triplet, func) + else: + self.__eventinfo[sequence] = [func, []] + return widget.bind(self, sequence, func, add) + + def unbind(self, sequence, funcid=None): + if type(sequence) is str and len(sequence) > 2 and \ + sequence[:2] == "<<" and sequence[-2:] == ">>" and \ + sequence in self.__eventinfo: + func, triplets = self.__eventinfo[sequence] + if func is not None: + for triplet in triplets: + self.__binders[triplet[1]].unbind(triplet, func) + self.__eventinfo[sequence][0] = None + return widget.unbind(self, sequence, funcid) + + def event_add(self, virtual, *sequences): + #print("event_add(%s, %s)" % (repr(virtual), repr(sequences)), + # file=sys.__stderr__) + if virtual not in self.__eventinfo: + self.__eventinfo[virtual] = [None, []] + + func, triplets = self.__eventinfo[virtual] + for seq in sequences: + triplet = _parse_sequence(seq) + if triplet is None: + #print("Tkinter event_add(%s)" % seq, file=sys.__stderr__) + widget.event_add(self, virtual, seq) + else: + if func is not None: + self.__binders[triplet[1]].bind(triplet, func) + triplets.append(triplet) + + def event_delete(self, virtual, *sequences): + if virtual not in self.__eventinfo: + return + func, triplets = self.__eventinfo[virtual] + for seq in sequences: + triplet = _parse_sequence(seq) + if triplet is None: + #print("Tkinter event_delete: %s" % seq, file=sys.__stderr__) + widget.event_delete(self, virtual, seq) + else: + if func is not None: + self.__binders[triplet[1]].unbind(triplet, func) + triplets.remove(triplet) + + def event_info(self, virtual=None): + if virtual is None or virtual not in self.__eventinfo: + return widget.event_info(self, virtual) + else: + return tuple(map(_triplet_to_sequence, + self.__eventinfo[virtual][1])) + \ + widget.event_info(self, virtual) + + def __del__(self): + for virtual in self.__eventinfo: + func, triplets = self.__eventinfo[virtual] + if func: + for triplet in triplets: + try: + self.__binders[triplet[1]].unbind(triplet, func) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + + _multicall_dict[widget] = MultiCall + return MultiCall + + +def _multi_call(parent): # htest # + top = tkinter.Toplevel(parent) + top.title("Test MultiCall") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = MultiCallCreator(tkinter.Text)(top) + text.pack() + text.focus_set() + + def bindseq(seq, n=[0]): + def handler(event): + print(seq) + text.bind("<>"%n[0], handler) + text.event_add("<>"%n[0], seq) + n[0] += 1 + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_mainmenu', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_multi_call) diff --git a/Lib/idlelib/outwin.py b/Lib/idlelib/outwin.py new file mode 100644 index 00000000000..8baa657550d --- /dev/null +++ b/Lib/idlelib/outwin.py @@ -0,0 +1,188 @@ +"""Editor window that can serve as an output file. +""" + +import re + +from tkinter import messagebox + +from idlelib.editor import EditorWindow + + +file_line_pats = [ + # order of patterns matters + r'file "([^"]*)", line (\d+)', + r'([^\s]+)\((\d+)\)', + r'^(\s*\S.*?):\s*(\d+):', # Win filename, maybe starting with spaces + r'([^\s]+):\s*(\d+):', # filename or path, ltrim + r'^\s*(\S.*?):\s*(\d+):', # Win abs path with embedded spaces, ltrim +] + +file_line_progs = None + + +def compile_progs(): + "Compile the patterns for matching to file name and line number." + global file_line_progs + file_line_progs = [re.compile(pat, re.IGNORECASE) + for pat in file_line_pats] + + +def file_line_helper(line): + """Extract file name and line number from line of text. + + Check if line of text contains one of the file/line patterns. + If it does and if the file and line are valid, return + a tuple of the file name and line number. If it doesn't match + or if the file or line is invalid, return None. + """ + if not file_line_progs: + compile_progs() + for prog in file_line_progs: + match = prog.search(line) + if match: + filename, lineno = match.group(1, 2) + try: + f = open(filename) + f.close() + break + except OSError: + continue + else: + return None + try: + return filename, int(lineno) + except TypeError: + return None + + +class OutputWindow(EditorWindow): + """An editor window that can serve as an output file. + + Also the future base class for the Python shell window. + This class has no input facilities. + + Adds binding to open a file at a line to the text widget. + """ + + # Our own right-button menu + rmenu_specs = [ + ("Cut", "<>", "rmenu_check_cut"), + ("Copy", "<>", "rmenu_check_copy"), + ("Paste", "<>", "rmenu_check_paste"), + (None, None, None), + ("Go to file/line", "<>", None), + ] + + allow_code_context = False + + def __init__(self, *args): + EditorWindow.__init__(self, *args) + self.text.bind("<>", self.goto_file_line) + + # Customize EditorWindow + def ispythonsource(self, filename): + "Python source is only part of output: do not colorize." + return False + + def short_title(self): + "Customize EditorWindow title." + return "Output" + + def maybesave(self): + "Customize EditorWindow to not display save file messagebox." + return 'yes' if self.get_saved() else 'no' + + # Act as output file + def write(self, s, tags=(), mark="insert"): + """Write text to text widget. + + The text is inserted at the given index with the provided + tags. The text widget is then scrolled to make it visible + and updated to display it, giving the effect of seeing each + line as it is added. + + Args: + s: Text to insert into text widget. + tags: Tuple of tag strings to apply on the insert. + mark: Index for the insert. + + Return: + Length of text inserted. + """ + assert isinstance(s, str) + self.text.insert(mark, s, tags) + self.text.see(mark) + self.text.update() + return len(s) + + def writelines(self, lines): + "Write each item in lines iterable." + for line in lines: + self.write(line) + + def flush(self): + "No flushing needed as write() directly writes to widget." + pass + + def showerror(self, *args, **kwargs): + messagebox.showerror(*args, **kwargs) + + def goto_file_line(self, event=None): + """Handle request to open file/line. + + If the selected or previous line in the output window + contains a file name and line number, then open that file + name in a new window and position on the line number. + + Otherwise, display an error messagebox. + """ + line = self.text.get("insert linestart", "insert lineend") + result = file_line_helper(line) + if not result: + # Try the previous line. This is handy e.g. in tracebacks, + # where you tend to right-click on the displayed source line + line = self.text.get("insert -1line linestart", + "insert -1line lineend") + result = file_line_helper(line) + if not result: + self.showerror( + "No special line", + "The line you point at doesn't look like " + "a valid file name followed by a line number.", + parent=self.text) + return + filename, lineno = result + self.flist.gotofileline(filename, lineno) + + +# These classes are currently not used but might come in handy +class OnDemandOutputWindow: + + tagdefs = { + # XXX Should use IdlePrefs.ColorPrefs + "stdout": {"foreground": "blue"}, + "stderr": {"foreground": "#007700"}, + } + + def __init__(self, flist): + self.flist = flist + self.owin = None + + def write(self, s, tags, mark): + if not self.owin: + self.setup() + self.owin.write(s, tags, mark) + + def setup(self): + self.owin = owin = OutputWindow(self.flist) + text = owin.text + for tag, cnf in self.tagdefs.items(): + if cnf: + text.tag_configure(tag, **cnf) + text.tag_raise('sel') + self.write = self.owin.write + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_outwin', verbosity=2, exit=False) diff --git a/Lib/idlelib/parenmatch.py b/Lib/idlelib/parenmatch.py new file mode 100644 index 00000000000..3fd7aadb2ae --- /dev/null +++ b/Lib/idlelib/parenmatch.py @@ -0,0 +1,183 @@ +"""ParenMatch -- for parenthesis matching. + +When you hit a right paren, the cursor should move briefly to the left +paren. Paren here is used generically; the matching applies to +parentheses, square brackets, and curly braces. +""" +from idlelib.hyperparser import HyperParser +from idlelib.config import idleConf + +_openers = {')':'(',']':'[','}':'{'} +CHECK_DELAY = 100 # milliseconds + +class ParenMatch: + """Highlight matching openers and closers, (), [], and {}. + + There are three supported styles of paren matching. When a right + paren (opener) is typed: + + opener -- highlight the matching left paren (closer); + parens -- highlight the left and right parens (opener and closer); + expression -- highlight the entire expression from opener to closer. + (For back compatibility, 'default' is a synonym for 'opener'). + + Flash-delay is the maximum milliseconds the highlighting remains. + Any cursor movement (key press or click) before that removes the + highlight. If flash-delay is 0, there is no maximum. + + TODO: + - Augment bell() with mismatch warning in status window. + - Highlight when cursor is moved to the right of a closer. + This might be too expensive to check. + """ + + RESTORE_VIRTUAL_EVENT_NAME = "<>" + # We want the restore event be called before the usual return and + # backspace events. + RESTORE_SEQUENCES = ("", "", + "", "") + + def __init__(self, editwin): + self.editwin = editwin + self.text = editwin.text + # Bind the check-restore event to the function restore_event, + # so that we can then use activate_restore (which calls event_add) + # and deactivate_restore (which calls event_delete). + editwin.text.bind(self.RESTORE_VIRTUAL_EVENT_NAME, + self.restore_event) + self.counter = 0 + self.is_restore_active = 0 + + @classmethod + def reload(cls): + cls.STYLE = idleConf.GetOption( + 'extensions','ParenMatch','style', default='opener') + cls.FLASH_DELAY = idleConf.GetOption( + 'extensions','ParenMatch','flash-delay', type='int',default=500) + cls.BELL = idleConf.GetOption( + 'extensions','ParenMatch','bell', type='bool', default=1) + cls.HILITE_CONFIG = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'hilite') + + def activate_restore(self): + "Activate mechanism to restore text from highlighting." + if not self.is_restore_active: + for seq in self.RESTORE_SEQUENCES: + self.text.event_add(self.RESTORE_VIRTUAL_EVENT_NAME, seq) + self.is_restore_active = True + + def deactivate_restore(self): + "Remove restore event bindings." + if self.is_restore_active: + for seq in self.RESTORE_SEQUENCES: + self.text.event_delete(self.RESTORE_VIRTUAL_EVENT_NAME, seq) + self.is_restore_active = False + + def flash_paren_event(self, event): + "Handle editor 'show surrounding parens' event (menu or shortcut)." + indices = (HyperParser(self.editwin, "insert") + .get_surrounding_brackets()) + self.finish_paren_event(indices) + return "break" + + def paren_closed_event(self, event): + "Handle user input of closer." + # If user bound non-closer to <>, quit. + closer = self.text.get("insert-1c") + if closer not in _openers: + return + hp = HyperParser(self.editwin, "insert-1c") + if not hp.is_in_code(): + return + indices = hp.get_surrounding_brackets(_openers[closer], True) + self.finish_paren_event(indices) + return # Allow calltips to see ')' + + def finish_paren_event(self, indices): + if indices is None and self.BELL: + self.text.bell() + return + self.activate_restore() + # self.create_tag(indices) + self.tagfuncs.get(self.STYLE, self.create_tag_expression)(self, indices) + # self.set_timeout() + (self.set_timeout_last if self.FLASH_DELAY else + self.set_timeout_none)() + + def restore_event(self, event=None): + "Remove effect of doing match." + self.text.tag_delete("paren") + self.deactivate_restore() + self.counter += 1 # disable the last timer, if there is one. + + def handle_restore_timer(self, timer_count): + if timer_count == self.counter: + self.restore_event() + + # any one of the create_tag_XXX methods can be used depending on + # the style + + def create_tag_opener(self, indices): + """Highlight the single paren that matches""" + self.text.tag_add("paren", indices[0]) + self.text.tag_config("paren", self.HILITE_CONFIG) + + def create_tag_parens(self, indices): + """Highlight the left and right parens""" + if self.text.get(indices[1]) in (')', ']', '}'): + rightindex = indices[1]+"+1c" + else: + rightindex = indices[1] + self.text.tag_add("paren", indices[0], indices[0]+"+1c", rightindex+"-1c", rightindex) + self.text.tag_config("paren", self.HILITE_CONFIG) + + def create_tag_expression(self, indices): + """Highlight the entire expression""" + if self.text.get(indices[1]) in (')', ']', '}'): + rightindex = indices[1]+"+1c" + else: + rightindex = indices[1] + self.text.tag_add("paren", indices[0], rightindex) + self.text.tag_config("paren", self.HILITE_CONFIG) + + tagfuncs = { + 'opener': create_tag_opener, + 'default': create_tag_opener, + 'parens': create_tag_parens, + 'expression': create_tag_expression, + } + + # any one of the set_timeout_XXX methods can be used depending on + # the style + + def set_timeout_none(self): + """Highlight will remain until user input turns it off + or the insert has moved""" + # After CHECK_DELAY, call a function which disables the "paren" tag + # if the event is for the most recent timer and the insert has changed, + # or schedules another call for itself. + self.counter += 1 + def callme(callme, self=self, c=self.counter, + index=self.text.index("insert")): + if index != self.text.index("insert"): + self.handle_restore_timer(c) + else: + self.editwin.text_frame.after(CHECK_DELAY, callme, callme) + self.editwin.text_frame.after(CHECK_DELAY, callme, callme) + + def set_timeout_last(self): + """The last highlight created will be removed after FLASH_DELAY millisecs""" + # associate a counter with an event; only disable the "paren" + # tag if the event is for the most recent timer. + self.counter += 1 + self.editwin.text_frame.after( + self.FLASH_DELAY, + lambda self=self, c=self.counter: self.handle_restore_timer(c)) + + +ParenMatch.reload() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_parenmatch', verbosity=2) diff --git a/Lib/idlelib/pathbrowser.py b/Lib/idlelib/pathbrowser.py new file mode 100644 index 00000000000..48a77875ba5 --- /dev/null +++ b/Lib/idlelib/pathbrowser.py @@ -0,0 +1,107 @@ +import importlib.machinery +import os +import sys + +from idlelib.browser import ModuleBrowser, ModuleBrowserTreeItem +from idlelib.tree import TreeItem + + +class PathBrowser(ModuleBrowser): + + def __init__(self, master, *, _htest=False, _utest=False): + """ + _htest - bool, change box location when running htest + """ + self.master = master + self._htest = _htest + self._utest = _utest + self.init() + + def settitle(self): + "Set window titles." + self.top.wm_title("Path Browser") + self.top.wm_iconname("Path Browser") + + def rootnode(self): + return PathBrowserTreeItem() + + +class PathBrowserTreeItem(TreeItem): + + def GetText(self): + return "sys.path" + + def GetSubList(self): + sublist = [] + for dir in sys.path: + item = DirBrowserTreeItem(dir) + sublist.append(item) + return sublist + + +class DirBrowserTreeItem(TreeItem): + + def __init__(self, dir, packages=[]): + self.dir = dir + self.packages = packages + + def GetText(self): + if not self.packages: + return self.dir + else: + return self.packages[-1] + ": package" + + def GetSubList(self): + try: + names = os.listdir(self.dir or os.curdir) + except OSError: + return [] + packages = [] + for name in names: + file = os.path.join(self.dir, name) + if self.ispackagedir(file): + nn = os.path.normcase(name) + packages.append((nn, name, file)) + packages.sort() + sublist = [] + for nn, name, file in packages: + item = DirBrowserTreeItem(file, self.packages + [name]) + sublist.append(item) + for nn, name in self.listmodules(names): + item = ModuleBrowserTreeItem(os.path.join(self.dir, name)) + sublist.append(item) + return sublist + + def ispackagedir(self, file): + " Return true for directories that are packages." + if not os.path.isdir(file): + return False + init = os.path.join(file, "__init__.py") + return os.path.exists(init) + + def listmodules(self, allnames): + modules = {} + suffixes = importlib.machinery.EXTENSION_SUFFIXES[:] + suffixes += importlib.machinery.SOURCE_SUFFIXES + suffixes += importlib.machinery.BYTECODE_SUFFIXES + sorted = [] + for suff in suffixes: + i = -len(suff) + for name in allnames[:]: + normed_name = os.path.normcase(name) + if normed_name[i:] == suff: + mod_name = name[:i] + if mod_name not in modules: + modules[mod_name] = None + sorted.append((normed_name, name)) + allnames.remove(name) + sorted.sort() + return sorted + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_pathbrowser', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(PathBrowser) diff --git a/Lib/idlelib/percolator.py b/Lib/idlelib/percolator.py new file mode 100644 index 00000000000..aa73427c491 --- /dev/null +++ b/Lib/idlelib/percolator.py @@ -0,0 +1,120 @@ +from idlelib.delegator import Delegator +from idlelib.redirector import WidgetRedirector + + +class Percolator: + + def __init__(self, text): + # XXX would be nice to inherit from Delegator + self.text = text + self.redir = WidgetRedirector(text) + self.top = self.bottom = Delegator(text) + self.bottom.insert = self.redir.register("insert", self.insert) + self.bottom.delete = self.redir.register("delete", self.delete) + self.filters = [] + + def close(self): + while self.top is not self.bottom: + self.removefilter(self.top) + self.top = None + self.bottom.setdelegate(None) + self.bottom = None + self.redir.close() + self.redir = None + self.text = None + + def insert(self, index, chars, tags=None): + # Could go away if inheriting from Delegator + self.top.insert(index, chars, tags) + + def delete(self, index1, index2=None): + # Could go away if inheriting from Delegator + self.top.delete(index1, index2) + + def insertfilter(self, filter): + # Perhaps rename to pushfilter()? + assert isinstance(filter, Delegator) + assert filter.delegate is None + filter.setdelegate(self.top) + self.top = filter + + def insertfilterafter(self, filter, after): + assert isinstance(filter, Delegator) + assert isinstance(after, Delegator) + assert filter.delegate is None + + f = self.top + f.resetcache() + while f is not after: + assert f is not self.bottom + f = f.delegate + f.resetcache() + + filter.setdelegate(f.delegate) + f.setdelegate(filter) + + def removefilter(self, filter): + # XXX Perhaps should only support popfilter()? + assert isinstance(filter, Delegator) + assert filter.delegate is not None + f = self.top + if f is filter: + self.top = filter.delegate + filter.setdelegate(None) + else: + while f.delegate is not filter: + assert f is not self.bottom + f.resetcache() + f = f.delegate + f.setdelegate(filter.delegate) + filter.setdelegate(None) + + +def _percolator(parent): # htest # + import tkinter as tk + + class Tracer(Delegator): + def __init__(self, name): + self.name = name + Delegator.__init__(self, None) + + def insert(self, *args): + print(self.name, ": insert", args) + self.delegate.insert(*args) + + def delete(self, *args): + print(self.name, ": delete", args) + self.delegate.delete(*args) + + top = tk.Toplevel(parent) + top.title("Test Percolator") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = tk.Text(top) + p = Percolator(text) + pin = p.insertfilter + pout = p.removefilter + t1 = Tracer("t1") + t2 = Tracer("t2") + + def toggle1(): + (pin if var1.get() else pout)(t1) + def toggle2(): + (pin if var2.get() else pout)(t2) + + text.pack() + text.focus_set() + var1 = tk.IntVar(parent) + cb1 = tk.Checkbutton(top, text="Tracer1", command=toggle1, variable=var1) + cb1.pack() + var2 = tk.IntVar(parent) + cb2 = tk.Checkbutton(top, text="Tracer2", command=toggle2, variable=var2) + cb2.pack() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_percolator', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_percolator) diff --git a/Lib/idlelib/pyparse.py b/Lib/idlelib/pyparse.py new file mode 100644 index 00000000000..8545c63e143 --- /dev/null +++ b/Lib/idlelib/pyparse.py @@ -0,0 +1,589 @@ +"""Define partial Python code Parser used by editor and hyperparser. + +Instances of ParseMap are used with str.translate. + +The following bound search and match functions are defined: +_synchre - start of popular statement; +_junkre - whitespace or comment line; +_match_stringre: string, possibly without closer; +_itemre - line that may have bracket structure start; +_closere - line that must be followed by dedent. +_chew_ordinaryre - non-special characters. +""" +import re + +# Reason last statement is continued (or C_NONE if it's not). +(C_NONE, C_BACKSLASH, C_STRING_FIRST_LINE, + C_STRING_NEXT_LINES, C_BRACKET) = range(5) + +# Find what looks like the start of a popular statement. + +_synchre = re.compile(r""" + ^ + [ \t]* + (?: while + | else + | def + | return + | assert + | break + | class + | continue + | elif + | try + | except + | raise + | import + | yield + ) + \b +""", re.VERBOSE | re.MULTILINE).search + +# Match blank line or non-indenting comment line. + +_junkre = re.compile(r""" + [ \t]* + (?: \# \S .* )? + \n +""", re.VERBOSE).match + +# Match any flavor of string; the terminating quote is optional +# so that we're robust in the face of incomplete program text. + +_match_stringre = re.compile(r""" + \""" [^"\\]* (?: + (?: \\. | "(?!"") ) + [^"\\]* + )* + (?: \""" )? + +| " [^"\\\n]* (?: \\. [^"\\\n]* )* "? + +| ''' [^'\\]* (?: + (?: \\. | '(?!'') ) + [^'\\]* + )* + (?: ''' )? + +| ' [^'\\\n]* (?: \\. [^'\\\n]* )* '? +""", re.VERBOSE | re.DOTALL).match + +# Match a line that starts with something interesting; +# used to find the first item of a bracket structure. + +_itemre = re.compile(r""" + [ \t]* + [^\s#\\] # if we match, m.end()-1 is the interesting char +""", re.VERBOSE).match + +# Match start of statements that should be followed by a dedent. + +_closere = re.compile(r""" + \s* + (?: return + | break + | continue + | raise + | pass + ) + \b +""", re.VERBOSE).match + +# Chew up non-special chars as quickly as possible. If match is +# successful, m.end() less 1 is the index of the last boring char +# matched. If match is unsuccessful, the string starts with an +# interesting char. + +_chew_ordinaryre = re.compile(r""" + [^[\](){}#'"\\]+ +""", re.VERBOSE).match + + +class ParseMap(dict): + r"""Dict subclass that maps anything not in dict to 'x'. + + This is designed to be used with str.translate in study1. + Anything not specifically mapped otherwise becomes 'x'. + Example: replace everything except whitespace with 'x'. + + >>> keepwhite = ParseMap((ord(c), ord(c)) for c in ' \t\n\r') + >>> "a + b\tc\nd".translate(keepwhite) + 'x x x\tx\nx' + """ + # Calling this triples access time; see bpo-32940 + def __missing__(self, key): + return 120 # ord('x') + + +# Map all ascii to 120 to avoid __missing__ call, then replace some. +trans = ParseMap.fromkeys(range(128), 120) +trans.update((ord(c), ord('(')) for c in "({[") # open brackets => '('; +trans.update((ord(c), ord(')')) for c in ")}]") # close brackets => ')'. +trans.update((ord(c), ord(c)) for c in "\"'\\\n#") # Keep these. + + +class Parser: + + def __init__(self, indentwidth, tabwidth): + self.indentwidth = indentwidth + self.tabwidth = tabwidth + + def set_code(self, s): + assert len(s) == 0 or s[-1] == '\n' + self.code = s + self.study_level = 0 + + def find_good_parse_start(self, is_char_in_string): + """ + Return index of a good place to begin parsing, as close to the + end of the string as possible. This will be the start of some + popular stmt like "if" or "def". Return None if none found: + the caller should pass more prior context then, if possible, or + if not (the entire program text up until the point of interest + has already been tried) pass 0 to set_lo(). + + This will be reliable iff given a reliable is_char_in_string() + function, meaning that when it says "no", it's absolutely + guaranteed that the char is not in a string. + """ + code, pos = self.code, None + + # Peek back from the end for a good place to start, + # but don't try too often; pos will be left None, or + # bumped to a legitimate synch point. + limit = len(code) + for tries in range(5): + i = code.rfind(":\n", 0, limit) + if i < 0: + break + i = code.rfind('\n', 0, i) + 1 # start of colon line (-1+1=0) + m = _synchre(code, i, limit) + if m and not is_char_in_string(m.start()): + pos = m.start() + break + limit = i + if pos is None: + # Nothing looks like a block-opener, or stuff does + # but is_char_in_string keeps returning true; most likely + # we're in or near a giant string, the colorizer hasn't + # caught up enough to be helpful, or there simply *aren't* + # any interesting stmts. In any of these cases we're + # going to have to parse the whole thing to be sure, so + # give it one last try from the start, but stop wasting + # time here regardless of the outcome. + m = _synchre(code) + if m and not is_char_in_string(m.start()): + pos = m.start() + return pos + + # Peeking back worked; look forward until _synchre no longer + # matches. + i = pos + 1 + while m := _synchre(code, i): + s, i = m.span() + if not is_char_in_string(s): + pos = s + return pos + + def set_lo(self, lo): + """ Throw away the start of the string. + + Intended to be called with the result of find_good_parse_start(). + """ + assert lo == 0 or self.code[lo-1] == '\n' + if lo > 0: + self.code = self.code[lo:] + + def _study1(self): + """Find the line numbers of non-continuation lines. + + As quickly as humanly possible , find the line numbers (0- + based) of the non-continuation lines. + Creates self.{goodlines, continuation}. + """ + if self.study_level >= 1: + return + self.study_level = 1 + + # Map all uninteresting characters to "x", all open brackets + # to "(", all close brackets to ")", then collapse runs of + # uninteresting characters. This can cut the number of chars + # by a factor of 10-40, and so greatly speed the following loop. + code = self.code + code = code.translate(trans) + code = code.replace('xxxxxxxx', 'x') + code = code.replace('xxxx', 'x') + code = code.replace('xx', 'x') + code = code.replace('xx', 'x') + code = code.replace('\nx', '\n') + # Replacing x\n with \n would be incorrect because + # x may be preceded by a backslash. + + # March over the squashed version of the program, accumulating + # the line numbers of non-continued stmts, and determining + # whether & why the last stmt is a continuation. + continuation = C_NONE + level = lno = 0 # level is nesting level; lno is line number + self.goodlines = goodlines = [0] + push_good = goodlines.append + i, n = 0, len(code) + while i < n: + ch = code[i] + i = i+1 + + # cases are checked in decreasing order of frequency + if ch == 'x': + continue + + if ch == '\n': + lno = lno + 1 + if level == 0: + push_good(lno) + # else we're in an unclosed bracket structure + continue + + if ch == '(': + level = level + 1 + continue + + if ch == ')': + if level: + level = level - 1 + # else the program is invalid, but we can't complain + continue + + if ch == '"' or ch == "'": + # consume the string + quote = ch + if code[i-1:i+2] == quote * 3: + quote = quote * 3 + firstlno = lno + w = len(quote) - 1 + i = i+w + while i < n: + ch = code[i] + i = i+1 + + if ch == 'x': + continue + + if code[i-1:i+w] == quote: + i = i+w + break + + if ch == '\n': + lno = lno + 1 + if w == 0: + # unterminated single-quoted string + if level == 0: + push_good(lno) + break + continue + + if ch == '\\': + assert i < n + if code[i] == '\n': + lno = lno + 1 + i = i+1 + continue + + # else comment char or paren inside string + + else: + # didn't break out of the loop, so we're still + # inside a string + if (lno - 1) == firstlno: + # before the previous \n in code, we were in the first + # line of the string + continuation = C_STRING_FIRST_LINE + else: + continuation = C_STRING_NEXT_LINES + continue # with outer loop + + if ch == '#': + # consume the comment + i = code.find('\n', i) + assert i >= 0 + continue + + assert ch == '\\' + assert i < n + if code[i] == '\n': + lno = lno + 1 + if i+1 == n: + continuation = C_BACKSLASH + i = i+1 + + # The last stmt may be continued for all 3 reasons. + # String continuation takes precedence over bracket + # continuation, which beats backslash continuation. + if (continuation != C_STRING_FIRST_LINE + and continuation != C_STRING_NEXT_LINES and level > 0): + continuation = C_BRACKET + self.continuation = continuation + + # Push the final line number as a sentinel value, regardless of + # whether it's continued. + assert (continuation == C_NONE) == (goodlines[-1] == lno) + if goodlines[-1] != lno: + push_good(lno) + + def get_continuation_type(self): + self._study1() + return self.continuation + + def _study2(self): + """ + study1 was sufficient to determine the continuation status, + but doing more requires looking at every character. study2 + does this for the last interesting statement in the block. + Creates: + self.stmt_start, stmt_end + slice indices of last interesting stmt + self.stmt_bracketing + the bracketing structure of the last interesting stmt; for + example, for the statement "say(boo) or die", + stmt_bracketing will be ((0, 0), (0, 1), (2, 0), (2, 1), + (4, 0)). Strings and comments are treated as brackets, for + the matter. + self.lastch + last interesting character before optional trailing comment + self.lastopenbracketpos + if continuation is C_BRACKET, index of last open bracket + """ + if self.study_level >= 2: + return + self._study1() + self.study_level = 2 + + # Set p and q to slice indices of last interesting stmt. + code, goodlines = self.code, self.goodlines + i = len(goodlines) - 1 # Index of newest line. + p = len(code) # End of goodlines[i] + while i: + assert p + # Make p be the index of the stmt at line number goodlines[i]. + # Move p back to the stmt at line number goodlines[i-1]. + q = p + for nothing in range(goodlines[i-1], goodlines[i]): + # tricky: sets p to 0 if no preceding newline + p = code.rfind('\n', 0, p-1) + 1 + # The stmt code[p:q] isn't a continuation, but may be blank + # or a non-indenting comment line. + if _junkre(code, p): + i = i-1 + else: + break + if i == 0: + # nothing but junk! + assert p == 0 + q = p + self.stmt_start, self.stmt_end = p, q + + # Analyze this stmt, to find the last open bracket (if any) + # and last interesting character (if any). + lastch = "" + stack = [] # stack of open bracket indices + push_stack = stack.append + bracketing = [(p, 0)] + while p < q: + # suck up all except ()[]{}'"#\\ + m = _chew_ordinaryre(code, p, q) + if m: + # we skipped at least one boring char + newp = m.end() + # back up over totally boring whitespace + i = newp - 1 # index of last boring char + while i >= p and code[i] in " \t\n": + i = i-1 + if i >= p: + lastch = code[i] + p = newp + if p >= q: + break + + ch = code[p] + + if ch in "([{": + push_stack(p) + bracketing.append((p, len(stack))) + lastch = ch + p = p+1 + continue + + if ch in ")]}": + if stack: + del stack[-1] + lastch = ch + p = p+1 + bracketing.append((p, len(stack))) + continue + + if ch == '"' or ch == "'": + # consume string + # Note that study1 did this with a Python loop, but + # we use a regexp here; the reason is speed in both + # cases; the string may be huge, but study1 pre-squashed + # strings to a couple of characters per line. study1 + # also needed to keep track of newlines, and we don't + # have to. + bracketing.append((p, len(stack)+1)) + lastch = ch + p = _match_stringre(code, p, q).end() + bracketing.append((p, len(stack))) + continue + + if ch == '#': + # consume comment and trailing newline + bracketing.append((p, len(stack)+1)) + p = code.find('\n', p, q) + 1 + assert p > 0 + bracketing.append((p, len(stack))) + continue + + assert ch == '\\' + p = p+1 # beyond backslash + assert p < q + if code[p] != '\n': + # the program is invalid, but can't complain + lastch = ch + code[p] + p = p+1 # beyond escaped char + + # end while p < q: + + self.lastch = lastch + self.lastopenbracketpos = stack[-1] if stack else None + self.stmt_bracketing = tuple(bracketing) + + def compute_bracket_indent(self): + """Return number of spaces the next line should be indented. + + Line continuation must be C_BRACKET. + """ + self._study2() + assert self.continuation == C_BRACKET + j = self.lastopenbracketpos + code = self.code + n = len(code) + origi = i = code.rfind('\n', 0, j) + 1 + j = j+1 # one beyond open bracket + # find first list item; set i to start of its line + while j < n: + m = _itemre(code, j) + if m: + j = m.end() - 1 # index of first interesting char + extra = 0 + break + else: + # this line is junk; advance to next line + i = j = code.find('\n', j) + 1 + else: + # nothing interesting follows the bracket; + # reproduce the bracket line's indentation + a level + j = i = origi + while code[j] in " \t": + j = j+1 + extra = self.indentwidth + return len(code[i:j].expandtabs(self.tabwidth)) + extra + + def get_num_lines_in_stmt(self): + """Return number of physical lines in last stmt. + + The statement doesn't have to be an interesting statement. This is + intended to be called when continuation is C_BACKSLASH. + """ + self._study1() + goodlines = self.goodlines + return goodlines[-1] - goodlines[-2] + + def compute_backslash_indent(self): + """Return number of spaces the next line should be indented. + + Line continuation must be C_BACKSLASH. Also assume that the new + line is the first one following the initial line of the stmt. + """ + self._study2() + assert self.continuation == C_BACKSLASH + code = self.code + i = self.stmt_start + while code[i] in " \t": + i = i+1 + startpos = i + + # See whether the initial line starts an assignment stmt; i.e., + # look for an = operator + endpos = code.find('\n', startpos) + 1 + found = level = 0 + while i < endpos: + ch = code[i] + if ch in "([{": + level = level + 1 + i = i+1 + elif ch in ")]}": + if level: + level = level - 1 + i = i+1 + elif ch == '"' or ch == "'": + i = _match_stringre(code, i, endpos).end() + elif ch == '#': + # This line is unreachable because the # makes a comment of + # everything after it. + break + elif level == 0 and ch == '=' and \ + (i == 0 or code[i-1] not in "=<>!") and \ + code[i+1] != '=': + found = 1 + break + else: + i = i+1 + + if found: + # found a legit =, but it may be the last interesting + # thing on the line + i = i+1 # move beyond the = + found = re.match(r"\s*\\", code[i:endpos]) is None + + if not found: + # oh well ... settle for moving beyond the first chunk + # of non-whitespace chars + i = startpos + while code[i] not in " \t\n": + i = i+1 + + return len(code[self.stmt_start:i].expandtabs(\ + self.tabwidth)) + 1 + + def get_base_indent_string(self): + """Return the leading whitespace on the initial line of the last + interesting stmt. + """ + self._study2() + i, n = self.stmt_start, self.stmt_end + j = i + code = self.code + while j < n and code[j] in " \t": + j = j + 1 + return code[i:j] + + def is_block_opener(self): + "Return True if the last interesting statement opens a block." + self._study2() + return self.lastch == ':' + + def is_block_closer(self): + "Return True if the last interesting statement closes a block." + self._study2() + return _closere(self.code, self.stmt_start) is not None + + def get_last_stmt_bracketing(self): + """Return bracketing structure of the last interesting statement. + + The returned tuple is in the format defined in _study2(). + """ + self._study2() + return self.stmt_bracketing + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_pyparse', verbosity=2) diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py new file mode 100755 index 00000000000..1b7c2af1a92 --- /dev/null +++ b/Lib/idlelib/pyshell.py @@ -0,0 +1,1699 @@ +#! /usr/bin/env python3 + +import sys +if __name__ == "__main__": + sys.modules['idlelib.pyshell'] = sys.modules['__main__'] + +try: + from tkinter import * +except ImportError: + print("** IDLE can't import Tkinter.\n" + "Your Python may not be configured for Tk. **", file=sys.__stderr__) + raise SystemExit(1) + +if sys.platform == 'win32': + from idlelib.util import fix_win_hidpi + fix_win_hidpi() + +from tkinter import messagebox + +from code import InteractiveInterpreter +import itertools +import linecache +import os +import os.path +import re +import socket +import subprocess +from textwrap import TextWrapper +import threading +import time +import tokenize +import warnings + +from idlelib.colorizer import ColorDelegator +from idlelib.config import idleConf +from idlelib.delegator import Delegator +from idlelib import debugger +from idlelib import debugger_r +from idlelib.editor import EditorWindow, fixwordbreaks +from idlelib.filelist import FileList +from idlelib.outwin import OutputWindow +from idlelib import replace +from idlelib import rpc +from idlelib.run import idle_formatwarning, StdInputFile, StdOutputFile +from idlelib.undo import UndoDelegator + +# Default for testing; defaults to True in main() for running. +use_subprocess = False + +HOST = '127.0.0.1' # python execution server on localhost loopback +PORT = 0 # someday pass in host, port for remote debug capability + +try: # In case IDLE started with -n. + eof = 'Ctrl-D (end-of-file)' + exit.eof = eof + quit.eof = eof +except NameError: # In case python started with -S. + pass + +# Override warnings module to write to warning_stream. Initialize to send IDLE +# internal warnings to the console. ScriptBinding.check_syntax() will +# temporarily redirect the stream to the shell window to display warnings when +# checking user's code. +warning_stream = sys.__stderr__ # None, at least on Windows, if no console. + +def idle_showwarning( + message, category, filename, lineno, file=None, line=None): + """Show Idle-format warning (after replacing warnings.showwarning). + + The differences are the formatter called, the file=None replacement, + which can be None, the capture of the consequence AttributeError, + and the output of a hard-coded prompt. + """ + if file is None: + file = warning_stream + try: + file.write(idle_formatwarning( + message, category, filename, lineno, line=line)) + file.write(">>> ") + except (AttributeError, OSError): + pass # if file (probably __stderr__) is invalid, skip warning. + +_warnings_showwarning = None + +def capture_warnings(capture): + "Replace warning.showwarning with idle_showwarning, or reverse." + + global _warnings_showwarning + if capture: + if _warnings_showwarning is None: + _warnings_showwarning = warnings.showwarning + warnings.showwarning = idle_showwarning + else: + if _warnings_showwarning is not None: + warnings.showwarning = _warnings_showwarning + _warnings_showwarning = None + +capture_warnings(True) + +def extended_linecache_checkcache(filename=None, + orig_checkcache=linecache.checkcache): + """Extend linecache.checkcache to preserve the entries + + Rather than repeating the linecache code, patch it to save the + entries, call the original linecache.checkcache() + (skipping them), and then restore the saved entries. + + orig_checkcache is bound at definition time to the original + method, allowing it to be patched. + """ + cache = linecache.cache + save = {} + for key in list(cache): + if key[:1] + key[-1:] == '<>': + save[key] = cache.pop(key) + orig_checkcache(filename) + cache.update(save) + +# Patch linecache.checkcache(): +linecache.checkcache = extended_linecache_checkcache + + +class PyShellEditorWindow(EditorWindow): + "Regular text edit window in IDLE, supports breakpoints" + + def __init__(self, *args): + self.breakpoints = [] + EditorWindow.__init__(self, *args) + self.text.bind("<>", self.set_breakpoint_event) + self.text.bind("<>", self.clear_breakpoint_event) + self.text.bind("<>", self.flist.open_shell) + + #TODO: don't read/write this from/to .idlerc when testing + self.breakpointPath = os.path.join( + idleConf.userdir, 'breakpoints.lst') + # whenever a file is changed, restore breakpoints + def filename_changed_hook(old_hook=self.io.filename_change_hook, + self=self): + self.restore_file_breaks() + old_hook() + self.io.set_filename_change_hook(filename_changed_hook) + if self.io.filename: + self.restore_file_breaks() + self.color_breakpoint_text() + + rmenu_specs = [ + ("Cut", "<>", "rmenu_check_cut"), + ("Copy", "<>", "rmenu_check_copy"), + ("Paste", "<>", "rmenu_check_paste"), + (None, None, None), + ("Set Breakpoint", "<>", None), + ("Clear Breakpoint", "<>", None) + ] + + def color_breakpoint_text(self, color=True): + "Turn colorizing of breakpoint text on or off" + if self.io is None: + # possible due to update in restore_file_breaks + return + if color: + theme = idleConf.CurrentTheme() + cfg = idleConf.GetHighlight(theme, "break") + else: + cfg = {'foreground': '', 'background': ''} + self.text.tag_config('BREAK', cfg) + + def set_breakpoint(self, lineno): + text = self.text + filename = self.io.filename + text.tag_add("BREAK", "%d.0" % lineno, "%d.0" % (lineno+1)) + try: + self.breakpoints.index(lineno) + except ValueError: # only add if missing, i.e. do once + self.breakpoints.append(lineno) + try: # update the subprocess debugger + debug = self.flist.pyshell.interp.debugger + debug.set_breakpoint(filename, lineno) + except: # but debugger may not be active right now.... + pass + + def set_breakpoint_event(self, event=None): + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + lineno = int(float(text.index("insert"))) + self.set_breakpoint(lineno) + + def clear_breakpoint_event(self, event=None): + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + lineno = int(float(text.index("insert"))) + try: + self.breakpoints.remove(lineno) + except: + pass + text.tag_remove("BREAK", "insert linestart",\ + "insert lineend +1char") + try: + debug = self.flist.pyshell.interp.debugger + debug.clear_breakpoint(filename, lineno) + except: + pass + + def clear_file_breaks(self): + if self.breakpoints: + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + self.breakpoints = [] + text.tag_remove("BREAK", "1.0", END) + try: + debug = self.flist.pyshell.interp.debugger + debug.clear_file_breaks(filename) + except: + pass + + def store_file_breaks(self): + "Save breakpoints when file is saved" + # XXX 13 Dec 2002 KBK Currently the file must be saved before it can + # be run. The breaks are saved at that time. If we introduce + # a temporary file save feature the save breaks functionality + # needs to be re-verified, since the breaks at the time the + # temp file is created may differ from the breaks at the last + # permanent save of the file. Currently, a break introduced + # after a save will be effective, but not persistent. + # This is necessary to keep the saved breaks synched with the + # saved file. + # + # Breakpoints are set as tagged ranges in the text. + # Since a modified file has to be saved before it is + # run, and since self.breakpoints (from which the subprocess + # debugger is loaded) is updated during the save, the visible + # breaks stay synched with the subprocess even if one of these + # unexpected breakpoint deletions occurs. + breaks = self.breakpoints + filename = self.io.filename + try: + with open(self.breakpointPath) as fp: + lines = fp.readlines() + except OSError: + lines = [] + try: + with open(self.breakpointPath, "w") as new_file: + for line in lines: + if not line.startswith(filename + '='): + new_file.write(line) + self.update_breakpoints() + breaks = self.breakpoints + if breaks: + new_file.write(filename + '=' + str(breaks) + '\n') + except OSError as err: + if not getattr(self.root, "breakpoint_error_displayed", False): + self.root.breakpoint_error_displayed = True + messagebox.showerror(title='IDLE Error', + message='Unable to update breakpoint list:\n%s' + % str(err), + parent=self.text) + + def restore_file_breaks(self): + self.text.update() # this enables setting "BREAK" tags to be visible + if self.io is None: + # can happen if IDLE closes due to the .update() call + return + filename = self.io.filename + if filename is None: + return + if os.path.isfile(self.breakpointPath): + with open(self.breakpointPath) as fp: + lines = fp.readlines() + for line in lines: + if line.startswith(filename + '='): + breakpoint_linenumbers = eval(line[len(filename)+1:]) + for breakpoint_linenumber in breakpoint_linenumbers: + self.set_breakpoint(breakpoint_linenumber) + + def update_breakpoints(self): + "Retrieves all the breakpoints in the current window" + text = self.text + ranges = text.tag_ranges("BREAK") + linenumber_list = self.ranges_to_linenumbers(ranges) + self.breakpoints = linenumber_list + + def ranges_to_linenumbers(self, ranges): + lines = [] + for index in range(0, len(ranges), 2): + lineno = int(float(ranges[index].string)) + end = int(float(ranges[index+1].string)) + while lineno < end: + lines.append(lineno) + lineno += 1 + return lines + +# XXX 13 Dec 2002 KBK Not used currently +# def saved_change_hook(self): +# "Extend base method - clear breaks if module is modified" +# if not self.get_saved(): +# self.clear_file_breaks() +# EditorWindow.saved_change_hook(self) + + def _close(self): + "Extend base method - clear breaks when module is closed" + self.clear_file_breaks() + EditorWindow._close(self) + + +class PyShellFileList(FileList): + "Extend base class: IDLE supports a shell and breakpoints" + + # override FileList's class variable, instances return PyShellEditorWindow + # instead of EditorWindow when new edit windows are created. + EditorWindow = PyShellEditorWindow + + pyshell = None + + def open_shell(self, event=None): + if self.pyshell: + self.pyshell.top.wakeup() + else: + self.pyshell = PyShell(self) + if self.pyshell: + if not self.pyshell.begin(): + return None + return self.pyshell + + +class ModifiedColorDelegator(ColorDelegator): + "Extend base class: colorizer for the shell window itself" + def recolorize_main(self): + self.tag_remove("TODO", "1.0", "iomark") + self.tag_add("SYNC", "1.0", "iomark") + ColorDelegator.recolorize_main(self) + + def removecolors(self): + # Don't remove shell color tags before "iomark" + for tag in self.tagdefs: + self.tag_remove(tag, "iomark", "end") + + +class ModifiedUndoDelegator(UndoDelegator): + "Extend base class: forbid insert/delete before the I/O mark" + def insert(self, index, chars, tags=None): + try: + if self.delegate.compare(index, "<", "iomark"): + self.delegate.bell() + return + except TclError: + pass + UndoDelegator.insert(self, index, chars, tags) + + def delete(self, index1, index2=None): + try: + if self.delegate.compare(index1, "<", "iomark"): + self.delegate.bell() + return + except TclError: + pass + UndoDelegator.delete(self, index1, index2) + + def undo_event(self, event): + # Temporarily monkey-patch the delegate's .insert() method to + # always use the "stdin" tag. This is needed for undo-ing + # deletions to preserve the "stdin" tag, because UndoDelegator + # doesn't preserve tags for deleted text. + orig_insert = self.delegate.insert + self.delegate.insert = \ + lambda index, chars: orig_insert(index, chars, "stdin") + try: + super().undo_event(event) + finally: + self.delegate.insert = orig_insert + + +class UserInputTaggingDelegator(Delegator): + """Delegator used to tag user input with "stdin".""" + def insert(self, index, chars, tags=None): + if tags is None: + tags = "stdin" + self.delegate.insert(index, chars, tags) + + +class MyRPCClient(rpc.RPCClient): + + def handle_EOF(self): + "Override the base class - just re-raise EOFError" + raise EOFError + +def restart_line(width, filename): # See bpo-38141. + """Return width long restart line formatted with filename. + + Fill line with balanced '='s, with any extras and at least one at + the beginning. Do not end with a trailing space. + """ + tag = f"= RESTART: {filename or 'Shell'} =" + if width >= len(tag): + div, mod = divmod((width -len(tag)), 2) + return f"{(div+mod)*'='}{tag}{div*'='}" + else: + return tag[:-2] # Remove ' ='. + + +class ModifiedInterpreter(InteractiveInterpreter): + + def __init__(self, tkconsole): + self.tkconsole = tkconsole + locals = sys.modules['__main__'].__dict__ + InteractiveInterpreter.__init__(self, locals=locals) + self.restarting = False + self.subprocess_arglist = None + self.port = PORT + self.original_compiler_flags = self.compile.compiler.flags + + _afterid = None + rpcclt = None + rpcsubproc = None + + def spawn_subprocess(self): + if self.subprocess_arglist is None: + self.subprocess_arglist = self.build_subprocess_arglist() + # gh-127060: Disable traceback colors + env = dict(os.environ, TERM='dumb') + self.rpcsubproc = subprocess.Popen(self.subprocess_arglist, env=env) + + def build_subprocess_arglist(self): + assert (self.port!=0), ( + "Socket should have been assigned a port number.") + w = ['-W' + s for s in sys.warnoptions] + # Maybe IDLE is installed and is being accessed via sys.path, + # or maybe it's not installed and the idle.py script is being + # run from the IDLE source directory. + del_exitf = idleConf.GetOption('main', 'General', 'delete-exitfunc', + default=False, type='bool') + command = f"__import__('idlelib.run').run.main({del_exitf!r})" + return [sys.executable] + w + ["-c", command, str(self.port)] + + def start_subprocess(self): + addr = (HOST, self.port) + # GUI makes several attempts to acquire socket, listens for connection + for i in range(3): + time.sleep(i) + try: + self.rpcclt = MyRPCClient(addr) + break + except OSError: + pass + else: + self.display_port_binding_error() + return None + # if PORT was 0, system will assign an 'ephemeral' port. Find it out: + self.port = self.rpcclt.listening_sock.getsockname()[1] + # if PORT was not 0, probably working with a remote execution server + if PORT != 0: + # To allow reconnection within the 2MSL wait (cf. Stevens TCP + # V1, 18.6), set SO_REUSEADDR. Note that this can be problematic + # on Windows since the implementation allows two active sockets on + # the same address! + self.rpcclt.listening_sock.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, 1) + self.spawn_subprocess() + #time.sleep(20) # test to simulate GUI not accepting connection + # Accept the connection from the Python execution server + self.rpcclt.listening_sock.settimeout(10) + try: + self.rpcclt.accept() + except TimeoutError: + self.display_no_subprocess_error() + return None + self.rpcclt.register("console", self.tkconsole) + self.rpcclt.register("stdin", self.tkconsole.stdin) + self.rpcclt.register("stdout", self.tkconsole.stdout) + self.rpcclt.register("stderr", self.tkconsole.stderr) + self.rpcclt.register("flist", self.tkconsole.flist) + self.rpcclt.register("linecache", linecache) + self.rpcclt.register("interp", self) + self.transfer_path(with_cwd=True) + self.poll_subprocess() + return self.rpcclt + + def restart_subprocess(self, with_cwd=False, filename=''): + if self.restarting: + return self.rpcclt + self.restarting = True + # close only the subprocess debugger + debug = self.getdebugger() + if debug: + try: + # Only close subprocess debugger, don't unregister gui_adap! + debugger_r.close_subprocess_debugger(self.rpcclt) + except: + pass + # Kill subprocess, spawn a new one, accept connection. + self.rpcclt.close() + self.terminate_subprocess() + console = self.tkconsole + was_executing = console.executing + console.executing = False + self.spawn_subprocess() + try: + self.rpcclt.accept() + except TimeoutError: + self.display_no_subprocess_error() + return None + self.transfer_path(with_cwd=with_cwd) + console.stop_readline() + # annotate restart in shell window and mark it + console.text.delete("iomark", "end-1c") + console.write('\n') + console.write(restart_line(console.width, filename)) + console.text.mark_set("restart", "end-1c") + console.text.mark_gravity("restart", "left") + if not filename: + console.showprompt() + # restart subprocess debugger + if debug: + # Restarted debugger connects to current instance of debug GUI + debugger_r.restart_subprocess_debugger(self.rpcclt) + # reload remote debugger breakpoints for all PyShellEditWindows + debug.load_breakpoints() + self.compile.compiler.flags = self.original_compiler_flags + self.restarting = False + return self.rpcclt + + def __request_interrupt(self): + self.rpcclt.remotecall("exec", "interrupt_the_server", (), {}) + + def interrupt_subprocess(self): + threading.Thread(target=self.__request_interrupt).start() + + def kill_subprocess(self): + if self._afterid is not None: + self.tkconsole.text.after_cancel(self._afterid) + try: + self.rpcclt.listening_sock.close() + except AttributeError: # no socket + pass + try: + self.rpcclt.close() + except AttributeError: # no socket + pass + self.terminate_subprocess() + self.tkconsole.executing = False + self.rpcclt = None + + def terminate_subprocess(self): + "Make sure subprocess is terminated" + try: + self.rpcsubproc.kill() + except OSError: + # process already terminated + return + else: + try: + self.rpcsubproc.wait() + except OSError: + return + + def transfer_path(self, with_cwd=False): + if with_cwd: # Issue 13506 + path = [''] # include Current Working Directory + path.extend(sys.path) + else: + path = sys.path + + self.runcommand("""if 1: + import sys as _sys + _sys.path = {!r} + del _sys + \n""".format(path)) + + active_seq = None + + def poll_subprocess(self): + clt = self.rpcclt + if clt is None: + return + try: + response = clt.pollresponse(self.active_seq, wait=0.05) + except (EOFError, OSError, KeyboardInterrupt): + # lost connection or subprocess terminated itself, restart + # [the KBI is from rpc.SocketIO.handle_EOF()] + if self.tkconsole.closing: + return + response = None + self.restart_subprocess() + if response: + self.tkconsole.resetoutput() + self.active_seq = None + how, what = response + console = self.tkconsole.console + if how == "OK": + if what is not None: + print(repr(what), file=console) + elif how == "EXCEPTION": + if self.tkconsole.getvar("<>"): + self.remote_stack_viewer() + elif how == "ERROR": + errmsg = "pyshell.ModifiedInterpreter: Subprocess ERROR:\n" + print(errmsg, what, file=sys.__stderr__) + print(errmsg, what, file=console) + # we received a response to the currently active seq number: + try: + self.tkconsole.endexecuting() + except AttributeError: # shell may have closed + pass + # Reschedule myself + if not self.tkconsole.closing: + self._afterid = self.tkconsole.text.after( + self.tkconsole.pollinterval, self.poll_subprocess) + + debugger = None + + def setdebugger(self, debugger): + self.debugger = debugger + + def getdebugger(self): + return self.debugger + + def open_remote_stack_viewer(self): + """Initiate the remote stack viewer from a separate thread. + + This method is called from the subprocess, and by returning from this + method we allow the subprocess to unblock. After a bit the shell + requests the subprocess to open the remote stack viewer which returns a + static object looking at the last exception. It is queried through + the RPC mechanism. + + """ + self.tkconsole.text.after(300, self.remote_stack_viewer) + return + + def remote_stack_viewer(self): + from idlelib import debugobj_r + oid = self.rpcclt.remotequeue("exec", "stackviewer", ("flist",), {}) + if oid is None: + self.tkconsole.root.bell() + return + item = debugobj_r.StubObjectTreeItem(self.rpcclt, oid) + from idlelib.tree import ScrolledCanvas, TreeNode + top = Toplevel(self.tkconsole.root) + theme = idleConf.CurrentTheme() + background = idleConf.GetHighlight(theme, 'normal')['background'] + sc = ScrolledCanvas(top, bg=background, highlightthickness=0) + sc.frame.pack(expand=1, fill="both") + node = TreeNode(sc.canvas, None, item) + node.expand() + # XXX Should GC the remote tree when closing the window + + gid = 0 + + def execsource(self, source): + "Like runsource() but assumes complete exec source" + filename = self.stuffsource(source) + self.execfile(filename, source) + + def execfile(self, filename, source=None): + "Execute an existing file" + if source is None: + with tokenize.open(filename) as fp: + source = fp.read() + if use_subprocess: + source = (f"__file__ = r'''{os.path.abspath(filename)}'''\n" + + source + "\ndel __file__") + try: + code = compile(source, filename, "exec") + except (OverflowError, SyntaxError): + self.tkconsole.resetoutput() + print('*** Error in script or command!\n' + 'Traceback (most recent call last):', + file=self.tkconsole.stderr) + InteractiveInterpreter.showsyntaxerror(self, filename) + self.tkconsole.showprompt() + else: + self.runcode(code) + + def runsource(self, source): + "Extend base class method: Stuff the source in the line cache first" + filename = self.stuffsource(source) + # at the moment, InteractiveInterpreter expects str + assert isinstance(source, str) + # InteractiveInterpreter.runsource() calls its runcode() method, + # which is overridden (see below) + return InteractiveInterpreter.runsource(self, source, filename) + + def stuffsource(self, source): + "Stuff source in the filename cache" + filename = "" % self.gid + self.gid = self.gid + 1 + lines = source.split("\n") + linecache.cache[filename] = len(source)+1, 0, lines, filename + return filename + + def prepend_syspath(self, filename): + "Prepend sys.path with file's directory if not already included" + self.runcommand("""if 1: + _filename = {!r} + import sys as _sys + from os.path import dirname as _dirname + _dir = _dirname(_filename) + if not _dir in _sys.path: + _sys.path.insert(0, _dir) + del _filename, _sys, _dirname, _dir + \n""".format(filename)) + + def showsyntaxerror(self, filename=None, **kwargs): + """Override Interactive Interpreter method: Use Colorizing + + Color the offending position instead of printing it and pointing at it + with a caret. + + """ + tkconsole = self.tkconsole + text = tkconsole.text + text.tag_remove("ERROR", "1.0", "end") + type, value, tb = sys.exc_info() + msg = getattr(value, 'msg', '') or value or "" + lineno = getattr(value, 'lineno', '') or 1 + offset = getattr(value, 'offset', '') or 0 + if offset == 0: + lineno += 1 #mark end of offending line + if lineno == 1: + pos = "iomark + %d chars" % (offset-1) + else: + pos = "iomark linestart + %d lines + %d chars" % \ + (lineno-1, offset-1) + tkconsole.colorize_syntax_error(text, pos) + tkconsole.resetoutput() + self.write("SyntaxError: %s\n" % msg) + tkconsole.showprompt() + + def showtraceback(self): + "Extend base class method to reset output properly" + self.tkconsole.resetoutput() + self.checklinecache() + InteractiveInterpreter.showtraceback(self) + if self.tkconsole.getvar("<>"): + self.tkconsole.open_stack_viewer() + + def checklinecache(self): + "Remove keys other than ''." + cache = linecache.cache + for key in list(cache): # Iterate list because mutate cache. + if key[:1] + key[-1:] != "<>": + del cache[key] + + def runcommand(self, code): + "Run the code without invoking the debugger" + # The code better not raise an exception! + if self.tkconsole.executing: + self.display_executing_dialog() + return 0 + if self.rpcclt: + self.rpcclt.remotequeue("exec", "runcode", (code,), {}) + else: + exec(code, self.locals) + return 1 + + def runcode(self, code): + "Override base class method" + if self.tkconsole.executing: + self.restart_subprocess() + self.checklinecache() + debugger = self.debugger + try: + self.tkconsole.beginexecuting() + if not debugger and self.rpcclt is not None: + self.active_seq = self.rpcclt.asyncqueue("exec", "runcode", + (code,), {}) + elif debugger: + debugger.run(code, self.locals) + else: + exec(code, self.locals) + except SystemExit: + if not self.tkconsole.closing: + if messagebox.askyesno( + "Exit?", + "Do you want to exit altogether?", + default="yes", + parent=self.tkconsole.text): + raise + else: + self.showtraceback() + else: + raise + except: + if use_subprocess: + print("IDLE internal error in runcode()", + file=self.tkconsole.stderr) + self.showtraceback() + self.tkconsole.endexecuting() + else: + if self.tkconsole.canceled: + self.tkconsole.canceled = False + print("KeyboardInterrupt", file=self.tkconsole.stderr) + else: + self.showtraceback() + finally: + if not use_subprocess: + try: + self.tkconsole.endexecuting() + except AttributeError: # shell may have closed + pass + + def write(self, s): + "Override base class method" + return self.tkconsole.stderr.write(s) + + def display_port_binding_error(self): + messagebox.showerror( + "Port Binding Error", + "IDLE can't bind to a TCP/IP port, which is necessary to " + "communicate with its Python execution server. This might be " + "because no networking is installed on this computer. " + "Run IDLE with the -n command line switch to start without a " + "subprocess and refer to Help/IDLE Help 'Running without a " + "subprocess' for further details.", + parent=self.tkconsole.text) + + def display_no_subprocess_error(self): + messagebox.showerror( + "Subprocess Connection Error", + "IDLE's subprocess didn't make connection.\n" + "See the 'Startup failure' section of the IDLE doc, online at\n" + "https://docs.python.org/3/library/idle.html#startup-failure", + parent=self.tkconsole.text) + + def display_executing_dialog(self): + messagebox.showerror( + "Already executing", + "The Python Shell window is already executing a command; " + "please wait until it is finished.", + parent=self.tkconsole.text) + + +class PyShell(OutputWindow): + from idlelib.squeezer import Squeezer + + shell_title = "IDLE Shell" + + # Override classes + ColorDelegator = ModifiedColorDelegator + UndoDelegator = ModifiedUndoDelegator + + # Override menus + menu_specs = [ + ("file", "_File"), + ("edit", "_Edit"), + ("debug", "_Debug"), + ("options", "_Options"), + ("window", "_Window"), + ("help", "_Help"), + ] + + # Extend right-click context menu + rmenu_specs = OutputWindow.rmenu_specs + [ + ("Squeeze", "<>"), + ] + _idx = 1 + len(list(itertools.takewhile( + lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs) + )) + rmenu_specs.insert(_idx, ("Copy with prompts", + "<>", + "rmenu_check_copy")) + del _idx + + allow_line_numbers = False + user_input_insert_tags = "stdin" + + # New classes + from idlelib.history import History + from idlelib.sidebar import ShellSidebar + + def __init__(self, flist=None): + ms = self.menu_specs + if ms[2][0] != "shell": + ms.insert(2, ("shell", "She_ll")) + self.interp = ModifiedInterpreter(self) + if flist is None: + root = Tk() + fixwordbreaks(root) + root.withdraw() + flist = PyShellFileList(root) + + self.shell_sidebar = None # initialized below + + OutputWindow.__init__(self, flist, None, None) + + self.usetabs = False + # indentwidth must be 8 when using tabs. See note in EditorWindow: + self.indentwidth = 4 + + self.sys_ps1 = sys.ps1 if hasattr(sys, 'ps1') else '>>>\n' + self.prompt_last_line = self.sys_ps1.split('\n')[-1] + self.prompt = self.sys_ps1 # Changes when debug active + + text = self.text + text.configure(wrap="char") + text.bind("<>", self.enter_callback) + text.bind("<>", self.linefeed_callback) + text.bind("<>", self.cancel_callback) + text.bind("<>", self.eof_callback) + text.bind("<>", self.open_stack_viewer) + text.bind("<>", self.toggle_debugger) + text.bind("<>", self.toggle_jit_stack_viewer) + text.bind("<>", self.copy_with_prompts_callback) + if use_subprocess: + text.bind("<>", self.view_restart_mark) + text.bind("<>", self.restart_shell) + self.squeezer = self.Squeezer(self) + text.bind("<>", + self.squeeze_current_text_event) + + self.save_stdout = sys.stdout + self.save_stderr = sys.stderr + self.save_stdin = sys.stdin + from idlelib import iomenu + self.stdin = StdInputFile(self, "stdin", + iomenu.encoding, iomenu.errors) + self.stdout = StdOutputFile(self, "stdout", + iomenu.encoding, iomenu.errors) + self.stderr = StdOutputFile(self, "stderr", + iomenu.encoding, "backslashreplace") + self.console = StdOutputFile(self, "console", + iomenu.encoding, iomenu.errors) + if not use_subprocess: + sys.stdout = self.stdout + sys.stderr = self.stderr + sys.stdin = self.stdin + try: + # page help() text to shell. + import pydoc # import must be done here to capture i/o rebinding. + # XXX KBK 27Dec07 use text viewer someday, but must work w/o subproc + pydoc.pager = pydoc.plainpager + except: + sys.stderr = sys.__stderr__ + raise + # + self.history = self.History(self.text) + # + self.pollinterval = 50 # millisec + + self.shell_sidebar = self.ShellSidebar(self) + + # Insert UserInputTaggingDelegator at the top of the percolator, + # but make calls to text.insert() skip it. This causes only insert + # events generated in Tcl/Tk to go through this delegator. + self.text.insert = self.per.top.insert + self.per.insertfilter(UserInputTaggingDelegator()) + + if not use_subprocess: + # Menu options "View Last Restart" and "Restart Shell" are disabled + self.update_menu_state("shell", 0, "disabled") + self.update_menu_state("shell", 1, "disabled") + + def ResetFont(self): + super().ResetFont() + + if self.shell_sidebar is not None: + self.shell_sidebar.update_font() + + def ResetColorizer(self): + super().ResetColorizer() + + theme = idleConf.CurrentTheme() + tag_colors = { + "stdin": {'background': None, 'foreground': None}, + "stdout": idleConf.GetHighlight(theme, "stdout"), + "stderr": idleConf.GetHighlight(theme, "stderr"), + "console": idleConf.GetHighlight(theme, "normal"), + } + for tag, tag_colors_config in tag_colors.items(): + self.text.tag_configure(tag, **tag_colors_config) + + if self.shell_sidebar is not None: + self.shell_sidebar.update_colors() + + def replace_event(self, event): + replace.replace(self.text, insert_tags="stdin") + return "break" + + def get_standard_extension_names(self): + return idleConf.GetExtensions(shell_only=True) + + def get_prompt_text(self, first, last): + """Return text between first and last with prompts added.""" + text = self.text.get(first, last) + lineno_range = range( + int(float(first)), + int(float(last)) + ) + prompts = [ + self.shell_sidebar.line_prompts.get(lineno) + for lineno in lineno_range + ] + return "\n".join( + line if prompt is None else f"{prompt} {line}" + for prompt, line in zip(prompts, text.splitlines()) + ) + "\n" + + + def copy_with_prompts_callback(self, event=None): + """Copy selected lines to the clipboard, with prompts. + + This makes the copied text useful for doc-tests and interactive + shell code examples. + + This always copies entire lines, even if only part of the first + and/or last lines is selected. + """ + text = self.text + selfirst = text.index('sel.first linestart') + if selfirst is None: # Should not be possible. + return # No selection, do nothing. + sellast = text.index('sel.last') + if sellast[-1] != '0': + sellast = text.index("sel.last+1line linestart") + text.clipboard_clear() + prompt_text = self.get_prompt_text(selfirst, sellast) + text.clipboard_append(prompt_text) + + reading = False + executing = False + canceled = False + endoffile = False + closing = False + _stop_readline_flag = False + + def set_warning_stream(self, stream): + global warning_stream + warning_stream = stream + + def get_warning_stream(self): + return warning_stream + + def toggle_debugger(self, event=None): + if self.executing: + messagebox.showerror("Don't debug now", + "You can only toggle the debugger when idle", + parent=self.text) + self.set_debugger_indicator() + return "break" + else: + db = self.interp.getdebugger() + if db: + self.close_debugger() + else: + self.open_debugger() + + def set_debugger_indicator(self): + db = self.interp.getdebugger() + self.setvar("<>", not not db) + + def toggle_jit_stack_viewer(self, event=None): + pass # All we need is the variable + + def close_debugger(self): + db = self.interp.getdebugger() + if db: + self.interp.setdebugger(None) + db.close() + if self.interp.rpcclt: + debugger_r.close_remote_debugger(self.interp.rpcclt) + self.resetoutput() + self.console.write("[DEBUG OFF]\n") + self.prompt = self.sys_ps1 + self.showprompt() + self.set_debugger_indicator() + + def open_debugger(self): + if self.interp.rpcclt: + dbg_gui = debugger_r.start_remote_debugger(self.interp.rpcclt, + self) + else: + dbg_gui = debugger.Debugger(self) + self.interp.setdebugger(dbg_gui) + dbg_gui.load_breakpoints() + self.prompt = "[DEBUG ON]\n" + self.sys_ps1 + self.showprompt() + self.set_debugger_indicator() + + def debug_menu_postcommand(self): + state = 'disabled' if self.executing else 'normal' + self.update_menu_state('debug', '*tack*iewer', state) + + def beginexecuting(self): + "Helper for ModifiedInterpreter" + self.resetoutput() + self.executing = True + + def endexecuting(self): + "Helper for ModifiedInterpreter" + self.executing = False + self.canceled = False + self.showprompt() + + def close(self): + "Extend EditorWindow.close()" + if self.executing: + response = messagebox.askokcancel( + "Kill?", + "Your program is still running!\n Do you want to kill it?", + default="ok", + parent=self.text) + if response is False: + return "cancel" + self.stop_readline() + self.canceled = True + self.closing = True + return EditorWindow.close(self) + + def _close(self): + "Extend EditorWindow._close(), shut down debugger and execution server" + self.close_debugger() + if use_subprocess: + self.interp.kill_subprocess() + # Restore std streams + sys.stdout = self.save_stdout + sys.stderr = self.save_stderr + sys.stdin = self.save_stdin + # Break cycles + self.interp = None + self.console = None + self.flist.pyshell = None + self.history = None + EditorWindow._close(self) + + def ispythonsource(self, filename): + "Override EditorWindow method: never remove the colorizer" + return True + + def short_title(self): + return self.shell_title + + SPLASHLINE = 'Enter "help" below or click "Help" above for more information.' + + def begin(self): + self.text.mark_set("iomark", "insert") + self.resetoutput() + if use_subprocess: + nosub = '' + client = self.interp.start_subprocess() + if not client: + self.close() + return False + else: + nosub = ("==== No Subprocess ====\n\n" + + "WARNING: Running IDLE without a Subprocess is deprecated\n" + + "and will be removed in a later version. See Help/IDLE Help\n" + + "for details.\n\n") + sys.displayhook = rpc.displayhook + + self.write("Python %s on %s\n%s\n%s" % + (sys.version, sys.platform, self.SPLASHLINE, nosub)) + self.text.focus_force() + self.showprompt() + # User code should use separate default Tk root window + import tkinter + tkinter._support_default_root = True + tkinter._default_root = None + return True + + def stop_readline(self): + if not self.reading: # no nested mainloop to exit. + return + self._stop_readline_flag = True + self.top.quit() + + def readline(self): + save = self.reading + try: + self.reading = True + self.top.mainloop() # nested mainloop() + finally: + self.reading = save + if self._stop_readline_flag: + self._stop_readline_flag = False + return "" + line = self.text.get("iomark", "end-1c") + if len(line) == 0: # may be EOF if we quit our mainloop with Ctrl-C + line = "\n" + self.resetoutput() + if self.canceled: + self.canceled = False + if not use_subprocess: + raise KeyboardInterrupt + if self.endoffile: + self.endoffile = False + line = "" + return line + + def isatty(self): + return True + + def cancel_callback(self, event=None): + try: + if self.text.compare("sel.first", "!=", "sel.last"): + return # Active selection -- always use default binding + except: + pass + if not (self.executing or self.reading): + self.resetoutput() + self.interp.write("KeyboardInterrupt\n") + self.showprompt() + return "break" + self.endoffile = False + self.canceled = True + if (self.executing and self.interp.rpcclt): + if self.interp.getdebugger(): + self.interp.restart_subprocess() + else: + self.interp.interrupt_subprocess() + if self.reading: + self.top.quit() # exit the nested mainloop() in readline() + return "break" + + def eof_callback(self, event): + if self.executing and not self.reading: + return # Let the default binding (delete next char) take over + if not (self.text.compare("iomark", "==", "insert") and + self.text.compare("insert", "==", "end-1c")): + return # Let the default binding (delete next char) take over + if not self.executing: + self.resetoutput() + self.close() + else: + self.canceled = False + self.endoffile = True + self.top.quit() + return "break" + + def linefeed_callback(self, event): + # Insert a linefeed without entering anything (still autoindented) + if self.reading: + self.text.insert("insert", "\n") + self.text.see("insert") + else: + self.newline_and_indent_event(event) + return "break" + + def enter_callback(self, event): + if self.executing and not self.reading: + return # Let the default binding (insert '\n') take over + # If some text is selected, recall the selection + # (but only if this before the I/O mark) + try: + sel = self.text.get("sel.first", "sel.last") + if sel: + if self.text.compare("sel.last", "<=", "iomark"): + self.recall(sel, event) + return "break" + except: + pass + # If we're strictly before the line containing iomark, recall + # the current line, less a leading prompt, less leading or + # trailing whitespace + if self.text.compare("insert", "<", "iomark linestart"): + # Check if there's a relevant stdin range -- if so, use it. + # Note: "stdin" blocks may include several successive statements, + # so look for "console" tags on the newline before each statement + # (and possibly on prompts). + prev = self.text.tag_prevrange("stdin", "insert") + if ( + prev and + self.text.compare("insert", "<", prev[1]) and + # The following is needed to handle empty statements. + "console" not in self.text.tag_names("insert") + ): + prev_cons = self.text.tag_prevrange("console", "insert") + if prev_cons and self.text.compare(prev_cons[1], ">=", prev[0]): + prev = (prev_cons[1], prev[1]) + next_cons = self.text.tag_nextrange("console", "insert") + if next_cons and self.text.compare(next_cons[0], "<", prev[1]): + prev = (prev[0], self.text.index(next_cons[0] + "+1c")) + self.recall(self.text.get(prev[0], prev[1]), event) + return "break" + next = self.text.tag_nextrange("stdin", "insert") + if next and self.text.compare("insert lineend", ">=", next[0]): + next_cons = self.text.tag_nextrange("console", "insert lineend") + if next_cons and self.text.compare(next_cons[0], "<", next[1]): + next = (next[0], self.text.index(next_cons[0] + "+1c")) + self.recall(self.text.get(next[0], next[1]), event) + return "break" + # No stdin mark -- just get the current line, less any prompt + indices = self.text.tag_nextrange("console", "insert linestart") + if indices and \ + self.text.compare(indices[0], "<=", "insert linestart"): + self.recall(self.text.get(indices[1], "insert lineend"), event) + else: + self.recall(self.text.get("insert linestart", "insert lineend"), event) + return "break" + # If we're between the beginning of the line and the iomark, i.e. + # in the prompt area, move to the end of the prompt + if self.text.compare("insert", "<", "iomark"): + self.text.mark_set("insert", "iomark") + # If we're in the current input and there's only whitespace + # beyond the cursor, erase that whitespace first + s = self.text.get("insert", "end-1c") + if s and not s.strip(): + self.text.delete("insert", "end-1c") + # If we're in the current input before its last line, + # insert a newline right at the insert point + if self.text.compare("insert", "<", "end-1c linestart"): + self.newline_and_indent_event(event) + return "break" + # We're in the last line; append a newline and submit it + self.text.mark_set("insert", "end-1c") + if self.reading: + self.text.insert("insert", "\n") + self.text.see("insert") + else: + self.newline_and_indent_event(event) + self.text.update_idletasks() + if self.reading: + self.top.quit() # Break out of recursive mainloop() + else: + self.runit() + return "break" + + def recall(self, s, event): + # remove leading and trailing empty or whitespace lines + s = re.sub(r'^\s*\n', '', s) + s = re.sub(r'\n\s*$', '', s) + lines = s.split('\n') + self.text.undo_block_start() + try: + self.text.tag_remove("sel", "1.0", "end") + self.text.mark_set("insert", "end-1c") + prefix = self.text.get("insert linestart", "insert") + if prefix.rstrip().endswith(':'): + self.newline_and_indent_event(event) + prefix = self.text.get("insert linestart", "insert") + self.text.insert("insert", lines[0].strip(), + self.user_input_insert_tags) + if len(lines) > 1: + orig_base_indent = re.search(r'^([ \t]*)', lines[0]).group(0) + new_base_indent = re.search(r'^([ \t]*)', prefix).group(0) + for line in lines[1:]: + if line.startswith(orig_base_indent): + # replace orig base indentation with new indentation + line = new_base_indent + line[len(orig_base_indent):] + self.text.insert('insert', '\n' + line.rstrip(), + self.user_input_insert_tags) + finally: + self.text.see("insert") + self.text.undo_block_stop() + + _last_newline_re = re.compile(r"[ \t]*(\n[ \t]*)?\z") + def runit(self): + index_before = self.text.index("end-2c") + line = self.text.get("iomark", "end-1c") + # Strip off last newline and surrounding whitespace. + # (To allow you to hit return twice to end a statement.) + line = self._last_newline_re.sub("", line) + input_is_complete = self.interp.runsource(line) + if not input_is_complete: + if self.text.get(index_before) == '\n': + self.text.tag_remove(self.user_input_insert_tags, index_before) + self.shell_sidebar.update_sidebar() + + def open_stack_viewer(self, event=None): # -n mode only + if self.interp.rpcclt: + return self.interp.remote_stack_viewer() + + from idlelib.stackviewer import StackBrowser + try: + StackBrowser(self.root, sys.last_exc, self.flist) + except: + messagebox.showerror("No stack trace", + "There is no stack trace yet.\n" + "(sys.last_exc is not defined)", + parent=self.text) + return None + + def view_restart_mark(self, event=None): + self.text.see("iomark") + self.text.see("restart") + + def restart_shell(self, event=None): + "Callback for Run/Restart Shell Cntl-F6" + self.interp.restart_subprocess(with_cwd=True) + + def showprompt(self): + self.resetoutput() + + prompt = self.prompt + if self.sys_ps1 and prompt.endswith(self.sys_ps1): + prompt = prompt[:-len(self.sys_ps1)] + self.text.tag_add("console", "iomark-1c") + self.console.write(prompt) + + self.shell_sidebar.update_sidebar() + self.text.mark_set("insert", "end-1c") + self.set_line_and_column() + self.io.reset_undo() + + def show_warning(self, msg): + width = self.interp.tkconsole.width + wrapper = TextWrapper(width=width, tabsize=8, expand_tabs=True) + wrapped_msg = '\n'.join(wrapper.wrap(msg)) + if not wrapped_msg.endswith('\n'): + wrapped_msg += '\n' + self.per.bottom.insert("iomark linestart", wrapped_msg, "stderr") + + def resetoutput(self): + source = self.text.get("iomark", "end-1c") + if self.history: + self.history.store(source) + if self.text.get("end-2c") != "\n": + self.text.insert("end-1c", "\n") + self.text.mark_set("iomark", "end-1c") + self.set_line_and_column() + self.ctip.remove_calltip_window() + + def write(self, s, tags=()): + try: + self.text.mark_gravity("iomark", "right") + count = OutputWindow.write(self, s, tags, "iomark") + self.text.mark_gravity("iomark", "left") + except: + raise ###pass # ### 11Aug07 KBK if we are expecting exceptions + # let's find out what they are and be specific. + if self.canceled: + self.canceled = False + if not use_subprocess: + raise KeyboardInterrupt + return count + + def rmenu_check_cut(self): + try: + if self.text.compare('sel.first', '<', 'iomark'): + return 'disabled' + except TclError: # no selection, so the index 'sel.first' doesn't exist + return 'disabled' + return super().rmenu_check_cut() + + def rmenu_check_paste(self): + if self.text.compare('insert','<','iomark'): + return 'disabled' + return super().rmenu_check_paste() + + def squeeze_current_text_event(self, event=None): + self.squeezer.squeeze_current_text() + self.shell_sidebar.update_sidebar() + + def on_squeezed_expand(self, index, text, tags): + self.shell_sidebar.update_sidebar() + + +def fix_x11_paste(root): + "Make paste replace selection on x11. See issue #5124." + if root._windowingsystem == 'x11': + for cls in 'Text', 'Entry', 'Spinbox': + root.bind_class( + cls, + '<>', + 'catch {%W delete sel.first sel.last}\n' + + root.bind_class(cls, '<>')) + + +usage_msg = """\ + +USAGE: idle [-deins] [-t title] [file]* + idle [-dns] [-t title] (-c cmd | -r file) [arg]* + idle [-dns] [-t title] - [arg]* + + -h print this help message and exit + -n run IDLE without a subprocess (DEPRECATED, + see Help/IDLE Help for details) + +The following options will override the IDLE 'settings' configuration: + + -e open an edit window + -i open a shell window + +The following options imply -i and will open a shell: + + -c cmd run the command in a shell, or + -r file run script from file + + -d enable the debugger + -s run $IDLESTARTUP or $PYTHONSTARTUP before anything else + -t title set title of shell window + +A default edit window will be bypassed when -c, -r, or - are used. + +[arg]* are passed to the command (-c) or script (-r) in sys.argv[1:]. + +Examples: + +idle + Open an edit window or shell depending on IDLE's configuration. + +idle foo.py foobar.py + Edit the files, also open a shell if configured to start with shell. + +idle -est "Baz" foo.py + Run $IDLESTARTUP or $PYTHONSTARTUP, edit foo.py, and open a shell + window with the title "Baz". + +idle -c "import sys; print(sys.argv)" "foo" + Open a shell window and run the command, passing "-c" in sys.argv[0] + and "foo" in sys.argv[1]. + +idle -d -s -r foo.py "Hello World" + Open a shell window, run a startup script, enable the debugger, and + run foo.py, passing "foo.py" in sys.argv[0] and "Hello World" in + sys.argv[1]. + +echo "import sys; print(sys.argv)" | idle - "foobar" + Open a shell window, run the script piped in, passing '' in sys.argv[0] + and "foobar" in sys.argv[1]. +""" + +def main(): + import getopt + from platform import system + from idlelib import testing # bool value + from idlelib import macosx + + global flist, root, use_subprocess + + capture_warnings(True) + use_subprocess = True + enable_shell = False + enable_edit = False + debug = False + cmd = None + script = None + startup = False + try: + opts, args = getopt.getopt(sys.argv[1:], "c:deihnr:st:") + except getopt.error as msg: + print(f"Error: {msg}\n{usage_msg}", file=sys.stderr) + sys.exit(2) + for o, a in opts: + if o == '-c': + cmd = a + enable_shell = True + if o == '-d': + debug = True + enable_shell = True + if o == '-e': + enable_edit = True + if o == '-h': + sys.stdout.write(usage_msg) + sys.exit() + if o == '-i': + enable_shell = True + if o == '-n': + print(" Warning: running IDLE without a subprocess is deprecated.", + file=sys.stderr) + use_subprocess = False + if o == '-r': + script = a + if os.path.isfile(script): + pass + else: + print("No script file: ", script) + sys.exit() + enable_shell = True + if o == '-s': + startup = True + enable_shell = True + if o == '-t': + PyShell.shell_title = a + enable_shell = True + if args and args[0] == '-': + cmd = sys.stdin.read() + enable_shell = True + # process sys.argv and sys.path: + for i in range(len(sys.path)): + sys.path[i] = os.path.abspath(sys.path[i]) + if args and args[0] == '-': + sys.argv = [''] + args[1:] + elif cmd: + sys.argv = ['-c'] + args + elif script: + sys.argv = [script] + args + elif args: + enable_edit = True + pathx = [] + for filename in args: + pathx.append(os.path.dirname(filename)) + for dir in pathx: + dir = os.path.abspath(dir) + if not dir in sys.path: + sys.path.insert(0, dir) + else: + dir = os.getcwd() + if dir not in sys.path: + sys.path.insert(0, dir) + # check the IDLE settings configuration (but command line overrides) + edit_start = idleConf.GetOption('main', 'General', + 'editor-on-startup', type='bool') + enable_edit = enable_edit or edit_start + enable_shell = enable_shell or not enable_edit + + # Setup root. Don't break user code run in IDLE process. + # Don't change environment when testing. + if use_subprocess and not testing: + NoDefaultRoot() + root = Tk(className="Idle") + root.withdraw() + from idlelib.run import fix_scaling + fix_scaling(root) + + # set application icon + icondir = os.path.join(os.path.dirname(__file__), 'Icons') + if system() == 'Windows': + iconfile = os.path.join(icondir, 'idle.ico') + root.wm_iconbitmap(default=iconfile) + elif not macosx.isAquaTk(): + if TkVersion >= 8.6: + ext = '.png' + sizes = (16, 32, 48, 256) + else: + ext = '.gif' + sizes = (16, 32, 48) + iconfiles = [os.path.join(icondir, 'idle_%d%s' % (size, ext)) + for size in sizes] + icons = [PhotoImage(master=root, file=iconfile) + for iconfile in iconfiles] + root.wm_iconphoto(True, *icons) + + # start editor and/or shell windows: + fixwordbreaks(root) + fix_x11_paste(root) + flist = PyShellFileList(root) + macosx.setupApp(root, flist) + + if enable_edit: + if not (cmd or script): + for filename in args[:]: + if flist.open(filename) is None: + # filename is a directory actually, disconsider it + args.remove(filename) + if not args: + flist.new() + + if enable_shell: + shell = flist.open_shell() + if not shell: + return # couldn't open shell + if macosx.isAquaTk() and flist.dict: + # On OSX: when the user has double-clicked on a file that causes + # IDLE to be launched the shell window will open just in front of + # the file she wants to see. Lower the interpreter window when + # there are open files. + shell.top.lower() + else: + shell = flist.pyshell + + # Handle remaining options. If any of these are set, enable_shell + # was set also, so shell must be true to reach here. + if debug: + shell.open_debugger() + if startup: + filename = os.environ.get("IDLESTARTUP") or \ + os.environ.get("PYTHONSTARTUP") + if filename and os.path.isfile(filename): + shell.interp.execfile(filename) + if cmd or script: + shell.interp.runcommand("""if 1: + import sys as _sys + _sys.argv = {!r} + del _sys + \n""".format(sys.argv)) + if cmd: + shell.interp.execsource(cmd) + elif script: + shell.interp.prepend_syspath(script) + shell.interp.execfile(script) + elif shell: + # If there is a shell window and no cmd or script in progress, + # check for problematic issues and print warning message(s) in + # the IDLE shell window; this is less intrusive than always + # opening a separate window. + + # Warn if the "Prefer tabs when opening documents" system + # preference is set to "Always". + prefer_tabs_preference_warning = macosx.preferTabsPreferenceWarning() + if prefer_tabs_preference_warning: + shell.show_warning(prefer_tabs_preference_warning) + + while flist.inversedict: # keep IDLE running while files are open. + root.mainloop() + root.destroy() + capture_warnings(False) + + +if __name__ == "__main__": + main() + +capture_warnings(False) # Make sure turned off; see issue 18081 diff --git a/Lib/idlelib/query.py b/Lib/idlelib/query.py new file mode 100644 index 00000000000..5f9bdc031e5 --- /dev/null +++ b/Lib/idlelib/query.py @@ -0,0 +1,390 @@ +""" +Dialogs that query users and verify the answer before accepting. + +Query is the generic base class for a popup dialog. +The user must either enter a valid answer or close the dialog. +Entries are validated when is entered or [Ok] is clicked. +Entries are ignored when [Cancel] or [X] are clicked. +The 'return value' is .result set to either a valid answer or None. + +Subclass SectionName gets a name for a new config file section. +Configdialog uses it for new highlight theme and keybinding set names. +Subclass ModuleName gets a name for File => Open Module. +Subclass HelpSource gets menu item and path for additions to Help menu. +""" +# Query and Section name result from splitting GetCfgSectionNameDialog +# of configSectionNameDialog.py (temporarily config_sec.py) into +# generic and specific parts. 3.6 only, July 2016. +# ModuleName.entry_ok came from editor.EditorWindow.load_module. +# HelpSource was extracted from configHelpSourceEdit.py (temporarily +# config_help.py), with darwin code moved from ok to path_ok. + +import importlib.util, importlib.abc +import os +import shlex +from sys import executable, platform # Platform is set for one test. + +from tkinter import Toplevel, StringVar, BooleanVar, W, E, S +from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton +from tkinter import filedialog +from tkinter.font import Font +from tkinter.simpledialog import _setup_dialog + +class Query(Toplevel): + """Base class for getting verified answer from a user. + + For this base class, accept any non-blank string. + """ + def __init__(self, parent, title, message, *, text0='', used_names={}, + _htest=False, _utest=False): + """Create modal popup, return when destroyed. + + Additional subclass init must be done before this unless + _utest=True is passed to suppress wait_window(). + + title - string, title of popup dialog + message - string, informational message to display + text0 - initial value for entry + used_names - names already in use + _htest - bool, change box location when running htest + _utest - bool, leave window hidden and not modal + """ + self.parent = parent # Needed for Font call. + self.message = message + self.text0 = text0 + self.used_names = used_names + + Toplevel.__init__(self, parent) + self.withdraw() # Hide while configuring, especially geometry. + self.title(title) + self.transient(parent) + if not _utest: # Otherwise fail when directly run unittest. + self.grab_set() + + _setup_dialog(self) + if self._windowingsystem == 'aqua': + self.bind("", self.cancel) + self.bind('', self.cancel) + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.bind('', self.ok) + self.bind("", self.ok) + + self.create_widgets() + self.update_idletasks() # Need here for winfo_reqwidth below. + self.geometry( # Center dialog over parent (or below htest box). + "+%d+%d" % ( + parent.winfo_rootx() + + (parent.winfo_width()/2 - self.winfo_reqwidth()/2), + parent.winfo_rooty() + + ((parent.winfo_height()/2 - self.winfo_reqheight()/2) + if not _htest else 150) + ) ) + self.resizable(height=False, width=False) + + if not _utest: + self.deiconify() # Unhide now that geometry set. + self.entry.focus_set() + self.wait_window() + + def create_widgets(self, ok_text='OK'): # Do not replace. + """Create entry (rows, extras, buttons. + + Entry stuff on rows 0-2, spanning cols 0-2. + Buttons on row 99, cols 1, 2. + """ + # Bind to self the widgets needed for entry_ok or unittest. + self.frame = frame = Frame(self, padding=10) + frame.grid(column=0, row=0, sticky='news') + frame.grid_columnconfigure(0, weight=1) + + entrylabel = Label(frame, anchor='w', justify='left', + text=self.message) + self.entryvar = StringVar(self, self.text0) + self.entry = Entry(frame, width=30, textvariable=self.entryvar) + self.error_font = Font(name='TkCaptionFont', + exists=True, root=self.parent) + self.entry_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + # Display or blank error by setting ['text'] =. + entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) + self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, + pady=[10,0]) + self.entry_error.grid(column=0, row=2, columnspan=3, padx=5, + sticky=W+E) + + self.create_extra() + + self.button_ok = Button( + frame, text=ok_text, default='active', command=self.ok) + self.button_cancel = Button( + frame, text='Cancel', command=self.cancel) + + self.button_ok.grid(column=1, row=99, padx=5) + self.button_cancel.grid(column=2, row=99, padx=5) + + def create_extra(self): pass # Override to add widgets. + + def showerror(self, message, widget=None): + #self.bell(displayof=self) + (widget or self.entry_error)['text'] = 'ERROR: ' + message + + def entry_ok(self): # Example: usually replace. + "Return non-blank entry or None." + entry = self.entry.get().strip() + if not entry: + self.showerror('blank line.') + return None + return entry + + def ok(self, event=None): # Do not replace. + '''If entry is valid, bind it to 'result' and destroy tk widget. + + Otherwise leave dialog open for user to correct entry or cancel. + ''' + self.entry_error['text'] = '' + entry = self.entry_ok() + if entry is not None: + self.result = entry + self.destroy() + else: + # [Ok] moves focus. ( does not.) Move it back. + self.entry.focus_set() + + def cancel(self, event=None): # Do not replace. + "Set dialog result to None and destroy tk widget." + self.result = None + self.destroy() + + def destroy(self): + self.grab_release() + super().destroy() + + +class SectionName(Query): + "Get a name for a config file section name." + # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837) + + def __init__(self, parent, title, message, used_names, + *, _htest=False, _utest=False): + super().__init__(parent, title, message, used_names=used_names, + _htest=_htest, _utest=_utest) + + def entry_ok(self): + "Return sensible ConfigParser section name or None." + name = self.entry.get().strip() + if not name: + self.showerror('no name specified.') + return None + elif len(name)>30: + self.showerror('name is longer than 30 characters.') + return None + elif name in self.used_names: + self.showerror('name is already in use.') + return None + return name + + +class ModuleName(Query): + "Get a module name for Open Module menu entry." + # Used in open_module (editor.EditorWindow until move to iobinding). + + def __init__(self, parent, title, message, text0, + *, _htest=False, _utest=False): + super().__init__(parent, title, message, text0=text0, + _htest=_htest, _utest=_utest) + + def entry_ok(self): + "Return entered module name as file path or None." + name = self.entry.get().strip() + if not name: + self.showerror('no name specified.') + return None + # XXX Ought to insert current file's directory in front of path. + try: + spec = importlib.util.find_spec(name) + except (ValueError, ImportError) as msg: + self.showerror(str(msg)) + return None + if spec is None: + self.showerror("module not found.") + return None + if not isinstance(spec.loader, importlib.abc.SourceLoader): + self.showerror("not a source-based module.") + return None + try: + file_path = spec.loader.get_filename(name) + except AttributeError: + self.showerror("loader does not support get_filename.") + return None + except ImportError: + # Some special modules require this (e.g. os.path) + try: + file_path = spec.loader.get_filename() + except TypeError: + self.showerror("loader failed to get filename.") + return None + return file_path + + +class Goto(Query): + "Get a positive line number for editor Go To Line." + # Used in editor.EditorWindow.goto_line_event. + + def entry_ok(self): + try: + lineno = int(self.entry.get()) + except ValueError: + self.showerror('not a base 10 integer.') + return None + if lineno <= 0: + self.showerror('not a positive integer.') + return None + return lineno + + +class HelpSource(Query): + "Get menu name and help source for Help menu." + # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9) + + def __init__(self, parent, title, *, menuitem='', filepath='', + used_names={}, _htest=False, _utest=False): + """Get menu entry and url/local file for Additional Help. + + User enters a name for the Help resource and a web url or file + name. The user can browse for the file. + """ + self.filepath = filepath + message = 'Name for item on Help menu:' + super().__init__( + parent, title, message, text0=menuitem, + used_names=used_names, _htest=_htest, _utest=_utest) + + def create_extra(self): + "Add path widjets to rows 10-12." + frame = self.frame + pathlabel = Label(frame, anchor='w', justify='left', + text='Help File Path: Enter URL or browse for file') + self.pathvar = StringVar(self, self.filepath) + self.path = Entry(frame, textvariable=self.pathvar, width=40) + browse = Button(frame, text='Browse', width=8, + command=self.browse_file) + self.path_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + + pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0], + sticky=W) + self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E, + pady=[10,0]) + browse.grid(column=2, row=11, padx=5, sticky=W+S) + self.path_error.grid(column=0, row=12, columnspan=3, padx=5, + sticky=W+E) + + def askfilename(self, filetypes, initdir, initfile): # htest # + # Extracted from browse_file so can mock for unittests. + # Cannot unittest as cannot simulate button clicks. + # Test by running htest, such as by running this file. + return filedialog.Open(parent=self, filetypes=filetypes)\ + .show(initialdir=initdir, initialfile=initfile) + + def browse_file(self): + filetypes = [ + ("HTML Files", "*.htm *.html", "TEXT"), + ("Text Files", "*.txt", "TEXT"), + ("All Files", "*")] + path = self.pathvar.get() + if path: + dir, base = os.path.split(path) + else: + base = None + if platform[:3] == 'win': + dir = os.path.join(os.path.dirname(executable), 'Doc') + if not os.path.isdir(dir): + dir = os.getcwd() + else: + dir = os.getcwd() + file = self.askfilename(filetypes, dir, base) + if file: + self.pathvar.set(file) + + item_ok = SectionName.entry_ok # localize for test override + + def path_ok(self): + "Simple validity check for menu file path" + path = self.path.get().strip() + if not path: #no path specified + self.showerror('no help file path specified.', self.path_error) + return None + elif not path.startswith(('www.', 'http')): + if path[:5] == 'file:': + path = path[5:] + if not os.path.exists(path): + self.showerror('help file path does not exist.', + self.path_error) + return None + if platform == 'darwin': # for Mac Safari + path = "file://" + path + return path + + def entry_ok(self): + "Return apparently valid (name, path) or None" + self.path_error['text'] = '' + name = self.item_ok() + path = self.path_ok() + return None if name is None or path is None else (name, path) + +class CustomRun(Query): + """Get settings for custom run of module. + + 1. Command line arguments to extend sys.argv. + 2. Whether to restart Shell or not. + """ + # Used in runscript.run_custom_event + + def __init__(self, parent, title, *, cli_args=[], + _htest=False, _utest=False): + """cli_args is a list of strings. + + The list is assigned to the default Entry StringVar. + The strings are displayed joined by ' ' for display. + """ + message = 'Command Line Arguments for sys.argv:' + super().__init__( + parent, title, message, text0=cli_args, + _htest=_htest, _utest=_utest) + + def create_extra(self): + "Add run mode on rows 10-12." + frame = self.frame + self.restartvar = BooleanVar(self, value=True) + restart = Checkbutton(frame, variable=self.restartvar, onvalue=True, + offvalue=False, text='Restart shell') + self.args_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + + restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w') + self.args_error.grid(column=0, row=12, columnspan=3, padx=5, + sticky='we') + + def cli_args_ok(self): + "Return command line arg list or None if error." + cli_string = self.entry.get().strip() + try: + cli_args = shlex.split(cli_string, posix=True) + except ValueError as err: + self.showerror(str(err)) + return None + return cli_args + + def entry_ok(self): + "Return apparently valid (cli_args, restart) or None." + cli_args = self.cli_args_ok() + restart = self.restartvar.get() + return None if cli_args is None else (cli_args, restart) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_query', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(Query, HelpSource, CustomRun) diff --git a/Lib/idlelib/redirector.py b/Lib/idlelib/redirector.py new file mode 100644 index 00000000000..8e2ba68d381 --- /dev/null +++ b/Lib/idlelib/redirector.py @@ -0,0 +1,174 @@ +from tkinter import TclError + +class WidgetRedirector: + """Support for redirecting arbitrary widget subcommands. + + Some Tk operations don't normally pass through tkinter. For example, if a + character is inserted into a Text widget by pressing a key, a default Tk + binding to the widget's 'insert' operation is activated, and the Tk library + processes the insert without calling back into tkinter. + + Although a binding to could be made via tkinter, what we really want + to do is to hook the Tk 'insert' operation itself. For one thing, we want + a text.insert call in idle code to have the same effect as a key press. + + When a widget is instantiated, a Tcl command is created whose name is the + same as the pathname widget._w. This command is used to invoke the various + widget operations, e.g. insert (for a Text widget). We are going to hook + this command and provide a facility ('register') to intercept the widget + operation. We will also intercept method calls on the tkinter class + instance that represents the tk widget. + + In IDLE, WidgetRedirector is used in Percolator to intercept Text + commands. The function being registered provides access to the top + of a Percolator chain. At the bottom of the chain is a call to the + original Tk widget operation. + """ + def __init__(self, widget): + '''Initialize attributes and setup redirection. + + _operations: dict mapping operation name to new function. + widget: the widget whose tcl command is to be intercepted. + tk: widget.tk, a convenience attribute, probably not needed. + orig: new name of the original tcl command. + + Since renaming to orig fails with TclError when orig already + exists, only one WidgetDirector can exist for a given widget. + ''' + self._operations = {} + self.widget = widget # widget instance + self.tk = tk = widget.tk # widget's root + w = widget._w # widget's (full) Tk pathname + self.orig = w + "_orig" + # Rename the Tcl command within Tcl: + tk.call("rename", w, self.orig) + # Create a new Tcl command whose name is the widget's pathname, and + # whose action is to dispatch on the operation passed to the widget: + tk.createcommand(w, self.dispatch) + + def __repr__(self): + w = self.widget + return f"{self.__class__.__name__,}({w.__class__.__name__}<{w._w}>)" + + def close(self): + "Unregister operations and revert redirection created by .__init__." + for operation in list(self._operations): + self.unregister(operation) + widget = self.widget + tk = widget.tk + w = widget._w + # Restore the original widget Tcl command. + tk.deletecommand(w) + tk.call("rename", self.orig, w) + del self.widget, self.tk # Should not be needed + # if instance is deleted after close, as in Percolator. + + def register(self, operation, function): + '''Return OriginalCommand(operation) after registering function. + + Registration adds an operation: function pair to ._operations. + It also adds a widget function attribute that masks the tkinter + class instance method. Method masking operates independently + from command dispatch. + + If a second function is registered for the same operation, the + first function is replaced in both places. + ''' + self._operations[operation] = function + setattr(self.widget, operation, function) + return OriginalCommand(self, operation) + + def unregister(self, operation): + '''Return the function for the operation, or None. + + Deleting the instance attribute unmasks the class attribute. + ''' + if operation in self._operations: + function = self._operations[operation] + del self._operations[operation] + try: + delattr(self.widget, operation) + except AttributeError: + pass + return function + else: + return None + + def dispatch(self, operation, *args): + '''Callback from Tcl which runs when the widget is referenced. + + If an operation has been registered in self._operations, apply the + associated function to the args passed into Tcl. Otherwise, pass the + operation through to Tk via the original Tcl function. + + Note that if a registered function is called, the operation is not + passed through to Tk. Apply the function returned by self.register() + to *args to accomplish that. For an example, see colorizer.py. + + ''' + operation = str(operation) # can be a Tcl_Obj + m = self._operations.get(operation) + try: + if m: + return m(*args) + else: + return self.tk.call((self.orig, operation) + args) + except TclError: + return "" + + +class OriginalCommand: + '''Callable for original tk command that has been redirected. + + Returned by .register; can be used in the function registered. + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + ''' + + def __init__(self, redir, operation): + '''Create .tk_call and .orig_and_operation for .__call__ method. + + .redir and .operation store the input args for __repr__. + .tk and .orig copy attributes of .redir (probably not needed). + ''' + self.redir = redir + self.operation = operation + self.tk = redir.tk # redundant with self.redir + self.orig = redir.orig # redundant with self.redir + # These two could be deleted after checking recipient code. + self.tk_call = redir.tk.call + self.orig_and_operation = (redir.orig, operation) + + def __repr__(self): + return f"{self.__class__.__name__,}({self.redir!r}, {self.operation!r})" + + def __call__(self, *args): + return self.tk_call(self.orig_and_operation + args) + + +def _widget_redirector(parent): # htest # + from tkinter import Toplevel, Text + + top = Toplevel(parent) + top.title("Test WidgetRedirector") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = Text(top) + text.pack() + text.focus_set() + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_redirector', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_widget_redirector) diff --git a/Lib/idlelib/replace.py b/Lib/idlelib/replace.py new file mode 100644 index 00000000000..3716d841568 --- /dev/null +++ b/Lib/idlelib/replace.py @@ -0,0 +1,299 @@ +"""Replace dialog for IDLE. Inherits SearchDialogBase for GUI. +Uses idlelib.searchengine.SearchEngine for search capability. +Defines various replace related functions like replace, replace all, +and replace+find. +""" +import re + +from tkinter import StringVar, TclError + +from idlelib.searchbase import SearchDialogBase +from idlelib import searchengine + + +def replace(text, insert_tags=None): + """Create or reuse a singleton ReplaceDialog instance. + + The singleton dialog saves user entries and preferences + across instances. + + Args: + text: Text widget containing the text to be searched. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_replacedialog"): + engine._replacedialog = ReplaceDialog(root, engine) + dialog = engine._replacedialog + searchphrase = text.get("sel.first", "sel.last") + dialog.open(text, searchphrase, insert_tags=insert_tags) + + +class ReplaceDialog(SearchDialogBase): + "Dialog for finding and replacing a pattern in text." + + title = "Replace Dialog" + icon = "Replace" + + def __init__(self, root, engine): + """Create search dialog for finding and replacing text. + + Uses SearchDialogBase as the basis for the GUI and a + searchengine instance to prepare the search. + + Attributes: + replvar: StringVar containing 'Replace with:' value. + replent: Entry widget for replvar. Created in + create_entries(). + ok: Boolean used in searchengine.search_text to indicate + whether the search includes the selection. + """ + super().__init__(root, engine) + self.replvar = StringVar(root) + self.insert_tags = None + + def open(self, text, searchphrase=None, *, insert_tags=None): + """Make dialog visible on top of others and ready to use. + + Also, set the search to include the current selection + (self.ok). + + Args: + text: Text widget being searched. + searchphrase: String phrase to search. + """ + SearchDialogBase.open(self, text, searchphrase) + self.ok = True + self.insert_tags = insert_tags + + def create_entries(self): + "Create base and additional label and text entry widgets." + SearchDialogBase.create_entries(self) + self.replent = self.make_entry("Replace with:", self.replvar)[0] + + def create_command_buttons(self): + """Create base and additional command buttons. + + The additional buttons are for Find, Replace, + Replace+Find, and Replace All. + """ + SearchDialogBase.create_command_buttons(self) + self.make_button("Find", self.find_it) + self.make_button("Replace", self.replace_it) + self.make_button("Replace+Find", self.default_command, isdef=True) + self.make_button("Replace All", self.replace_all) + + def find_it(self, event=None): + "Handle the Find button." + self.do_find(False) + + def replace_it(self, event=None): + """Handle the Replace button. + + If the find is successful, then perform replace. + """ + if self.do_find(self.ok): + self.do_replace() + + def default_command(self, event=None): + """Handle the Replace+Find button as the default command. + + First performs a replace and then, if the replace was + successful, a find next. + """ + if self.do_find(self.ok): + if self.do_replace(): # Only find next match if replace succeeded. + # A bad re can cause it to fail. + self.do_find(False) + + def _replace_expand(self, m, repl): + "Expand replacement text if regular expression." + if self.engine.isre(): + try: + new = m.expand(repl) + except re.PatternError: + self.engine.report_error(repl, 'Invalid Replace Expression') + new = None + else: + new = repl + + return new + + def replace_all(self, event=None): + """Handle the Replace All button. + + Search text for occurrences of the Find value and replace + each of them. The 'wrap around' value controls the start + point for searching. If wrap isn't set, then the searching + starts at the first occurrence after the current selection; + if wrap is set, the replacement starts at the first line. + The replacement is always done top-to-bottom in the text. + """ + prog = self.engine.getprog() + if not prog: + return + repl = self.replvar.get() + text = self.text + res = self.engine.search_text(text, prog) + if not res: + self.bell() + return + text.tag_remove("sel", "1.0", "end") + text.tag_remove("hit", "1.0", "end") + line = res[0] + col = res[1].start() + if self.engine.iswrap(): + line = 1 + col = 0 + ok = True + first = last = None + # XXX ought to replace circular instead of top-to-bottom when wrapping + text.undo_block_start() + while res := self.engine.search_forward( + text, prog, line, col, wrap=False, ok=ok): + line, m = res + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + orig = m.group() + new = self._replace_expand(m, repl) + if new is None: + break + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + if new == orig: + text.mark_set("insert", last) + else: + text.mark_set("insert", first) + if first != last: + text.delete(first, last) + if new: + text.insert(first, new, self.insert_tags) + col = i + len(new) + ok = False + text.undo_block_stop() + if first and last: + self.show_hit(first, last) + self.close() + + def do_find(self, ok=False): + """Search for and highlight next occurrence of pattern in text. + + No text replacement is done with this option. + """ + if not self.engine.getprog(): + return False + text = self.text + res = self.engine.search_text(text, None, ok) + if not res: + self.bell() + return False + line, m = res + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + self.show_hit(first, last) + self.ok = True + return True + + def do_replace(self): + "Replace search pattern in text with replacement value." + prog = self.engine.getprog() + if not prog: + return False + text = self.text + try: + first = pos = text.index("sel.first") + last = text.index("sel.last") + except TclError: + pos = None + if not pos: + first = last = pos = text.index("insert") + line, col = searchengine.get_line_col(pos) + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + m = prog.match(chars, col) + if not prog: + return False + new = self._replace_expand(m, self.replvar.get()) + if new is None: + return False + text.mark_set("insert", first) + text.undo_block_start() + if m.group(): + text.delete(first, last) + if new: + text.insert(first, new, self.insert_tags) + text.undo_block_stop() + self.show_hit(first, text.index("insert")) + self.ok = False + return True + + def show_hit(self, first, last): + """Highlight text between first and last indices. + + Text is highlighted via the 'hit' tag and the marked + section is brought into view. + + The colors from the 'hit' tag aren't currently shown + when the text is displayed. This is due to the 'sel' + tag being added first, so the colors in the 'sel' + config are seen instead of the colors for 'hit'. + """ + text = self.text + text.mark_set("insert", first) + text.tag_remove("sel", "1.0", "end") + text.tag_add("sel", first, last) + text.tag_remove("hit", "1.0", "end") + if first == last: + text.tag_add("hit", first) + else: + text.tag_add("hit", first, last) + text.see("insert") + text.update_idletasks() + + def close(self, event=None): + "Close the dialog and remove hit tags." + SearchDialogBase.close(self, event) + self.text.tag_remove("hit", "1.0", "end") + self.insert_tags = None + + +def _replace_dialog(parent): # htest # + from tkinter import Toplevel, Text, END, SEL + from tkinter.ttk import Frame, Button + + top = Toplevel(parent) + top.title("Test ReplaceDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + # mock undo delegator methods + def undo_block_start(): + pass + + def undo_block_stop(): + pass + + frame = Frame(top) + frame.pack() + text = Text(frame, inactiveselectbackground='gray') + text.undo_block_start = undo_block_start + text.undo_block_stop = undo_block_stop + text.pack() + text.insert("insert","This is a sample sTring\nPlus MORE.") + text.focus_set() + + def show_replace(): + text.tag_add(SEL, "1.0", END) + replace(text) + text.tag_remove(SEL, "1.0", END) + + button = Button(frame, text="Replace", command=show_replace) + button.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_replace', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_replace_dialog) diff --git a/Lib/idlelib/rpc.py b/Lib/idlelib/rpc.py new file mode 100644 index 00000000000..3f0b2230dd1 --- /dev/null +++ b/Lib/idlelib/rpc.py @@ -0,0 +1,635 @@ +"""RPC Implementation, originally written for the Python Idle IDE + +For security reasons, GvR requested that Idle's Python execution server process +connect to the Idle process, which listens for the connection. Since Idle has +only one client per server, this was not a limitation. + + +---------------------------------+ +-------------+ + | socketserver.BaseRequestHandler | | SocketIO | + +---------------------------------+ +-------------+ + ^ | register() | + | | unregister()| + | +-------------+ + | ^ ^ + | | | + | + -------------------+ | + | | | + +-------------------------+ +-----------------+ + | RPCHandler | | RPCClient | + | [attribute of RPCServer]| | | + +-------------------------+ +-----------------+ + +The RPCServer handler class is expected to provide register/unregister methods. +RPCHandler inherits the mix-in class SocketIO, which provides these methods. + +See the Idle run.main() docstring for further information on how this was +accomplished in Idle. + +""" +import builtins +import copyreg +import io +import marshal +import os +import pickle +import queue +import select +import socket +import socketserver +import struct +import sys +import threading +import traceback +import types + +def unpickle_code(ms): + "Return code object from marshal string ms." + co = marshal.loads(ms) + assert isinstance(co, types.CodeType) + return co + +def pickle_code(co): + "Return unpickle function and tuple with marshalled co code object." + assert isinstance(co, types.CodeType) + ms = marshal.dumps(co) + return unpickle_code, (ms,) + +def dumps(obj, protocol=None): + "Return pickled (or marshalled) string for obj." + # IDLE passes 'None' to select pickle.DEFAULT_PROTOCOL. + f = io.BytesIO() + p = CodePickler(f, protocol) + p.dump(obj) + return f.getvalue() + + +class CodePickler(pickle.Pickler): + dispatch_table = {types.CodeType: pickle_code, **copyreg.dispatch_table} + + +BUFSIZE = 8*1024 +LOCALHOST = '127.0.0.1' + +class RPCServer(socketserver.TCPServer): + + def __init__(self, addr, handlerclass=None): + if handlerclass is None: + handlerclass = RPCHandler + socketserver.TCPServer.__init__(self, addr, handlerclass) + + def server_bind(self): + "Override TCPServer method, no bind() phase for connecting entity" + pass + + def server_activate(self): + """Override TCPServer method, connect() instead of listen() + + Due to the reversed connection, self.server_address is actually the + address of the Idle Client to which we are connecting. + + """ + self.socket.connect(self.server_address) + + def get_request(self): + "Override TCPServer method, return already connected socket" + return self.socket, self.server_address + + def handle_error(self, request, client_address): + """Override TCPServer method + + Error message goes to __stderr__. No error message if exiting + normally or socket raised EOF. Other exceptions not handled in + server code will cause os._exit. + + """ + try: + raise + except SystemExit: + raise + except: + erf = sys.__stderr__ + print('\n' + '-'*40, file=erf) + print('Unhandled server exception!', file=erf) + print('Thread: %s' % threading.current_thread().name, file=erf) + print('Client Address: ', client_address, file=erf) + print('Request: ', repr(request), file=erf) + traceback.print_exc(file=erf) + print('\n*** Unrecoverable, server exiting!', file=erf) + print('-'*40, file=erf) + os._exit(0) + +#----------------- end class RPCServer -------------------- + +objecttable = {} +request_queue = queue.Queue(0) +response_queue = queue.Queue(0) + + +class SocketIO: + + nextseq = 0 + + def __init__(self, sock, objtable=None, debugging=None): + self.sockthread = threading.current_thread() + if debugging is not None: + self.debugging = debugging + self.sock = sock + if objtable is None: + objtable = objecttable + self.objtable = objtable + self.responses = {} + self.cvars = {} + + def close(self): + sock = self.sock + self.sock = None + if sock is not None: + sock.close() + + def exithook(self): + "override for specific exit action" + os._exit(0) + + def debug(self, *args): + if not self.debugging: + return + s = self.location + " " + str(threading.current_thread().name) + for a in args: + s = s + " " + str(a) + print(s, file=sys.__stderr__) + + def register(self, oid, object_): + self.objtable[oid] = object_ + + def unregister(self, oid): + try: + del self.objtable[oid] + except KeyError: + pass + + def localcall(self, seq, request): + self.debug("localcall:", request) + try: + how, (oid, methodname, args, kwargs) = request + except TypeError: + return ("ERROR", "Bad request format") + if oid not in self.objtable: + return ("ERROR", f"Unknown object id: {oid!r}") + obj = self.objtable[oid] + if methodname == "__methods__": + methods = {} + _getmethods(obj, methods) + return ("OK", methods) + if methodname == "__attributes__": + attributes = {} + _getattributes(obj, attributes) + return ("OK", attributes) + if not hasattr(obj, methodname): + return ("ERROR", f"Unsupported method name: {methodname!r}") + method = getattr(obj, methodname) + try: + if how == 'CALL': + ret = method(*args, **kwargs) + if isinstance(ret, RemoteObject): + ret = remoteref(ret) + return ("OK", ret) + elif how == 'QUEUE': + request_queue.put((seq, (method, args, kwargs))) + return("QUEUED", None) + else: + return ("ERROR", "Unsupported message type: %s" % how) + except SystemExit: + raise + except KeyboardInterrupt: + raise + except OSError: + raise + except Exception as ex: + return ("CALLEXC", ex) + except: + msg = "*** Internal Error: rpc.py:SocketIO.localcall()\n\n"\ + " Object: %s \n Method: %s \n Args: %s\n" + print(msg % (oid, method, args), file=sys.__stderr__) + traceback.print_exc(file=sys.__stderr__) + return ("EXCEPTION", None) + + def remotecall(self, oid, methodname, args, kwargs): + self.debug("remotecall:asynccall: ", oid, methodname) + seq = self.asynccall(oid, methodname, args, kwargs) + return self.asyncreturn(seq) + + def remotequeue(self, oid, methodname, args, kwargs): + self.debug("remotequeue:asyncqueue: ", oid, methodname) + seq = self.asyncqueue(oid, methodname, args, kwargs) + return self.asyncreturn(seq) + + def asynccall(self, oid, methodname, args, kwargs): + request = ("CALL", (oid, methodname, args, kwargs)) + seq = self.newseq() + if threading.current_thread() != self.sockthread: + cvar = threading.Condition() + self.cvars[seq] = cvar + self.debug(("asynccall:%d:" % seq), oid, methodname, args, kwargs) + self.putmessage((seq, request)) + return seq + + def asyncqueue(self, oid, methodname, args, kwargs): + request = ("QUEUE", (oid, methodname, args, kwargs)) + seq = self.newseq() + if threading.current_thread() != self.sockthread: + cvar = threading.Condition() + self.cvars[seq] = cvar + self.debug(("asyncqueue:%d:" % seq), oid, methodname, args, kwargs) + self.putmessage((seq, request)) + return seq + + def asyncreturn(self, seq): + self.debug("asyncreturn:%d:call getresponse(): " % seq) + response = self.getresponse(seq, wait=0.05) + self.debug(("asyncreturn:%d:response: " % seq), response) + return self.decoderesponse(response) + + def decoderesponse(self, response): + how, what = response + if how == "OK": + return what + if how == "QUEUED": + return None + if how == "EXCEPTION": + self.debug("decoderesponse: EXCEPTION") + return None + if how == "EOF": + self.debug("decoderesponse: EOF") + self.decode_interrupthook() + return None + if how == "ERROR": + self.debug("decoderesponse: Internal ERROR:", what) + raise RuntimeError(what) + if how == "CALLEXC": + self.debug("decoderesponse: Call Exception:", what) + raise what + raise SystemError(how, what) + + def decode_interrupthook(self): + "" + raise EOFError + + def mainloop(self): + """Listen on socket until I/O not ready or EOF + + pollresponse() will loop looking for seq number None, which + never comes, and exit on EOFError. + + """ + try: + self.getresponse(myseq=None, wait=0.05) + except EOFError: + self.debug("mainloop:return") + return + + def getresponse(self, myseq, wait): + response = self._getresponse(myseq, wait) + if response is not None: + how, what = response + if how == "OK": + response = how, self._proxify(what) + return response + + def _proxify(self, obj): + if isinstance(obj, RemoteProxy): + return RPCProxy(self, obj.oid) + if isinstance(obj, list): + return list(map(self._proxify, obj)) + # XXX Check for other types -- not currently needed + return obj + + def _getresponse(self, myseq, wait): + self.debug("_getresponse:myseq:", myseq) + if threading.current_thread() is self.sockthread: + # this thread does all reading of requests or responses + while True: + response = self.pollresponse(myseq, wait) + if response is not None: + return response + else: + # wait for notification from socket handling thread + cvar = self.cvars[myseq] + cvar.acquire() + while myseq not in self.responses: + cvar.wait() + response = self.responses[myseq] + self.debug("_getresponse:%s: thread woke up: response: %s" % + (myseq, response)) + del self.responses[myseq] + del self.cvars[myseq] + cvar.release() + return response + + def newseq(self): + self.nextseq = seq = self.nextseq + 2 + return seq + + def putmessage(self, message): + self.debug("putmessage:%d:" % message[0]) + try: + s = dumps(message) + except pickle.PicklingError: + print("Cannot pickle:", repr(message), file=sys.__stderr__) + raise + s = struct.pack(" 0: + try: + r, w, x = select.select([], [self.sock], []) + n = self.sock.send(s[:BUFSIZE]) + except (AttributeError, TypeError): + raise OSError("socket no longer exists") + s = s[n:] + + buff = b'' + bufneed = 4 + bufstate = 0 # meaning: 0 => reading count; 1 => reading data + + def pollpacket(self, wait): + self._stage0() + if len(self.buff) < self.bufneed: + r, w, x = select.select([self.sock.fileno()], [], [], wait) + if len(r) == 0: + return None + try: + s = self.sock.recv(BUFSIZE) + except OSError: + raise EOFError + if len(s) == 0: + raise EOFError + self.buff += s + self._stage0() + return self._stage1() + + def _stage0(self): + if self.bufstate == 0 and len(self.buff) >= 4: + s = self.buff[:4] + self.buff = self.buff[4:] + self.bufneed = struct.unpack("= self.bufneed: + packet = self.buff[:self.bufneed] + self.buff = self.buff[self.bufneed:] + self.bufneed = 4 + self.bufstate = 0 + return packet + + def pollmessage(self, wait): + packet = self.pollpacket(wait) + if packet is None: + return None + try: + message = pickle.loads(packet) + except pickle.UnpicklingError: + print("-----------------------", file=sys.__stderr__) + print("cannot unpickle packet:", repr(packet), file=sys.__stderr__) + traceback.print_stack(file=sys.__stderr__) + print("-----------------------", file=sys.__stderr__) + raise + return message + + def pollresponse(self, myseq, wait): + """Handle messages received on the socket. + + Some messages received may be asynchronous 'call' or 'queue' requests, + and some may be responses for other threads. + + 'call' requests are passed to self.localcall() with the expectation of + immediate execution, during which time the socket is not serviced. + + 'queue' requests are used for tasks (which may block or hang) to be + processed in a different thread. These requests are fed into + request_queue by self.localcall(). Responses to queued requests are + taken from response_queue and sent across the link with the associated + sequence numbers. Messages in the queues are (sequence_number, + request/response) tuples and code using this module removing messages + from the request_queue is responsible for returning the correct + sequence number in the response_queue. + + pollresponse() will loop until a response message with the myseq + sequence number is received, and will save other responses in + self.responses and notify the owning thread. + + """ + while True: + # send queued response if there is one available + try: + qmsg = response_queue.get(0) + except queue.Empty: + pass + else: + seq, response = qmsg + message = (seq, ('OK', response)) + self.putmessage(message) + # poll for message on link + try: + message = self.pollmessage(wait) + if message is None: # socket not ready + return None + except EOFError: + self.handle_EOF() + return None + except AttributeError: + return None + seq, resq = message + how = resq[0] + self.debug("pollresponse:%d:myseq:%s" % (seq, myseq)) + # process or queue a request + if how in ("CALL", "QUEUE"): + self.debug("pollresponse:%d:localcall:call:" % seq) + response = self.localcall(seq, resq) + self.debug("pollresponse:%d:localcall:response:%s" + % (seq, response)) + if how == "CALL": + self.putmessage((seq, response)) + elif how == "QUEUE": + # don't acknowledge the 'queue' request! + pass + continue + # return if completed message transaction + elif seq == myseq: + return resq + # must be a response for a different thread: + else: + cv = self.cvars.get(seq, None) + # response involving unknown sequence number is discarded, + # probably intended for prior incarnation of server + if cv is not None: + cv.acquire() + self.responses[seq] = resq + cv.notify() + cv.release() + continue + + def handle_EOF(self): + "action taken upon link being closed by peer" + self.EOFhook() + self.debug("handle_EOF") + for key in self.cvars: + cv = self.cvars[key] + cv.acquire() + self.responses[key] = ('EOF', None) + cv.notify() + cv.release() + # call our (possibly overridden) exit function + self.exithook() + + def EOFhook(self): + "Classes using rpc client/server can override to augment EOF action" + pass + +#----------------- end class SocketIO -------------------- + +class RemoteObject: + # Token mix-in class + pass + + +def remoteref(obj): + oid = id(obj) + objecttable[oid] = obj + return RemoteProxy(oid) + + +class RemoteProxy: + + def __init__(self, oid): + self.oid = oid + + +class RPCHandler(socketserver.BaseRequestHandler, SocketIO): + + debugging = False + location = "#S" # Server + + def __init__(self, sock, addr, svr): + svr.current_handler = self ## cgt xxx + SocketIO.__init__(self, sock) + socketserver.BaseRequestHandler.__init__(self, sock, addr, svr) + + def handle(self): + "handle() method required by socketserver" + self.mainloop() + + def get_remote_proxy(self, oid): + return RPCProxy(self, oid) + + +class RPCClient(SocketIO): + + debugging = False + location = "#C" # Client + + nextseq = 1 # Requests coming from the client are odd numbered + + def __init__(self, address, family=socket.AF_INET, type=socket.SOCK_STREAM): + self.listening_sock = socket.socket(family, type) + self.listening_sock.bind(address) + self.listening_sock.listen(1) + + def accept(self): + working_sock, address = self.listening_sock.accept() + if self.debugging: + print("****** Connection request from ", address, file=sys.__stderr__) + if address[0] == LOCALHOST: + SocketIO.__init__(self, working_sock) + else: + print("** Invalid host: ", address, file=sys.__stderr__) + raise OSError + + def get_remote_proxy(self, oid): + return RPCProxy(self, oid) + + +class RPCProxy: + + __methods = None + __attributes = None + + def __init__(self, sockio, oid): + self.sockio = sockio + self.oid = oid + + def __getattr__(self, name): + if self.__methods is None: + self.__getmethods() + if self.__methods.get(name): + return MethodProxy(self.sockio, self.oid, name) + if self.__attributes is None: + self.__getattributes() + if name in self.__attributes: + value = self.sockio.remotecall(self.oid, '__getattribute__', + (name,), {}) + return value + else: + raise AttributeError(name) + + def __getattributes(self): + self.__attributes = self.sockio.remotecall(self.oid, + "__attributes__", (), {}) + + def __getmethods(self): + self.__methods = self.sockio.remotecall(self.oid, + "__methods__", (), {}) + +def _getmethods(obj, methods): + # Helper to get a list of methods from an object + # Adds names to dictionary argument 'methods' + for name in dir(obj): + attr = getattr(obj, name) + if callable(attr): + methods[name] = 1 + if isinstance(obj, type): + for super in obj.__bases__: + _getmethods(super, methods) + +def _getattributes(obj, attributes): + for name in dir(obj): + attr = getattr(obj, name) + if not callable(attr): + attributes[name] = 1 + + +class MethodProxy: + + def __init__(self, sockio, oid, name): + self.sockio = sockio + self.oid = oid + self.name = name + + def __call__(self, /, *args, **kwargs): + value = self.sockio.remotecall(self.oid, self.name, args, kwargs) + return value + + +# XXX KBK 09Sep03 We need a proper unit test for this module. Previously +# existing test code was removed at Rev 1.27 (r34098). + +def displayhook(value): + """Override standard display hook to use non-locale encoding""" + if value is None: + return + # Set '_' to None to avoid recursion + builtins._ = None + text = repr(value) + try: + sys.stdout.write(text) + except UnicodeEncodeError: + # let's use ascii while utf8-bmp codec doesn't present + encoding = 'ascii' + bytes = text.encode(encoding, 'backslashreplace') + text = bytes.decode(encoding, 'strict') + sys.stdout.write(text) + sys.stdout.write("\n") + builtins._ = value + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_rpc', verbosity=2,) diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py new file mode 100644 index 00000000000..a30db99a619 --- /dev/null +++ b/Lib/idlelib/run.py @@ -0,0 +1,653 @@ +""" idlelib.run + +Simplified, pyshell.ModifiedInterpreter spawns a subprocess with +f'''{sys.executable} -c "__import__('idlelib.run').run.main()"''' +'.run' is needed because __import__ returns idlelib, not idlelib.run. +""" +import contextlib +import functools +import io +import linecache +import queue +import sys +import textwrap +import time +import traceback +import _thread as thread +import threading +import warnings + +import idlelib # testing +from idlelib import autocomplete # AutoComplete, fetch_encodings +from idlelib import calltip # Calltip +from idlelib import debugger_r # start_debugger +from idlelib import debugobj_r # remote_object_tree_item +from idlelib import iomenu # encoding +from idlelib import rpc # multiple objects +from idlelib import stackviewer # StackTreeItem +import __main__ + +import tkinter # Use tcl and, if startup fails, messagebox. +if not hasattr(sys.modules['idlelib.run'], 'firstrun'): + # Undo modifications of tkinter by idlelib imports; see bpo-25507. + for mod in ('simpledialog', 'messagebox', 'font', + 'dialog', 'filedialog', 'commondialog', + 'ttk'): + delattr(tkinter, mod) + del sys.modules['tkinter.' + mod] + # Avoid AttributeError if run again; see bpo-37038. + sys.modules['idlelib.run'].firstrun = False + +LOCALHOST = '127.0.0.1' + +try: + eof = 'Ctrl-D (end-of-file)' + exit.eof = eof + quit.eof = eof +except NameError: # In case subprocess started with -S (maybe in future). + pass + + +def idle_formatwarning(message, category, filename, lineno, line=None): + """Format warnings the IDLE way.""" + + s = "\nWarning (from warnings module):\n" + s += f' File \"{filename}\", line {lineno}\n' + if line is None: + line = linecache.getline(filename, lineno) + line = line.strip() + if line: + s += " %s\n" % line + s += f"{category.__name__}: {message}\n" + return s + +def idle_showwarning_subproc( + message, category, filename, lineno, file=None, line=None): + """Show Idle-format warning after replacing warnings.showwarning. + + The only difference is the formatter called. + """ + if file is None: + file = sys.stderr + try: + file.write(idle_formatwarning( + message, category, filename, lineno, line)) + except OSError: + pass # the file (probably stderr) is invalid - this warning gets lost. + +_warnings_showwarning = None + +def capture_warnings(capture): + "Replace warning.showwarning with idle_showwarning_subproc, or reverse." + + global _warnings_showwarning + if capture: + if _warnings_showwarning is None: + _warnings_showwarning = warnings.showwarning + warnings.showwarning = idle_showwarning_subproc + else: + if _warnings_showwarning is not None: + warnings.showwarning = _warnings_showwarning + _warnings_showwarning = None + +capture_warnings(True) + +if idlelib.testing: + # gh-121008: When testing IDLE, don't create a Tk object to avoid side + # effects such as installing a PyOS_InputHook hook. + def handle_tk_events(): + pass +else: + tcl = tkinter.Tcl() + + def handle_tk_events(tcl=tcl): + """Process any tk events that are ready to be dispatched if tkinter + has been imported, a tcl interpreter has been created and tk has been + loaded.""" + tcl.eval("update") + +# Thread shared globals: Establish a queue between a subthread (which handles +# the socket) and the main thread (which runs user code), plus global +# completion, exit and interruptible (the main thread) flags: + +exit_now = False +quitting = False +interruptible = False + +def main(del_exitfunc=False): + """Start the Python execution server in a subprocess + + In the Python subprocess, RPCServer is instantiated with handlerclass + MyHandler, which inherits register/unregister methods from RPCHandler via + the mix-in class SocketIO. + + When the RPCServer 'server' is instantiated, the TCPServer initialization + creates an instance of run.MyHandler and calls its handle() method. + handle() instantiates a run.Executive object, passing it a reference to the + MyHandler object. That reference is saved as attribute rpchandler of the + Executive instance. The Executive methods have access to the reference and + can pass it on to entities that they command + (e.g. debugger_r.Debugger.start_debugger()). The latter, in turn, can + call MyHandler(SocketIO) register/unregister methods via the reference to + register and unregister themselves. + + """ + global exit_now + global quitting + global no_exitfunc + no_exitfunc = del_exitfunc + #time.sleep(15) # test subprocess not responding + try: + assert(len(sys.argv) > 1) + port = int(sys.argv[-1]) + except: + print("IDLE Subprocess: no IP port passed in sys.argv.", + file=sys.__stderr__) + return + + capture_warnings(True) + sys.argv[:] = [""] + threading.Thread(target=manage_socket, + name='SockThread', + args=((LOCALHOST, port),), + daemon=True, + ).start() + + while True: + try: + if exit_now: + try: + exit() + except KeyboardInterrupt: + # exiting but got an extra KBI? Try again! + continue + try: + request = rpc.request_queue.get(block=True, timeout=0.05) + except queue.Empty: + request = None + # Issue 32207: calling handle_tk_events here adds spurious + # queue.Empty traceback to event handling exceptions. + if request: + seq, (method, args, kwargs) = request + ret = method(*args, **kwargs) + rpc.response_queue.put((seq, ret)) + else: + handle_tk_events() + except KeyboardInterrupt: + if quitting: + exit_now = True + continue + except SystemExit: + capture_warnings(False) + raise + except: + type, value, tb = sys.exc_info() + try: + print_exception() + rpc.response_queue.put((seq, None)) + except: + # Link didn't work, print same exception to __stderr__ + traceback.print_exception(type, value, tb, file=sys.__stderr__) + exit() + else: + continue + +def manage_socket(address): + for i in range(3): + time.sleep(i) + try: + server = MyRPCServer(address, MyHandler) + break + except OSError as err: + print("IDLE Subprocess: OSError: " + err.args[1] + + ", retrying....", file=sys.__stderr__) + socket_error = err + else: + print("IDLE Subprocess: Connection to " + "IDLE GUI failed, exiting.", file=sys.__stderr__) + show_socket_error(socket_error, address) + global exit_now + exit_now = True + return + server.handle_request() # A single request only + +def show_socket_error(err, address): + "Display socket error from manage_socket." + import tkinter + from tkinter.messagebox import showerror + root = tkinter.Tk() + fix_scaling(root) + root.withdraw() + showerror( + "Subprocess Connection Error", + f"IDLE's subprocess can't connect to {address[0]}:{address[1]}.\n" + f"Fatal OSError #{err.errno}: {err.strerror}.\n" + "See the 'Startup failure' section of the IDLE doc, online at\n" + "https://docs.python.org/3/library/idle.html#startup-failure", + parent=root) + root.destroy() + + +def get_message_lines(typ, exc, tb): + "Return line composing the exception message." + if typ in (AttributeError, NameError): + # 3.10+ hints are not directly accessible from python (#44026). + err = io.StringIO() + with contextlib.redirect_stderr(err): + sys.__excepthook__(typ, exc, tb) + return [err.getvalue().split("\n")[-2] + "\n"] + else: + return traceback.format_exception_only(typ, exc) + + +def print_exception(): + import linecache + linecache.checkcache() + flush_stdout() + efile = sys.stderr + typ, val, tb = excinfo = sys.exc_info() + sys.last_type, sys.last_value, sys.last_traceback = excinfo + sys.last_exc = val + seen = set() + + def print_exc(typ, exc, tb): + seen.add(id(exc)) + context = exc.__context__ + cause = exc.__cause__ + if cause is not None and id(cause) not in seen: + print_exc(type(cause), cause, cause.__traceback__) + print("\nThe above exception was the direct cause " + "of the following exception:\n", file=efile) + elif (context is not None and + not exc.__suppress_context__ and + id(context) not in seen): + print_exc(type(context), context, context.__traceback__) + print("\nDuring handling of the above exception, " + "another exception occurred:\n", file=efile) + if tb: + tbe = traceback.extract_tb(tb) + print('Traceback (most recent call last):', file=efile) + exclude = ("run.py", "rpc.py", "threading.py", "queue.py", + "debugger_r.py", "bdb.py") + cleanup_traceback(tbe, exclude) + traceback.print_list(tbe, file=efile) + lines = get_message_lines(typ, exc, tb) + for line in lines: + print(line, end='', file=efile) + + print_exc(typ, val, tb) + +def cleanup_traceback(tb, exclude): + "Remove excluded traces from beginning/end of tb; get cached lines" + orig_tb = tb[:] + while tb: + for rpcfile in exclude: + if tb[0][0].count(rpcfile): + break # found an exclude, break for: and delete tb[0] + else: + break # no excludes, have left RPC code, break while: + del tb[0] + while tb: + for rpcfile in exclude: + if tb[-1][0].count(rpcfile): + break + else: + break + del tb[-1] + if len(tb) == 0: + # exception was in IDLE internals, don't prune! + tb[:] = orig_tb[:] + print("** IDLE Internal Exception: ", file=sys.stderr) + rpchandler = rpc.objecttable['exec'].rpchandler + for i in range(len(tb)): + fn, ln, nm, line = tb[i] + if nm == '?': + nm = "-toplevel-" + if not line and fn.startswith(" 1.4: + for name in tkinter.font.names(root): + font = tkinter.font.Font(root=root, name=name, exists=True) + size = int(font['size']) + if size < 0: + font['size'] = round(-0.75*size) + + +def fixdoc(fun, text): + tem = (fun.__doc__ + '\n\n') if fun.__doc__ is not None else '' + fun.__doc__ = tem + textwrap.fill(textwrap.dedent(text)) + +RECURSIONLIMIT_DELTA = 30 + +def install_recursionlimit_wrappers(): + """Install wrappers to always add 30 to the recursion limit.""" + # see: bpo-26806 + + @functools.wraps(sys.setrecursionlimit) + def setrecursionlimit(*args, **kwargs): + # mimic the original sys.setrecursionlimit()'s input handling + if kwargs: + raise TypeError( + "setrecursionlimit() takes no keyword arguments") + try: + limit, = args + except ValueError: + raise TypeError(f"setrecursionlimit() takes exactly one " + f"argument ({len(args)} given)") + if not limit > 0: + raise ValueError( + "recursion limit must be greater or equal than 1") + + return setrecursionlimit.__wrapped__(limit + RECURSIONLIMIT_DELTA) + + fixdoc(setrecursionlimit, f"""\ + This IDLE wrapper adds {RECURSIONLIMIT_DELTA} to prevent possible + uninterruptible loops.""") + + @functools.wraps(sys.getrecursionlimit) + def getrecursionlimit(): + return getrecursionlimit.__wrapped__() - RECURSIONLIMIT_DELTA + + fixdoc(getrecursionlimit, f"""\ + This IDLE wrapper subtracts {RECURSIONLIMIT_DELTA} to compensate + for the {RECURSIONLIMIT_DELTA} IDLE adds when setting the limit.""") + + # add the delta to the default recursion limit, to compensate + sys.setrecursionlimit(sys.getrecursionlimit() + RECURSIONLIMIT_DELTA) + + sys.setrecursionlimit = setrecursionlimit + sys.getrecursionlimit = getrecursionlimit + + +def uninstall_recursionlimit_wrappers(): + """Uninstall the recursion limit wrappers from the sys module. + + IDLE only uses this for tests. Users can import run and call + this to remove the wrapping. + """ + if ( + getattr(sys.setrecursionlimit, '__wrapped__', None) and + getattr(sys.getrecursionlimit, '__wrapped__', None) + ): + sys.setrecursionlimit = sys.setrecursionlimit.__wrapped__ + sys.getrecursionlimit = sys.getrecursionlimit.__wrapped__ + sys.setrecursionlimit(sys.getrecursionlimit() - RECURSIONLIMIT_DELTA) + + +class MyRPCServer(rpc.RPCServer): + + def handle_error(self, request, client_address): + """Override RPCServer method for IDLE + + Interrupt the MainThread and exit server if link is dropped. + + """ + global quitting + try: + raise + except SystemExit: + raise + except EOFError: + global exit_now + exit_now = True + thread.interrupt_main() + except: + erf = sys.__stderr__ + print(textwrap.dedent(f""" + {'-'*40} + Unhandled exception in user code execution server!' + Thread: {threading.current_thread().name} + IDLE Client Address: {client_address} + Request: {request!r} + """), file=erf) + traceback.print_exc(limit=-20, file=erf) + print(textwrap.dedent(f""" + *** Unrecoverable, server exiting! + + Users should never see this message; it is likely transient. + If this recurs, report this with a copy of the message + and an explanation of how to make it repeat. + {'-'*40}"""), file=erf) + quitting = True + thread.interrupt_main() + + +# Pseudofiles for shell-remote communication (also used in pyshell) + +class StdioFile(io.TextIOBase): + + def __init__(self, shell, tags, encoding='utf-8', errors='strict'): + self.shell = shell + # GH-78889: accessing unpickleable attributes freezes Shell. + # IDLE only needs methods; allow 'width' for possible use. + self.shell._RPCProxy__attributes = {'width': 1} + self.tags = tags + self._encoding = encoding + self._errors = errors + + @property + def encoding(self): + return self._encoding + + @property + def errors(self): + return self._errors + + @property + def name(self): + return '<%s>' % self.tags + + def isatty(self): + return True + + +class StdOutputFile(StdioFile): + + def writable(self): + return True + + def write(self, s): + if self.closed: + raise ValueError("write to closed file") + s = str.encode(s, self.encoding, self.errors).decode(self.encoding, self.errors) + return self.shell.write(s, self.tags) + + +class StdInputFile(StdioFile): + _line_buffer = '' + + def readable(self): + return True + + def read(self, size=-1): + if self.closed: + raise ValueError("read from closed file") + if size is None: + size = -1 + elif not isinstance(size, int): + raise TypeError('must be int, not ' + type(size).__name__) + result = self._line_buffer + self._line_buffer = '' + if size < 0: + while line := self.shell.readline(): + result += line + else: + while len(result) < size: + line = self.shell.readline() + if not line: break + result += line + self._line_buffer = result[size:] + result = result[:size] + return result + + def readline(self, size=-1): + if self.closed: + raise ValueError("read from closed file") + if size is None: + size = -1 + elif not isinstance(size, int): + raise TypeError('must be int, not ' + type(size).__name__) + line = self._line_buffer or self.shell.readline() + if size < 0: + size = len(line) + eol = line.find('\n', 0, size) + if eol >= 0: + size = eol + 1 + self._line_buffer = line[size:] + return line[:size] + + def close(self): + self.shell.close() + + +class MyHandler(rpc.RPCHandler): + + def handle(self): + """Override base method""" + executive = Executive(self) + self.register("exec", executive) + self.console = self.get_remote_proxy("console") + sys.stdin = StdInputFile(self.console, "stdin", + iomenu.encoding, iomenu.errors) + sys.stdout = StdOutputFile(self.console, "stdout", + iomenu.encoding, iomenu.errors) + sys.stderr = StdOutputFile(self.console, "stderr", + iomenu.encoding, "backslashreplace") + + sys.displayhook = rpc.displayhook + # page help() text to shell. + import pydoc # import must be done here to capture i/o binding + pydoc.pager = pydoc.plainpager + + # Keep a reference to stdin so that it won't try to exit IDLE if + # sys.stdin gets changed from within IDLE's shell. See issue17838. + self._keep_stdin = sys.stdin + + install_recursionlimit_wrappers() + + self.interp = self.get_remote_proxy("interp") + rpc.RPCHandler.getresponse(self, myseq=None, wait=0.05) + + def exithook(self): + "override SocketIO method - wait for MainThread to shut us down" + time.sleep(10) + + def EOFhook(self): + "Override SocketIO method - terminate wait on callback and exit thread" + global quitting + quitting = True + thread.interrupt_main() + + def decode_interrupthook(self): + "interrupt awakened thread" + global quitting + quitting = True + thread.interrupt_main() + + +class Executive: + + def __init__(self, rpchandler): + self.rpchandler = rpchandler + if idlelib.testing is False: + self.locals = __main__.__dict__ + self.calltip = calltip.Calltip() + self.autocomplete = autocomplete.AutoComplete() + else: + self.locals = {} + + def runcode(self, code): + global interruptible + try: + self.user_exc_info = None + interruptible = True + try: + exec(code, self.locals) + finally: + interruptible = False + except SystemExit as e: + if e.args: # SystemExit called with an argument. + ob = e.args[0] + if not isinstance(ob, (type(None), int)): + print('SystemExit: ' + str(ob), file=sys.stderr) + # Return to the interactive prompt. + except: + self.user_exc_info = sys.exc_info() # For testing, hook, viewer. + if quitting: + exit() + if sys.excepthook is sys.__excepthook__: + print_exception() + else: + try: + sys.excepthook(*self.user_exc_info) + except: + self.user_exc_info = sys.exc_info() # For testing. + print_exception() + jit = self.rpchandler.console.getvar("<>") + if jit: + self.rpchandler.interp.open_remote_stack_viewer() + else: + flush_stdout() + + def interrupt_the_server(self): + if interruptible: + thread.interrupt_main() + + def start_the_debugger(self, gui_adap_oid): + return debugger_r.start_debugger(self.rpchandler, gui_adap_oid) + + def stop_the_debugger(self, idb_adap_oid): + "Unregister the Idb Adapter. Link objects and Idb then subject to GC" + self.rpchandler.unregister(idb_adap_oid) + + def get_the_calltip(self, name): + return self.calltip.fetch_tip(name) + + def get_the_completion_list(self, what, mode): + return self.autocomplete.fetch_completions(what, mode) + + def stackviewer(self, flist_oid=None): + if self.user_exc_info: + _, exc, tb = self.user_exc_info + else: + return None + flist = None + if flist_oid is not None: + flist = self.rpchandler.get_remote_proxy(flist_oid) + while tb and tb.tb_frame.f_globals["__name__"] in ["rpc", "run"]: + tb = tb.tb_next + exc.__traceback__ = tb + item = stackviewer.StackTreeItem(exc, flist) + return debugobj_r.remote_object_tree_item(item) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_run', verbosity=2) + +capture_warnings(False) # Make sure turned off; see bpo-18081. diff --git a/Lib/idlelib/runscript.py b/Lib/idlelib/runscript.py new file mode 100644 index 00000000000..55712e90460 --- /dev/null +++ b/Lib/idlelib/runscript.py @@ -0,0 +1,213 @@ +"""Execute code from an editor. + +Check module: do a full syntax check of the current module. +Also run the tabnanny to catch any inconsistent tabs. + +Run module: also execute the module's code in the __main__ namespace. +The window must have been saved previously. The module is added to +sys.modules, and is also added to the __main__ namespace. + +TODO: Specify command line arguments in a dialog box. +""" +import os +import tabnanny +import time +import tokenize + +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib import macosx +from idlelib import pyshell +from idlelib.query import CustomRun +from idlelib import outwin + +indent_message = """Error: Inconsistent indentation detected! + +1) Your indentation is outright incorrect (easy to fix), OR + +2) Your indentation mixes tabs and spaces. + +To fix case 2, change all tabs to spaces by using Edit->Select All followed \ +by Format->Untabify Region and specify the number of columns used by each tab. +""" + + +class ScriptBinding: + + def __init__(self, editwin): + self.editwin = editwin + # Provide instance variables referenced by debugger + # XXX This should be done differently + self.flist = self.editwin.flist + self.root = self.editwin.root + # cli_args is list of strings that extends sys.argv + self.cli_args = [] + self.perf = 0.0 # Workaround for macOS 11 Uni2; see bpo-42508. + + def check_module_event(self, event): + if isinstance(self.editwin, outwin.OutputWindow): + self.editwin.text.bell() + return 'break' + filename = self.getfilename() + if not filename: + return 'break' + if not self.checksyntax(filename): + return 'break' + if not self.tabnanny(filename): + return 'break' + return "break" + + def tabnanny(self, filename): + # XXX: tabnanny should work on binary files as well + with tokenize.open(filename) as f: + try: + tabnanny.process_tokens(tokenize.generate_tokens(f.readline)) + except tokenize.TokenError as msg: + msgtxt, (lineno, start) = msg.args + self.editwin.gotoline(lineno) + self.errorbox("Tabnanny Tokenizing Error", + "Token Error: %s" % msgtxt) + return False + except tabnanny.NannyNag as nag: + # The error messages from tabnanny are too confusing... + self.editwin.gotoline(nag.get_lineno()) + self.errorbox("Tab/space error", indent_message) + return False + return True + + def checksyntax(self, filename): + self.shell = shell = self.flist.open_shell() + saved_stream = shell.get_warning_stream() + shell.set_warning_stream(shell.stderr) + with open(filename, 'rb') as f: + source = f.read() + if b'\r' in source: + source = source.replace(b'\r\n', b'\n') + source = source.replace(b'\r', b'\n') + if source and source[-1] != ord(b'\n'): + source = source + b'\n' + editwin = self.editwin + text = editwin.text + text.tag_remove("ERROR", "1.0", "end") + try: + # If successful, return the compiled code + return compile(source, filename, "exec") + except (SyntaxError, OverflowError, ValueError) as value: + msg = getattr(value, 'msg', '') or value or "" + lineno = getattr(value, 'lineno', '') or 1 + offset = getattr(value, 'offset', '') or 0 + if offset == 0: + lineno += 1 #mark end of offending line + pos = "0.0 + %d lines + %d chars" % (lineno-1, offset-1) + editwin.colorize_syntax_error(text, pos) + self.errorbox("SyntaxError", "%-20s" % msg) + return False + finally: + shell.set_warning_stream(saved_stream) + + def run_custom_event(self, event): + return self.run_module_event(event, customize=True) + + def run_module_event(self, event, *, customize=False): + """Run the module after setting up the environment. + + First check the syntax. Next get customization. If OK, make + sure the shell is active and then transfer the arguments, set + the run environment's working directory to the directory of the + module being executed and also add that directory to its + sys.path if not already included. + """ + if macosx.isCocoaTk() and (time.perf_counter() - self.perf < .05): + return 'break' + if isinstance(self.editwin, outwin.OutputWindow): + self.editwin.text.bell() + return 'break' + filename = self.getfilename() + if not filename: + return 'break' + code = self.checksyntax(filename) + if not code: + return 'break' + if not self.tabnanny(filename): + return 'break' + if customize: + title = f"Customize {self.editwin.short_title()} Run" + run_args = CustomRun(self.shell.text, title, + cli_args=self.cli_args).result + if not run_args: # User cancelled. + return 'break' + self.cli_args, restart = run_args if customize else ([], True) + interp = self.shell.interp + if pyshell.use_subprocess and restart: + interp.restart_subprocess( + with_cwd=False, filename=filename) + dirname = os.path.dirname(filename) + argv = [filename] + if self.cli_args: + argv += self.cli_args + interp.runcommand(f"""if 1: + __file__ = {filename!r} + import sys as _sys + from os.path import basename as _basename + argv = {argv!r} + if (not _sys.argv or + _basename(_sys.argv[0]) != _basename(__file__) or + len(argv) > 1): + _sys.argv = argv + import os as _os + _os.chdir({dirname!r}) + del _sys, argv, _basename, _os + \n""") + interp.prepend_syspath(filename) + # XXX KBK 03Jul04 When run w/o subprocess, runtime warnings still + # go to __stderr__. With subprocess, they go to the shell. + # Need to change streams in pyshell.ModifiedInterpreter. + interp.runcode(code) + return 'break' + + def getfilename(self): + """Get source filename. If not saved, offer to save (or create) file + + The debugger requires a source file. Make sure there is one, and that + the current version of the source buffer has been saved. If the user + declines to save or cancels the Save As dialog, return None. + + If the user has configured IDLE for Autosave, the file will be + silently saved if it already exists and is dirty. + + """ + filename = self.editwin.io.filename + if not self.editwin.get_saved(): + autosave = idleConf.GetOption('main', 'General', + 'autosave', type='bool') + if autosave and filename: + self.editwin.io.save(None) + else: + confirm = self.ask_save_dialog() + self.editwin.text.focus_set() + if confirm: + self.editwin.io.save(None) + filename = self.editwin.io.filename + else: + filename = None + return filename + + def ask_save_dialog(self): + msg = "Source Must Be Saved\n" + 5*' ' + "OK to Save?" + confirm = messagebox.askokcancel(title="Save Before Run or Check", + message=msg, + default=messagebox.OK, + parent=self.editwin.text) + return confirm + + def errorbox(self, title, message): + # XXX This should really be a function of EditorWindow... + messagebox.showerror(title, message, parent=self.editwin.text) + self.editwin.text.focus_set() + self.perf = time.perf_counter() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_runscript', verbosity=2,) diff --git a/Lib/idlelib/scrolledlist.py b/Lib/idlelib/scrolledlist.py new file mode 100644 index 00000000000..4fb418db326 --- /dev/null +++ b/Lib/idlelib/scrolledlist.py @@ -0,0 +1,151 @@ +from tkinter import * +from tkinter.ttk import Frame, Scrollbar + +from idlelib import macosx + + +class ScrolledList: + + default = "(None)" + + def __init__(self, master, **options): + # Create top frame, with scrollbar and listbox + self.master = master + self.frame = frame = Frame(master) + self.frame.pack(fill="both", expand=1) + self.vbar = vbar = Scrollbar(frame, name="vbar") + self.vbar.pack(side="right", fill="y") + self.listbox = listbox = Listbox(frame, exportselection=0, + background="white") + if options: + listbox.configure(options) + listbox.pack(expand=1, fill="both") + # Tie listbox and scrollbar together + vbar["command"] = listbox.yview + listbox["yscrollcommand"] = vbar.set + # Bind events to the list box + listbox.bind("", self.click_event) + listbox.bind("", self.double_click_event) + if macosx.isAquaTk(): + listbox.bind("", self.popup_event) + listbox.bind("", self.popup_event) + else: + listbox.bind("", self.popup_event) + listbox.bind("", self.up_event) + listbox.bind("", self.down_event) + # Mark as empty + self.clear() + + def close(self): + self.frame.destroy() + + def clear(self): + self.listbox.delete(0, "end") + self.empty = 1 + self.listbox.insert("end", self.default) + + def append(self, item): + if self.empty: + self.listbox.delete(0, "end") + self.empty = 0 + self.listbox.insert("end", str(item)) + + def get(self, index): + return self.listbox.get(index) + + def click_event(self, event): + self.listbox.activate("@%d,%d" % (event.x, event.y)) + index = self.listbox.index("active") + self.select(index) + self.on_select(index) + return "break" + + def double_click_event(self, event): + index = self.listbox.index("active") + self.select(index) + self.on_double(index) + return "break" + + menu = None + + def popup_event(self, event): + if not self.menu: + self.make_menu() + menu = self.menu + self.listbox.activate("@%d,%d" % (event.x, event.y)) + index = self.listbox.index("active") + self.select(index) + menu.tk_popup(event.x_root, event.y_root) + return "break" + + def make_menu(self): + menu = Menu(self.listbox, tearoff=0) + self.menu = menu + self.fill_menu() + + def up_event(self, event): + index = self.listbox.index("active") + if self.listbox.selection_includes(index): + index = index - 1 + else: + index = self.listbox.size() - 1 + if index < 0: + self.listbox.bell() + else: + self.select(index) + self.on_select(index) + return "break" + + def down_event(self, event): + index = self.listbox.index("active") + if self.listbox.selection_includes(index): + index = index + 1 + else: + index = 0 + if index >= self.listbox.size(): + self.listbox.bell() + else: + self.select(index) + self.on_select(index) + return "break" + + def select(self, index): + self.listbox.focus_set() + self.listbox.activate(index) + self.listbox.selection_clear(0, "end") + self.listbox.selection_set(index) + self.listbox.see(index) + + # Methods to override for specific actions + + def fill_menu(self): + pass + + def on_select(self, index): + pass + + def on_double(self, index): + pass + + +def _scrolled_list(parent): # htest # + top = Toplevel(parent) + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x+200, y + 175)) + + class MyScrolledList(ScrolledList): + def fill_menu(self): self.menu.add_command(label="right click") + def on_select(self, index): print("select", self.get(index)) + def on_double(self, index): print("double", self.get(index)) + + scrolled_list = MyScrolledList(top) + for i in range(30): + scrolled_list.append("Item %02d" % i) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_scrolledlist', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_scrolled_list) diff --git a/Lib/idlelib/search.py b/Lib/idlelib/search.py new file mode 100644 index 00000000000..935a4832257 --- /dev/null +++ b/Lib/idlelib/search.py @@ -0,0 +1,165 @@ +"""Search dialog for Find, Find Again, and Find Selection + functionality. + + Inherits from SearchDialogBase for GUI and uses searchengine + to prepare search pattern. +""" +from tkinter import TclError + +from idlelib import searchengine +from idlelib.searchbase import SearchDialogBase + +def _setup(text): + """Return the new or existing singleton SearchDialog instance. + + The singleton dialog saves user entries and preferences + across instances. + + Args: + text: Text widget containing the text to be searched. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_searchdialog"): + engine._searchdialog = SearchDialog(root, engine) + return engine._searchdialog + +def find(text): + """Open the search dialog. + + Module-level function to access the singleton SearchDialog + instance and open the dialog. If text is selected, it is + used as the search phrase; otherwise, the previous entry + is used. No search is done with this command. + """ + pat = text.get("sel.first", "sel.last") + return _setup(text).open(text, pat) # Open is inherited from SDBase. + +def find_again(text): + """Repeat the search for the last pattern and preferences. + + Module-level function to access the singleton SearchDialog + instance to search again using the user entries and preferences + from the last dialog. If there was no prior search, open the + search dialog; otherwise, perform the search without showing the + dialog. + """ + return _setup(text).find_again(text) + +def find_selection(text): + """Search for the selected pattern in the text. + + Module-level function to access the singleton SearchDialog + instance to search using the selected text. With a text + selection, perform the search without displaying the dialog. + Without a selection, use the prior entry as the search phrase + and don't display the dialog. If there has been no prior + search, open the search dialog. + """ + return _setup(text).find_selection(text) + + +class SearchDialog(SearchDialogBase): + "Dialog for finding a pattern in text." + + def create_widgets(self): + "Create the base search dialog and add a button for Find Next." + SearchDialogBase.create_widgets(self) + # TODO - why is this here and not in a create_command_buttons? + self.make_button("Find Next", self.default_command, isdef=True) + + def default_command(self, event=None): + "Handle the Find Next button as the default command." + if not self.engine.getprog(): + return + self.find_again(self.text) + + def find_again(self, text): + """Repeat the last search. + + If no search was previously run, open a new search dialog. In + this case, no search is done. + + If a search was previously run, the search dialog won't be + shown and the options from the previous search (including the + search pattern) will be used to find the next occurrence + of the pattern. Next is relative based on direction. + + Position the window to display the located occurrence in the + text. + + Return True if the search was successful and False otherwise. + """ + if not self.engine.getpat(): + self.open(text) + return False + if not self.engine.getprog(): + return False + res = self.engine.search_text(text) + if res: + line, m = res + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + try: + selfirst = text.index("sel.first") + sellast = text.index("sel.last") + if selfirst == first and sellast == last: + self.bell() + return False + except TclError: + pass + text.tag_remove("sel", "1.0", "end") + text.tag_add("sel", first, last) + text.mark_set("insert", self.engine.isback() and first or last) + text.see("insert") + return True + else: + self.bell() + return False + + def find_selection(self, text): + """Search for selected text with previous dialog preferences. + + Instead of using the same pattern for searching (as Find + Again does), this first resets the pattern to the currently + selected text. If the selected text isn't changed, then use + the prior search phrase. + """ + pat = text.get("sel.first", "sel.last") + if pat: + self.engine.setcookedpat(pat) + return self.find_again(text) + + +def _search_dialog(parent): # htest # + "Display search test box." + from tkinter import Toplevel, Text + from tkinter.ttk import Frame, Button + + top = Toplevel(parent) + top.title("Test SearchDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + frame = Frame(top) + frame.pack() + text = Text(frame, inactiveselectbackground='gray') + text.pack() + text.insert("insert","This is a sample string.\n"*5) + + def show_find(): + text.tag_add('sel', '1.0', 'end') + _setup(text).open(text) + text.tag_remove('sel', '1.0', 'end') + + button = Button(frame, text="Search (selection ignored)", command=show_find) + button.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_search', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_search_dialog) diff --git a/Lib/idlelib/searchbase.py b/Lib/idlelib/searchbase.py new file mode 100644 index 00000000000..c68a6ca339a --- /dev/null +++ b/Lib/idlelib/searchbase.py @@ -0,0 +1,210 @@ +'''Define SearchDialogBase used by Search, Replace, and Grep dialogs.''' + +from tkinter import Toplevel +from tkinter.ttk import Frame, Entry, Label, Button, Checkbutton, Radiobutton +from tkinter.simpledialog import _setup_dialog + + +class SearchDialogBase: + '''Create most of a 3 or 4 row, 3 column search dialog. + + The left and wide middle column contain: + 1 or 2 labeled text entry lines (make_entry, create_entries); + a row of standard Checkbuttons (make_frame, create_option_buttons), + each of which corresponds to a search engine Variable; + a row of dialog-specific Check/Radiobuttons (create_other_buttons). + + The narrow right column contains command buttons + (make_button, create_command_buttons). + These are bound to functions that execute the command. + + Except for command buttons, this base class is not limited to items + common to all three subclasses. Rather, it is the Find dialog minus + the "Find Next" command, its execution function, and the + default_command attribute needed in create_widgets. The other + dialogs override attributes and methods, the latter to replace and + add widgets. + ''' + + title = "Search Dialog" # replace in subclasses + icon = "Search" + needwrapbutton = 1 # not in Find in Files + + def __init__(self, root, engine): + '''Initialize root, engine, and top attributes. + + top (level widget): set in create_widgets() called from open(). + frame: container for all widgets in dialog. + text (Text searched): set in open(), only used in subclasses(). + ent (ry): created in make_entry() called from create_entry(). + row (of grid): 0 in create_widgets(), +1 in make_entry/frame(). + default_command: set in subclasses, used in create_widgets(). + + title (of dialog): class attribute, override in subclasses. + icon (of dialog): ditto, use unclear if cannot minimize dialog. + ''' + self.root = root + self.bell = root.bell + self.engine = engine + self.top = None + + def open(self, text, searchphrase=None): + "Make dialog visible on top of others and ready to use." + self.text = text + if not self.top: + self.create_widgets() + else: + self.top.deiconify() + self.top.tkraise() + self.top.transient(text.winfo_toplevel()) + if searchphrase: + self.ent.delete(0,"end") + self.ent.insert("end",searchphrase) + self.ent.focus_set() + self.ent.selection_range(0, "end") + self.ent.icursor(0) + self.top.grab_set() + + def close(self, event=None): + "Put dialog away for later use." + if self.top: + self.top.grab_release() + self.top.transient('') + self.top.withdraw() + + def create_widgets(self): + '''Create basic 3 row x 3 col search (find) dialog. + + Other dialogs override subsidiary create_x methods as needed. + Replace and Find-in-Files add another entry row. + ''' + top = Toplevel(self.root) + top.bind("", self.default_command) + top.bind("", self.close) + top.protocol("WM_DELETE_WINDOW", self.close) + top.wm_title(self.title) + top.wm_iconname(self.icon) + _setup_dialog(top) + self.top = top + self.frame = Frame(top, padding=5) + self.frame.grid(sticky="nwes") + top.grid_columnconfigure(0, weight=100) + top.grid_rowconfigure(0, weight=100) + + self.row = 0 + self.frame.grid_columnconfigure(0, pad=2, weight=0) + self.frame.grid_columnconfigure(1, pad=2, minsize=100, weight=100) + + self.create_entries() # row 0 (and maybe 1), cols 0, 1 + self.create_option_buttons() # next row, cols 0, 1 + self.create_other_buttons() # next row, cols 0, 1 + self.create_command_buttons() # col 2, all rows + + def make_entry(self, label_text, var): + '''Return (entry, label), . + + entry - gridded labeled Entry for text entry. + label - Label widget, returned for testing. + ''' + label = Label(self.frame, text=label_text) + label.grid(row=self.row, column=0, sticky="nw") + entry = Entry(self.frame, textvariable=var, exportselection=0) + entry.grid(row=self.row, column=1, sticky="nwe") + self.row = self.row + 1 + return entry, label + + def create_entries(self): + "Create one or more entry lines with make_entry." + self.ent = self.make_entry("Find:", self.engine.patvar)[0] + + def make_frame(self,labeltext=None): + '''Return (frame, label). + + frame - gridded labeled Frame for option or other buttons. + label - Label widget, returned for testing. + ''' + if labeltext: + label = Label(self.frame, text=labeltext) + label.grid(row=self.row, column=0, sticky="nw") + else: + label = '' + frame = Frame(self.frame) + frame.grid(row=self.row, column=1, columnspan=1, sticky="nwe") + self.row = self.row + 1 + return frame, label + + def create_option_buttons(self): + '''Return (filled frame, options) for testing. + + Options is a list of searchengine booleanvar, label pairs. + A gridded frame from make_frame is filled with a Checkbutton + for each pair, bound to the var, with the corresponding label. + ''' + frame = self.make_frame("Options")[0] + engine = self.engine + options = [(engine.revar, "Regular expression"), + (engine.casevar, "Match case"), + (engine.wordvar, "Whole word")] + if self.needwrapbutton: + options.append((engine.wrapvar, "Wrap around")) + for var, label in options: + btn = Checkbutton(frame, variable=var, text=label) + btn.pack(side="left", fill="both") + return frame, options + + def create_other_buttons(self): + '''Return (frame, others) for testing. + + Others is a list of value, label pairs. + A gridded frame from make_frame is filled with radio buttons. + ''' + frame = self.make_frame("Direction")[0] + var = self.engine.backvar + others = [(1, 'Up'), (0, 'Down')] + for val, label in others: + btn = Radiobutton(frame, variable=var, value=val, text=label) + btn.pack(side="left", fill="both") + return frame, others + + def make_button(self, label, command, isdef=0): + "Return command button gridded in command frame." + b = Button(self.buttonframe, + text=label, command=command, + default=isdef and "active" or "normal") + cols,rows=self.buttonframe.grid_size() + b.grid(pady=1,row=rows,column=0,sticky="ew") + self.buttonframe.grid(rowspan=rows+1) + return b + + def create_command_buttons(self): + "Place buttons in vertical command frame gridded on right." + f = self.buttonframe = Frame(self.frame) + f.grid(row=0,column=2,padx=2,pady=2,ipadx=2,ipady=2) + + b = self.make_button("Close", self.close) + b.lower() + + +class _searchbase(SearchDialogBase): # htest # + "Create auto-opening dialog with no text connection." + + def __init__(self, parent): + import re + from idlelib import searchengine + + self.root = parent + self.engine = searchengine.get(parent) + self.create_widgets() + print(parent.geometry()) + width,height, x,y = list(map(int, re.split('[x+]', parent.geometry()))) + self.top.geometry("+%d+%d" % (x + 40, y + 175)) + + def default_command(self, dummy): pass + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_searchbase', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_searchbase) diff --git a/Lib/idlelib/searchengine.py b/Lib/idlelib/searchengine.py new file mode 100644 index 00000000000..ceb38cfaef9 --- /dev/null +++ b/Lib/idlelib/searchengine.py @@ -0,0 +1,234 @@ +'''Define SearchEngine for search dialogs.''' +import re + +from tkinter import StringVar, BooleanVar, TclError +from tkinter import messagebox + +def get(root): + '''Return the singleton SearchEngine instance for the process. + + The single SearchEngine saves settings between dialog instances. + If there is not a SearchEngine already, make one. + ''' + if not hasattr(root, "_searchengine"): + root._searchengine = SearchEngine(root) + # This creates a cycle that persists until root is deleted. + return root._searchengine + + +class SearchEngine: + """Handles searching a text widget for Find, Replace, and Grep.""" + + def __init__(self, root): + '''Initialize Variables that save search state. + + The dialogs bind these to the UI elements present in the dialogs. + ''' + self.root = root # need for report_error() + self.patvar = StringVar(root, '') # search pattern + self.revar = BooleanVar(root, False) # regular expression? + self.casevar = BooleanVar(root, False) # match case? + self.wordvar = BooleanVar(root, False) # match whole word? + self.wrapvar = BooleanVar(root, True) # wrap around buffer? + self.backvar = BooleanVar(root, False) # search backwards? + + # Access methods + + def getpat(self): + return self.patvar.get() + + def setpat(self, pat): + self.patvar.set(pat) + + def isre(self): + return self.revar.get() + + def iscase(self): + return self.casevar.get() + + def isword(self): + return self.wordvar.get() + + def iswrap(self): + return self.wrapvar.get() + + def isback(self): + return self.backvar.get() + + # Higher level access methods + + def setcookedpat(self, pat): + "Set pattern after escaping if re." + # called only in search.py: 66 + if self.isre(): + pat = re.escape(pat) + self.setpat(pat) + + def getcookedpat(self): + pat = self.getpat() + if not self.isre(): # if True, see setcookedpat + pat = re.escape(pat) + if self.isword(): + pat = r"\b%s\b" % pat + return pat + + def getprog(self): + "Return compiled cooked search pattern." + pat = self.getpat() + if not pat: + self.report_error(pat, "Empty regular expression") + return None + pat = self.getcookedpat() + flags = 0 + if not self.iscase(): + flags = flags | re.IGNORECASE + try: + prog = re.compile(pat, flags) + except re.PatternError as e: + self.report_error(pat, e.msg, e.pos) + return None + return prog + + def report_error(self, pat, msg, col=None): + # Derived class could override this with something fancier + msg = "Error: " + str(msg) + if pat: + msg = msg + "\nPattern: " + str(pat) + if col is not None: + msg = msg + "\nOffset: " + str(col) + messagebox.showerror("Regular expression error", + msg, master=self.root) + + def search_text(self, text, prog=None, ok=0): + '''Return (lineno, matchobj) or None for forward/backward search. + + This function calls the right function with the right arguments. + It directly return the result of that call. + + Text is a text widget. Prog is a precompiled pattern. + The ok parameter is a bit complicated as it has two effects. + + If there is a selection, the search begin at either end, + depending on the direction setting and ok, with ok meaning that + the search starts with the selection. Otherwise, search begins + at the insert mark. + + To aid progress, the search functions do not return an empty + match at the starting position unless ok is True. + ''' + + if not prog: + prog = self.getprog() + if not prog: + return None # Compilation failed -- stop + wrap = self.wrapvar.get() + first, last = get_selection(text) + if self.isback(): + if ok: + start = last + else: + start = first + line, col = get_line_col(start) + res = self.search_backward(text, prog, line, col, wrap, ok) + else: + if ok: + start = first + else: + start = last + line, col = get_line_col(start) + res = self.search_forward(text, prog, line, col, wrap, ok) + return res + + def search_forward(self, text, prog, line, col, wrap, ok=0): + wrapped = 0 + startline = line + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + while chars: + m = prog.search(chars[:-1], col) + if m: + if ok or m.end() > col: + return line, m + line = line + 1 + if wrapped and line > startline: + break + col = 0 + ok = 1 + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + if not chars and wrap: + wrapped = 1 + wrap = 0 + line = 1 + chars = text.get("1.0", "2.0") + return None + + def search_backward(self, text, prog, line, col, wrap, ok=0): + wrapped = 0 + startline = line + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + while True: + m = search_reverse(prog, chars[:-1], col) + if m: + if ok or m.start() < col: + return line, m + line = line - 1 + if wrapped and line < startline: + break + ok = 1 + if line <= 0: + if not wrap: + break + wrapped = 1 + wrap = 0 + pos = text.index("end-1c") + line, col = map(int, pos.split(".")) + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + col = len(chars) - 1 + return None + + +def search_reverse(prog, chars, col): + '''Search backwards and return an re match object or None. + + This is done by searching forwards until there is no match. + Prog: compiled re object with a search method returning a match. + Chars: line of text, without \\n. + Col: stop index for the search; the limit for match.end(). + ''' + m = prog.search(chars) + if not m: + return None + found = None + i, j = m.span() # m.start(), m.end() == match slice indexes + while i < col and j <= col: + found = m + if i == j: + j = j+1 + m = prog.search(chars, j) + if not m: + break + i, j = m.span() + return found + +def get_selection(text): + '''Return tuple of 'line.col' indexes from selection or insert mark. + ''' + try: + first = text.index("sel.first") + last = text.index("sel.last") + except TclError: + first = last = None + if not first: + first = text.index("insert") + if not last: + last = first + return first, last + +def get_line_col(index): + '''Return (line, col) tuple of ints from 'line.col' string.''' + line, col = map(int, index.split(".")) # Fails on invalid index + return line, col + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_searchengine', verbosity=2) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py new file mode 100644 index 00000000000..aa19a24e3ed --- /dev/null +++ b/Lib/idlelib/sidebar.py @@ -0,0 +1,543 @@ +"""Line numbering implementation for IDLE as an extension. +Includes BaseSideBar which can be extended for other sidebar based extensions +""" +import contextlib +import functools +import itertools + +import tkinter as tk +from tkinter.font import Font +from idlelib.config import idleConf +from idlelib.delegator import Delegator +from idlelib import macosx + + +def get_lineno(text, index): + """Return the line number of an index in a Tk text widget.""" + text_index = text.index(index) + return int(float(text_index)) if text_index else None + + +def get_end_linenumber(text): + """Return the number of the last line in a Tk text widget.""" + return get_lineno(text, 'end-1c') + + +def get_displaylines(text, index): + """Display height, in lines, of a logical line in a Tk text widget.""" + return text.count(f"{index} linestart", + f"{index} lineend", + "displaylines", return_ints=True) + +def get_widget_padding(widget): + """Get the total padding of a Tk widget, including its border.""" + # TODO: use also in codecontext.py + manager = widget.winfo_manager() + if manager == 'pack': + info = widget.pack_info() + elif manager == 'grid': + info = widget.grid_info() + else: + raise ValueError(f"Unsupported geometry manager: {manager}") + + # All values are passed through getint(), since some + # values may be pixel objects, which can't simply be added to ints. + padx = sum(map(widget.tk.getint, [ + info['padx'], + widget.cget('padx'), + widget.cget('border'), + ])) + pady = sum(map(widget.tk.getint, [ + info['pady'], + widget.cget('pady'), + widget.cget('border'), + ])) + return padx, pady + + +@contextlib.contextmanager +def temp_enable_text_widget(text): + text.configure(state=tk.NORMAL) + try: + yield + finally: + text.configure(state=tk.DISABLED) + + +class BaseSideBar: + """A base class for sidebars using Text.""" + def __init__(self, editwin): + self.editwin = editwin + self.parent = editwin.text_frame + self.text = editwin.text + + self.is_shown = False + + self.main_widget = self.init_widgets() + + self.bind_events() + + self.update_font() + self.update_colors() + + def init_widgets(self): + """Initialize the sidebar's widgets, returning the main widget.""" + raise NotImplementedError + + def update_font(self): + """Update the sidebar text font, usually after config changes.""" + raise NotImplementedError + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + raise NotImplementedError + + def grid(self): + """Layout the widget, always using grid layout.""" + raise NotImplementedError + + def show_sidebar(self): + if not self.is_shown: + self.grid() + self.is_shown = True + + def hide_sidebar(self): + if self.is_shown: + self.main_widget.grid_forget() + self.is_shown = False + + def yscroll_event(self, *args, **kwargs): + """Hook for vertical scrolling for sub-classes to override.""" + raise NotImplementedError + + def redirect_yscroll_event(self, *args, **kwargs): + """Redirect vertical scrolling to the main editor text widget. + + The scroll bar is also updated. + """ + self.editwin.vbar.set(*args) + return self.yscroll_event(*args, **kwargs) + + def redirect_focusin_event(self, event): + """Redirect focus-in events to the main editor text widget.""" + self.text.focus_set() + return 'break' + + def redirect_mousebutton_event(self, event, event_name): + """Redirect mouse button events to the main editor text widget.""" + self.text.focus_set() + self.text.event_generate(event_name, x=0, y=event.y) + return 'break' + + def redirect_mousewheel_event(self, event): + """Redirect mouse wheel events to the editwin text widget.""" + self.text.event_generate('', + x=0, y=event.y, delta=event.delta) + return 'break' + + def bind_events(self): + self.text['yscrollcommand'] = self.redirect_yscroll_event + + # Ensure focus is always redirected to the main editor text widget. + self.main_widget.bind('', self.redirect_focusin_event) + + # Redirect mouse scrolling to the main editor text widget. + # + # Note that without this, scrolling with the mouse only scrolls + # the line numbers. + self.main_widget.bind('', self.redirect_mousewheel_event) + + # Redirect mouse button events to the main editor text widget, + # except for the left mouse button (1). + # + # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. + def bind_mouse_event(event_name, target_event_name): + handler = functools.partial(self.redirect_mousebutton_event, + event_name=target_event_name) + self.main_widget.bind(event_name, handler) + + for button in [2, 3, 4, 5]: + for event_name in (f'', + f'', + f'', + ): + bind_mouse_event(event_name, target_event_name=event_name) + + # Convert double- and triple-click events to normal click events, + # since event_generate() doesn't allow generating such events. + for event_name in (f'', + f'', + ): + bind_mouse_event(event_name, + target_event_name=f'') + + # start_line is set upon to allow selecting a range of rows + # by dragging. It is cleared upon . + start_line = None + + # last_y is initially set upon and is continuously updated + # upon , until or the mouse button is released. + # It is used in text_auto_scroll(), which is called repeatedly and + # does have a mouse event available. + last_y = None + + # auto_scrolling_after_id is set whenever text_auto_scroll is + # scheduled via .after(). It is used to stop the auto-scrolling + # upon , as well as to avoid scheduling the function several + # times in parallel. + auto_scrolling_after_id = None + + def drag_update_selection_and_insert_mark(y_coord): + """Helper function for drag and selection event handlers.""" + lineno = get_lineno(self.text, f"@0,{y_coord}") + a, b = sorted([start_line, lineno]) + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") + self.text.mark_set("insert", + f"{lineno if lineno == a else lineno + 1}.0") + + def b1_mousedown_handler(event): + nonlocal start_line + nonlocal last_y + start_line = int(float(self.text.index(f"@0,{event.y}"))) + last_y = event.y + + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', b1_mousedown_handler) + + def b1_mouseup_handler(event): + # On mouse up, we're no longer dragging. Set the shared persistent + # variables to None to represent this. + nonlocal start_line + nonlocal last_y + start_line = None + last_y = None + self.text.event_generate('', x=0, y=event.y) + self.main_widget.bind('', b1_mouseup_handler) + + def b1_drag_handler(event): + nonlocal last_y + if last_y is None: # i.e. if not currently dragging + return + last_y = event.y + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', b1_drag_handler) + + def text_auto_scroll(): + """Mimic Text auto-scrolling when dragging outside of it.""" + # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670 + nonlocal auto_scrolling_after_id + y = last_y + if y is None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None + return + elif y < 0: + self.text.yview_scroll(-1 + y, 'pixels') + drag_update_selection_and_insert_mark(y) + elif y > self.main_widget.winfo_height(): + self.text.yview_scroll(1 + y - self.main_widget.winfo_height(), + 'pixels') + drag_update_selection_and_insert_mark(y) + auto_scrolling_after_id = \ + self.main_widget.after(50, text_auto_scroll) + + def b1_leave_handler(event): + # Schedule the initial call to text_auto_scroll(), if not already + # scheduled. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is None: + nonlocal last_y + last_y = event.y + auto_scrolling_after_id = \ + self.main_widget.after(0, text_auto_scroll) + self.main_widget.bind('', b1_leave_handler) + + def b1_enter_handler(event): + # Cancel the scheduling of text_auto_scroll(), if it exists. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is not None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None + self.main_widget.bind('', b1_enter_handler) + + +class EndLineDelegator(Delegator): + """Generate callbacks with the current end line number. + + The provided callback is called after every insert and delete. + """ + def __init__(self, changed_callback): + Delegator.__init__(self) + self.changed_callback = changed_callback + + def insert(self, index, chars, tags=None): + self.delegate.insert(index, chars, tags) + self.changed_callback(get_end_linenumber(self.delegate)) + + def delete(self, index1, index2=None): + self.delegate.delete(index1, index2) + self.changed_callback(get_end_linenumber(self.delegate)) + + +class LineNumbers(BaseSideBar): + """Line numbers support for editor windows.""" + def __init__(self, editwin): + super().__init__(editwin) + + end_line_delegator = EndLineDelegator(self.update_sidebar_text) + # Insert the delegator after the undo delegator, so that line numbers + # are properly updated after undo and redo actions. + self.editwin.per.insertfilterafter(end_line_delegator, + after=self.editwin.undo) + + def init_widgets(self): + _padx, pady = get_widget_padding(self.text) + self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, + padx=2, pady=pady, + borderwidth=0, highlightthickness=0) + self.sidebar_text.config(state=tk.DISABLED) + + self.prev_end = 1 + self._sidebar_width_type = type(self.sidebar_text['width']) + with temp_enable_text_widget(self.sidebar_text): + self.sidebar_text.insert('insert', '1', 'linenumber') + self.sidebar_text.config(takefocus=False, exportselection=False) + self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) + + end = get_end_linenumber(self.text) + self.update_sidebar_text(end) + + return self.sidebar_text + + def grid(self): + self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) + + def update_font(self): + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + self.sidebar_text['font'] = font + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') + foreground = colors['foreground'] + background = colors['background'] + self.sidebar_text.config( + fg=foreground, bg=background, + selectforeground=foreground, selectbackground=background, + inactiveselectbackground=background, + ) + + def update_sidebar_text(self, end): + """ + Perform the following action: + Each line sidebar_text contains the linenumber for that line + Synchronize with editwin.text so that both sidebar_text and + editwin.text contain the same number of lines""" + if end == self.prev_end: + return + + width_difference = len(str(end)) - len(str(self.prev_end)) + if width_difference: + cur_width = int(float(self.sidebar_text['width'])) + new_width = cur_width + width_difference + self.sidebar_text['width'] = self._sidebar_width_type(new_width) + + with temp_enable_text_widget(self.sidebar_text): + if end > self.prev_end: + new_text = '\n'.join(itertools.chain( + [''], + map(str, range(self.prev_end + 1, end + 1)), + )) + self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') + else: + self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') + + self.prev_end = end + + def yscroll_event(self, *args, **kwargs): + self.sidebar_text.yview_moveto(args[0]) + return 'break' + + +class WrappedLineHeightChangeDelegator(Delegator): + def __init__(self, callback): + """ + callback - Callable, will be called when an insert, delete or replace + action on the text widget may require updating the shell + sidebar. + """ + Delegator.__init__(self) + self.callback = callback + + def insert(self, index, chars, tags=None): + is_single_line = '\n' not in chars + if is_single_line: + before_displaylines = get_displaylines(self, index) + + self.delegate.insert(index, chars, tags) + + if is_single_line: + after_displaylines = get_displaylines(self, index) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + def delete(self, index1, index2=None): + if index2 is None: + index2 = index1 + "+1c" + is_single_line = get_lineno(self, index1) == get_lineno(self, index2) + if is_single_line: + before_displaylines = get_displaylines(self, index1) + + self.delegate.delete(index1, index2) + + if is_single_line: + after_displaylines = get_displaylines(self, index1) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + +class ShellSidebar(BaseSideBar): + """Sidebar for the PyShell window, for prompts etc.""" + def __init__(self, editwin): + self.canvas = None + self.line_prompts = {} + + super().__init__(editwin) + + change_delegator = \ + WrappedLineHeightChangeDelegator(self.change_callback) + # Insert the TextChangeDelegator after the last delegator, so that + # the sidebar reflects final changes to the text widget contents. + d = self.editwin.per.top + if d.delegate is not self.text: + while d.delegate is not self.editwin.per.bottom: + d = d.delegate + self.editwin.per.insertfilterafter(change_delegator, after=d) + + self.is_shown = True + + def init_widgets(self): + self.canvas = tk.Canvas(self.parent, width=30, + borderwidth=0, highlightthickness=0, + takefocus=False) + self.update_sidebar() + self.grid() + return self.canvas + + def bind_events(self): + super().bind_events() + + self.main_widget.bind( + # AquaTk defines <2> as the right button, not <3>. + "" if macosx.isAquaTk() else "", + self.context_menu_event, + ) + + def context_menu_event(self, event): + rmenu = tk.Menu(self.main_widget, tearoff=0) + has_selection = bool(self.text.tag_nextrange('sel', '1.0')) + def mkcmd(eventname): + return lambda: self.text.event_generate(eventname) + rmenu.add_command(label='Copy', + command=mkcmd('<>'), + state='normal' if has_selection else 'disabled') + rmenu.add_command(label='Copy with prompts', + command=mkcmd('<>'), + state='normal' if has_selection else 'disabled') + rmenu.tk_popup(event.x_root, event.y_root) + return "break" + + def grid(self): + self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) + + def change_callback(self): + if self.is_shown: + self.update_sidebar() + + def update_sidebar(self): + text = self.text + text_tagnames = text.tag_names + canvas = self.canvas + line_prompts = self.line_prompts = {} + + canvas.delete(tk.ALL) + + index = text.index("@0,0") + if index.split('.', 1)[1] != '0': + index = text.index(f'{index}+1line linestart') + while (lineinfo := text.dlineinfo(index)) is not None: + y = lineinfo[1] + prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") + prompt = ( + '>>>' if "console" in prev_newline_tagnames else + '...' if "stdin" in prev_newline_tagnames else + None + ) + if prompt: + canvas.create_text(2, y, anchor=tk.NW, text=prompt, + font=self.font, fill=self.colors[0]) + lineno = get_lineno(text, index) + line_prompts[lineno] = prompt + index = text.index(f'{index}+1line') + + def yscroll_event(self, *args, **kwargs): + """Redirect vertical scrolling to the main editor text widget. + + The scroll bar is also updated. + """ + self.change_callback() + return 'break' + + def update_font(self): + """Update the sidebar text font, usually after config changes.""" + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + tk_font = Font(self.text, font=font) + char_width = max(tk_font.measure(char) for char in ['>', '.']) + self.canvas.configure(width=char_width * 3 + 4) + self.font = font + self.change_callback() + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') + prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') + foreground = prompt_colors['foreground'] + background = linenumbers_colors['background'] + self.colors = (foreground, background) + self.canvas.configure(background=background) + self.change_callback() + + +def _sidebar_number_scrolling(parent): # htest # + from idlelib.idle_test.test_sidebar import Dummy_editwin + + top = tk.Toplevel(parent) + text_frame = tk.Frame(top) + text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + text_frame.rowconfigure(1, weight=1) + text_frame.columnconfigure(1, weight=1) + + font = idleConf.GetFont(top, 'main', 'EditorWindow') + text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) + text.grid(row=1, column=1, sticky=tk.NSEW) + + editwin = Dummy_editwin(text) + editwin.vbar = tk.Scrollbar(text_frame) + + linenumbers = LineNumbers(editwin) + linenumbers.show_sidebar() + + text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_sidebar_number_scrolling) diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py new file mode 100644 index 00000000000..929c3fd3a50 --- /dev/null +++ b/Lib/idlelib/squeezer.py @@ -0,0 +1,345 @@ +"""An IDLE extension to avoid having very long texts printed in the shell. + +A common problem in IDLE's interactive shell is printing of large amounts of +text into the shell. This makes looking at the previous history difficult. +Worse, this can cause IDLE to become very slow, even to the point of being +completely unusable. + +This extension will automatically replace long texts with a small button. +Double-clicking this button will remove it and insert the original text instead. +Middle-clicking will copy the text to the clipboard. Right-clicking will open +the text in a separate viewing window. + +Additionally, any output can be manually "squeezed" by the user. This includes +output written to the standard error stream ("stderr"), such as exception +messages and their tracebacks. +""" +import re + +import tkinter as tk +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib.textview import view_text +from idlelib.tooltip import Hovertip +from idlelib import macosx + + +def count_lines_with_wrapping(s, linewidth=80): + """Count the number of lines in a given string. + + Lines are counted as if the string was wrapped so that lines are never over + linewidth characters long. + + Tabs are considered tabwidth characters long. + """ + tabwidth = 8 # Currently always true in Shell. + pos = 0 + linecount = 1 + current_column = 0 + + for m in re.finditer(r"[\t\n]", s): + # Process the normal chars up to tab or newline. + numchars = m.start() - pos + pos += numchars + current_column += numchars + + # Deal with tab or newline. + if s[pos] == '\n': + # Avoid the `current_column == 0` edge-case, and while we're + # at it, don't bother adding 0. + if current_column > linewidth: + # If the current column was exactly linewidth, divmod + # would give (1,0), even though a new line hadn't yet + # been started. The same is true if length is any exact + # multiple of linewidth. Therefore, subtract 1 before + # dividing a non-empty line. + linecount += (current_column - 1) // linewidth + linecount += 1 + current_column = 0 + else: + assert s[pos] == '\t' + current_column += tabwidth - (current_column % tabwidth) + + # If a tab passes the end of the line, consider the entire + # tab as being on the next line. + if current_column > linewidth: + linecount += 1 + current_column = tabwidth + + pos += 1 # After the tab or newline. + + # Process remaining chars (no more tabs or newlines). + current_column += len(s) - pos + # Avoid divmod(-1, linewidth). + if current_column > 0: + linecount += (current_column - 1) // linewidth + else: + # Text ended with newline; don't count an extra line after it. + linecount -= 1 + + return linecount + + +class ExpandingButton(tk.Button): + """Class for the "squeezed" text buttons used by Squeezer + + These buttons are displayed inside a Tk Text widget in place of text. A + user can then use the button to replace it with the original text, copy + the original text to the clipboard or view the original text in a separate + window. + + Each button is tied to a Squeezer instance, and it knows to update the + Squeezer instance when it is expanded (and therefore removed). + """ + def __init__(self, s, tags, numoflines, squeezer): + self.s = s + self.tags = tags + self.numoflines = numoflines + self.squeezer = squeezer + self.editwin = editwin = squeezer.editwin + self.text = text = editwin.text + # The base Text widget is needed to change text before iomark. + self.base_text = editwin.per.bottom + + line_plurality = "lines" if numoflines != 1 else "line" + button_text = f"Squeezed text ({numoflines} {line_plurality})." + tk.Button.__init__(self, text, text=button_text, + background="#FFFFC0", activebackground="#FFFFE0") + + button_tooltip_text = ( + "Double-click to expand, right-click for more options." + ) + Hovertip(self, button_tooltip_text, hover_delay=80) + + self.bind("", self.expand) + if macosx.isAquaTk(): + # AquaTk defines <2> as the right button, not <3>. + self.bind("", self.context_menu_event) + else: + self.bind("", self.context_menu_event) + self.selection_handle( # X windows only. + lambda offset, length: s[int(offset):int(offset) + int(length)]) + + self.is_dangerous = None + self.after_idle(self.set_is_dangerous) + + def set_is_dangerous(self): + dangerous_line_len = 50 * self.text.winfo_width() + self.is_dangerous = ( + self.numoflines > 1000 or + len(self.s) > 50000 or + any( + len(line_match.group(0)) >= dangerous_line_len + for line_match in re.finditer(r'[^\n]+', self.s) + ) + ) + + def expand(self, event=None): + """expand event handler + + This inserts the original text in place of the button in the Text + widget, removes the button and updates the Squeezer instance. + + If the original text is dangerously long, i.e. expanding it could + cause a performance degradation, ask the user for confirmation. + """ + if self.is_dangerous is None: + self.set_is_dangerous() + if self.is_dangerous: + confirm = messagebox.askokcancel( + title="Expand huge output?", + message="\n\n".join([ + "The squeezed output is very long: %d lines, %d chars.", + "Expanding it could make IDLE slow or unresponsive.", + "It is recommended to view or copy the output instead.", + "Really expand?" + ]) % (self.numoflines, len(self.s)), + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + return "break" + + index = self.text.index(self) + self.base_text.insert(index, self.s, self.tags) + self.base_text.delete(self) + self.editwin.on_squeezed_expand(index, self.s, self.tags) + self.squeezer.expandingbuttons.remove(self) + + def copy(self, event=None): + """copy event handler + + Copy the original text to the clipboard. + """ + self.clipboard_clear() + self.clipboard_append(self.s) + + def view(self, event=None): + """view event handler + + View the original text in a separate text viewer window. + """ + view_text(self.text, "Squeezed Output Viewer", self.s, + modal=False, wrap='none') + + rmenu_specs = ( + # Item structure: (label, method_name). + ('copy', 'copy'), + ('view', 'view'), + ) + + def context_menu_event(self, event): + self.text.mark_set("insert", "@%d,%d" % (event.x, event.y)) + rmenu = tk.Menu(self.text, tearoff=0) + for label, method_name in self.rmenu_specs: + rmenu.add_command(label=label, command=getattr(self, method_name)) + rmenu.tk_popup(event.x_root, event.y_root) + return "break" + + +class Squeezer: + """Replace long outputs in the shell with a simple button. + + This avoids IDLE's shell slowing down considerably, and even becoming + completely unresponsive, when very long outputs are written. + """ + @classmethod + def reload(cls): + """Load class variables from config.""" + cls.auto_squeeze_min_lines = idleConf.GetOption( + "main", "PyShell", "auto-squeeze-min-lines", + type="int", default=50, + ) + + def __init__(self, editwin): + """Initialize settings for Squeezer. + + editwin is the shell's Editor window. + self.text is the editor window text widget. + self.base_test is the actual editor window Tk text widget, rather than + EditorWindow's wrapper. + self.expandingbuttons is the list of all buttons representing + "squeezed" output. + """ + self.editwin = editwin + self.text = text = editwin.text + + # Get the base Text widget of the PyShell object, used to change + # text before the iomark. PyShell deliberately disables changing + # text before the iomark via its 'text' attribute, which is + # actually a wrapper for the actual Text widget. Squeezer, + # however, needs to make such changes. + self.base_text = editwin.per.bottom + + # Twice the text widget's border width and internal padding; + # pre-calculated here for the get_line_width() method. + self.window_width_delta = 2 * ( + int(text.cget('border')) + + int(text.cget('padx')) + ) + + self.expandingbuttons = [] + + # Replace the PyShell instance's write method with a wrapper, + # which inserts an ExpandingButton instead of a long text. + def mywrite(s, tags=(), write=editwin.write): + # Only auto-squeeze text which has just the "stdout" tag. + if tags != "stdout": + return write(s, tags) + + # Only auto-squeeze text with at least the minimum + # configured number of lines. + auto_squeeze_min_lines = self.auto_squeeze_min_lines + # First, a very quick check to skip very short texts. + if len(s) < auto_squeeze_min_lines: + return write(s, tags) + # Now the full line-count check. + numoflines = self.count_lines(s) + if numoflines < auto_squeeze_min_lines: + return write(s, tags) + + # Create an ExpandingButton instance. + expandingbutton = ExpandingButton(s, tags, numoflines, self) + + # Insert the ExpandingButton into the Text widget. + text.mark_gravity("iomark", tk.RIGHT) + text.window_create("iomark", window=expandingbutton, + padx=3, pady=5) + text.see("iomark") + text.update() + text.mark_gravity("iomark", tk.LEFT) + + # Add the ExpandingButton to the Squeezer's list. + self.expandingbuttons.append(expandingbutton) + + editwin.write = mywrite + + def count_lines(self, s): + """Count the number of lines in a given text. + + Before calculation, the tab width and line length of the text are + fetched, so that up-to-date values are used. + + Lines are counted as if the string was wrapped so that lines are never + over linewidth characters long. + + Tabs are considered tabwidth characters long. + """ + return count_lines_with_wrapping(s, self.editwin.width) + + def squeeze_current_text(self): + """Squeeze the text block where the insertion cursor is. + + If the cursor is not in a squeezable block of text, give the + user a small warning and do nothing. + """ + # Set tag_name to the first valid tag found on the "insert" cursor. + tag_names = self.text.tag_names(tk.INSERT) + for tag_name in ("stdout", "stderr"): + if tag_name in tag_names: + break + else: + # The insert cursor doesn't have a "stdout" or "stderr" tag. + self.text.bell() + return "break" + + # Find the range to squeeze. + start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") + s = self.text.get(start, end) + + # If the last char is a newline, remove it from the range. + if len(s) > 0 and s[-1] == '\n': + end = self.text.index("%s-1c" % end) + s = s[:-1] + + # Delete the text. + self.base_text.delete(start, end) + + # Prepare an ExpandingButton. + numoflines = self.count_lines(s) + expandingbutton = ExpandingButton(s, tag_name, numoflines, self) + + # insert the ExpandingButton to the Text + self.text.window_create(start, window=expandingbutton, + padx=3, pady=5) + + # Insert the ExpandingButton to the list of ExpandingButtons, + # while keeping the list ordered according to the position of + # the buttons in the Text widget. + i = len(self.expandingbuttons) + while i > 0 and self.text.compare(self.expandingbuttons[i-1], + ">", expandingbutton): + i -= 1 + self.expandingbuttons.insert(i, expandingbutton) + + return "break" + + +Squeezer.reload() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) + + # Add htest. diff --git a/Lib/idlelib/stackviewer.py b/Lib/idlelib/stackviewer.py new file mode 100644 index 00000000000..95042d4debd --- /dev/null +++ b/Lib/idlelib/stackviewer.py @@ -0,0 +1,134 @@ +# Rename to stackbrowser or possibly consolidate with browser. + +import linecache +import os + +import tkinter as tk + +from idlelib.debugobj import ObjectTreeItem, make_objecttreeitem +from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas + +def StackBrowser(root, exc, flist=None, top=None): + global sc, item, node # For testing. + if top is None: + top = tk.Toplevel(root) + sc = ScrolledCanvas(top, bg="white", highlightthickness=0) + sc.frame.pack(expand=1, fill="both") + item = StackTreeItem(exc, flist) + node = TreeNode(sc.canvas, None, item) + node.expand() + + +class StackTreeItem(TreeItem): + + def __init__(self, exc, flist=None): + self.flist = flist + self.stack = self.get_stack(None if exc is None else exc.__traceback__) + self.text = f"{type(exc).__name__}: {str(exc)}" + + def get_stack(self, tb): + stack = [] + if tb and tb.tb_frame is None: + tb = tb.tb_next + while tb is not None: + stack.append((tb.tb_frame, tb.tb_lineno)) + tb = tb.tb_next + return stack + + def GetText(self): # Titlecase names are overrides. + return self.text + + def GetSubList(self): + sublist = [] + for info in self.stack: + item = FrameTreeItem(info, self.flist) + sublist.append(item) + return sublist + + +class FrameTreeItem(TreeItem): + + def __init__(self, info, flist): + self.info = info + self.flist = flist + + def GetText(self): + frame, lineno = self.info + try: + modname = frame.f_globals["__name__"] + except: + modname = "?" + code = frame.f_code + filename = code.co_filename + funcname = code.co_name + sourceline = linecache.getline(filename, lineno) + sourceline = sourceline.strip() + if funcname in ("?", "", None): + item = "%s, line %d: %s" % (modname, lineno, sourceline) + else: + item = "%s.%s(...), line %d: %s" % (modname, funcname, + lineno, sourceline) + return item + + def GetSubList(self): + frame, lineno = self.info + sublist = [] + if frame.f_globals is not frame.f_locals: + item = VariablesTreeItem("", frame.f_locals, self.flist) + sublist.append(item) + item = VariablesTreeItem("", frame.f_globals, self.flist) + sublist.append(item) + return sublist + + def OnDoubleClick(self): + if self.flist: + frame, lineno = self.info + filename = frame.f_code.co_filename + if os.path.isfile(filename): + self.flist.gotofileline(filename, lineno) + + +class VariablesTreeItem(ObjectTreeItem): + + def GetText(self): + return self.labeltext + + def GetLabelText(self): + return None + + def IsExpandable(self): + return len(self.object) > 0 + + def GetSubList(self): + sublist = [] + for key in self.object.keys(): # self.object not necessarily dict. + try: + value = self.object[key] + except KeyError: + continue + def setfunction(value, key=key, object_=self.object): + object_[key] = value + item = make_objecttreeitem(key + " =", value, setfunction) + sublist.append(item) + return sublist + + +def _stackbrowser(parent): # htest # + from idlelib.pyshell import PyShellFileList + top = tk.Toplevel(parent) + top.title("Test StackViewer") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x + 50, y + 175)) + flist = PyShellFileList(top) + try: # to obtain a traceback object + intentional_name_error + except NameError as e: + StackBrowser(top, e, flist=flist, top=top) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_stackviewer', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_stackbrowser) diff --git a/Lib/idlelib/statusbar.py b/Lib/idlelib/statusbar.py new file mode 100644 index 00000000000..8445d4cc8df --- /dev/null +++ b/Lib/idlelib/statusbar.py @@ -0,0 +1,52 @@ +from tkinter.ttk import Label, Frame + + +class MultiStatusBar(Frame): + + def __init__(self, master, **kw): + Frame.__init__(self, master, **kw) + self.labels = {} + + def set_label(self, name, text='', side='left', width=0): + if name not in self.labels: + label = Label(self, borderwidth=0, anchor='w') + label.pack(side=side, pady=0, padx=4) + self.labels[name] = label + else: + label = self.labels[name] + if width != 0: + label.config(width=width) + label.config(text=text) + + +def _multistatus_bar(parent): # htest # + from tkinter import Toplevel, Text + from tkinter.ttk import Frame, Button + top = Toplevel(parent) + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" %(x, y + 175)) + top.title("Test multistatus bar") + + frame = Frame(top) + text = Text(frame, height=5, width=40) + text.pack() + msb = MultiStatusBar(frame) + msb.set_label("one", "hello") + msb.set_label("two", "world") + msb.pack(side='bottom', fill='x') + + def change(): + msb.set_label("one", "foo") + msb.set_label("two", "bar") + + button = Button(top, text="Update status", command=change) + button.pack(side='bottom') + frame.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_statusbar', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_multistatus_bar) diff --git a/Lib/idlelib/textview.py b/Lib/idlelib/textview.py new file mode 100644 index 00000000000..23f0f4cb502 --- /dev/null +++ b/Lib/idlelib/textview.py @@ -0,0 +1,193 @@ +"""Simple text browser for IDLE + +""" +from tkinter import Toplevel, Text, TclError,\ + HORIZONTAL, VERTICAL, NS, EW, NSEW, NONE, WORD, SUNKEN +from tkinter.ttk import Frame, Scrollbar, Button +from tkinter.messagebox import showerror + +from idlelib.colorizer import color_config + + +class AutoHideScrollbar(Scrollbar): + """A scrollbar that is automatically hidden when not needed. + + Only the grid geometry manager is supported. + """ + def set(self, lo, hi): + if float(lo) > 0.0 or float(hi) < 1.0: + self.grid() + else: + self.grid_remove() + super().set(lo, hi) + + def pack(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "pack"') + + def place(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "place"') + + +class ScrollableTextFrame(Frame): + """Display text with scrollbar(s).""" + + def __init__(self, master, wrap=NONE, **kwargs): + """Create a frame for Textview. + + master - master widget for this frame + wrap - type of text wrapping to use ('word', 'char' or 'none') + + All parameters except for 'wrap' are passed to Frame.__init__(). + + The Text widget is accessible via the 'text' attribute. + + Note: Changing the wrapping mode of the text widget after + instantiation is not supported. + """ + super().__init__(master, **kwargs) + + text = self.text = Text(self, wrap=wrap) + text.grid(row=0, column=0, sticky=NSEW) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # vertical scrollbar + self.yscroll = AutoHideScrollbar(self, orient=VERTICAL, + takefocus=False, + command=text.yview) + self.yscroll.grid(row=0, column=1, sticky=NS) + text['yscrollcommand'] = self.yscroll.set + + # horizontal scrollbar - only when wrap is set to NONE + if wrap == NONE: + self.xscroll = AutoHideScrollbar(self, orient=HORIZONTAL, + takefocus=False, + command=text.xview) + self.xscroll.grid(row=1, column=0, sticky=EW) + text['xscrollcommand'] = self.xscroll.set + else: + self.xscroll = None + + +class ViewFrame(Frame): + "Display TextFrame and Close button." + def __init__(self, parent, contents, wrap='word'): + """Create a frame for viewing text with a "Close" button. + + parent - parent widget for this frame + contents - text to display + wrap - type of text wrapping to use ('word', 'char' or 'none') + + The Text widget is accessible via the 'text' attribute. + """ + super().__init__(parent) + self.parent = parent + self.bind('', self.ok) + self.bind('', self.ok) + self.textframe = ScrollableTextFrame(self, relief=SUNKEN, height=700) + + text = self.text = self.textframe.text + text.insert('1.0', contents) + text.configure(wrap=wrap, highlightthickness=0, state='disabled') + color_config(text) + text.focus_set() + + self.button_ok = button_ok = Button( + self, text='Close', command=self.ok, takefocus=False) + self.textframe.pack(side='top', expand=True, fill='both') + button_ok.pack(side='bottom') + + def ok(self, event=None): + """Dismiss text viewer dialog.""" + self.parent.destroy() + + +class ViewWindow(Toplevel): + "A simple text viewer dialog for IDLE." + + def __init__(self, parent, title, contents, modal=True, wrap=WORD, + *, _htest=False, _utest=False): + """Show the given text in a scrollable window with a 'close' button. + + If modal is left True, users cannot interact with other windows + until the textview window is closed. + + parent - parent of this dialog + title - string which is title of popup dialog + contents - text to display in dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') + _htest - bool; change box location when running htest. + _utest - bool; don't wait_window when running unittest. + """ + super().__init__(parent) + self['borderwidth'] = 5 + # Place dialog below parent if running htest. + x = parent.winfo_rootx() + 10 + y = parent.winfo_rooty() + (10 if not _htest else 100) + self.geometry(f'=750x500+{x}+{y}') + + self.title(title) + self.viewframe = ViewFrame(self, contents, wrap=wrap) + self.protocol("WM_DELETE_WINDOW", self.ok) + self.button_ok = button_ok = Button(self, text='Close', + command=self.ok, takefocus=False) + self.viewframe.pack(side='top', expand=True, fill='both') + + self.is_modal = modal + if self.is_modal: + self.transient(parent) + self.grab_set() + if not _utest: + self.wait_window() + + def ok(self, event=None): + """Dismiss text viewer dialog.""" + if self.is_modal: + self.grab_release() + self.destroy() + + +def view_text(parent, title, contents, modal=True, wrap='word', _utest=False): + """Create text viewer for given text. + + parent - parent of this dialog + title - string which is the title of popup dialog + contents - text to display in this dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') + modal - controls if users can interact with other windows while this + dialog is displayed + _utest - bool; controls wait_window on unittest + """ + return ViewWindow(parent, title, contents, modal, wrap=wrap, _utest=_utest) + + +def view_file(parent, title, filename, encoding, modal=True, wrap='word', + _utest=False): + """Create text viewer for text in filename. + + Return error message if file cannot be read. Otherwise calls view_text + with contents of the file. + """ + try: + with open(filename, encoding=encoding) as file: + contents = file.read() + except OSError: + showerror(title='File Load Error', + message=f'Unable to load file {filename!r} .', + parent=parent) + except UnicodeDecodeError as err: + showerror(title='Unicode Decode Error', + message=str(err), + parent=parent) + else: + return view_text(parent, title, contents, modal, wrap=wrap, + _utest=_utest) + return None + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_textview', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(ViewWindow) diff --git a/Lib/idlelib/tooltip.py b/Lib/idlelib/tooltip.py new file mode 100644 index 00000000000..df5b1fe1dcf --- /dev/null +++ b/Lib/idlelib/tooltip.py @@ -0,0 +1,190 @@ +"""Tools for displaying tool-tips. + +This includes: + * an abstract base-class for different kinds of tooltips + * a simple text-only Tooltip class +""" +from tkinter import * + + +class TooltipBase: + """abstract base class for tooltips""" + + def __init__(self, anchor_widget): + """Create a tooltip. + + anchor_widget: the widget next to which the tooltip will be shown + + Note that a widget will only be shown when showtip() is called. + """ + self.anchor_widget = anchor_widget + self.tipwindow = None + + def __del__(self): + self.hidetip() + + def showtip(self): + """display the tooltip""" + if self.tipwindow: + return + self.tipwindow = tw = Toplevel(self.anchor_widget) + # show no border on the top level window + tw.wm_overrideredirect(1) + try: + # This command is only needed and available on Tk >= 8.4.0 for OSX. + # Without it, call tips intrude on the typing process by grabbing + # the focus. + tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, + "help", "noActivates") + except TclError: + pass + + self.position_window() + self.showcontents() + self.tipwindow.update_idletasks() # Needed on MacOS -- see #34275. + self.tipwindow.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + def position_window(self): + """(re)-set the tooltip's screen position""" + x, y = self.get_position() + root_x = self.anchor_widget.winfo_rootx() + x + root_y = self.anchor_widget.winfo_rooty() + y + self.tipwindow.wm_geometry("+%d+%d" % (root_x, root_y)) + + def get_position(self): + """choose a screen position for the tooltip""" + # The tip window must be completely outside the anchor widget; + # otherwise when the mouse enters the tip window we get + # a leave event and it disappears, and then we get an enter + # event and it reappears, and so on forever :-( + # + # Note: This is a simplistic implementation; sub-classes will likely + # want to override this. + return 20, self.anchor_widget.winfo_height() + 1 + + def showcontents(self): + """content display hook for sub-classes""" + # See ToolTip for an example + raise NotImplementedError + + def hidetip(self): + """hide the tooltip""" + # Note: This is called by __del__, so careful when overriding/extending + tw = self.tipwindow + self.tipwindow = None + if tw: + try: + tw.destroy() + except TclError: # pragma: no cover + pass + + +class OnHoverTooltipBase(TooltipBase): + """abstract base class for tooltips, with delayed on-hover display""" + + def __init__(self, anchor_widget, hover_delay=1000): + """Create a tooltip with a mouse hover delay. + + anchor_widget: the widget next to which the tooltip will be shown + hover_delay: time to delay before showing the tooltip, in milliseconds + + Note that a widget will only be shown when showtip() is called, + e.g. after hovering over the anchor widget with the mouse for enough + time. + """ + super().__init__(anchor_widget) + self.hover_delay = hover_delay + + self._after_id = None + self._id1 = self.anchor_widget.bind("", self._show_event) + self._id2 = self.anchor_widget.bind("", self._hide_event) + self._id3 = self.anchor_widget.bind("