├── .coverage
├── .github
├── FUNDING.yml
└── workflows
│ ├── coverage.yml
│ ├── docs.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── NOTICE
├── README.md
├── docs
├── CHANGELOG.md
├── CNAME
├── assets
│ ├── collection-tree.png
│ ├── compact-mode-and-themes.mp4
│ ├── contextual-help.png
│ ├── curl-export.png
│ ├── default-collection.png
│ ├── environments.mp4
│ ├── home-efficiency-video.mp4
│ ├── home-image-ad-15aug24.svg
│ ├── jump-mode.png
│ ├── request-method-dropdown.png
│ ├── scripts-tab.png
│ ├── search-requests.png
│ ├── url-autocomplete.gif
│ └── url-bar.png
├── faq.md
├── guide
│ ├── collections.md
│ ├── command_palette.md
│ ├── configuration.md
│ ├── environments.md
│ ├── external_tools.md
│ ├── help_system.md
│ ├── importing.md
│ ├── index.md
│ ├── keymap.md
│ ├── navigation.md
│ ├── requests.md
│ ├── scripting.md
│ └── themes.md
├── home-image.afdesign
├── index.md
├── overrides
│ └── home.html
├── roadmap.md
└── stylesheets
│ └── extra.css
├── mkdocs.yml
├── pyproject.toml
├── src
└── posting
│ ├── __init__.py
│ ├── __main__.py
│ ├── _start_time.py
│ ├── app.py
│ ├── auth.py
│ ├── collection.py
│ ├── commands.py
│ ├── config.py
│ ├── exit_codes.py
│ ├── files.py
│ ├── help_data.py
│ ├── help_screen.py
│ ├── highlight_url.py
│ ├── highlighters.py
│ ├── importing
│ ├── curl.py
│ ├── open_api.py
│ └── postman.py
│ ├── jump_overlay.py
│ ├── jumper.py
│ ├── locations.py
│ ├── messages.py
│ ├── posting.scss
│ ├── request_headers.py
│ ├── save_request.py
│ ├── scripts.py
│ ├── suggesters.py
│ ├── themes.py
│ ├── tuple_to_multidict.py
│ ├── types.py
│ ├── urls.py
│ ├── user_host.py
│ ├── variables.py
│ ├── version.py
│ ├── widgets
│ ├── __init__.py
│ ├── center_middle.py
│ ├── collection
│ │ ├── browser.py
│ │ └── new_request_modal.py
│ ├── confirmation.py
│ ├── datatable.py
│ ├── input.py
│ ├── key_value.py
│ ├── request
│ │ ├── __init__.py
│ │ ├── form_editor.py
│ │ ├── header_editor.py
│ │ ├── method_selection.py
│ │ ├── query_editor.py
│ │ ├── request_auth.py
│ │ ├── request_body.py
│ │ ├── request_editor.py
│ │ ├── request_metadata.py
│ │ ├── request_options.py
│ │ ├── request_scripts.py
│ │ └── url_bar.py
│ ├── response
│ │ ├── cookies_table.py
│ │ ├── response_area.py
│ │ ├── response_body.py
│ │ ├── response_headers.py
│ │ ├── response_trace.py
│ │ └── script_output.py
│ ├── rich_log.py
│ ├── select.py
│ ├── tabbed_content.py
│ ├── text_area.py
│ ├── tree.py
│ ├── variable_autocomplete.py
│ └── variable_input.py
│ ├── xresources.py
│ └── yaml.py
├── tests
├── __snapshots__
│ └── test_snapshots
│ │ ├── TestCommandPalette.test_can_run_command__hide_collection_browser.svg
│ │ ├── TestCommandPalette.test_can_type_to_filter_options.svg
│ │ ├── TestCommandPalette.test_loads_and_shows_discovery_options[compact].svg
│ │ ├── TestCommandPalette.test_loads_and_shows_discovery_options[standard].svg
│ │ ├── TestConfig.test_config.svg
│ │ ├── TestCurlExport.test_curl_export.svg
│ │ ├── TestCurlExport.test_curl_export_no_setup.svg
│ │ ├── TestCustomThemeComplex.test_highlighting_applied_from_custom_theme__json.svg
│ │ ├── TestCustomThemeComplex.test_highlighting_applied_from_custom_theme__url.svg
│ │ ├── TestCustomThemeSimple.test_theme_sensible_defaults__json.svg
│ │ ├── TestCustomThemeSimple.test_theme_sensible_defaults__url.svg
│ │ ├── TestCustomThemeSimple.test_theme_set_on_startup_and_in_command_palette.svg
│ │ ├── TestDisableRowInTable.test_disable_row_in_table.svg
│ │ ├── TestEditKeyValues.test_edit_mode_can_edit_header_keys_and_values_as_expected.svg
│ │ ├── TestEditKeyValues.test_edit_mode_displays_correctly.svg
│ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[body].svg
│ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[headers].svg
│ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[method].svg
│ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[query].svg
│ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[url].svg
│ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears.svg
│ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[enter].svg
│ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[escape].svg
│ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[tab].svg
│ │ ├── TestHeaderAutoCompletion.test_header_value_auto_completion_list_accepts_selection.svg
│ │ ├── TestHeaderAutoCompletion.test_header_value_auto_completion_list_appears.svg
│ │ ├── TestHelpScreen.test_help_screen_appears.svg
│ │ ├── TestJumpMode.test_click_switch.svg
│ │ ├── TestJumpMode.test_focus_switch.svg
│ │ ├── TestJumpMode.test_loads[compact].svg
│ │ ├── TestJumpMode.test_loads[standard].svg
│ │ ├── TestLoadingRequest.test_request_loaded_into_view__auth.svg
│ │ ├── TestLoadingRequest.test_request_loaded_into_view__body.svg
│ │ ├── TestLoadingRequest.test_request_loaded_into_view__headers.svg
│ │ ├── TestLoadingRequest.test_request_loaded_into_view__options.svg
│ │ ├── TestLoadingRequest.test_request_loaded_into_view__query_params.svg
│ │ ├── TestMethodSelection.test_select_post_method.svg
│ │ ├── TestNewRequest.test_cannot_create_request_invalid_filename.svg
│ │ ├── TestNewRequest.test_cannot_create_request_without_name.svg
│ │ ├── TestNewRequest.test_cannot_supply_invalid_path_in_collection.svg
│ │ ├── TestNewRequest.test_dialog_loads_and_can_be_used.svg
│ │ ├── TestNewRequest.test_new_request_added_to_tree_correctly_and_notification_shown.svg
│ │ ├── TestSave.test_no_request_selected__dialog_is_prefilled_correctly.svg
│ │ ├── TestScripts.test_script_runs.svg
│ │ ├── TestSendRequest.test_send_request[compact].svg
│ │ ├── TestSendRequest.test_send_request[standard].svg
│ │ ├── TestUrlBar.test_dropdown_appears_on_typing.svg
│ │ ├── TestUrlBar.test_dropdown_completion_selected_via_enter_key.svg
│ │ ├── TestUrlBar.test_dropdown_completion_selected_via_tab_key.svg
│ │ ├── TestUrlBar.test_dropdown_filters_on_typing.svg
│ │ ├── TestUserInterfaceShortcuts.test_expand_request_section.svg
│ │ ├── TestUserInterfaceShortcuts.test_expand_then_reset.svg
│ │ ├── TestUserInterfaceShortcuts.test_hide_collection_browser.svg
│ │ ├── TestVariables.test_resolved_variables_highlight_and_preview[compact].svg
│ │ ├── TestVariables.test_resolved_variables_highlight_and_preview[standard].svg
│ │ └── TestVariables.test_unresolved_variables_highlighted.svg
├── posting_snapshot_app.py
├── resources
│ └── snapshot_report_template.jinja2
├── sample-collections
│ ├── echo-post-01.posting.yaml
│ ├── echo.posting.yaml
│ ├── get-random-user.posting.yaml
│ ├── jsonplaceholder
│ │ ├── posts
│ │ │ ├── comments
│ │ │ │ ├── edit.posting.yaml
│ │ │ │ ├── get-comments-query.posting.yaml
│ │ │ │ └── get-comments.posting.yaml
│ │ │ ├── create.posting.yaml
│ │ │ ├── delete.posting.yaml
│ │ │ ├── get-all.posting.yaml
│ │ │ └── get-one.posting.yaml
│ │ ├── todos
│ │ │ ├── get-all.posting.yaml
│ │ │ └── get-one.posting.yaml
│ │ └── users
│ │ │ ├── create.posting.yaml
│ │ │ ├── delete.posting.yaml
│ │ │ ├── get-all.posting.yaml
│ │ │ ├── get-one.posting.yaml
│ │ │ └── update.posting.yaml
│ └── scripts
│ │ └── my_script.py
├── sample-configs
│ ├── custom_theme.yaml
│ ├── custom_theme2.yaml
│ ├── general.yaml
│ └── modified_config.yaml
├── sample-envs
│ ├── sample_base.env
│ └── sample_extra.env
├── sample-importable-collections
│ ├── Fixer.postman_collection.json
│ ├── postman_collection.json
│ └── test-postman-collection.json
├── sample-themes
│ ├── another_test.yml
│ └── serene_ocean.yaml
├── test_curl_export.py
├── test_curl_import.py
├── test_files.py
├── test_open_api_import.py
├── test_postman_import.py
├── test_snapshots.py
├── test_urls.py
└── test_variables.py
└── uv.lock
/.coverage:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/.coverage
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [darrenburns] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
15 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | # This is a follow up job that runs after the CI job has completed.
2 | # It'll post a code coverage comment on pull requests.
3 | name: Post Coverage Comment
4 |
5 | on:
6 | workflow_run:
7 | workflows: ["Continuous Integration"]
8 | types:
9 | - completed
10 |
11 | jobs:
12 | test:
13 | name: Post Coverage Comment
14 | runs-on: ubuntu-latest
15 | if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
16 | permissions:
17 | pull-requests: write
18 | contents: write
19 | actions: read
20 | steps:
21 | # !!! DO NOT run actions/checkout here, for security reasons !!!
22 | # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
23 | - name: Post comment
24 | uses: py-cov-action/python-coverage-comment-action@v3
25 | with:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: docs
2 | on:
3 | push:
4 | branches:
5 | - main
6 | permissions:
7 | contents: write
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Configure Git Credentials
14 | run: |
15 | git config user.name github-actions[bot]
16 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com
17 | - uses: actions/setup-python@v5
18 | with:
19 | python-version: 3.x
20 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
21 | - uses: actions/cache@v4
22 | with:
23 | key: mkdocs-material-${{ env.cache_id }}
24 | path: .cache
25 | restore-keys: |
26 | mkdocs-material-
27 | - run: pip install mkdocs-material
28 | - run: mkdocs gh-deploy --force
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - "main"
8 |
9 | env:
10 | PYTEST_ADDOPTS: "--color=yes"
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | contents: write
17 | pull-requests: write
18 | steps:
19 | - uses: actions/checkout@v4
20 | - name: Install a specific version of uv
21 | uses: astral-sh/setup-uv@v5
22 | with:
23 | version: "0.6.3"
24 | enable-cache: true
25 | - run: uv python install # Will read from .python-version
26 | - name: Install Dependencies
27 | run: uv sync --all-extras --dev
28 | - name: Run Tests
29 | run: |
30 | uv run make test-ci
31 | - name: Attach Code Coverage
32 | uses: py-cov-action/python-coverage-comment-action@v3.25
33 | with:
34 | GITHUB_TOKEN: ${{ github.token }}
35 |
36 | - name: Save Snapshot Report
37 | if: always()
38 | uses: actions/upload-artifact@v4
39 | with:
40 | name: snapshot-report-posting
41 | path: snapshot_report.html
42 |
43 | - name: Store Pull Request Comment
44 | uses: actions/upload-artifact@v4
45 | if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true'
46 | with:
47 | # If you use a different name, update COMMENT_ARTIFACT_NAME accordingly
48 | name: python-coverage-comment-action
49 | # If you use a different name, update COMMENT_FILENAME accordingly
50 | path: python-coverage-comment-action.txt
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # python generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 |
9 | # venv
10 | .venv
11 |
12 | # editor
13 | .vscode/
14 | .idea/
15 | pyrightconfig.json
16 |
17 | snapshot_report.html
18 | *.afdesign
19 | *.afdesign*
20 | *.afphoto
21 | *.afphoto*
22 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.7
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Posting
2 |
3 | Posting is open to contributions! 🚀
4 |
5 | Contributions of any type, no matter the "size" and form, are welcome!
6 | Bug reports, typo fixes, ideas, questions, and code are all valuable ways of contributing.
7 | If you're new to open source and would like a bit of extra guidance, don't be afraid to ask - we all start somewhere 😌
8 |
9 | ## How do I contribute?
10 |
11 | You can suggest ideas by creating a discussion, or report bugs by creating an issue.
12 |
13 | If you wish to contribute code, and it's a change which is not an "objective improvement", please open a discussion first.
14 | An "objective improvement" is a change which _indisputably_ improves Posting.
15 | Some examples include adding test coverage, performance improvements, bug fixes, and fixing clear inconsistencies.
16 |
17 | By opening a discussion, we can make sure:
18 |
19 | - You're not working on something that someone else has already started.
20 | - The feature is within Posting's scope.
21 | - We can iron out the details before you go and commit a bunch of time to it!
22 | - Maintainers can give you tips, and the discussion will be a place you can ask for help and guidance.
23 |
24 | ## Development
25 |
26 | We use [uv](https://docs.astral.sh/uv/getting-started/installation/) to manage the development dependencies.
27 |
28 | To setup an environment for development, run:
29 |
30 | ```bash
31 | uv sync
32 | ```
33 |
34 | This will create a virtual environment with the dependencies installed.
35 | Activate the virutal environment with:
36 |
37 | ```bash
38 | source .venv/bin/activate
39 | # or, with fish shell:
40 | source .venv/bin/activate.fish
41 | ```
42 |
43 | Then you can run Posting with:
44 |
45 | ```bash
46 | posting
47 | ```
48 |
49 | If you wish to connect to the Textual dev tools (strongly recommended), you can run `posting` like this:
50 |
51 | ```bash
52 | TEXTUAL=devtools,debug posting
53 | ```
54 |
55 | The repo includes a test collection which is used in the automated tests, but you can also load it in for manual testing.
56 |
57 | ```bash
58 | TEXTUAL=devtools,debug posting --collection tests/sample-collections/ --env tests/sample-envs/sample_base.env --env tests/sample-envs/sample_extra.env
59 | ```
60 |
61 | ### Running the tests
62 |
63 | To run the tests, you MUST use the `Makefile` commands.
64 |
65 | ```bash
66 | make test
67 | ```
68 |
69 | This will run the tests in parallel, but isolate a few tests which need to be run serially.
70 | If you try to run the tests using a raw `pytest` command, you're gonna have a bad time.
71 |
72 | ### Snapshot testing
73 |
74 | Snapshot testing is the primary way we test the UI of Posting.
75 |
76 | It works by taking a "screenshot" of the running app at (actually an SVG!) the end of the test, and comparing it to the previous screenshot for that test.
77 | If it matches, the test passes. If it doesn't, the test fails and you'll be able to view a report which shows the differences.
78 |
79 | It's your job to look at this diff and consider whether the new output (on the left) is correct or not.
80 |
81 | If the results are all as expected, you can update the snapshots by running:
82 |
83 | ```bash
84 | make test-snapshot-update
85 | ```
86 |
87 | This will update the snapshots saved on disk for all the tests which failed.
88 | You should commit these changes into the repo - they're essentially the "source of truth" for what the UI of Posting should look like under different circumstances.
89 |
90 | ### Update the changelog
91 |
92 | A changelog is maintained in the `docs/CHANGELOG.md` file, which follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
93 |
94 | When you're making a change which should be recorded in the changelog.
95 | You should add your change to the `## Unreleased` section of the changelog.
96 | If the `## Unreleased` section doesn't exist, you should add it at the top.
97 |
98 | ## Feeling unsure?
99 |
100 | If you're feeling a bit stuck, just open a discussion and I'll do my best to help you out!
101 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | .PHONY: test
3 | test:
4 | $(run) pytest --cov=posting tests/ -n 24 -m "not serial" $(ARGS)
5 | $(run) pytest --cov-report term-missing --cov-append --cov=posting tests/ -m serial $(ARGS)
6 |
7 | .PHONY: test-snapshot-update
8 | test-snapshot-update:
9 | $(run) pytest --cov=posting tests/ -n 24 -m "not serial" --snapshot-update $(ARGS)
10 | $(run) pytest --cov-report term-missing --cov-append --cov=posting tests/ -m serial --snapshot-update $(ARGS)
11 |
12 |
13 | .PHONY: test-ci
14 | test-ci:
15 | $(run) pytest --cov=posting tests/ --cov-report term-missing $(ARGS)
16 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | This product includes software developed by [Encode OSS Ltd.](https://github.com/encode), under the BSD-3-Clause License:
2 |
3 | BSD 3-Clause License
4 |
5 | Copyright © 2019, Encode OSS Ltd. All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
8 |
9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
10 |
11 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
12 |
13 | Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
14 |
15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Posting
2 |
3 | **A powerful HTTP client that lives in your terminal.**
4 |
5 | Posting is an HTTP client, not unlike Postman and Insomnia. As a TUI application, it can be used over SSH and enables efficient keyboard-centric workflows. Your requests are stored locally in simple YAML files, so they're easy to read and version control.
6 |
7 |
8 |
9 | Some notable features include:
10 |
11 | - "jump mode" navigation
12 | - environments/variables
13 | - autocompletion
14 | - syntax highlighting using tree-sitter
15 | - Vim keys
16 | - customizable keybindings
17 | - user-defined themes
18 | - run Python code before and after requests
19 | - extensive configuration
20 | - open in $EDITOR/$PAGER
21 | - import curl commands by pasting them into the URL bar
22 | - export requests as cURL commands
23 | - import from Postman and OpenAPI specs
24 | - a command palette for quickly accessing functionality
25 |
26 | Visit the [website](https://posting.sh) for more information, the roadmap, and the user guide.
27 |
28 | ## Installation
29 |
30 | Posting can be installed via [uv](https://docs.astral.sh/uv/getting-started/installation/) on MacOS, Linux, and Windows.
31 |
32 | ```bash
33 | # quickly install uv on MacOS/Linux
34 | curl -LsSf https://astral.sh/uv/install.sh | sh
35 |
36 | # install Posting (will also quickly install Python 3.13 if needed)
37 | uv tool install --python 3.13 posting
38 | ```
39 |
40 | Now you can run Posting via the command line:
41 |
42 | ```bash
43 | posting
44 | ```
45 |
46 | Homebrew and NixOS are not officially supported at the moment.
47 |
48 | ### Prefer `pipx`?
49 |
50 | If you'd prefer to use `pipx`, that works too: `pipx install posting`.
51 |
52 | ## Learn More
53 |
54 | Learn more about Posting at [https://posting.sh](https://posting.sh).
55 |
56 | Posting was built with [Textual](https://github.com/textualize/textual).
57 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | POSTING.SH
--------------------------------------------------------------------------------
/docs/assets/collection-tree.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/collection-tree.png
--------------------------------------------------------------------------------
/docs/assets/compact-mode-and-themes.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/compact-mode-and-themes.mp4
--------------------------------------------------------------------------------
/docs/assets/contextual-help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/contextual-help.png
--------------------------------------------------------------------------------
/docs/assets/curl-export.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/curl-export.png
--------------------------------------------------------------------------------
/docs/assets/default-collection.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/default-collection.png
--------------------------------------------------------------------------------
/docs/assets/environments.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/environments.mp4
--------------------------------------------------------------------------------
/docs/assets/home-efficiency-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/home-efficiency-video.mp4
--------------------------------------------------------------------------------
/docs/assets/jump-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/jump-mode.png
--------------------------------------------------------------------------------
/docs/assets/request-method-dropdown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/request-method-dropdown.png
--------------------------------------------------------------------------------
/docs/assets/scripts-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/scripts-tab.png
--------------------------------------------------------------------------------
/docs/assets/search-requests.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/search-requests.png
--------------------------------------------------------------------------------
/docs/assets/url-autocomplete.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/url-autocomplete.gif
--------------------------------------------------------------------------------
/docs/assets/url-bar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/url-bar.png
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | # Frequently Asked Questions
2 |
3 | ## Using Posting
4 |
5 | ### How do I edit headers or query parameters?
6 |
7 | Right now, you need to delete the row and re-create it with the correct values. Inline editing is planned, but not yet implemented.
8 |
9 | ## Contributing
10 |
11 | ### How do I suggest a feature?
12 |
13 | You can suggest a feature by opening a Discussion on the [GitHub repository](https://github.com/darrenburns/posting/discussions) under the "Ideas" category.
14 |
15 | ### How do I report a bug?
16 |
17 | You can report a bug by opening an Issue on the [GitHub repository](https://github.com/darrenburns/posting/issues).
18 |
19 | ### How do I contribute code to Posting?
20 |
21 | You can contribute code to Posting by opening a Pull Request on the [GitHub repository](https://github.com/darrenburns/posting/pulls).
22 |
23 | However, reporting bugs and suggesting features is also a great way to contribute!
24 |
25 | A guide to contributing is coming soon.
26 |
27 | ## General
28 |
29 | ### How was Posting built?
30 |
31 | Posting is built using [Textual](https://textual.textualize.io/), a Python framework for building terminal-based applications.
32 |
33 | ### Who is the original creator of Posting?
34 |
35 | Posting was originally created by [Darren Burns](https://github.com/darrenburns), an open-source developer from Scotland, UK.
--------------------------------------------------------------------------------
/docs/guide/collections.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | A *collection* is just a directory on your file system which may or may not contain requests in the `.posting.yaml` format.
4 |
5 | There's absolutely nothing special about a collection.
6 | It contains no "special files" or metadata -- it's just a directory.
7 | It could even be empty.
8 | "Collection" is simply the name we give to the directory which we've loaded into Posting.
9 |
10 | ## The collection browser
11 |
12 | Posting displays the currently open collection in the sidebar.
13 | This is called the *collection browser*.
14 |
15 | { height=300px }
16 |
17 | The name of the currently open collection is displayed in the bottom right corner of the collection browser.
18 | In the example above, the collection is named "sample-collection".
19 |
20 | You can navigate this sidebar using the keyboard or mouse.
21 | Open a request by clicking on it or pressing ++enter++ while it has focus,
22 | and it'll be loaded into the main body of the UI.
23 | A marker will also appear to the left of the request's title, indicating that the request is open.
24 | A save operation will overwrite the currently open request.
25 |
26 | !!! example "Keyboard shortcuts"
27 |
28 | The collection browser supports various keyboard shortcuts for quick navigation. For example ++shift+j++ and ++shift+k++ can be used to jump through sub-collections.
29 | Press ++f1++ while the browser has focus to view the full list of shortcuts.
30 |
31 |
32 | The collection browser can be moved to the left or right side of the screen by setting the `collection_browser.position` configuration option
33 | to either `"left"` or `"right"`.
34 |
35 | ## The default collection
36 |
37 | If you launch Posting without a `--collection` argument, it will load the *default collection*, which is stored in Posting's reserved data directory on your file system.
38 |
39 | The default collection can be thought of as a *system wide collection*.
40 | It's a place to keep useful requests that you can easily access from anywhere, without having to manually specify a `--collection` argument.
41 |
42 | You can check where this is by running `posting locate collection`.
43 | The default collection is named "default", that name will be displayed in the bottom right corner of the collection browser.
44 |
45 | 
46 |
47 | This is useful to get started quickly, but you'll probably want to create your own collection directory and load it instead.
48 | This makes it easier to organize your requests and check them into version control.
49 |
50 | ## Creating a collection
51 |
52 | A collection is just a directory, so you can create a collection by simply creating an empty directory anywhere on your file system.
53 |
54 | With the directory created, it's time to load it into Posting...
55 |
56 | ## Loading a collection
57 |
58 | If you want to load a collection, you can do so by passing the path to the collection directory to Posting:
59 |
60 | ```bash
61 | posting --collection path/to/collection
62 | ```
63 |
64 | ### Example
65 |
66 | To open a collection (a directory containing requests), use the `--collection` option:
67 |
68 | ```bash
69 | posting --collection path/to/collection
70 | ```
71 |
72 | This will recursively find and display requests in the sidebar.
73 | If you don't supply a directory, Posting will use the default collection directory.
74 | You can check where the default collection is by running `posting locate collection`.
--------------------------------------------------------------------------------
/docs/guide/command_palette.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | The *command palette* is a way to search for and execute commands in Posting.
4 |
5 | Some functionality in Posting can only be accessed through the command palette.
6 |
7 | It can be used to switch themes, show/hide parts of the UI, and more.
8 |
9 | ### Using the command palette
10 |
11 | Press ++ctrl+p++ to open the command palette.
12 |
--------------------------------------------------------------------------------
/docs/guide/environments.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | You can use *variables* in input fields and text areas using the `${VARIABLE_NAME}` or `$VARIABLE_NAME` syntax.
4 | These variables will be substituted into outgoing requests.
5 |
6 |
7 |
8 |
9 |
10 | ## Loading variables
11 |
12 | Variables are stored in `.env` files, and loaded using the `--env` option.
13 |
14 | Here's what a `.env` file might look like:
15 |
16 | ```bash
17 | # file: dev.env
18 | API_KEY="dev-api-key"
19 | ENV_NAME="dev"
20 | BASE_URL="https://${ENV_NAME}.example.com"
21 | ```
22 |
23 | To make these variables available in the UI, you can load them using the `--env` option:
24 |
25 | ```bash
26 | posting --env dev.env
27 | ```
28 |
29 | You can load multiple `.env` files by specifying the `--env` option multiple times:
30 |
31 | ```bash
32 | posting --env dev.env --env shared.env
33 | ```
34 |
35 | This allows you to build up a set of variables which are common to all environments, and then override them for specific environments.
36 |
37 | ## Autoloading `.env` files
38 |
39 | If no `--env` options are provided, Posting will automatically load a `.env` file in the current directory if it exists.
40 |
41 | ## Using environment variables
42 |
43 | By default, Posting will only use variables defined in `.env` files that have been explicitly loaded using the `--env` option.
44 |
45 | If you want to permit using environment variables that exist on the host machine (i.e. those which are not defined in any `.env` files), you must set the `use_host_environment` config option to `true` (or set the environment variable `POSTING_USE_HOST_ENVIRONMENT=true`).
46 |
47 | ## Practical example
48 |
49 | Imagine you're testing an API which exists in both `dev` and `prod` environments.
50 |
51 | The `dev` and `prod` environments share some common variables, but differ in many ways too.
52 | We can model this by having a single `shared.env` file which contains variables which are shared between environments, and then a `dev.env` and `prod.env` file which contain environment specific variables.
53 |
54 | ```bash
55 | # file: shared.env
56 | API_PATH="/api/v1"
57 | ENV_NAME="shared"
58 |
59 | # file: dev.env
60 | API_KEY="dev-api-key"
61 | ENV_NAME="dev"
62 | BASE_URL="https://${ENV_NAME}.example.com"
63 |
64 | # file: prod.env
65 | API_KEY="prod-api-key"
66 | ENV_NAME="prod"
67 | BASE_URL="https://${ENV_NAME}.example.com"
68 | ```
69 |
70 | When working in the `dev` environment, you can then load all of the shared variables and all of the development environment specific variables using the `--env` option:
71 |
72 | ```bash
73 | posting --env shared.env --env dev.env
74 | ```
75 |
76 | This will load all of the shared variables from `shared.env`, and then load the variables from `dev.env`. Since `ENV_NAME` appears in both files, the value from the `dev.env` file will be used since that was the last one specified.
77 |
78 | Note that you do *not* need to restart to load changes made to these files,
79 | so you can open and edit your env files in an editor of your choice alongside Posting.
80 |
81 | ### Environment specific config
82 |
83 | Since all Posting configuration options can also be specified as environment variables, we can also put environment specific config inside `.env` files. There's a dedicated "Configuration" section in this document which covers this in more detail.
84 |
85 | For example, if you wanted to use a light theme in the prod environment (as a subtle reminder that you're in production!), you could set the environment variable `POSTING_THEME=solarized-light` inside the `prod.env` file.
86 |
87 | Note that configuration files take precedence over environment variables, so if you set a value in both a `.env` file and a `config.yaml`, the value from the `config.yaml` file will be used.
88 |
--------------------------------------------------------------------------------
/docs/guide/external_tools.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | You can quickly switch between Posting and external editors and pagers.
4 |
5 | For example, you could edit request bodies in `vim`, and then browse the JSON response body in `less` or `fx`.
6 |
7 | You can even configure a custom pager specifically for browsing JSON.
8 |
9 | ## External Editors
10 |
11 | With a multi-line text area focused, press ++f4++ to open the file in your
12 | configured external editor.
13 |
14 | The configured external editor can be set as `editor` in your `config.yaml`
15 | file.
16 | For example:
17 |
18 | ```yaml title="config.yaml"
19 | editor: vim
20 | ```
21 |
22 | Alternatively, you can set the `POSTING_EDITOR` environment variable.
23 |
24 | ```bash
25 | export POSTING_EDITOR=vim
26 | ```
27 |
28 | If neither is set, Posting will try to use the `EDITOR` environment variable.
29 |
30 | ## External Pagers
31 |
32 | With a multi-line text area focused, press ++f3++ to open the file in your
33 | configured external pager.
34 |
35 | The configured external pager can be set as `pager` in your `config.yaml`
36 | file.
37 | For example:
38 |
39 | ```yaml title="config.yaml"
40 | pager: less
41 | ```
42 |
43 | Alternatively, you can set the `POSTING_PAGER` environment variable.
44 |
45 | ```bash
46 | export POSTING_PAGER=less
47 | ```
48 |
49 | ### JSON Pager
50 |
51 | You can use a custom pager for viewing JSON using the `pager_json` setting in
52 | your `config.yaml` file.
53 | For example:
54 |
55 | ```yaml title="config.yaml"
56 | pager_json: fx
57 | ```
58 |
59 | Alternatively, you can set the `POSTING_PAGER_JSON` environment variable.
60 |
61 | ```bash
62 | export POSTING_PAGER_JSON=fx
63 | ```
64 |
65 | If neither is set, Posting will try to use the default pager lookup rules discussed earlier.
66 |
67 | ## Exporting to curl
68 |
69 | > *Added in Posting 2.4.0*
70 |
71 | Open the command palette and select `export: copy as curl`.
72 | This will transform the open request into a cURL command, and copy it to your clipboard.
73 |
74 | 
75 |
76 | You can optionally supply extra arguments to pass to curl by setting the `curl_export_extra_args` setting in your `config.yaml` file.
77 |
78 | ```yaml title="config.yaml"
79 | curl_export_extra_args: "--verbose -w %{time_total} %{http_code}"
80 | ```
81 |
82 | This will be inserted directly into the command that gets copied to your clipboard, immediately after `curl `,
83 | producing a command like the following:
84 |
85 | ```bash
86 | curl --verbose -w %{time_total} %{http_code} -X POST ...
87 | ```
88 |
89 |
--------------------------------------------------------------------------------
/docs/guide/help_system.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Posting has a *built-in help system*, which can be used to get information about the currently focused widget.
4 |
5 | ### Getting help for the focused widget
6 |
7 | With a widget focused, press `f1` to open a help window for that widget.
8 |
9 |
10 |
11 | Most widgets offer more keybindings and functionality than meets the eye, and more than what is shown in the application footer.
12 |
13 | The help window explains how to use the focused widget, and lists all of the keybindings offered by it.
--------------------------------------------------------------------------------
/docs/guide/importing.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Posting supports importing from external sources.
4 |
5 | ## Importing from curl
6 |
7 | !!! example "This feature is experimental."
8 |
9 | You can import a curl command by pasting it into the URL bar.
10 |
11 | This will fill out the request details in the UI based on the curl command you pasted, overwriting any existing values.
12 |
13 | ## Importing from OpenAPI
14 |
15 | !!! example "This feature is experimental."
16 |
17 | Posting can convert OpenAPI 3.x specs into collections.
18 |
19 | To import an OpenAPI Specification, use the `posting import path/to/openapi.yaml` command.
20 |
21 | You can optionally supply an output directory.
22 |
23 | If no output directory is supplied, the default collection directory will be used.
24 |
25 | Posting will attempt to build a file structure in the collection that aligns with the URL structure of the imported API.
26 |
27 | ## Importing from Postman
28 |
29 | !!! example "This feature is experimental."
30 |
31 | Collections can be imported from Postman.
32 |
33 | To import a Postman collection, use the `posting import --type postman path/to/postman_collection.json` command.
34 |
35 | You can optionally supply an output directory with the `-o` option.
36 | If no output directory is supplied, the default collection directory will be used (check where this is using `posting locate collection`).
37 |
38 | Variables will also be imported from the Postman collection and placed in a `.env` file inside the collection directory.
39 |
--------------------------------------------------------------------------------
/docs/guide/keymap.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | As explained in the [Help System](./help_system.md) section, you can view the keybindings for any widget by pressing ++f1++ or ++ctrl+question-mark++ when that widget has focus.
4 |
5 | If you wish to use different keybindings, you can do so by editing the `keymap` section of your `config.yaml` file.
6 | Check the location of that file on your system by running `posting locate config` on the command line.
7 |
8 | ### Changing the keymap
9 |
10 | Actions in Posting have unique IDs which map to a keybinding (listed at the bottom of this page).
11 | For any of these IDs, you can change the keybinding by adding an entry to the `keymap` section of your `config.yaml` file:
12 |
13 | ```yaml
14 | keymap:
15 | :
16 | ```
17 |
18 | Here's an example of changing the keybinding for the "Send Request" action:
19 |
20 | ```yaml
21 | keymap:
22 | send-request: ctrl+r
23 | ```
24 |
25 | After adding the above entry to `config.yaml` and restarting Posting, you'll notice that that the footer of the app now shows `^r` to send a request rather than the default `^j`.
26 |
27 | Now you can press `^r` to send a request *instead of* `^j`.
28 |
29 | You can also have multiple keys map to the same action by separating them with commas:
30 |
31 | ```yaml
32 | keymap:
33 | send-request: ctrl+r,ctrl+i
34 | ```
35 |
36 | Note that by adding an entry to the `keymap` you are overriding the default keybinding for that action, so if you wish to keep the default keybinding, you'll need to specify it again:
37 |
38 | ```yaml
39 | keymap:
40 | send-request: ctrl+r,ctrl+i,ctrl+j
41 | ```
42 |
43 | ### Key format
44 |
45 | Support for keys in the terminal varies between terminals, multiplexers and operating systems.
46 | It's a complex topic, and one that may involve some trial and error.
47 | Some keys might be intercepted before reaching Posting, and your emulator might not support certain keys.
48 |
49 | - To specify ++ctrl+x++, use `ctrl+x`.
50 | - To specify ++ctrl+shift+x++, use `ctrl+X` (control plus uppercase "X").
51 | - To specify multiple keys, separate them with commas: `ctrl+shift+left,ctrl+y`.
52 | - To specify a function key, use `f`. For example, ++f1++ would be `f1`.
53 | - To specify `@` (at) use `at` (*not* e.g. ++shift+2++ as this only applies to some keyboard layouts).
54 | - Arrow keys can be specified as `left`, `right`, `up` and `down`.
55 | - `shift` works as a modifier non-printable keys e.g. `shift+backspace`, `shift+enter`, `shift+right` are all acceptable. Support may vary depending on your emulator.
56 | - `alt` also works as a modifier e.g. `alt+enter`.
57 | - `ctrl+enter`, `alt+enter`,`ctrl+backspace`, `ctrl+shift+enter`, `ctrl+shift+space` etc. are supported if your terminal supports the Kitty keyboard protocol.
58 | - Other keys include (but are not limited to) `comma`, `full_stop`, `colon`, `semicolon`, `quotation_mark`, `apostrophe`, `left_bracket`, `right_square_bracket`, `left_square_bracket`, `backslash`, `vertical_line` (pipe |), `plus`, `minus`, `equals_sign`, `slash`, `asterisk`,`tilde`, `percent_sign`.
59 |
60 | The only way to know for sure which keys are supported in your particular terminal emulator is to install Textual, run `textual keys`, press the key you want to use, and look at the `key` field of the printed output.
61 |
62 | !!! example "Work in progress"
63 | In the future, I hope to make it easier to discover which keys are supported and when key presses they correspond to for a particular environment directly within Posting. This will likely take the form of a CLI command that outputs key names and their corresponding key presses. For now, if you need assistance, please open a discussion on [GitHub](https://github.com/darrenburns/posting/discussions).
64 |
65 | ### Binding IDs
66 |
67 | These are the IDs of the actions that you can change the keybinding for:
68 |
69 | - `send-request` - Send the current request. Default: `ctrl+j,alt+enter`.
70 | - `focus-method` - Focus the method selector. Default: `ctrl+t`.
71 | - `focus-url` - Focus the URL input. Default: `ctrl+l`.
72 | - `save-request` - Save the current request. Default: `ctrl+s`.
73 | - `expand-section` - Expand or shrink the section which has focus. Default: `ctrl+m`.
74 | - `toggle-collection` - Toggle the collection browser. Default: `ctrl+h`.
75 | - `new-request` - Create a new request. Default: `ctrl+n`.
76 | - `commands` - Open the command palette. Default: `ctrl+p`.
77 | - `help` - Open the help dialog for the currently focused widget. Default: `f1,ctrl+question_mark`.
78 | - `quit` - Quit the application. Default: `ctrl+c`.
79 | - `jump` - Enter jump mode. Default: `ctrl+o`.
80 | - `open-in-pager` - Open the content of the focused text area in your $PAGER/$POSTING_PAGER/$POSTING_PAGER_JSON. Default: `f3`.
81 | - `open-in-editor` - Open the content of the focused text area in your $EDITOR/$POSTING_EDITOR. Default: `f4`.
82 | - `search-requests` - Go to a request by name. Default: `ctrl+shift+p`.
83 |
--------------------------------------------------------------------------------
/docs/guide/navigation.md:
--------------------------------------------------------------------------------
1 | Posting can be navigated using either mouse or keyboard.
2 |
3 | ## Jump mode
4 |
5 | Jump mode is the fastest way to get around.
6 |
7 | Press ctrl+o to enter jump mode, followed by the key corresponding to the widget you want to switch focus to (jump to).
8 |
9 | 
10 |
11 | With the default layout, the positioning of keys on the overlays is similar to the positioning of the keys on a QWERTY keyboard.
12 |
13 | To exit jump mode, press esc.
14 |
15 | ## Tab navigation
16 |
17 | tab and shift+tab will move focus between widgets,
18 | and j/k/up/down will move around within a widget.
19 |
20 | Some widgets have additional keybindings for navigation.
21 | You can check these by pressing f1 while it is focused.
22 |
23 | Where it makes sense, up and down will also move between widgets.
24 |
25 | ## Mouse navigation
26 |
27 | You can also navigate Posting entirely using the mouse, very much like a typical GUI application.
28 |
29 | If a widget shows a scrollbar, you can use the mouse wheel or trackpad gestures to scroll through its content.
30 | Scrollbars can also be clicked and dragged.
31 |
32 | If you hold shift and scroll using the trackpad or mousewheel, the content will scroll horizontally (if there's a horizontal scrollbar).
33 |
34 | ## Searching and jumping to requests
35 |
36 | Press ctrl+shift+p to open the fuzzy search popup (configurable using the `search-requests` keybinding, see [keymap](./keymap.md)).
37 |
38 | Type the name of the request you want to jump to and press enter to open it.
39 |
40 | 
41 |
42 | ## Contextual help
43 |
44 | Many widgets have additional bindings for navigation other than those displayed in the footer.
45 | You can view the full list of keybindings for the currently focused widget, as well as additional usage information and tips, by pressing f1 or ctrl+? (or ctrl+shift+/).
46 |
47 | 
48 |
49 | ## Automatic focus switching
50 |
51 | You can use the `focus.on_startup` and `focus.on_response` configuration options to control which widget is focused when the app starts and when a response is received.
52 |
53 | | Config | Default value | Description |
54 | |----------------------|---------------|-------------|
55 | | `focus.on_startup` | `"url"`, `"method", "collection"` (Default: `"url"`) | Automatically focus the URL bar, method, or collection browser when the app starts. |
56 | | `focus.on_response` | `"body"`, `"tabs"` (Default: `unset`)| Automatically focus the response tabs or response body text area when a response is received. |
57 | | `focus.on_request_open` | `"headers"`, `"body"`, `"query"`, `"info"`, `"url"`, `"method"` (Default: `unset`) | Automatically focus the specified target when a request is opened from the collection browser. |
58 |
59 | ## Exiting
60 |
61 | Quit Posting by pressing ctrl+c, or by opening the command palette and selecting "Quit".
--------------------------------------------------------------------------------
/docs/guide/requests.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Requests are stored directly on your file system as simple YAML files, suffixed with `.posting.yaml` - easy to read, understand, and version control!
4 |
5 | ## Example
6 |
7 | Here's an example of what a request file looks like:
8 |
9 | ```yaml
10 | name: Create user
11 | description: Adds a new user to the system.
12 | method: POST
13 | url: https://jsonplaceholder.typicode.com/users
14 | body:
15 | content: |-
16 | {
17 | "firstName": "John",
18 | "email": "john.doe@example.com"
19 | }
20 | headers:
21 | - name: Content-Type
22 | value: application/json
23 | params:
24 | - name: sendWelcomeEmail
25 | value: 'true'
26 | ```
27 |
28 | ## Creating a new request
29 |
30 | Press ++ctrl+n++ to create a new request.
31 |
32 | You'll be prompted to supply a name for the request.
33 | By default, this name is used to generate the filename, but you can also choose your own filename if you wish.
34 |
35 | !!! tip
36 | If you already have a collection loaded, the path in the "New Request" dialog will be pre-filled based on the position of the cursor in the collection tree, so moving the cursor to the correct location *before* pressing ++ctrl+n++ will save you from needing to type out the path.
37 |
38 | Within the "Path in collection" field of this dialog, it's important to note that `.` refers to the currently loaded *collection* directory (that is, the directory that was loaded using the `--collection` option), and *not* necessarily the current working directory.
39 |
40 | ### Duplicating a request
41 |
42 | With a the cursor over a request in the collection tree, press ++d++ to create a duplicate of that request. This will bring up a dialog allowing you to change the name and description of the request, or move it to another location.
43 |
44 | To skip the dialog and quickly duplicate the request, press ++shift+d++, creating it as a sibling of the original request. The file name of the new request will be generated automatically. You can always modify the name and description after it's created in the `Info` tab.
45 |
46 | ## Saving a request
47 |
48 | Press ++ctrl+s++ to save the currently open request.
49 |
50 | If you haven't saved the request yet, a dialog will appear, prompting you to give the request a name, and to select a directory to save it in.
51 |
52 | !!! tip "Folders"
53 |
54 | Requests can be saved to folders - simply include a `/` in the `Path in collection` field when you save the request,
55 | and Posting will create the required directory structure for you.
56 |
57 | If the request is already saved on disk, ++ctrl+s++ will overwrite the previous version with your new changes.
58 |
59 | ## Loading requests
60 |
61 | Requests are stored on your file system as simple YAML files, suffixed with `.posting.yaml`.
62 |
63 | A directory can be loaded into Posting using the `--collection` option, and all `.posting.yaml` files in that directory will be displayed in the sidebar.
64 |
65 | ## Deleting a request
66 |
67 | You can delete a request by moving the cursor over it in the tree, and pressing ++backspace++.
68 |
--------------------------------------------------------------------------------
/docs/guide/scripting.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | You can attach simple Python scripts to requests inside the `Scripts` tab, and have Posting run them at various stages of the request lifecycle. This powerful feature allows you to:
4 |
5 | - Perform setup before a request (e.g. setting variables, preparing data)
6 | - Set or modify headers, query parameters, and other request properties
7 | - Print logs and messages
8 | - Set variables to be used in later requests (e.g. authentication tokens)
9 | - Inspect request and response objects, and manipulate them
10 | - Pretty much anything else you can think of doing with Python!
11 |
12 |
13 |
14 | ## Script types
15 |
16 | Posting supports three types of scripts, which run at different points in the request/response lifecycle:
17 |
18 | 1. **Setup Scripts**: Runs before the request is constructed. This is useful for setting initial variables which may be substituted into the request.
19 | 2. **Pre-request Scripts**: Runs after the request has been constructed and variables have been substituted, but before the request is sent. You can directly modify the request object here.
20 | 3. **Post-response Scripts**: Runs after the response is received. This is useful for extracting data from the response, or for performing cleanup.
21 |
22 | ## Writing scripts
23 |
24 | In the context of Posting, a "script" is a regular Python function.
25 |
26 | By default, if you specify a path to a Python file, Posting will look for and execute the following functions at the appropriate times:
27 |
28 | - `setup(posting: Posting) -> None`
29 | - `on_request(request: RequestModel, posting: Posting) -> None`
30 | - `on_response(response: httpx.Response, posting: Posting) -> None`
31 |
32 | However, you can have Posting call any function you wish using the syntax `path/to/script.py:function_to_run`.
33 |
34 | Note that relative paths are relative to the collection directory.
35 | This ensures that if you place scripts inside your collection directory,
36 | they're included when you share a collection with others.
37 |
38 | Note that you do not need to specify all of the arguments when writing these functions. Posting will only pass the number of arguments that you've specified when it calls your function. For example, you could define a your `on_request` function as `def on_request(request: RequestModel) -> None` and Posting would call it with `on_request(request: RequestModel)` without passing the `posting` argument.
39 |
40 | ## Editing scripts
41 |
42 | When you edit a script, it'll automatically be reloaded.
43 | This means you can keep Posting open while editing it.
44 |
45 | Posting also allows you to quickly jump to your editor (assuming you've set the `$EDITOR` or `$POSTING_EDITOR` environment variables) to edit a script.
46 | Press ++ctrl+e++ while a script input field inside the `Scripts` tab is focused to open the path in your editor.
47 |
48 | !!! warning
49 | As of version 2.0.0, the script file must exist *before* pressing ++ctrl+e++. Posting will not create the file for you.
50 |
51 | ## Script logs
52 |
53 | If your script writes to `stdout` or `stderr`, you'll see the output in the `Scripts` tab in the Response section.
54 | This output is not persisted on disk.
55 |
56 | ### Example: Setup script
57 |
58 | The **setup script** is run before the request is built.
59 | You can set variables in the setup script that can be used in the request.
60 | For example, you could use `httpx` to fetch an access token, then set the token as a variable so that it may be substituted into the request.
61 |
62 | ```python
63 | def setup(posting: Posting) -> None:
64 | # Set a variable which may be used in this request
65 | # (or other requests to follow)
66 | posting.set_variable("auth_token", "1234567890")
67 | ```
68 |
69 | With this setup script attached to a request, you can then reference the `auth_token` variable in the request UI by typing `$auth_token`.
70 | The `$auth_token` variable will remain for the duration of the session,
71 | so you may wish to add a check to see if it has already been set in this session:
72 |
73 | ```python
74 | def setup(posting: Posting) -> None:
75 | if not posting.get_variable("auth_token"):
76 | posting.set_variable("auth_token", "1234567890")
77 | ```
78 |
79 | ### Example: Pre-request script
80 |
81 | The **pre-request script** is run after the request has been constructed and variables have been substituted, right before the request is sent.
82 |
83 | You can directly modify the `RequestModel` object in this function, for example to set headers, query parameters, etc.
84 | The code snippet below shows some of the API.
85 |
86 | ```python
87 | from posting import Auth, Header, RequestModel, Posting
88 |
89 |
90 | def on_request(request: RequestModel, posting: Posting) -> None:
91 | # Add a custom header to the request.
92 | request.headers.append(Header(name="X-Custom-Header", value="foo"))
93 |
94 | # Set auth on the request.
95 | request.auth = Auth.basic_auth("username", "password")
96 | # request.auth = Auth.digest_auth("username", "password")
97 | # request.auth = Auth.bearer_token_auth("token")
98 |
99 | # This will be captured and written to the log.
100 | print("Request is being sent!")
101 |
102 | # Make a notification pop-up in the UI.
103 | posting.notify("Request is being sent!")
104 | ```
105 |
106 | ### Example: Post-response script
107 |
108 | The **post-response script** is run after the response is received.
109 | You can use this to extract data from the response, for example a JWT token,
110 | and set it as a variable to be used in later requests.
111 |
112 | ```python
113 | from posting import Posting
114 |
115 |
116 | def on_response(response: httpx.Response, posting: Posting) -> None:
117 | # Print the status code of the response to the log.
118 | print(response.status_code)
119 |
120 | # Set a variable to be used in later requests.
121 | # You can write '$auth_token' in the UI and it will be substituted with
122 | # the value of the $auth_token variable.
123 | posting.set_variable("auth_token", response.headers["Authorization"])
124 | ```
125 |
126 | ### The `Posting` object
127 |
128 | The `Posting` object provides access to the application context and useful methods:
129 |
130 | - `set_variable(name: str, value: object) -> None`: Set a session variable
131 | - `get_variable(name: str, default: object | None = None) -> object | None`: Get a session variable
132 | - `clear_variable(name: str) -> None`: Clear a specific session variable
133 | - `clear_all_variables() -> None`: Clear all session variables
134 | - `notify(message: str, title: str = "", severity: str = "information", timeout: float | None = None)`: Send a notification to the user
135 |
136 | Note that variables are described as "session variables" because they persist for the duration of the session (until you close Posting).
137 |
138 | ### Execution environment
139 |
140 | Scripts run in the same process and environment as Posting, so you should take care to avoid performing damaging global operations such as monkey-patching standard library modules.
141 |
142 | #### Libraries
143 |
144 | You can make use of any library that is available in the Python environment that Posting is running in. This means you can use all of the Python standard library as well as any of Posting's dependencies (such as `httpx`, `pyyaml`, `pydantic`, etc).
145 |
146 | If you install Posting with `uv`, you can easily add extra dependencies which you can then use in your scripts:
147 |
148 | ```bash
149 | uv tool install posting --with
150 | ```
151 |
--------------------------------------------------------------------------------
/docs/guide/themes.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Posting ships with several built-in themes, and also supports custom, user-made themes.
4 |
5 | When editing a theme on disk, Posting can show a live preview of the theme in effect, making it easy to design and test themes.
6 |
7 | ### Creating a theme
8 |
9 | You can check where Posting will look for user-defined themes by running `posting locate themes` in your terminal.
10 | Place custom themes in this directory and Posting will load them on startup.
11 | Theme files must be suffixed with `.yaml`, but the rest of the filename is unused by Posting.
12 | Built-in themes are *not* in this directory, but are part of the Posting code itself.
13 |
14 | Here's an example theme file:
15 |
16 | ```yaml
17 | name: example # use this name in your config file
18 | primary: '#4e78c4' # buttons, fixed table columns
19 | secondary: '#f39c12' # method selector, some minor labels
20 | accent: '#e74c3c' # header text, scrollbars, cursors, focus highlights
21 | background: '#0e1726' # background colors
22 | surface: '#17202a' # panels, etc
23 | error: '#e74c3c' # error messages
24 | success: '#2ecc71' # success messages
25 | warning: '#f1c40f' # warning messages
26 |
27 | # Optional metadata
28 | author: Darren Burns
29 | description: A dark theme with a blue primary color.
30 | homepage: https://github.com/darrenburns/posting
31 | ```
32 |
33 | After adding a theme, you'll need to restart Posting for it to take effect.
34 |
35 | To use the theme, you can specify it in your `config.yaml` file:
36 |
37 | ```yaml
38 | theme: example
39 | ```
40 |
41 | Note that the theme name is *not* defined by the filename, but by the `name` field in the theme file.
42 |
43 | !!! tip
44 |
45 | If you edit a theme on disk while Posting is using it, the UI will automatically
46 | refresh to reflect the changes you've made. This is enabled by default, but if you'd
47 | like to disable it, you can set `watch_themes` to `false` in your `config.yaml`.
48 |
49 | #### Syntax highlighting
50 |
51 | Syntax highlighted elements such as the URL bar, text areas, and fields which contain variables will be colored based on the semantic colors defined in the theme (`primary`, `secondary`, etc) by default.
52 |
53 | If you'd like more control over the syntax highlighting, you can specify a custom syntax highlighting colors inside the theme file.
54 |
55 | The example below illustrates some of the options available when it comes to customizing syntax highlighting.
56 |
57 | ```yaml
58 | text_area:
59 | cursor: 'reverse' # style the block cursor
60 | cursor_line: 'underline' # style the line the cursor is on
61 | selection: 'reverse' # style the selected text
62 | gutter: 'bold #50e3c2' # style the gutter
63 | matched_bracket: 'black on green' # style the matched bracket
64 | url:
65 | base: 'italic #50e3c2' # style the 'base' of the url
66 | protocol: 'bold #b8e986' # style the protocol
67 | syntax:
68 | json_key: 'italic #4a90e2' # style json keys
69 | json_number: '#50e3c2' # style json numbers
70 | json_string: '#b8e986' # style json strings
71 | json_boolean: '#b8e986' # style json booleans
72 | json_null: 'underline #b8e986' # style json null values
73 | ```
74 |
75 | #### Method styles
76 |
77 | You can also specify custom styles for methods in the collection tree.
78 |
79 | Here's an example:
80 |
81 | ```yaml
82 | method:
83 | get: 'underline #50e3c2'
84 | post: 'italic #b8e986'
85 | put: 'bold #b8e986'
86 | delete: 'strikethrough #b8e986'
87 | ```
88 |
89 | ### X resources themes
90 |
91 | Posting supports using X resources for theming. To use this, enable the `use_xresources` option (see above).
92 |
93 | It requires the `xrdb` executable on your `PATH` and `xrdb -query` must return the following variables:
94 |
95 | | Xresources | Description |
96 | |-------------|-----------|
97 | | *color0 | primary color: used for button backgrounds and fixed table columns |
98 | | *color8 | secondary color: used in method selector and some minor labels |
99 | | *color1 | error color: used for error messages |
100 | | *color2 | success color: used for success messages |
101 | | *color3 | warning color: used for warning messages |
102 | | *color4 | accent color: used for header text, scrollbars, cursors, focus highlights |
103 | | *background | background color |
104 | | *color7 | surface/panel color |
105 |
106 | If these conditions are met, themes called `xresources-dark` and `xresources-light` will be available for use.
107 |
--------------------------------------------------------------------------------
/docs/home-image.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/home-image.afdesign
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: The API client that lives in your terminal
3 | template: home.html
4 | ---
5 |
--------------------------------------------------------------------------------
/docs/roadmap.md:
--------------------------------------------------------------------------------
1 | ## About this document
2 |
3 | If you have any feedback or suggestions, please open a [new discussion on GitHub](https://github.com/darrenburns/posting/discussions/). This roadmap is driven by community requests, so please open a discussion if you'd like to see something added.
4 |
5 |
46 |
47 | ## Ongoing 🔄
48 |
49 | Features that are currently being worked on.
50 |
51 | - Add contributing guide Documentation
52 |
53 | ## Upcoming 🚀
54 |
55 | Features planned for the near future.
56 |
57 | - Editing key/value editor rows without having to delete/re-add them UX
58 | - Adjustable padding in UI via config file UI
59 | - Don't require user to type `http://` or `https://` in URL field UX
60 | - Documentation on changing the UI at runtime (e.g. showing/hiding sections, etc.) Documentation
61 | - Documentation on using 3rd party libraries in scripts Documentation
62 | - Transparent background support (experimentation) UI
63 | - In-app information about headers Documentation
64 | - A better footer UX UI
65 | - The footer currently contains too many bindings. There should be a way to show that it is scrollable, possibly showing grouping of keybindings.
66 |
67 | ## Longer Term 🔮
68 |
69 | Features that are planned for future development but are not immediate priorities.
70 |
71 | - Directional navigation UI UX
72 | - Jump mode 2-stage jump - if you press shift+[jump target key], then it'll jump to the target and then show a secondary overlay of available targets within that section UX
73 | - Manually resize sections (sidebar, request, response) UI
74 | - Searching in responses (this will likely be simpler with upcoming Textual changes) Requests
75 | - File watcher so that if the request changes on disk then the UI updates to reflect it Requests
76 | - Translating to other languages Documentation
77 | - I'd like to support e.g. Chinese, but need to investigate how that would render with double width characters in the terminal.
78 | - Warning when switching request when there are unsaved changes UX
79 | - Request tagging: the ability to add tags to requests, and filter by tag Requests
80 | - Making it clear which HTTP headers are set automatically UX
81 | - Collection switcher Collection
82 | - Environment switcher Environment
83 | - Viewing the currently loaded environment keys/values in a popup Environment
84 | - Changing the environment at runtime via command palette Environment
85 | - WebSocket and SSE support Realtime
86 | - Quickly open MDN links for headers UI
87 | - Add rotating logging Logging
88 | - Variable completion autocompletion in TextAreas Environment
89 | - Variable resolution highlighting in TextAreas Environment
90 | - Status bar? Showing the currently selected env, collection, current path, whether there's unsaved changes, etc. UI
91 | - Highlighting variables in *tables* to show if they've resolved or not Environment
92 | - Create a `_template.posting.yaml` file for request templates Requests
93 | - OAuth2 implementation (need to scope out what's involved) Auth
94 | - Adding test framework Testing
95 | - Uploading files Requests
96 | - Cookie editor Requests
97 | - Import from Postman (PR is open, needs further work) Import
98 |
99 | ## Completed ✓
100 |
101 | Features that have been implemented and are available in the current version.
102 |
103 | - Keymaps UI
104 | - Pre-request and post-response scripts Scripting
105 | - Parse cURL commands Import
106 | - Watching environment files for changes & updating the UI Environment
107 | - Bearer token auth Auth
108 | - Add "quit" to command palette and footer UX
109 | - More user friendly errors UX
110 | - Duplicate request from the tree Collection
111 | - Quickly duplicate request from the tree Collection
112 | - Colour-coding for request types (i.e. GET is green, POST is blue, etc.) UI
113 | - Delete request from the tree Collection
114 | - Inserting into the collection tree in sorted order, not at the bottom Collection
115 | - External documentation Documentation
116 | - Enabling and disabling rows in tables UX
117 | - Custom themes, loaded from theme directory UI
118 | - Dynamic in-app help system Documentation
119 | - Specify certificate path via config or CLI Security
120 |
121 |
122 | ## Legend
123 |
124 | The following tags are used to categorize features:
125 |
126 |
127 |
User Interface improvements
UI
128 |
Collection management
Collection
129 |
Environment handling
Environment
130 |
Authentication methods
Auth
131 |
Import capabilities
Import
132 |
Scripting capabilities
Scripting
133 |
Documentation
Documentation
134 |
135 |
136 |
Testing capabilities
Testing
137 |
Security features
Security
138 |
Logging capabilities
Logging
139 |
--------------------------------------------------------------------------------
/docs/stylesheets/extra.css:
--------------------------------------------------------------------------------
1 | h1, h2, h3, h4, h5, h6 {
2 | font-family: "Roboto Mono", monospace;
3 | font-weight: 700;
4 | font-style: normal;
5 | font-optical-sizing: auto;
6 | color: #f0f0f0 !important;
7 | }
8 |
9 | body {
10 | background-image: linear-gradient(
11 | 0deg,
12 | hsl(272deg 33% 11%) 0%,
13 | hsl(276deg 30% 9%) 20%,
14 | hsl(279deg 29% 7%) 40%,
15 | hsl(282deg 24% 4%) 60%,
16 | hsl(313deg 30% 7%) 80%,
17 | hsl(311, 35%, 12%) 90%,
18 | hsl(307, 58%, 22%) 100%
19 | );
20 | background-repeat: no-repeat;
21 | background-attachment: fixed;
22 | background-size: auto;
23 |
24 | }
25 |
26 | code {
27 | background: hsla(287deg, 100%, 10%, 0.4) !important;
28 | border: 1px solid hsl(287deg 100% 30%) !important;
29 | }
30 |
31 | table {
32 | background: hsla(287deg, 100%, 10%, 0.4) !important;
33 | border: 1px solid hsl(287deg 100% 30%) !important;
34 | }
35 |
36 | span.filename {
37 | background: hsla(287deg, 100%, 10%, 0.4) !important;
38 | border: 1px solid hsl(287deg 100% 30%) !important;
39 | border-bottom: none !important;
40 | color: hsla(286, 42%, 73%, 0.526) !important;
41 | }
42 |
43 | .md-nav__link {
44 | background-color: transparent !important;
45 | box-shadow: none !important;
46 | }
47 | .md-nav__link--active {
48 | color: hsl(287, 72%, 66%) !important;
49 | }
50 |
51 | nav.md-nav.md-nav--secondary {
52 | border-left-color: hsl(287, 72%, 66%) !important;
53 | }
54 |
55 | nav.md-tabs {
56 | background: transparent !important;
57 | box-shadow: none !important;
58 | box-sizing: content-box;
59 | max-width: 800px;
60 | margin: 0 auto;
61 |
62 | & .md-tabs__link {
63 | color: #f0f0f0 !important;
64 | opacity: 0.93;
65 | font-size: 0.83rem;
66 | position: relative;
67 | text-decoration: none;
68 | }
69 |
70 | & .md-tabs__link::after {
71 | content: '';
72 | position: absolute;
73 | width: 0;
74 | height: 2px;
75 | bottom: -2px;
76 | left: 50%;
77 | background-color: hsl(287, 72%, 66%);
78 | transition: width 0.16s ease-out, left 0.16s ease-out;
79 | }
80 |
81 | & .md-tabs__link:hover::after {
82 | width: 100%;
83 | left: 0;
84 | }
85 |
86 | & .md-tabs__item--active {
87 | font-weight: bold;
88 | a {
89 | text-decoration: none;
90 | position: relative;
91 | }
92 | a::after {
93 | content: '';
94 | position: absolute;
95 | width: 100%;
96 | height: 2px;
97 | bottom: -2px;
98 | left: 0;
99 | background-color: hsl(287, 72%, 66%);
100 | transition: all 0.16s ease-in-out;
101 | }
102 | a:hover::after {
103 | height: 3px;
104 | box-shadow: 0 0 5px hsl(287, 72%, 66%);
105 | }
106 | }
107 | }
108 | .md-header{position:initial;background-color: rgba(0,0,0,0);}
109 | .md-header--shadow {
110 | box-shadow: none;
111 | }
112 | .md-content {
113 | background-color: rgba(0,0,0,0);
114 | padding-bottom: 2.5rem;
115 | }
116 | .md-nav__title {
117 | box-shadow: none !important;
118 | background-color: transparent !important;
119 | }
120 |
121 | article p {
122 | color: #f0f0f0f0;
123 | }
124 |
125 | p a {
126 | color: hsl(287, 72%, 66%) !important;
127 | }
128 |
129 | a:hover {
130 | color: hsl(287, 72%, 66%) !important;
131 | }
132 |
133 | a.posting-button {
134 | color: whitesmoke;
135 | padding: 0.7rem 1.4rem;
136 | background-color: hsl(287deg 100% 10%);
137 | border: 3px solid hsl(287deg 100% 30%);
138 | border-radius: 2px;
139 | font-family: "Roboto Mono", monospace;
140 |
141 | &:hover {
142 | color: whitesmoke;
143 | font-weight: 600;
144 | border: 3px solid rgba(255, 100, 203, 1);
145 | }
146 | }
147 |
148 | .monospace {
149 | font-family: "Roboto Mono", monospace;
150 | }
151 |
152 | .md-search__output mark {
153 | color: hsl(287, 72%, 66%);
154 | }
155 |
156 | .md-search-result__more div {
157 | color: hsl(287, 72%, 66%) !important;
158 | opacity: 0.8;
159 | }
160 |
161 | article {
162 | max-width: 840px;
163 | margin: 0 auto !important;
164 | padding: 0 1rem;
165 |
166 | & p mark {
167 | background-color: rgba(255, 100, 203, 0.25) !important;
168 | }
169 | }
170 |
171 | footer {
172 | & div.md-ellipsis {
173 | font-family: "Roboto Mono", monospace;
174 | }
175 | }
176 |
177 | @media (min-width: 1220px) {
178 | article {
179 | margin: 0 0 !important;
180 | }
181 | }
182 |
183 | :root {
184 |
185 | }
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Posting
2 | site_url: https://posting.sh
3 | repo_url: https://github.com/darrenburns/posting
4 | repo_name: darrenburns/posting
5 | theme:
6 | name: material
7 | palette:
8 | scheme: slate
9 | primary: custom
10 | custom_dir: docs/overrides
11 | icon:
12 | logo: fontawesome/solid/paper-plane
13 | font:
14 | text: Open Sans
15 | features:
16 | - search.suggest
17 | - search.highlight
18 | - header.autohide
19 | - navigation.tabs
20 | - navigation.tabs.sticky
21 | - navigation.footer
22 | - toc.integrate
23 | extra_css:
24 | - stylesheets/extra.css
25 | nav:
26 | - Home: "index.md"
27 | - Guide:
28 | - "Getting Started": "guide/index.md"
29 | - "Navigation": "guide/navigation.md"
30 | - "Collections": "guide/collections.md"
31 | - "Requests": "guide/requests.md"
32 | - "Configuration": "guide/configuration.md"
33 | - "Environments": "guide/environments.md"
34 | - "Command Palette": "guide/command_palette.md"
35 | - "Themes": "guide/themes.md"
36 | - "External Tools": "guide/external_tools.md"
37 | - "Keymaps": "guide/keymap.md"
38 | - "Importing": "guide/importing.md"
39 | - "Scripting": "guide/scripting.md"
40 | - "Help System": "guide/help_system.md"
41 | - Roadmap: "roadmap.md"
42 | - Changelog: "CHANGELOG.md"
43 | - FAQ: "faq.md"
44 | plugins:
45 | - search
46 | - tags
47 | markdown_extensions:
48 | - abbr
49 | - admonition
50 | - attr_list
51 | - def_list
52 | - toc:
53 | permalink: true
54 | - footnotes
55 | - tables
56 | - pymdownx.keys
57 | - pymdownx.highlight:
58 | use_pygments: true
59 | - pymdownx.superfences
60 | - pymdownx.details
61 | extra:
62 | social:
63 | - icon: fontawesome/brands/mastodon
64 | link: https://fosstodon.org/@darrenburns
65 | - icon: fontawesome/brands/x-twitter
66 | link: https://x.com/_darrenburns
67 | - icon: fontawesome/brands/github
68 | link: https://github.com/darrenburns
69 | copyright: Copyright © 2024 Darren Burns
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "posting"
3 | version = "2.7.0"
4 | description = "The modern API client that lives in your terminal."
5 | authors = [
6 | { name = "Darren Burns", email = "darrenb900@gmail.com" }
7 | ]
8 | dependencies = [
9 | "click>=8.1.7,<9.0.0",
10 | "xdg-base-dirs>=6.0.1,<7.0.0",
11 | "click-default-group>=1.2.4,<2.0.0",
12 | "httpx[brotli]==0.28.1",
13 | # pinned httpx automatically since we monkeypatch _main.py
14 | "openapi-pydantic>=0.5.0",
15 | "pyperclip>=1.9.0,<2.0.0",
16 | "pydantic>=2.9.2,<3.0.0",
17 | "pyyaml>=6.0.2,<7.0.0",
18 | "pydantic-settings>=2.4.0,<3.0.0",
19 | "python-dotenv>=1.0.1,<2.0.0",
20 | # pinned intentionally
21 | "watchfiles>=0.24.0",
22 | "textual[syntax]==3.0.0",
23 | "textual-autocomplete>=4.0.4,<5.0.0",
24 | ]
25 | readme = "README.md"
26 | requires-python = ">= 3.11"
27 | license = { file = "LICENSE" }
28 | keywords = ["tui", "http", "client", "terminal", "api", "testing", "textual", "cli", "posting", "developer-tool"]
29 | classifiers = [
30 | "Development Status :: 5 - Production/Stable",
31 | "Environment :: Console",
32 | "Intended Audience :: Developers",
33 | "License :: OSI Approved :: Apache Software License",
34 | "Programming Language :: Python :: 3",
35 | "Programming Language :: Python :: 3.11",
36 | "Programming Language :: Python :: 3.12",
37 | ]
38 |
39 | [project.urls]
40 | Homepage = "https://github.com/darrenburns/posting"
41 | Repository = "https://github.com/darrenburns/posting"
42 | Issues = "https://github.com/darrenburns/posting/issues"
43 | Documentation = "https://github.com/darrenburns/posting/blob/main/README.md"
44 |
45 | [build-system]
46 | requires = ["hatchling"]
47 | build-backend = "hatchling.build"
48 |
49 | [project.scripts]
50 | posting = "posting.__main__:cli"
51 |
52 | [tool.uv]
53 | dev-dependencies = [
54 | "textual-dev>=1.5.1",
55 | "pytest>=8.3.1",
56 | "jinja2>=3.1.4",
57 | "syrupy>=4.6.1",
58 | "pytest-xdist>=3.6.1",
59 | "pytest-cov>=5.0.0",
60 | "pytest-textual-snapshot>=1.0.0",
61 | "mkdocs-material>=9.5.30",
62 | "pyinstrument>=5.0.1",
63 | ]
64 |
65 | [tool.hatch.metadata]
66 | allow-direct-references = true
67 |
68 | [tool.hatch.build.targets.wheel]
69 | packages = ["src/posting"]
70 |
71 | [tool.pytest.ini_options]
72 | markers = [
73 | "serial", # used to indicate tests must not run in parallel
74 | ]
75 |
76 | [tool.coverage.run]
77 | relative_files = true
78 |
--------------------------------------------------------------------------------
/src/posting/__init__.py:
--------------------------------------------------------------------------------
1 | # This import should be the first thing to run, to ensure that
2 | # the START_TIME is set as early as possible.
3 | from posting._start_time import START_TIME # type: ignore # noqa: F401
4 |
5 |
6 | # This is a hack to prevent httpx from importing _main.py
7 | import sys
8 | sys.modules['httpx._main'] = None
9 |
10 | from .collection import (
11 | Auth,
12 | Cookie,
13 | Header,
14 | QueryParam,
15 | RequestBody,
16 | RequestModel,
17 | FormItem,
18 | Options,
19 | Scripts,
20 | )
21 | from .scripts import Posting
22 |
23 |
24 | __all__ = [
25 | "Auth",
26 | "Cookie",
27 | "Header",
28 | "QueryParam",
29 | "RequestBody",
30 | "RequestModel",
31 | "FormItem",
32 | "Options",
33 | "Scripts",
34 | "Posting",
35 | ]
36 |
--------------------------------------------------------------------------------
/src/posting/__main__.py:
--------------------------------------------------------------------------------
1 | """The main entry point for the Posting CLI."""
2 |
3 | from pathlib import Path
4 | import click
5 | import os
6 |
7 | from click_default_group import DefaultGroup
8 | from rich.console import Console
9 |
10 | from posting.app import Posting
11 | from posting.collection import Collection
12 | from posting.config import Settings
13 | from posting.locations import (
14 | config_file,
15 | default_collection_directory,
16 | theme_directory,
17 | )
18 | from posting.variables import load_variables
19 |
20 |
21 | def create_config_file() -> None:
22 | f = config_file()
23 | if f.exists():
24 | return
25 |
26 | try:
27 | f.touch()
28 | except OSError:
29 | pass
30 |
31 |
32 | def create_default_collection() -> Path:
33 | f = default_collection_directory()
34 | if f.exists():
35 | return f
36 |
37 | try:
38 | f.mkdir(parents=True)
39 | except OSError:
40 | pass
41 |
42 | return f
43 |
44 |
45 | @click.group(cls=DefaultGroup, default="default", default_if_no_args=True)
46 | def cli() -> None:
47 | """A TUI for testing HTTP APIs."""
48 |
49 |
50 | @cli.command()
51 | @click.option(
52 | "--collection",
53 | "-c",
54 | type=click.Path(exists=True),
55 | help="Path to the collection directory",
56 | )
57 | @click.option(
58 | "--env",
59 | "-e",
60 | type=click.Path(exists=True),
61 | help="Path to the .env environment file(s)",
62 | multiple=True,
63 | )
64 | def default(collection: str | None = None, env: tuple[str, ...] = ()) -> None:
65 | create_config_file()
66 | default_collection = create_default_collection()
67 | collection_path = Path(collection) if collection else default_collection
68 | app = make_posting(
69 | collection=collection_path,
70 | env=env,
71 | using_default_collection=collection is None,
72 | )
73 | app.run()
74 |
75 |
76 | @cli.command()
77 | @click.argument(
78 | "thing_to_locate",
79 | )
80 | def locate(thing_to_locate: str) -> None:
81 | if thing_to_locate == "config":
82 | print("Config file:")
83 | print(config_file())
84 | elif thing_to_locate == "collection":
85 | print("Default collection directory:")
86 | print(default_collection_directory())
87 | elif thing_to_locate == "themes":
88 | print("Themes directory:")
89 | print(theme_directory())
90 | else:
91 | # This shouldn't happen because the type annotation should enforce that
92 | # the only valid options are "config" and "collection".
93 | print(f"Unknown thing to locate: {thing_to_locate!r}")
94 |
95 |
96 | @cli.command(name="import")
97 | @click.argument("spec_path", type=click.Path(exists=True))
98 | @click.option(
99 | "--output",
100 | "-o",
101 | type=click.Path(),
102 | help="Path to save the imported collection",
103 | default=None,
104 | )
105 | @click.option(
106 | "--type", "-t", default="openapi", help="Specify spec type [openapi, postman]"
107 | )
108 | def import_spec(spec_path: str, output: str | None, type: str) -> None:
109 | """Import an OpenAPI specification into a Posting collection."""
110 | console = Console()
111 | console.print(
112 | "Importing is currently an experimental feature.", style="bold yellow"
113 | )
114 |
115 | output_path = None
116 | if output:
117 | output_path = Path(output)
118 |
119 | try:
120 | if type.lower() == "openapi":
121 | # We defer this import as it takes 64ms on an M4 MacBook Pro,
122 | # and is only needed for a single CLI command - not for the main TUI.
123 | from posting.importing.open_api import import_openapi_spec
124 |
125 | spec_type = "OpenAPI"
126 | collection = import_openapi_spec(spec_path)
127 | if output_path is None:
128 | output_path = (
129 | Path(default_collection_directory()) / f"{collection.name}"
130 | )
131 |
132 | output_path.mkdir(parents=True, exist_ok=True)
133 | collection.path = output_path
134 | collection.save_to_disk(output_path)
135 | elif type.lower() == "postman":
136 | from posting.importing.postman import import_postman_spec, create_env_file
137 |
138 | spec_type = "Postman"
139 | collection, postman_collection = import_postman_spec(spec_path, output)
140 | if output_path is None:
141 | output_path = (
142 | Path(default_collection_directory()) / f"{collection.name}"
143 | )
144 |
145 | output_path.mkdir(parents=True, exist_ok=True)
146 | collection.path = output_path
147 | collection.save_to_disk(output_path)
148 |
149 | # Create the environment file in the collection directory.
150 | env_file = create_env_file(
151 | output_path, f"{collection.name}.env", postman_collection.variable
152 | )
153 | console.print(f"Created environment file {str(env_file)!r}.")
154 | else:
155 | console.print(f"Unknown spec type: {type!r}", style="red")
156 | return
157 |
158 | console.print(
159 | f"Successfully imported {spec_type!r} spec to {str(output_path)!r}"
160 | )
161 | except Exception:
162 | console.print("An error occurred during the import process.", style="red")
163 | console.print(
164 | "Ensure you're importing the correct type of collection.", style="red"
165 | )
166 | console.print(
167 | "For Postman collections, use `posting import --type postman `",
168 | style="red",
169 | )
170 | console.print(
171 | "No luck? Please include the traceback below in your issue report.",
172 | style="red",
173 | )
174 | console.print_exception()
175 |
176 |
177 | @cli.command(name="sponsors")
178 | def sponsors() -> None:
179 | """Show the list of sponsors."""
180 | print("Thanks to everyone below who contributed to the development of Posting 🚀\n")
181 |
182 | # Sponsors are added to the list below, name on the left, description on the right
183 | sponsors = [
184 | ("Michael Howard", "https://github.com/elithper"),
185 | ]
186 | for sponsor in sponsors:
187 | print(f"{sponsor[0]} - {sponsor[1]}")
188 |
189 | print()
190 | print("If you'd like to sponsor the development of Posting, please visit:")
191 | print("https://github.com/sponsors/darrenburns")
192 |
193 |
194 | def make_posting(
195 | collection: Path,
196 | env: tuple[str, ...] = (),
197 | using_default_collection: bool = False,
198 | ) -> Posting:
199 | """Return a Posting instance with the given collection and environment."""
200 | collection_tree = Collection.from_directory(str(collection.resolve()))
201 |
202 | # if env empty then load current directory posting.env file if it exists
203 | if not env and os.path.exists("posting.env"):
204 | env = ("posting.env",)
205 |
206 | env_paths = tuple(Path(e).resolve() for e in env)
207 | settings = Settings(_env_file=env_paths) # type: ignore[call-arg]
208 | load_variables(env_paths, settings.use_host_environment)
209 |
210 | return Posting(settings, env_paths, collection_tree, not using_default_collection)
211 |
212 |
213 | if __name__ == "__main__":
214 | cli()
215 |
--------------------------------------------------------------------------------
/src/posting/_start_time.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 |
4 | START_TIME = time.perf_counter_ns()
5 |
--------------------------------------------------------------------------------
/src/posting/auth.py:
--------------------------------------------------------------------------------
1 | from typing import Generator
2 |
3 | import httpx
4 |
5 |
6 | class HttpxBearerTokenAuth(httpx.Auth):
7 | def __init__(self, token: str):
8 | self.token = token
9 |
10 | def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
11 | request.headers["Authorization"] = f"Bearer {self.token}"
12 | yield request
13 |
--------------------------------------------------------------------------------
/src/posting/exit_codes.py:
--------------------------------------------------------------------------------
1 | GENERAL_ERROR = 1
2 |
--------------------------------------------------------------------------------
/src/posting/files.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from posting.save_request import FILE_SUFFIX
4 |
5 |
6 | import os
7 | import re
8 |
9 |
10 | def is_valid_filename(filename: str) -> bool:
11 | # Check if the filename is empty or None
12 | if not filename or filename.strip() == "":
13 | return False
14 |
15 | # Ensure the filename doesn't contain path separators
16 | if os.path.sep in filename or (os.path.altsep and os.path.altsep in filename):
17 | return False
18 |
19 | # Check if the filename is too long (255 characters is a common limit)
20 | if len(filename) > 255:
21 | return False
22 |
23 | # Check for reserved names (Windows)
24 | reserved_names = [
25 | "CON",
26 | "PRN",
27 | "AUX",
28 | "NUL",
29 | "COM1",
30 | "COM2",
31 | "COM3",
32 | "COM4",
33 | "COM5",
34 | "COM6",
35 | "COM7",
36 | "COM8",
37 | "COM9",
38 | "LPT1",
39 | "LPT2",
40 | "LPT3",
41 | "LPT4",
42 | "LPT5",
43 | "LPT6",
44 | "LPT7",
45 | "LPT8",
46 | "LPT9",
47 | ]
48 | name_without_ext = os.path.splitext(filename)[0].upper()
49 | if name_without_ext in reserved_names:
50 | return False
51 |
52 | if (
53 | re.search(r"\.\.", filename)
54 | or filename.startswith(".")
55 | or filename.endswith(".")
56 | ):
57 | return False
58 |
59 | return True
60 |
61 |
62 | def get_request_file_stem_and_suffix(file_name: str) -> tuple[str, str]:
63 | if file_name.endswith(FILE_SUFFIX) or file_name.endswith("posting.yml"):
64 | file_name_parts = file_name.split(".")
65 | file_suffix = ".".join(file_name_parts[-2:])
66 | file_stem = ".".join(file_name_parts[:-2])
67 | else:
68 | raise ValueError(f"Not a request file: {file_name}")
69 | return file_stem, file_suffix
70 |
71 |
72 | def request_file_exists(file_name: str, parent_directory: Path) -> bool:
73 | """Return True if a file with the same stem exists in the given parent directory, otherwise False.
74 |
75 | Args:
76 | file_name (str): The name of the file to check for.
77 | parent_directory (Path): The parent directory to check in.
78 |
79 | Returns:
80 | bool: True if a file with the same stem exists in the given parent directory, otherwise False.
81 | """
82 | for path in parent_directory.iterdir():
83 | if not path.name.endswith(FILE_SUFFIX) or path.name.endswith("posting.yml"):
84 | continue
85 |
86 | stem = path.stem
87 | i = stem.rfind(".")
88 | if 0 < i < len(stem) - 1:
89 | name = stem[:i]
90 | file_stem, _ = get_request_file_stem_and_suffix(file_name)
91 | if name == file_stem:
92 | return True
93 | else:
94 | continue
95 |
96 | return False
97 |
98 |
99 | def get_unique_request_filename(file_name: str, parent_directory: Path) -> str:
100 | """
101 | Generate a unique filename by appending a number if the file already exists.
102 |
103 | Args:
104 | file_name (str): The original filename.
105 | parent_directory (Path): The directory where the file will be saved.
106 |
107 | Returns:
108 | str: A unique filename with a number appended if necessary.
109 | """
110 | if not request_file_exists(file_name, parent_directory):
111 | return file_name
112 |
113 | file_stem, _ = get_request_file_stem_and_suffix(file_name)
114 |
115 | # Check for existing numbered files to determine pad width and next number
116 | existing_files = set(parent_directory.glob(f"{file_stem}*posting.yaml")) | set(
117 | parent_directory.glob(f"{file_stem}*posting.yml")
118 | )
119 | existing_file_names = {f.name for f in existing_files}
120 |
121 | # Keep checking candidate file names until we find a unique one.
122 | candidate_name = file_name
123 | while candidate_name in existing_file_names:
124 | # get the highest number
125 | try:
126 | candidate_stem, _ = get_request_file_stem_and_suffix(candidate_name)
127 | except ValueError:
128 | # Not a request file.
129 | continue
130 |
131 | split_candidate_stem = candidate_stem.rsplit("-", 1)
132 | if len(split_candidate_stem) == 2:
133 | try:
134 | width = len(split_candidate_stem[1])
135 | number = int(split_candidate_stem[1])
136 | except ValueError:
137 | width = 2
138 | number = 0
139 | else:
140 | width = 2
141 | number = 0
142 |
143 | number = int(number) + 1
144 | candidate_name = f"{file_stem}-{number:0{width}d}.posting.yaml"
145 |
146 | return candidate_name
147 |
--------------------------------------------------------------------------------
/src/posting/help_data.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 |
3 |
4 | @dataclass
5 | class HelpData:
6 | """Data relating to the widget to be displayed in the HelpScreen"""
7 |
8 | title: str = field(default="")
9 | """Title of the widget"""
10 | description: str = field(default="")
11 | """Markdown description to be displayed in the HelpScreen"""
12 |
--------------------------------------------------------------------------------
/src/posting/help_screen.py:
--------------------------------------------------------------------------------
1 | from typing import Protocol, runtime_checkable
2 | from rich.text import Text
3 | from textual.app import ComposeResult
4 | from textual.binding import Binding
5 | from textual.containers import Vertical, VerticalScroll
6 | from textual.screen import ModalScreen
7 | from textual.widget import Widget
8 | from textual.widgets import Label, Markdown
9 |
10 | from posting.help_data import HelpData
11 | from posting.widgets.datatable import PostingDataTable
12 |
13 |
14 | @runtime_checkable
15 | class Helpable(Protocol):
16 | """Widgets which contain information to be displayed in the HelpScreen
17 | should implement this protocol."""
18 |
19 | help: HelpData
20 |
21 |
22 | class HelpModalHeader(Label):
23 | """The top help bar"""
24 |
25 | DEFAULT_CSS = """
26 | HelpModalHeader {
27 | background: $background-lighten-1;
28 | color: $text-muted;
29 | }
30 | """
31 |
32 |
33 | class HelpModalFooter(Label):
34 | """The bottom help bar"""
35 |
36 | DEFAULT_CSS = """
37 | HelpModalFooter {
38 | background: $background-lighten-1;
39 | color: $text-muted;
40 | }
41 | """
42 |
43 |
44 | class HelpModalFocusNote(Label):
45 | """A note below the help screen."""
46 |
47 |
48 | class HelpScreen(ModalScreen[None]):
49 | DEFAULT_CSS = """
50 | HelpScreen {
51 | align: center middle;
52 | & > VerticalScroll {
53 | background: $background;
54 | padding: 1 2;
55 | width: 65%;
56 | height: 80%;
57 | border: wide $background-lighten-2;
58 | border-title-color: $text;
59 | border-title-background: $background;
60 | border-title-style: bold;
61 | }
62 |
63 | & DataTable#bindings-table {
64 | width: 1fr;
65 | height: 1fr;
66 | }
67 |
68 | & HelpModalHeader {
69 | dock: top;
70 | width: 1fr;
71 | content-align: center middle;
72 | }
73 |
74 | #footer-area {
75 | dock: bottom;
76 | height: auto;
77 | margin-top: 1;
78 | & HelpModalFocusNote {
79 | width: 1fr;
80 | content-align: center middle;
81 | color: $text-muted 40%;
82 | }
83 |
84 | & HelpModalFooter {
85 | width: 1fr;
86 | content-align: center middle;
87 | }
88 | }
89 |
90 |
91 | & #bindings-title {
92 | width: 1fr;
93 | content-align: center middle;
94 | background: $background-lighten-1;
95 | color: $text-muted;
96 | }
97 |
98 | & #help-description-wrapper {
99 | dock: top;
100 | max-height: 50%;
101 | margin-top: 1;
102 | height: auto;
103 | width: 1fr;
104 | & #help-description {
105 | margin: 0;
106 | width: 1fr;
107 | height: auto;
108 | }
109 | }
110 | }
111 | """
112 |
113 | BINDINGS = [
114 | Binding("escape", "dismiss('')", "Close Help"),
115 | ]
116 |
117 | def __init__(
118 | self,
119 | widget: Widget,
120 | name: str | None = None,
121 | id: str | None = None,
122 | classes: str | None = None,
123 | ) -> None:
124 | super().__init__(name, id, classes)
125 | self.widget = widget
126 |
127 | def compose(self) -> ComposeResult:
128 | with VerticalScroll() as vs:
129 | vs.can_focus = False
130 | widget = self.widget
131 | # If the widget has help text, render it.
132 | if isinstance(widget, Helpable):
133 | help = widget.help
134 | help_title = help.title
135 | vs.border_title = f"[not bold]Focused Widget Help ([b]{help_title}[/])"
136 | if help_title:
137 | yield HelpModalHeader(f"[b]{help_title}[/]")
138 | help_markdown = help.description
139 |
140 | if help_markdown:
141 | help_markdown = help_markdown.strip()
142 | with VerticalScroll(id="help-description-wrapper") as vs:
143 | yield Markdown(help_markdown, id="help-description")
144 | else:
145 | yield Label(
146 | f"No help available for {help.title}",
147 | id="help-description",
148 | )
149 | else:
150 | name = widget.__class__.__name__
151 | vs.border_title = f"Focused Widget Help ([b]{name}[/])"
152 | yield HelpModalHeader(f"[b]{name}[/] Help")
153 |
154 | bindings = widget._bindings
155 | keys: list[tuple[str, list[Binding]]] = list(
156 | bindings.key_to_bindings.items()
157 | )
158 |
159 | if keys:
160 | yield Label(" [b]All Keybindings[/]", id="bindings-title")
161 | table = PostingDataTable(
162 | id="bindings-table",
163 | cursor_type="row",
164 | zebra_stripes=True,
165 | )
166 | table.cursor_vertical_escape = False
167 | table.add_columns("Key", "Description")
168 | for _key, bindings in keys:
169 | table.add_row(
170 | Text(
171 | ", ".join(
172 | binding.key_display
173 | if binding.key_display
174 | else self.app.get_key_display(binding)
175 | for binding in bindings
176 | ),
177 | style="bold",
178 | no_wrap=True,
179 | end="",
180 | ),
181 | bindings[0].description.lower(),
182 | )
183 | yield table
184 |
185 | with Vertical(id="footer-area"):
186 | yield HelpModalFooter("Press [b]ESC[/] to dismiss.")
187 | yield HelpModalFocusNote(
188 | "[b]Note:[/] This page relates to the widget that is currently focused."
189 | )
190 |
--------------------------------------------------------------------------------
/src/posting/highlight_url.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/highlight_url.py
--------------------------------------------------------------------------------
/src/posting/highlighters.py:
--------------------------------------------------------------------------------
1 | import re
2 | from rich.highlighter import Highlighter
3 | from rich.style import Style
4 | from rich.text import Text
5 | from textual.widgets import Input
6 | from posting.themes import UrlStyles, VariableStyles
7 |
8 | from posting.variables import (
9 | find_variable_end,
10 | find_variable_start,
11 | find_variables,
12 | get_variables,
13 | is_cursor_within_variable,
14 | )
15 |
16 |
17 | _URL_REGEX = re.compile(r"(?Phttps?)://(?P[^/]+)(?P/[^ ]*)?")
18 |
19 |
20 | def highlight_url(text: Text, styles: UrlStyles) -> None:
21 | for match in _URL_REGEX.finditer(text.plain):
22 | protocol_start, protocol_end = match.span("protocol")
23 | base_start, base_end = match.span("base")
24 | separator_start, separator_end = protocol_end, protocol_end + 3
25 |
26 | text.stylize(styles.protocol or "#818cf8", protocol_start, protocol_end)
27 | text.stylize(styles.separator or "dim b", separator_start, separator_end)
28 | text.stylize(styles.base or "#00C168", base_start, base_end)
29 |
30 | for index, char in enumerate(text.plain):
31 | if char == "/":
32 | text.stylize(styles.separator or "dim b", index, index + 1)
33 |
34 |
35 | def highlight_variables(text: Text, styles: VariableStyles) -> None:
36 | for match in find_variables(text.plain):
37 | variable_name, start, end = match
38 | if variable_name not in get_variables():
39 | text.stylize(Style.parse(styles.unresolved or "dim"), start, end)
40 | else:
41 | text.stylize(Style.parse(styles.resolved or ""), start, end)
42 |
43 |
44 | class VariableHighlighter(Highlighter):
45 | def __init__(self, variable_styles: VariableStyles | None = None) -> None:
46 | super().__init__()
47 | self.variable_styles = variable_styles
48 |
49 | def highlight(self, text: Text) -> None:
50 | if self.variable_styles is None:
51 | return
52 | highlight_variables(text, self.variable_styles)
53 |
54 |
55 | class VariablesAndUrlHighlighter(Highlighter):
56 | def __init__(self, input: Input) -> None:
57 | super().__init__()
58 | self.input = input
59 | self.variable_styles: VariableStyles = VariableStyles()
60 | self.url_styles: UrlStyles = UrlStyles()
61 |
62 | def highlight(self, text: Text) -> None:
63 | if text.plain == "":
64 | return
65 |
66 | highlight_url(text, self.url_styles)
67 | highlight_variables(text, self.variable_styles)
68 |
69 | input = self.input
70 | cursor_position = input.cursor_position # type:ignore
71 | value: str = input.value
72 |
73 | if is_cursor_within_variable(cursor_position, value): # type: ignore
74 | start = find_variable_start(cursor_position, value) # type: ignore
75 | end = find_variable_end(cursor_position, value) # type: ignore
76 | text.stylize("u", start, end)
77 |
--------------------------------------------------------------------------------
/src/posting/importing/postman.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from pathlib import Path
4 | import json
5 | import re
6 | from urllib.parse import urlparse, urlunparse
7 |
8 | from pydantic import BaseModel, Field
9 |
10 | from rich.console import Console
11 |
12 | from posting.collection import (
13 | APIInfo,
14 | Collection,
15 | FormItem,
16 | Header,
17 | QueryParam,
18 | RequestBody,
19 | RequestModel,
20 | HttpRequestMethod,
21 | )
22 |
23 |
24 | class Variable(BaseModel):
25 | key: str
26 | value: str | None = None
27 | src: str | list[str] | None = None
28 | fileNotInWorkingDirectoryWarning: str | None = None
29 | filesNotInWorkingDirectory: list[str] | None = None
30 | type: str | None = None
31 | disabled: bool | None = None
32 |
33 |
34 | class RawRequestOptions(BaseModel):
35 | language: str
36 |
37 |
38 | class RequestOptions(BaseModel):
39 | raw: RawRequestOptions
40 |
41 |
42 | class Body(BaseModel):
43 | mode: str
44 | options: RequestOptions | None = None
45 | raw: str | None = None
46 | formdata: list[Variable] | None = None
47 |
48 |
49 | class Url(BaseModel):
50 | raw: str
51 | host: list[str] | None = None
52 | path: list[str] | None = None
53 | query: list[Variable] | None = None
54 |
55 |
56 | class PostmanRequest(BaseModel):
57 | method: HttpRequestMethod
58 | url: str | Url | None = None
59 | header: list[Variable] | None = None
60 | description: str | None = None
61 | body: Body | None = None
62 |
63 |
64 | class RequestItem(BaseModel):
65 | name: str
66 | item: list["RequestItem"] | None = None
67 | request: PostmanRequest | None = None
68 |
69 |
70 | class PostmanCollection(BaseModel):
71 | info: dict[str, str] = Field(default_factory=dict)
72 | variable: list[Variable] = Field(default_factory=list)
73 | item: list[RequestItem]
74 |
75 |
76 | # Converts variable names like userId to $USER_ID, or user-id to $USER_ID
77 | def sanitize_variables(string: str) -> str:
78 | underscore_case = re.sub(r"(? str:
83 | def replace_match(match: re.Match[str]) -> str:
84 | value = match.group(1)
85 | return f"${sanitize_variables(value)}"
86 |
87 | transformed = re.sub(r"\{\{([\w-]+)\}\}", replace_match, string)
88 | return transformed
89 |
90 |
91 | def create_env_file(path: Path, env_filename: str, variables: list[Variable]) -> Path:
92 | env_content: list[str] = []
93 |
94 | for var in variables:
95 | env_content.append(f"{sanitize_variables(var.key)}={var.value}")
96 |
97 | env_file = path / env_filename
98 | env_file.write_text("\n".join(env_content))
99 | return env_file
100 |
101 |
102 | def format_request(name: str, request: PostmanRequest) -> RequestModel:
103 | # Extract the raw URL first
104 | raw_url_with_query: str = ""
105 | if request.url is not None:
106 | raw_url_with_query = (
107 | request.url.raw if isinstance(request.url, Url) else request.url
108 | )
109 |
110 | # Parse the URL and remove query parameters
111 | parsed_url = urlparse(raw_url_with_query)
112 | # Reconstruct the URL without the query string
113 | url_without_query = urlunparse(
114 | (
115 | parsed_url.scheme,
116 | parsed_url.netloc,
117 | parsed_url.path,
118 | parsed_url.params, # Keep fragment/params if they exist
119 | "", # Empty query string
120 | parsed_url.fragment,
121 | )
122 | )
123 | sanitized_url = sanitize_str(url_without_query)
124 |
125 | posting_request = RequestModel(
126 | name=name,
127 | method=request.method,
128 | description=request.description if request.description is not None else "",
129 | url=sanitized_url,
130 | )
131 |
132 | if request.header is not None:
133 | for header in request.header:
134 | posting_request.headers.append(
135 | Header(
136 | name=header.key,
137 | value=header.value if header.value is not None else "",
138 | enabled=True,
139 | )
140 | )
141 |
142 | # Add query params to the request (they've been removed from the URL)
143 | if (
144 | request.url is not None
145 | and isinstance(request.url, Url)
146 | and request.url.query is not None
147 | ):
148 | for param in request.url.query:
149 | posting_request.params.append(
150 | QueryParam(
151 | name=param.key,
152 | value=param.value if param.value is not None else "",
153 | enabled=param.disabled if param.disabled is not None else True,
154 | )
155 | )
156 |
157 | if request.body is not None and request.body.raw is not None:
158 | if (
159 | request.body.mode == "raw"
160 | and request.body.options is not None
161 | and request.body.options.raw.language == "json"
162 | ):
163 | posting_request.body = RequestBody(content=sanitize_str(request.body.raw))
164 | elif request.body.mode == "formdata" and request.body.formdata is not None:
165 | form_data: list[FormItem] = [
166 | FormItem(
167 | name=data.key,
168 | value=data.value if data.value is not None else "",
169 | enabled=data.disabled is False,
170 | )
171 | for data in request.body.formdata
172 | ]
173 | posting_request.body = RequestBody(form_data=form_data)
174 |
175 | return posting_request
176 |
177 |
178 | def process_item(
179 | item: RequestItem, parent_collection: Collection, base_path: Path
180 | ) -> None:
181 | if item.item is not None:
182 | # This is a folder - create a subcollection
183 | child_path = base_path / item.name
184 | child_collection = Collection(path=child_path, name=item.name)
185 | parent_collection.children.append(child_collection)
186 |
187 | # Process items in this folder
188 | for sub_item in item.item:
189 | process_item(sub_item, child_collection, child_path)
190 |
191 | if item.request is not None:
192 | # This is a request - add it to the current collection
193 | file_name = "".join(
194 | word.capitalize()
195 | for word in re.sub(r"[^A-Za-z0-9\.]+", " ", item.name).split()
196 | )
197 | request = format_request(item.name, item.request)
198 | request_path = parent_collection.path / f"{file_name}.posting.yaml"
199 | request.path = request_path
200 | parent_collection.requests.append(request)
201 |
202 |
203 | def import_postman_spec(
204 | spec_path: str | Path, output_path: str | Path | None
205 | ) -> tuple[Collection, PostmanCollection]:
206 | """Import a Postman collection from a file and save it to disk."""
207 | console = Console()
208 | console.print(f"Importing Postman spec from {spec_path!r}.")
209 |
210 | spec_path = Path(spec_path)
211 | with open(spec_path, "r") as file:
212 | spec_dict = json.load(file)
213 |
214 | spec = PostmanCollection(**spec_dict)
215 |
216 | info = APIInfo(
217 | title=spec.info["name"],
218 | description=spec.info.get("description", "No description"),
219 | specSchema=spec.info["schema"],
220 | version="2.0.0",
221 | )
222 |
223 | base_dir = spec_path.parent
224 | if output_path is not None:
225 | base_dir = Path(output_path) if isinstance(output_path, str) else output_path
226 |
227 | base_dir.mkdir(parents=True, exist_ok=True)
228 | main_collection = Collection(path=base_dir, name=info.title)
229 | main_collection.readme = main_collection.generate_readme(info)
230 |
231 | for item in spec.item:
232 | process_item(item, main_collection, base_dir)
233 |
234 | return main_collection, spec
235 |
--------------------------------------------------------------------------------
/src/posting/jump_overlay.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 | from textual import events, log
3 | from textual.app import ComposeResult
4 | from textual.binding import Binding
5 | from textual.containers import Center
6 | from textual.screen import ModalScreen
7 | from textual.widget import Widget
8 | from textual.widgets import Label
9 | import asyncio
10 | import time
11 |
12 | if TYPE_CHECKING:
13 | from posting.jumper import Jumper
14 |
15 |
16 | class JumpOverlay(ModalScreen[str | Widget | None]):
17 | """Overlay showing the jump targets.
18 | Dismissed with the ID of the widget the jump was requested for on closing,
19 | or a reference to the widget. Is dismissed with None if the user dismissed
20 | the overlay without making a selection."""
21 |
22 | DEFAULT_CSS = """\
23 | JumpOverlay {
24 | background: black 25%;
25 | }
26 | """
27 |
28 | BINDINGS = [
29 | Binding("escape", "dismiss_overlay", "Dismiss", show=False),
30 | ]
31 |
32 | def __init__(
33 | self,
34 | jumper: "Jumper",
35 | name: str | None = None,
36 | id: str | None = None,
37 | classes: str | None = None,
38 | ) -> None:
39 | super().__init__(name=name, id=id, classes=classes)
40 | self.jumper: Jumper = jumper
41 | self.keys_to_widgets: dict[str, Widget | str] = {}
42 | self._resize_counter = 0
43 | self._last_resize_time: float = 0
44 | self._debounce_running: bool = False
45 |
46 | def on_key(self, key_event: events.Key) -> None:
47 | # We need to stop the bubbling of these keys, because if they
48 | # arrive at the parent after the overlay is closed, then the parent
49 | # will handle the key event, resulting in the focus being shifted
50 | # again (unexpectedly) after the jump target was focused.
51 | log.debug(f"Handling jump key press: {key_event.key}")
52 | if key_event.key == "tab" or key_event.key == "shift+tab":
53 | key_event.stop()
54 | key_event.prevent_default()
55 |
56 | if self.is_active:
57 | # If they press a key corresponding to a jump target,
58 | # then we jump to it.
59 | target = self.keys_to_widgets.get(key_event.key)
60 | if target is not None:
61 | self.dismiss(target)
62 | return
63 |
64 | def action_dismiss_overlay(self) -> None:
65 | self.dismiss(None)
66 |
67 | async def on_resize(self) -> None:
68 | self._resize_counter += 1
69 | if self._resize_counter == 1:
70 | return
71 |
72 | # Update the last resize time
73 | self._last_resize_time = time.time()
74 |
75 | # Start the debounce task if it's not already running
76 | if not self._debounce_running:
77 | self._debounce_running = True
78 | asyncio.create_task(self._debounced_recompose())
79 |
80 | async def on_unmount(self) -> None:
81 | # Nothing to cancel since we're using a different approach
82 | self._debounce_running = False
83 |
84 | async def _debounced_recompose(self) -> None:
85 | try:
86 | while self._debounce_running:
87 | # Get the current time
88 | current_time = time.time()
89 | # Calculate time since last resize
90 | time_since_last_resize = current_time - self._last_resize_time
91 |
92 | if time_since_last_resize >= 0.05:
93 | await self.recompose()
94 | break
95 |
96 | # Otherwise, wait a bit and check again
97 | await asyncio.sleep(0.1)
98 |
99 | self._debounce_running = False
100 | except asyncio.CancelledError:
101 | self._debounce_running = False
102 |
103 | def _sync(self) -> None:
104 | self.overlays = self.jumper.get_overlays()
105 | self.keys_to_widgets = {v.key: v.widget for v in self.overlays.values()}
106 |
107 | def compose(self) -> ComposeResult:
108 | self._sync()
109 | for offset, jump_info in self.overlays.items():
110 | key, _widget = jump_info
111 | label = Label(key, classes="textual-jump-label")
112 | x, y = offset
113 | label.styles.margin = y, x
114 | yield label
115 | with Center(id="textual-jump-info"):
116 | yield Label("Press a key to jump")
117 | with Center(id="textual-jump-dismiss"):
118 | yield Label("[b]ESC[/] to dismiss")
119 |
--------------------------------------------------------------------------------
/src/posting/jumper.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Mapping, NamedTuple, Protocol, runtime_checkable
2 | from textual.errors import NoWidget
3 |
4 | from textual.geometry import Offset
5 | from textual.screen import Screen
6 | from textual.widget import Widget
7 |
8 |
9 | @runtime_checkable
10 | class Jumpable(Protocol):
11 | """A widget which we can jump focus to."""
12 |
13 | jump_key: str
14 |
15 |
16 | class JumpInfo(NamedTuple):
17 | """Information returned by the jumper for each jump target."""
18 |
19 | key: str
20 | """The key which should trigger the jump."""
21 |
22 | widget: str | Widget
23 | """Either the ID or a direct reference to the widget."""
24 |
25 |
26 | class Jumper:
27 | """An Amp-like jumping mechanism for quick spatial navigation"""
28 |
29 | def __init__(self, ids_to_keys: Mapping[str, str], screen: Screen[Any]) -> None:
30 | self.ids_to_keys = ids_to_keys
31 | self.keys_to_ids = {v: k for k, v in ids_to_keys.items()}
32 | self.screen = screen
33 |
34 | def get_overlays(self) -> dict[Offset, JumpInfo]:
35 | """Return a dictionary of all the jump targets"""
36 | screen = self.screen
37 | children: list[Widget] = screen.walk_children(Widget)
38 | overlays: dict[Offset, JumpInfo] = {}
39 | ids_to_keys = self.ids_to_keys
40 | for child in children:
41 | try:
42 | widget_x, widget_y = screen.get_offset(child)
43 | except NoWidget:
44 | # The widget might not be visible in the layout
45 | # due to it being hidden in some modes.
46 | continue
47 |
48 | widget_offset = Offset(widget_x, widget_y)
49 | if child.id and child.id in ids_to_keys:
50 | overlays[widget_offset] = JumpInfo(
51 | ids_to_keys[child.id],
52 | child.id,
53 | )
54 | elif isinstance(child, Jumpable):
55 | overlays[widget_offset] = JumpInfo(
56 | child.jump_key,
57 | child,
58 | )
59 |
60 | return overlays
61 |
--------------------------------------------------------------------------------
/src/posting/locations.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from xdg_base_dirs import xdg_config_home, xdg_data_home
4 |
5 |
6 | def _posting_directory(root: Path) -> Path:
7 | directory = root / "posting"
8 | directory.mkdir(exist_ok=True, parents=True)
9 | return directory
10 |
11 |
12 | def data_directory() -> Path:
13 | """Return (possibly creating) the application data directory."""
14 | return _posting_directory(xdg_data_home())
15 |
16 |
17 | def theme_directory() -> Path:
18 | """Return (possibly creating) the themes directory."""
19 | theme_dir = data_directory() / "themes"
20 | theme_dir.mkdir(exist_ok=True, parents=True)
21 | return theme_dir
22 |
23 |
24 | def default_collection_directory() -> Path:
25 | """Return (possibly creating) the default collection directory."""
26 | return data_directory() / "default"
27 |
28 |
29 | def config_directory() -> Path:
30 | """Return (possibly creating) the application config directory."""
31 | return _posting_directory(xdg_config_home())
32 |
33 |
34 | def config_file() -> Path:
35 | return config_directory() / "config.yaml"
36 |
--------------------------------------------------------------------------------
/src/posting/messages.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from httpx import Response
3 | from textual.message import Message
4 |
5 |
6 | @dataclass
7 | class HttpResponseReceived(Message):
8 | response: Response
9 |
--------------------------------------------------------------------------------
/src/posting/save_request.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import re
3 |
4 |
5 | FILE_SUFFIX = ".posting.yaml"
6 |
7 |
8 | def slugify(text: str) -> str:
9 | """Slugify a string."""
10 | text = text.lower()
11 | text = re.sub(r"[^a-z0-9]+", "-", text)
12 | return text.strip("-")
13 |
14 |
15 | def generate_request_filename(request_title: str) -> str:
16 | """Generate a filename for a request, NOT including the file suffix."""
17 | return slugify(request_title)
18 |
--------------------------------------------------------------------------------
/src/posting/scripts.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | from pathlib import Path
5 | from types import ModuleType
6 | from typing import TYPE_CHECKING, Callable, Any
7 | import threading
8 |
9 | from httpx import Response
10 | from textual.notifications import SeverityLevel
11 |
12 | from posting.collection import RequestModel
13 | from posting.variables import get_variables, update_variables
14 |
15 | if TYPE_CHECKING:
16 | from posting.app import Posting as PostingApp
17 |
18 | # Global cache for loaded modules
19 | _MODULE_CACHE: dict[str, ModuleType] = {}
20 | _CACHE_LOCK = threading.Lock()
21 |
22 |
23 | class Posting:
24 | """A class that provides access to Posting's API from within a script."""
25 |
26 | def __init__(self, app: PostingApp):
27 | self._app: PostingApp = app
28 | """The Textual App instance for Posting."""
29 |
30 | self.request: RequestModel | None = None
31 | """The request that is currently being processed."""
32 |
33 | self.response: Response | None = None
34 | """The response received, if it's available."""
35 |
36 | @property
37 | def variables(self) -> dict[str, object]:
38 | """Get the variables available in the environment.
39 |
40 | This includes variables loaded from the environment and
41 | any variables that have been set in scripts for this session.
42 | """
43 | return get_variables()
44 |
45 | def get_variable(self, name: str, default: object | None = None) -> object | None:
46 | """Get a session variable. This doesn't include variables set via environment files
47 | or env vars.
48 |
49 | Use the `variables` property to get a dict containing all
50 | variables, including those set via environment files or
51 | env vars.
52 |
53 | Args:
54 | name: The name of the variable to get.
55 | default: The default value to return if the variable is not found.
56 | """
57 | return self._app.session_env.get(name, default)
58 |
59 | def set_variable(self, name: str, value: object) -> None:
60 | """Set a session variable, which persists until the app shuts
61 | down, and overrides any variables loaded from the environment
62 | with the same name.
63 |
64 | Args:
65 | name: The name of the variable to set.
66 | value: The value of the variable to set.
67 | """
68 | self._app.session_env[name] = value
69 | update_variables(self._app.session_env)
70 |
71 | def clear_variable(self, name: str) -> None:
72 | """Clear a session variable.
73 |
74 | Args:
75 | name: The name of the variable to clear.
76 | """
77 | if name in self._app.session_env:
78 | del self._app.session_env[name]
79 | update_variables(self._app.session_env)
80 |
81 | def clear_all_variables(self) -> None:
82 | """Clear all session variables."""
83 | self._app.session_env.clear()
84 | update_variables(self._app.session_env)
85 |
86 | def notify(
87 | self,
88 | message: str,
89 | *,
90 | title: str = "",
91 | severity: SeverityLevel = "information",
92 | timeout: float | None = None,
93 | ):
94 | """Send a toast message, which will appear at the bottom
95 | right corner of Posting. This is useful for grabbing the
96 | user's attention even if they're not directly viewing the
97 | Scripts tab.
98 |
99 | Args:
100 | message: The message to display in the toast body.
101 | title: The title of the toast.
102 | severity: The severity of the message.
103 | timeout: Number of seconds the toast will be displayed for.
104 | """
105 | self._app.notify(
106 | message=message,
107 | title=title,
108 | severity=severity,
109 | timeout=timeout,
110 | )
111 |
112 |
113 | def clear_module_cache():
114 | """
115 | Clear the global module cache in a thread-safe manner.
116 | """
117 | with _CACHE_LOCK:
118 | _MODULE_CACHE.clear()
119 |
120 |
121 | def execute_script(
122 | collection_root: Path, script_path: Path, function_name: str
123 | ) -> Callable[..., Any] | None:
124 | """
125 | Execute a Python script from the given path and extract a specified function.
126 | Uses caching to prevent multiple executions of the same script.
127 |
128 | Args:
129 | collection_root: Path to the root of the collection.
130 | script_path: Path to the Python script file, relative to the collection root.
131 | function_name: Name of the function to extract from the script.
132 |
133 | Returns:
134 | The extracted function if found, None otherwise.
135 |
136 | Raises:
137 | FileNotFoundError: If the script file does not exist or is outside the collection.
138 | Exception: If there's an error during script execution.
139 | """
140 | # Ensure the script_path is relative to the collection root
141 | full_script_path = (collection_root / script_path).resolve()
142 |
143 | # Check if the script is within the collection root
144 | if not full_script_path.is_relative_to(collection_root):
145 | raise FileNotFoundError(
146 | f"Script path {script_path} is outside the collection root"
147 | )
148 |
149 | if not full_script_path.is_file():
150 | raise FileNotFoundError(f"Script not found: {full_script_path}")
151 |
152 | script_dir = full_script_path.parent
153 | module_name = full_script_path.stem
154 | module_key = str(full_script_path)
155 |
156 | try:
157 | sys.path.insert(0, str(script_dir))
158 | module = _import_script_as_module(full_script_path, module_name, module_key)
159 | return _validate_function(getattr(module, function_name, None))
160 | finally:
161 | sys.path.remove(str(script_dir))
162 |
163 |
164 | def _import_script_as_module(
165 | script_path: Path, module_name: str, module_key: str
166 | ) -> ModuleType:
167 | """Import the script file as a module, using cache if available."""
168 | with _CACHE_LOCK:
169 | if module_key in _MODULE_CACHE:
170 | return _MODULE_CACHE[module_key]
171 |
172 | import importlib.util
173 |
174 | spec = importlib.util.spec_from_file_location(module_name, script_path)
175 | if spec is None:
176 | raise ImportError(
177 | f"Could not load spec for module {module_name} from {script_path}"
178 | )
179 |
180 | module = importlib.util.module_from_spec(spec)
181 | if spec.loader is None:
182 | raise ImportError(f"Could not load module {module_name} from {script_path}")
183 |
184 | spec.loader.exec_module(module)
185 | with _CACHE_LOCK:
186 | _MODULE_CACHE[module_key] = module
187 |
188 | return module
189 |
190 |
191 | def _validate_function(func: Any) -> Callable[..., Any] | None:
192 | """
193 | Validate that the provided object is a callable function.
194 |
195 | Args:
196 | func: The object to validate.
197 |
198 | Returns:
199 | The function if it's callable, None otherwise.
200 | """
201 | return func if callable(func) else None
202 |
203 |
204 | def uncache_module(script_path: str) -> None:
205 | """
206 | Clear a specific module from the global module cache.
207 |
208 | Args:
209 | script_path: Path to the script file.
210 | """
211 | module_key = str(script_path)
212 | with _CACHE_LOCK:
213 | if module_key in _MODULE_CACHE:
214 | del _MODULE_CACHE[module_key]
215 |
--------------------------------------------------------------------------------
/src/posting/suggesters.py:
--------------------------------------------------------------------------------
1 | # Maps lowercased HTTP headers to lists of suggestions for the header's value.
2 | HEADER_SUGGESTION_LISTS = {
3 | "content-type": [
4 | "text/",
5 | "text/html",
6 | "application/",
7 | "application/json",
8 | "application/xml",
9 | "application/x-www-form-urlencoded",
10 | "multipart/form-data",
11 | "image/",
12 | "image/png",
13 | "image/jpeg",
14 | ],
15 | }
16 |
--------------------------------------------------------------------------------
/src/posting/tuple_to_multidict.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from collections import defaultdict
3 | from typing import TypeVar
4 |
5 | K = TypeVar("K")
6 | V = TypeVar("V")
7 |
8 |
9 | def tuples_to_dict(tuple_list: list[tuple[K, V]]) -> dict[K, list[V]]:
10 | result: dict[K, list[V]] = defaultdict(list)
11 | for key, value in tuple_list:
12 | result[key].append(value)
13 | return result
14 |
--------------------------------------------------------------------------------
/src/posting/types.py:
--------------------------------------------------------------------------------
1 | from typing import Literal, Optional, Tuple, Union
2 |
3 | PostingLayout = Literal["horizontal", "vertical"]
4 |
5 | # From httpx - seems to not be public.
6 | CertTypes = Union[
7 | # certfile
8 | str,
9 | # (certfile, keyfile)
10 | Tuple[str, Optional[str]],
11 | # (certfile, keyfile, password)
12 | Tuple[str, Optional[str], Optional[str]],
13 | ]
14 |
--------------------------------------------------------------------------------
/src/posting/urls.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | def ensure_protocol(url: str) -> str:
4 | """Default the protocol to http:// if no protocol is present.
5 |
6 | Args:
7 | url: The URL to ensure has a protocol.
8 |
9 | Returns:
10 | The URL with a the http:// protocol if no protocol was present,
11 | otherwise the original URL.
12 | """
13 | if re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', url):
14 | return url
15 | return f"http://{url}"
--------------------------------------------------------------------------------
/src/posting/user_host.py:
--------------------------------------------------------------------------------
1 | import getpass
2 | import socket
3 |
4 | from rich.text import Text
5 |
6 | from posting.config import SETTINGS
7 |
8 |
9 | def get_user_host_string() -> Text:
10 | try:
11 | username = getpass.getuser()
12 | hostname = SETTINGS.get().heading.hostname or socket.gethostname()
13 | return Text.from_markup(f"{username}@{hostname}")
14 | except Exception:
15 | return Text("unknown@unknown")
16 |
--------------------------------------------------------------------------------
/src/posting/variables.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from functools import lru_cache
3 |
4 | import re
5 | import os
6 | from pathlib import Path
7 | from dotenv import dotenv_values
8 |
9 |
10 | _VARIABLES_PATTERN = re.compile(
11 | r"(?:(?:^|[^\$])(?:\$\$)*)(\$(?:\{([a-zA-Z_]\w*)\}|([a-zA-Z_]\w*)|$))"
12 | )
13 |
14 |
15 | class SharedVariables:
16 | def __init__(self):
17 | self._variables: dict[str, object] = {}
18 |
19 | def get(self) -> dict[str, object]:
20 | return self._variables.copy()
21 |
22 | def set(self, variables: dict[str, object]) -> None:
23 | self._variables = variables
24 |
25 | def update(self, new_variables: dict[str, object]) -> None:
26 | self._variables.update(new_variables)
27 |
28 |
29 | VARIABLES = SharedVariables()
30 |
31 |
32 | def get_variables() -> dict[str, object]:
33 | return VARIABLES.get()
34 |
35 |
36 | def load_variables(
37 | environment_files: tuple[Path, ...],
38 | use_host_environment: bool,
39 | avoid_cache: bool = False,
40 | ) -> dict[str, object]:
41 | """Load the variables that are currently available in the environment.
42 |
43 | This will likely involve reading from a set of environment files,
44 | but it could also involve reading from the host machine's environment
45 | if `use_host_environment` is True.
46 |
47 | This will make them available via the `get_variables` function.
48 |
49 | Args:
50 | environment_files: The environment files to load variables from.
51 | use_host_environment: Whether to use env vars from the host machine.
52 | avoid_cache: Whether to avoid using cached variables (so do a full lookup).
53 | overlay_variables: Additional variables to overlay on top of the result.
54 | """
55 |
56 | existing_variables = get_variables()
57 | if existing_variables and not avoid_cache:
58 | return {key: value for key, value in existing_variables.items()}
59 |
60 | variables: dict[str, object] = {
61 | key: value
62 | for file in environment_files
63 | for key, value in dotenv_values(file).items()
64 | }
65 | if use_host_environment:
66 | host_env_variables = {key: value for key, value in os.environ.items()}
67 | variables = {**variables, **host_env_variables}
68 |
69 | VARIABLES.set(variables)
70 | return variables
71 |
72 |
73 | def update_variables(new_variables: dict[str, object]) -> None:
74 | """Update the current variables with new values.
75 |
76 | This function safely updates the shared variables with new key-value pairs.
77 | If a key already exists, its value will be updated. If it doesn't exist, it will be added.
78 |
79 | Args:
80 | new_variables: A dictionary containing the new variables to update or add.
81 | """
82 | VARIABLES.update(new_variables)
83 |
84 |
85 | @lru_cache()
86 | def find_variables(template_str: str) -> list[tuple[str, int, int]]:
87 | return [
88 | (m.group(2) or m.group(3), m.start(1), m.end(1))
89 | for m in re.finditer(_VARIABLES_PATTERN, template_str)
90 | if m.group(2) or m.group(3)
91 | ]
92 |
93 |
94 | @lru_cache()
95 | def variable_range_at_cursor(cursor: int, text: str) -> tuple[int, int] | None:
96 | if not text or cursor < 0 or cursor > len(text):
97 | return None
98 |
99 | for match in _VARIABLES_PATTERN.finditer(text):
100 | start, end = match.span(1)
101 | if start < cursor and (
102 | cursor < end or not match.group(2) and cursor == end == len(text)
103 | ):
104 | return start, end
105 | return None
106 |
107 |
108 | def is_cursor_within_variable(cursor: int, text: str) -> bool:
109 | return variable_range_at_cursor(cursor, text) is not None
110 |
111 |
112 | def find_variable_start(cursor: int, text: str) -> int:
113 | variable_range = variable_range_at_cursor(cursor, text)
114 | return variable_range[0] if variable_range is not None else cursor
115 |
116 |
117 | def find_variable_end(cursor: int, text: str) -> int:
118 | if not text:
119 | return cursor
120 |
121 | variable_range = variable_range_at_cursor(cursor, text)
122 | return variable_range[1] if variable_range is not None else len(text)
123 |
124 |
125 | def get_variable_at_cursor(cursor: int, text: str) -> str | None:
126 | variable_range = variable_range_at_cursor(cursor, text)
127 | if variable_range is None:
128 | return None
129 |
130 | return text[variable_range[0] : variable_range[1]]
131 |
132 |
133 | @lru_cache()
134 | def extract_variable_name(variable_text: str) -> str:
135 | """
136 | Extract the variable name from a variable reference.
137 |
138 | Args:
139 | variable_text: The text containing the variable reference.
140 |
141 | Returns:
142 | str: The extracted variable name.
143 |
144 | Examples:
145 | >>> extract_variable_name("$var")
146 | 'var'
147 | >>> extract_variable_name("${MY_VAR}")
148 | 'MY_VAR'
149 | """
150 | if variable_text.startswith("${") and variable_text.endswith("}"):
151 | return variable_text[2:-1]
152 | elif variable_text.startswith("$"):
153 | return variable_text[1:]
154 | return variable_text # Return as-is if it doesn't match expected formats
155 |
156 |
157 | class SubstitutionError(Exception):
158 | """Raised when the user refers to a variable that doesn't exist."""
159 |
--------------------------------------------------------------------------------
/src/posting/version.py:
--------------------------------------------------------------------------------
1 | from importlib.metadata import version
2 |
3 | VERSION = version("posting")
4 |
--------------------------------------------------------------------------------
/src/posting/widgets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/widgets/__init__.py
--------------------------------------------------------------------------------
/src/posting/widgets/center_middle.py:
--------------------------------------------------------------------------------
1 | from textual.widget import Widget
2 |
3 |
4 | class CenterMiddle(Widget, inherit_bindings=False):
5 | """A container which aligns children on both axes."""
6 |
7 | DEFAULT_CSS = """
8 | CenterMiddle {
9 | align: center middle;
10 | width: 1fr;
11 | height: 1fr;
12 | }
13 | """
14 |
--------------------------------------------------------------------------------
/src/posting/widgets/confirmation.py:
--------------------------------------------------------------------------------
1 | """A modal screen for confirming a destructive action."""
2 |
3 | from typing import Literal
4 | from textual import on
5 | from textual.app import ComposeResult
6 | from textual.binding import Binding
7 | from textual.containers import Horizontal, Vertical
8 | from textual.screen import ModalScreen
9 | from textual.widgets import Button, Static
10 |
11 |
12 | class ConfirmationModal(ModalScreen[bool]):
13 | DEFAULT_CSS = """
14 | ConfirmationModal {
15 | align: center middle;
16 | height: auto;
17 | & #confirmation-buttons {
18 | margin-top: 1;
19 | width: 100%;
20 | height: 1;
21 | align: center middle;
22 |
23 | & > Button {
24 | width: 1fr;
25 | }
26 | }
27 | }
28 | """
29 |
30 | BINDINGS = [
31 | Binding(
32 | "left,right,up,down,h,j,k,l",
33 | "move_focus",
34 | "Navigate",
35 | show=False,
36 | )
37 | ]
38 |
39 | def __init__(
40 | self,
41 | message: str,
42 | confirm_text: str = "Yes \\[y]",
43 | confirm_binding: str = "y",
44 | cancel_text: str = "No \\[n]",
45 | cancel_binding: str = "n",
46 | auto_focus: Literal["confirm", "cancel"] | None = "confirm",
47 | name: str | None = None,
48 | id: str | None = None,
49 | classes: str | None = None,
50 | ) -> None:
51 | super().__init__(name=name, id=id, classes=classes)
52 | self.message = message
53 | self.confirm_text = confirm_text
54 | self.confirm_binding = confirm_binding
55 | self.cancel_text = cancel_text
56 | self.cancel_binding = cancel_binding
57 | self.auto_focus = auto_focus
58 |
59 | def on_mount(self) -> None:
60 | self._bindings.bind(self.confirm_binding, "screen.dismiss(True)")
61 | self._bindings.bind(self.cancel_binding, "screen.dismiss(False)")
62 | self._bindings.bind("escape", "screen.dismiss(False)")
63 | if self.auto_focus is not None:
64 | self.query_one(f"#{self.auto_focus}-button").focus()
65 |
66 | def compose(self) -> ComposeResult:
67 | with Vertical(id="confirmation-screen", classes="modal-body") as container:
68 | container.border_title = "Confirm"
69 | yield Static(self.message)
70 | with Horizontal(id="confirmation-buttons"):
71 | yield Button(self.confirm_text, id="confirm-button")
72 | yield Button(self.cancel_text, id="cancel-button")
73 |
74 | @on(Button.Pressed, "#confirm-button")
75 | def confirm(self) -> None:
76 | self.dismiss(True)
77 |
78 | @on(Button.Pressed, "#cancel-button")
79 | def cancel(self) -> None:
80 | self.dismiss(False)
81 |
82 | def action_move_focus(self) -> None:
83 | # It's enough to just call focus_next here as there are only two buttons.
84 | self.screen.focus_next()
85 |
--------------------------------------------------------------------------------
/src/posting/widgets/input.py:
--------------------------------------------------------------------------------
1 | from rich.style import Style
2 | from textual.theme import Theme
3 | from textual.widgets import Input
4 |
5 |
6 | from posting.config import SETTINGS
7 |
8 |
9 | class PostingInput(Input):
10 | def on_mount(self) -> None:
11 | self.cursor_blink = SETTINGS.get().text_input.blinking_cursor
12 |
13 | self._theme_cursor_style: Style | None = None
14 |
15 | self.on_theme_change(self.app.current_theme)
16 | self.app.theme_changed_signal.subscribe(self, self.on_theme_change)
17 |
18 | @property
19 | def cursor_style(self) -> Style:
20 | return (
21 | self._theme_cursor_style
22 | if self._theme_cursor_style is not None
23 | else self.get_component_rich_style("input--cursor")
24 | )
25 |
26 | def on_theme_change(self, theme: Theme) -> None:
27 | cursor_style = theme.variables.get("input-cursor")
28 | self._theme_cursor_style = Style.parse(cursor_style) if cursor_style else None
29 | self.refresh()
30 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/widgets/request/__init__.py
--------------------------------------------------------------------------------
/src/posting/widgets/request/form_editor.py:
--------------------------------------------------------------------------------
1 | from typing import Iterable
2 | from rich.text import Text
3 | from textual.app import ComposeResult
4 | from textual.binding import Binding
5 | from textual.containers import Vertical
6 | from posting.collection import FormItem
7 | from posting.widgets.datatable import PostingDataTable
8 | from posting.widgets.key_value import KeyValueEditor, KeyValueInput
9 | from posting.widgets.variable_input import VariableInput
10 |
11 |
12 | class FormTable(PostingDataTable):
13 | BINDINGS = [
14 | Binding("backspace", action="remove_row", description="Remove row"),
15 | Binding("space", action="toggle_row", description="Toggle row"),
16 | ]
17 |
18 | def on_mount(self):
19 | self.fixed_columns = 1
20 | self.show_header = False
21 | self.cursor_type = "row"
22 | self.zebra_stripes = True
23 | self.row_disable = True
24 | self.add_columns("Key", "Value")
25 |
26 | def to_model(self) -> list[FormItem]:
27 | form_data: list[FormItem] = []
28 | for row_index in range(self.row_count):
29 | row = self.get_row_at(row_index)
30 | form_data.append(
31 | FormItem(
32 | name=row[0].plain if isinstance(row[0], Text) else row[0],
33 | value=row[1].plain if isinstance(row[1], Text) else row[1],
34 | enabled=self.is_row_enabled_at(row_index),
35 | )
36 | )
37 | return form_data
38 |
39 |
40 | class FormEditor(Vertical):
41 | """An editor for form body data."""
42 |
43 | def compose(self) -> ComposeResult:
44 | yield KeyValueEditor(
45 | FormTable(),
46 | KeyValueInput(
47 | VariableInput(placeholder="Key"),
48 | VariableInput(placeholder="Value"),
49 | ),
50 | empty_message="There is no form data.",
51 | )
52 |
53 | def to_model(self) -> list[FormItem]:
54 | return self.query_one(FormTable).to_model()
55 |
56 | def replace_all_rows(
57 | self, rows: Iterable[Iterable[str]], enableStates: Iterable[bool] | None = None
58 | ) -> None:
59 | self.query_one(FormTable).replace_all_rows(rows, enableStates)
60 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/method_selection.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from rich.console import RenderableType
3 |
4 | from textual import on
5 | from textual.binding import Binding
6 | from textual.message import Message
7 | from textual.widgets import Select
8 | from posting.collection import HttpRequestMethod
9 | from posting.help_data import HelpData
10 |
11 | from posting.widgets.select import PostingSelect
12 |
13 |
14 | class MethodSelector(PostingSelect[str]):
15 | help = HelpData(
16 | title="Method Selector",
17 | description="""\
18 | Select the HTTP method for the request.
19 | You can select a method by typing a single letter. For example, pressing `g`
20 | will set the method to `GET`.
21 | The dropdown does not need to be expanded in order to select a method.
22 | """,
23 | )
24 |
25 | BINDING_GROUP_TITLE = "HTTP Method Selector"
26 |
27 | BINDINGS = [
28 | Binding("g", "select_method('GET')", "GET", show=False),
29 | Binding("p", "select_method('POST')", "POST", show=False),
30 | Binding("a", "select_method('PATCH')", "PATCH", show=False),
31 | Binding("u", "select_method('PUT')", "PUT", show=False),
32 | Binding("d", "select_method('DELETE')", "DELETE", show=False),
33 | Binding("o", "select_method('OPTIONS')", "OPTIONS", show=False),
34 | Binding("h", "select_method('HEAD')", "HEAD", show=False),
35 | ]
36 |
37 | def __init__(
38 | self,
39 | *,
40 | prompt: str = "Method",
41 | value: str = "GET",
42 | name: str | None = None,
43 | id: str | None = None,
44 | classes: str | None = None,
45 | disabled: bool = False,
46 | tooltip: RenderableType | None = None,
47 | ):
48 | super().__init__(
49 | [
50 | ("[u]G[/]ET", "GET"),
51 | ("[u]P[/]OST", "POST"),
52 | ("P[u]U[/]T", "PUT"),
53 | ("[u]D[/]ELETE", "DELETE"),
54 | ("P[u]A[/]TCH", "PATCH"),
55 | ("[u]H[/]EAD", "HEAD"),
56 | ("[u]O[/]PTIONS", "OPTIONS"),
57 | ],
58 | prompt=prompt,
59 | allow_blank=False,
60 | value=value,
61 | name=name,
62 | id=id,
63 | classes=classes,
64 | disabled=disabled,
65 | tooltip=tooltip,
66 | )
67 |
68 | @dataclass
69 | class MethodChanged(Message):
70 | value: HttpRequestMethod
71 | select: "MethodSelector"
72 |
73 | @property
74 | def control(self) -> "MethodSelector":
75 | return self.select
76 |
77 | @on(Select.Changed)
78 | def method_selected(self, event: Select.Changed) -> None:
79 | event.stop()
80 | if event.value is not Select.BLANK:
81 | self.post_message(
82 | MethodSelector.MethodChanged(value=event.value, select=self)
83 | )
84 |
85 | def action_select_method(self, method: str) -> None:
86 | self.value = method
87 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/query_editor.py:
--------------------------------------------------------------------------------
1 | from rich.text import Text
2 | from textual.app import ComposeResult
3 | from textual.binding import Binding
4 | from textual.containers import Vertical
5 | from textual.widgets import Input
6 | from posting.collection import QueryParam
7 |
8 | from posting.widgets.datatable import PostingDataTable
9 | from posting.widgets.key_value import KeyValueEditor, KeyValueInput
10 | from posting.widgets.variable_input import VariableInput
11 |
12 |
13 | class ParamsTable(PostingDataTable):
14 | """
15 | The parameters table.
16 | """
17 |
18 | BINDINGS = [
19 | Binding("backspace", action="remove_row", description="Remove row"),
20 | Binding("space", action="toggle_row", description="Toggle row"),
21 | ]
22 |
23 | def on_mount(self):
24 | self.fixed_columns = 1
25 | self.show_header = False
26 | self.cursor_type = "row"
27 | self.zebra_stripes = True
28 | self.row_disable = True
29 | self.add_columns("Key", "Value")
30 |
31 | def watch_has_focus(self, value: bool) -> None:
32 | self._scroll_cursor_into_view()
33 | return super().watch_has_focus(value)
34 |
35 | def to_model(self) -> list[QueryParam]:
36 | params: list[QueryParam] = []
37 | for row_index in range(self.row_count):
38 | row = self.get_row_at(row_index)
39 | params.append(
40 | QueryParam(
41 | name=row[0].plain if isinstance(row[0], Text) else row[0],
42 | value=row[1].plain if isinstance(row[1], Text) else row[1],
43 | enabled=self.is_row_enabled_at(row_index),
44 | )
45 | )
46 | return params
47 |
48 |
49 | class QueryStringEditor(Vertical):
50 | """
51 | The query string editor.
52 | """
53 |
54 | def compose(self) -> ComposeResult:
55 | yield KeyValueEditor(
56 | ParamsTable(),
57 | KeyValueInput(
58 | VariableInput(placeholder="Key", id="query-key-input"),
59 | VariableInput(placeholder="Value"),
60 | button_label="Add parameter",
61 | ),
62 | empty_message="No query parameters",
63 | )
64 |
65 | @property
66 | def query_key_input(self) -> Input:
67 | return self.query_one("#query-key-input", Input)
68 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/request_body.py:
--------------------------------------------------------------------------------
1 | from textual.app import ComposeResult
2 | from textual.containers import Horizontal, Vertical
3 | from textual.widgets import ContentSwitcher, Label
4 | from posting.help_data import HelpData
5 |
6 | from posting.widgets.center_middle import CenterMiddle
7 | from posting.widgets.request.form_editor import FormEditor
8 | from posting.widgets.select import PostingSelect
9 | from posting.widgets.text_area import PostingTextArea, TextAreaFooter, TextEditor
10 |
11 |
12 | class RequestBodyEditor(Vertical):
13 | """
14 | A container for the request body text area and the request body type selector.
15 | """
16 |
17 | def compose(self) -> ComposeResult:
18 | with Horizontal(id="request-body-type-select-container"):
19 | yield PostingSelect(
20 | # These values are also referred to inside MainScreen.
21 | # When we load a request, we need to set the correct
22 | # value in the select.
23 | options=[
24 | ("None", "no-body-label"),
25 | ("Raw (json, text, etc.)", "text-body-editor"),
26 | ("Form data (x-www-form-urlencoded)", "form-body-editor"),
27 | ],
28 | id="request-body-type-select",
29 | allow_blank=False,
30 | )
31 | with ContentSwitcher(
32 | initial="no-body-label",
33 | id="request-body-type-content-switcher",
34 | ):
35 | yield CenterMiddle(
36 | Label("No request body"),
37 | id="no-body-label",
38 | )
39 | text_area = RequestBodyTextArea(language="json")
40 | yield TextEditor(
41 | text_area=text_area,
42 | footer=TextAreaFooter(text_area),
43 | id="text-body-editor",
44 | )
45 | yield FormEditor(
46 | id="form-body-editor",
47 | )
48 |
49 |
50 | class RequestBodyTextArea(PostingTextArea):
51 | """
52 | For editing request bodies.
53 | """
54 |
55 | BINDING_GROUP_TITLE = "Request Body Text Area"
56 |
57 | help = HelpData(
58 | title="Request Body Text Area",
59 | description="""\
60 | A text area for entering the request body.
61 | Press `ESC` to focus the text area footer bar.
62 |
63 | Hold `shift` and move the cursor or click and drag to select text.
64 | """,
65 | )
66 |
67 | def on_mount(self):
68 | self.tab_behavior = "indent"
69 | self.show_line_numbers = True
70 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/request_editor.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING, Any, cast
2 | from textual import on
3 | from textual.app import ComposeResult
4 | from textual.containers import Vertical
5 | from textual.lazy import Lazy
6 | from textual.widgets import ContentSwitcher, Select, TabPane
7 | from posting.collection import RequestBody
8 | from posting.widgets.request.form_editor import FormEditor
9 |
10 | from posting.widgets.request.header_editor import HeaderEditor
11 | from posting.widgets.request.query_editor import QueryStringEditor
12 | from posting.widgets.request.request_auth import RequestAuth
13 | from posting.widgets.request.request_body import RequestBodyEditor
14 | from posting.widgets.request.request_metadata import RequestMetadata
15 | from posting.widgets.request.request_options import RequestOptions
16 | from posting.widgets.request.request_scripts import RequestScripts
17 | from posting.widgets.tabbed_content import PostingTabbedContent
18 | from posting.widgets.text_area import TextEditor
19 |
20 |
21 | if TYPE_CHECKING:
22 | from posting.app import Posting
23 |
24 |
25 | class RequestEditorTabbedContent(PostingTabbedContent):
26 | pass
27 |
28 |
29 | class RequestEditor(Vertical):
30 | """
31 | The request editor.
32 | """
33 |
34 | def compose(self) -> ComposeResult:
35 | app = cast("Posting", self.app)
36 | with Vertical() as vertical:
37 | vertical.border_title = "Request"
38 | with RequestEditorTabbedContent():
39 | with TabPane("Headers", id="headers-pane"):
40 | yield HeaderEditor()
41 | with TabPane("Body", id="body-pane"):
42 | yield Lazy(RequestBodyEditor())
43 | with TabPane("Query", id="query-pane"):
44 | yield Lazy(QueryStringEditor())
45 | with TabPane("Auth", id="auth-pane"):
46 | yield Lazy(RequestAuth())
47 | with TabPane("Info", id="info-pane"):
48 | yield Lazy(RequestMetadata())
49 | with TabPane("Scripts", id="scripts-pane"):
50 | yield Lazy(RequestScripts(collection_root=app.collection.path))
51 | with TabPane("Options", id="options-pane"):
52 | yield Lazy(RequestOptions())
53 |
54 | def on_mount(self):
55 | self.border_title = "Request"
56 | self.add_class("section")
57 |
58 | @on(Select.Changed, selector="#request-body-type-select")
59 | def request_body_type_changed(self, event: Select.Changed) -> None:
60 | content_switcher = self.request_body_content_switcher
61 | content_switcher.current = event.value
62 |
63 | @property
64 | def request_body_type_select(self) -> Select[str]:
65 | return self.query_one("#request-body-type-select", Select)
66 |
67 | @property
68 | def request_body_content_switcher(self) -> ContentSwitcher:
69 | return self.query_one("#request-body-type-content-switcher", ContentSwitcher)
70 |
71 | @property
72 | def text_editor(self) -> TextEditor:
73 | return self.query_one("#text-body-editor", TextEditor)
74 |
75 | @property
76 | def form_editor(self) -> FormEditor:
77 | return self.query_one("#form-body-editor", FormEditor)
78 |
79 | @property
80 | def query_editor(self) -> QueryStringEditor:
81 | return self.query_one(QueryStringEditor)
82 |
83 | def to_request_model_args(self) -> dict[str, Any]:
84 | """Returns a dictionary containing the arguments that should be
85 | passed to the httpx.Request object. The keys will depend on the
86 | content type that the user has selected."""
87 | content_switcher = self.request_body_content_switcher
88 | current = content_switcher.current
89 | text_editor = self.text_editor
90 | if current == "no-body-label":
91 | return {"body": None}
92 | elif current == "text-body-editor":
93 | # We need to check the chosen content type in the TextEditor
94 | # We can look at the language to determine the content type.
95 | return {
96 | "body": RequestBody(
97 | content=text_editor.text,
98 | content_type=text_editor.content_type,
99 | )
100 | }
101 | elif current == "form-body-editor":
102 | return {
103 | "body": RequestBody(
104 | form_data=self.form_editor.to_model(),
105 | content_type="application/x-www-form-urlencoded",
106 | )
107 | }
108 | return {}
109 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/request_metadata.py:
--------------------------------------------------------------------------------
1 | from textual.app import ComposeResult
2 | from textual.containers import VerticalScroll
3 | from textual.reactive import Reactive, reactive
4 | from textual.widgets import Input, Label
5 |
6 | from posting.collection import RequestModel
7 | from posting.widgets.text_area import PostingTextArea, ReadOnlyTextArea
8 | from posting.widgets.variable_input import VariableInput
9 |
10 |
11 | class RequestMetadata(VerticalScroll):
12 | request: Reactive[RequestModel | None] = reactive(None, init=False)
13 |
14 | def watch_request(self, request: RequestModel | None) -> None:
15 | """When the request changes, update the form."""
16 | if request is None:
17 | self.request_name_input.value = ""
18 | self.request_description_textarea.text = ""
19 | self.request_path_text_area.text = "Request not saved to disk."
20 | else:
21 | self.request_name_input.value = request.name or ""
22 | self.request_description_textarea.text = request.description
23 | self.request_path_text_area.text = (
24 | str(request.path) if request.path else "Request not saved to disk."
25 | )
26 |
27 | def compose(self) -> ComposeResult:
28 | self.can_focus = False
29 | yield Label("Name [dim]optional[/dim]")
30 | yield VariableInput(placeholder="Enter a name…", id="name-input")
31 | yield Label("Description [dim]optional[/dim]")
32 | yield PostingTextArea(id="description-textarea")
33 | yield Label("Path [dim]read-only[/dim]")
34 | yield ReadOnlyTextArea(
35 | "Request not saved to disk.", select_on_focus=True, id="request-path"
36 | )
37 |
38 | @property
39 | def request_name_input(self) -> Input:
40 | return self.query_one("#name-input", Input)
41 |
42 | @property
43 | def request_description_textarea(self) -> PostingTextArea:
44 | return self.query_one("#description-textarea", PostingTextArea)
45 |
46 | @property
47 | def request_path_text_area(self) -> ReadOnlyTextArea:
48 | return self.query_one("#request-path", ReadOnlyTextArea)
49 |
50 | @property
51 | def request_name(self) -> str:
52 | return self.request_name_input.value
53 |
54 | @property
55 | def description(self) -> str:
56 | return self.request_description_textarea.text
57 |
--------------------------------------------------------------------------------
/src/posting/widgets/request/request_options.py:
--------------------------------------------------------------------------------
1 | from textual import on
2 | from textual.app import ComposeResult
3 | from textual.binding import Binding
4 | from textual.containers import Vertical, VerticalScroll
5 | from textual.events import DescendantFocus
6 | from textual.widgets import Checkbox, Input, Label, Static
7 |
8 | from posting.collection import Options
9 | from posting.widgets.variable_input import VariableInput
10 |
11 |
12 | class RequestOptions(VerticalScroll):
13 | DEFAULT_CSS = """\
14 | RequestOptions {
15 |
16 | Checkbox {
17 | height: 1;
18 | padding: 0 1;
19 | border: none;
20 | background: transparent;
21 | &:focus {
22 | border: none;
23 | & .toggle--label {
24 | text-style: not underline;
25 | }
26 | }
27 | }
28 |
29 | #proxy-option {
30 | padding: 0 1 0 2;
31 | height: 2;
32 | }
33 |
34 | #timeout-option {
35 | padding: 0 1 0 2;
36 | height: 2;
37 | }
38 |
39 | & #option-description {
40 | dock: right;
41 | height: 1fr;
42 | width: 50%;
43 | max-width: 50%;
44 | background: transparent;
45 | color: $text-muted;
46 | padding: 1 2;
47 | display: none;
48 | border-left: $accent 30%;
49 |
50 | &.show {
51 | display: block;
52 | }
53 | }
54 | }
55 | """
56 |
57 | BINDINGS = [
58 | Binding("down,j", "screen.focus_next", "Next"),
59 | Binding("up,k", "screen.focus_previous", "Previous"),
60 | ]
61 |
62 | def __init__(self):
63 | super().__init__()
64 | self.can_focus = False
65 |
66 | self.options = Options()
67 |
68 | self.descriptions = {
69 | "follow-redirects": "Follow redirects when the server responds with a 3xx status code.",
70 | "verify": "Verify SSL certificates when making requests.",
71 | "attach-cookies": "Attach cookies to outgoing requests to the same domain.",
72 | "proxy-url": "Proxy URL to use for requests.\ne.g. http://user:password@localhost:8080",
73 | "timeout": "Timeout for the request in seconds.",
74 | }
75 |
76 | def compose(self) -> ComposeResult:
77 | yield Checkbox(
78 | "Follow redirects",
79 | value=self.options.follow_redirects,
80 | id="follow-redirects",
81 | )
82 | yield Checkbox(
83 | "Verify SSL certificates",
84 | value=self.options.verify_ssl,
85 | id="verify",
86 | )
87 | yield Checkbox(
88 | "Attach cookies",
89 | value=self.options.attach_cookies,
90 | id="attach-cookies",
91 | )
92 |
93 | with Vertical(id="proxy-option"):
94 | yield Label("Proxy URL:")
95 | yield VariableInput(id="proxy-url")
96 |
97 | with Vertical(id="timeout-option"):
98 | yield Label("Timeout:")
99 | yield VariableInput(
100 | value=str(self.options.timeout),
101 | id="timeout",
102 | type="number",
103 | validate_on={"changed"},
104 | )
105 |
106 | # A panel which the description of the option will be
107 | # displayed inside.
108 | yield Static("", id="option-description")
109 |
110 | @on(Checkbox.Changed)
111 | def on_checkbox_change(self, event: Checkbox.Changed) -> None:
112 | """Handle the checkbox change event."""
113 | match event.checkbox.id:
114 | case "follow-redirects":
115 | self.options.follow_redirects = event.value
116 | case "verify":
117 | self.options.verify_ssl = event.value
118 | case "attach-cookies":
119 | self.options.attach_cookies = event.value
120 | case _:
121 | pass
122 |
123 | @on(Input.Changed, selector="#proxy-url")
124 | def on_proxy_url_changed(self, event: Input.Changed) -> None:
125 | """Handle the input change event."""
126 | self.options.proxy_url = event.value
127 |
128 | @on(Input.Changed, selector="#timeout")
129 | def on_timeout_changed(self, event: Input.Changed) -> None:
130 | """Handle the input change event."""
131 | try:
132 | self.options.timeout = float(event.value)
133 | except ValueError:
134 | self.options.timeout = 5.0
135 |
136 | @on(DescendantFocus)
137 | def on_descendant_focus(self, event: DescendantFocus) -> None:
138 | """Show the description of the option when the user focuses on it."""
139 | focused_id = event.control.id
140 | description_label = self.query_one("#option-description", Static)
141 | has_description = focused_id in self.descriptions
142 | description_label.set_class(has_description, "show")
143 | if has_description:
144 | description_label.update(self.descriptions[focused_id])
145 | else:
146 | description_label.update("")
147 |
148 | def to_model(self) -> Options:
149 | """Export the options to a model."""
150 | return self.options
151 |
152 | def load_options(self, options: Options) -> None:
153 | """Load the options into the widget.
154 |
155 | Note that this is just setting the values on the widget.
156 |
157 | The change events emitted from the widgets are the things that
158 | result in the internal Options model state being updated.
159 | """
160 | self.follow_redirects_checkbox.value = options.follow_redirects
161 | self.verify_ssl_checkbox.value = options.verify_ssl
162 | self.attach_cookies_checkbox.value = options.attach_cookies
163 | self.proxy_url_input.value = options.proxy_url
164 | self.timeout_input.value = str(options.timeout)
165 |
166 | @property
167 | def follow_redirects_checkbox(self) -> Checkbox:
168 | return self.query_one("#follow-redirects", Checkbox)
169 |
170 | @property
171 | def verify_ssl_checkbox(self) -> Checkbox:
172 | return self.query_one("#verify", Checkbox)
173 |
174 | @property
175 | def attach_cookies_checkbox(self) -> Checkbox:
176 | return self.query_one("#attach-cookies", Checkbox)
177 |
178 | @property
179 | def proxy_url_input(self) -> Input:
180 | return self.query_one("#proxy-url", Input)
181 |
182 | @property
183 | def timeout_input(self) -> Input:
184 | return self.query_one("#timeout", Input)
185 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/cookies_table.py:
--------------------------------------------------------------------------------
1 | from textual import on
2 | from textual.app import ComposeResult
3 | from textual.containers import Vertical
4 | from textual.widgets import Label
5 | from posting.widgets.center_middle import CenterMiddle
6 | from posting.widgets.datatable import PostingDataTable
7 |
8 |
9 | class CookiesSection(Vertical):
10 | def compose(self) -> ComposeResult:
11 | yield CenterMiddle(Label("No cookies"), id="empty-message")
12 | yield PostingDataTable(id="cookies-table")
13 |
14 | def on_mount(self) -> None:
15 | self.table.show_header = False
16 | self.table.cursor_type = "row"
17 | self.table.zebra_stripes = True
18 | self.table.fixed_columns = 1
19 | self.table.add_columns(*["Name", "Value"])
20 |
21 | @on(PostingDataTable.RowsRemoved)
22 | def rows_removed(self, event: PostingDataTable.RowsRemoved) -> None:
23 | rows = event.data_table.row_count
24 | self.set_class(rows == 0, "empty")
25 |
26 | @on(PostingDataTable.RowsAdded)
27 | def rows_added(self, event: PostingDataTable.RowsAdded) -> None:
28 | rows = event.data_table.row_count
29 | self.set_class(rows == 0, "empty")
30 |
31 | @property
32 | def table(self) -> PostingDataTable:
33 | return self.query_one("#cookies-table", PostingDataTable)
34 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/response_area.py:
--------------------------------------------------------------------------------
1 | import json
2 | import httpx
3 | from textual.lazy import Lazy
4 | from posting.config import SETTINGS
5 |
6 | from posting.widgets.response.response_trace import ResponseTrace
7 | from posting.widgets.response.script_output import ScriptOutput
8 | from posting.widgets.tabbed_content import PostingTabbedContent
9 | from posting.widgets.text_area import TextAreaFooter, TextEditor
10 | from posting.widgets.response.cookies_table import CookiesSection
11 | from posting.widgets.response.response_body import ResponseTextArea
12 | from posting.widgets.response.response_headers import ResponseHeadersTable
13 |
14 | from textual.app import ComposeResult
15 | from textual.containers import Vertical
16 | from textual.reactive import Reactive, reactive
17 | from textual.widgets import TabPane
18 | from textual.widgets._tabbed_content import ContentTabs
19 |
20 |
21 | class ResponseTabbedContent(PostingTabbedContent):
22 | pass
23 |
24 |
25 | class ResponseArea(Vertical):
26 | """
27 | The response area.
28 | """
29 |
30 | COMPONENT_CLASSES = {
31 | "border-title-status",
32 | }
33 |
34 | response: Reactive[httpx.Response | None] = reactive(None)
35 |
36 | def on_mount(self) -> None:
37 | self.border_title = "Response"
38 | self._latest_response: httpx.Response | None = None
39 | self.add_class("section")
40 | self.app.theme_changed_signal.subscribe(self, self.on_theme_change)
41 |
42 | def compose(self) -> ComposeResult:
43 | with ResponseTabbedContent(disabled=self.response is None):
44 | with TabPane("Body", id="response-body-pane"):
45 | text_area = ResponseTextArea(language="json")
46 | yield TextEditor(
47 | text_area,
48 | TextAreaFooter(text_area),
49 | )
50 | with TabPane("Headers", id="response-headers-pane"):
51 | yield Lazy(ResponseHeadersTable())
52 | with TabPane("Cookies", id="response-cookies-pane"):
53 | yield Lazy(CookiesSection())
54 | with TabPane("Scripts", id="response-scripts-pane"):
55 | yield Lazy(ScriptOutput())
56 | with TabPane("Trace", id="response-trace-pane"):
57 | yield Lazy(ResponseTrace())
58 |
59 | def on_theme_change(self, _) -> None:
60 | if self._latest_response:
61 | self.border_title = self._make_border_title(self._latest_response)
62 |
63 | def watch_response(self, response: httpx.Response | None) -> None:
64 | self._latest_response = response
65 | if response is None:
66 | return
67 | else:
68 | self.query_one(ResponseTabbedContent).disabled = False
69 |
70 | self.add_class("response-ready")
71 |
72 | content_type = response.headers.get("content-type")
73 | if content_type:
74 | language = content_type_to_language(content_type)
75 | self.text_editor.language = language
76 |
77 | # Update the body text area with the body content.
78 | response_text_area = self.text_editor.text_area
79 | response_text = response.text
80 | response_settings = SETTINGS.get().response
81 | if response_text_area.language == "json" and response_settings.prettify_json:
82 | try:
83 | response_text = json.dumps(
84 | json.loads(response_text), indent=2, ensure_ascii=False
85 | )
86 | except json.JSONDecodeError:
87 | pass
88 |
89 | response_text_area.text = response_text
90 |
91 | # Update the response headers table with the response headers.
92 | response_headers_table = self.headers_table
93 | response_headers_table.clear()
94 | response_headers_table.add_rows(
95 | [(name, value) for name, value in response.headers.items()]
96 | )
97 |
98 | # Update the response cookies table with the cookies from the response.
99 | cookies_section = self.cookies_section
100 | cookies_section.table.clear()
101 | cookies_section.table.add_rows(
102 | [(name, value) for name, value in response.cookies.items()]
103 | )
104 |
105 | self.remove_class("success", "warning", "error")
106 | if response.status_code < 300:
107 | self.add_class("success")
108 | elif response.status_code < 400:
109 | self.add_class("warning")
110 | else:
111 | self.add_class("error")
112 |
113 | self.border_title = self._make_border_title(response)
114 |
115 | settings = SETTINGS.get()
116 | if settings.response.show_size_and_time:
117 | self.border_subtitle = f"{human_readable_size(len(response.content))} in {response.elapsed.total_seconds() * 1000:.2f}[dim]ms[/]"
118 |
119 | def _make_border_title(self, response: httpx.Response) -> str:
120 | style = self.get_component_rich_style("border-title-status")
121 | return f"Response [{style}] {response.status_code} {response.reason_phrase} [/]"
122 |
123 | @property
124 | def text_editor(self) -> TextEditor:
125 | return self.query_one(TextEditor)
126 |
127 | @property
128 | def headers_table(self) -> ResponseHeadersTable:
129 | return self.query_one(ResponseHeadersTable)
130 |
131 | @property
132 | def cookies_section(self) -> CookiesSection:
133 | return self.query_one(CookiesSection)
134 |
135 | @property
136 | def tabbed_content(self) -> ResponseTabbedContent:
137 | return self.query_one(ResponseTabbedContent)
138 |
139 | @property
140 | def content_tabs(self) -> ContentTabs:
141 | return self.tabbed_content.query_one(ContentTabs)
142 |
143 |
144 | def content_type_to_language(content_type: str) -> str | None:
145 | """Given the value of an HTTP content-type header, return the name
146 | of the language to use in the response body text area."""
147 | if content_type.startswith("application/json"):
148 | return "json"
149 | elif content_type.startswith("text/html") or content_type.startswith(
150 | "application/xml"
151 | ):
152 | return "html"
153 | elif content_type.startswith("text/css"):
154 | return "css"
155 | elif content_type.startswith("text/plain"):
156 | return None
157 | return "json"
158 |
159 |
160 | def human_readable_size(size: float, decimal_places: int = 2) -> str: # type: ignore
161 | for unit in ["B", "KB", "MB", "GB", "TB"]:
162 | if size < 1024:
163 | return f"{size:.{decimal_places}f}[dim]{unit}[/]"
164 | size /= 1024
165 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/response_body.py:
--------------------------------------------------------------------------------
1 | from textual import on
2 | from textual.widgets import TextArea
3 | from posting.help_data import HelpData
4 |
5 | from posting.widgets.text_area import ReadOnlyTextArea
6 |
7 |
8 | class ResponseTextArea(ReadOnlyTextArea):
9 | """
10 | For displaying responses.
11 | """
12 |
13 | help = HelpData(
14 | title="Response Body Text Area",
15 | description="""\
16 | A *read-only* text area for displaying the response body.
17 | Supports several Vim keys (see table below).
18 | Hold `shift` and move the cursor or click and drag to select text.
19 | Press `v` to toggle *visual mode*, equivalent to keeping `shift` held down.
20 | Copy to the clipboard by pressing `y`. If no text is selected, the entire response body is copied.
21 |
22 | Open the response in your `$PAGER` by pressing `f3`. A custom pager (e.g. `fx`)
23 | can be used for JSON responses by setting the `pager_json` config to the command.
24 | """,
25 | )
26 |
27 | BINDING_GROUP_TITLE = "Response Body Text Area"
28 |
29 | @on(TextArea.Changed)
30 | def on_change(self, event: TextArea.Changed) -> None:
31 | empty = len(self.text) == 0
32 | self.set_class(empty, "empty")
33 | self.show_line_numbers = not empty
34 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/response_headers.py:
--------------------------------------------------------------------------------
1 | from posting.widgets.datatable import PostingDataTable
2 |
3 |
4 | class ResponseHeadersTable(PostingDataTable):
5 | def on_mount(self) -> None:
6 | self.show_header = False
7 | self.cursor_type = "row"
8 | self.zebra_stripes = True
9 | self.fixed_columns = 1
10 | self.add_columns(*["Header", "Value"])
11 | self.cursor_vertical_escape = False
12 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/response_trace.py:
--------------------------------------------------------------------------------
1 | import time
2 | from typing import Any, Literal
3 | from textual.app import ComposeResult
4 | from textual.containers import VerticalScroll
5 | from textual.widgets import Label
6 |
7 |
8 | Event = Literal[
9 | "connection.connect_tcp.started",
10 | "connection.connect_tcp.complete",
11 | "connection.connect_tcp.failed",
12 | "connection.connect_unix_socket.started",
13 | "connection.connect_unix_socket.complete",
14 | "connection.connect_unix_socket.failed",
15 | "connection.start_tls.started",
16 | "connection.start_tls.complete",
17 | "connection.start_tls.failed",
18 | "http11.send_request_headers.started",
19 | "http11.send_request_headers.complete",
20 | "http11.send_request_headers.failed",
21 | "http11.send_request_body.started",
22 | "http11.send_request_body.complete",
23 | "http11.send_request_body.failed",
24 | "http11.receive_response_body.started",
25 | "http11.receive_response_body.complete",
26 | "http11.receive_response_body.failed",
27 | "http11.response_closed.started",
28 | "http11.response_closed.complete",
29 | "http11.response_closed.failed",
30 | ]
31 |
32 |
33 | class ResponseTrace(VerticalScroll):
34 | DEFAULT_CSS = """\
35 | ResponseTrace {
36 | padding: 0 2;
37 | }
38 | """
39 |
40 | def __init__(
41 | self,
42 | name: str | None = None,
43 | id: str | None = None,
44 | classes: str | None = None,
45 | disabled: bool = False,
46 | ) -> None:
47 | super().__init__(name=name, id=id, classes=classes, disabled=disabled)
48 | self.events: dict[str, dict[str, float]] = {}
49 |
50 | def compose(self) -> ComposeResult:
51 | self.can_focus = False
52 | if len(self.events) == 0:
53 | yield Label("Send a request to view the trace.")
54 | else:
55 | for event_name, status_times in self.events.items():
56 | if "completed" in status_times:
57 | duration_ns = status_times["completed"] - status_times["started"]
58 | duration_ms = duration_ns / 1000000
59 | yield Label(
60 | f"[b]{event_name}[/b]: [green]{duration_ms:.2f}ms[/green]"
61 | )
62 | elif "failed" in status_times:
63 | yield Label(f"[b]{event_name}[/b]: [red]failed[/red]")
64 | else:
65 | yield Label(f"[b]{event_name}[/b]: [yellow]waiting[/yellow]")
66 |
67 | async def log_event(self, event_name: Event, info: dict[str, Any]) -> None:
68 | event_name, status = event_name.rsplit(".", maxsplit=1)
69 | events = self.events
70 | match status:
71 | case "started":
72 | events[event_name] = {"started": time.perf_counter_ns()}
73 | case "complete":
74 | events[event_name]["completed"] = time.perf_counter_ns()
75 | case "failed":
76 | events[event_name]["failed"] = time.perf_counter_ns()
77 | case _:
78 | pass
79 |
80 | await self.recompose()
81 |
82 | def trace_complete(self) -> None:
83 | self.events = {}
84 |
--------------------------------------------------------------------------------
/src/posting/widgets/response/script_output.py:
--------------------------------------------------------------------------------
1 | """Tab for displaying the output of a script.
2 | https://github.com/sergeyklay/gohugo-theme-ed/blob/main/exampleSite/hugo.toml
3 | This could be test results, logs, or other output from pre-request or
4 | post-response scripts.
5 | """
6 |
7 | from typing import Literal
8 | from textual.app import ComposeResult
9 | from textual.binding import Binding
10 | from textual.containers import Horizontal, Vertical, VerticalScroll
11 | from textual.reactive import Reactive, reactive
12 | from textual.widgets import Label, RichLog
13 |
14 | from posting.help_data import HelpData
15 | from posting.widgets.rich_log import PostingRichLog
16 |
17 | ScriptStatus = Literal["success", "error", "no-script"]
18 |
19 |
20 | class ScriptOutput(VerticalScroll):
21 | help = HelpData(
22 | title="Script Output",
23 | description="""\
24 | This log displays the output of scripts that executed during the last request.
25 | """,
26 | )
27 |
28 | BINDING_GROUP_TITLE = "Script Output"
29 |
30 | setup_status: Reactive[ScriptStatus] = reactive("no-script")
31 | request_status: Reactive[ScriptStatus] = reactive("no-script")
32 | response_status: Reactive[ScriptStatus] = reactive("no-script")
33 |
34 | def compose(self) -> ComposeResult:
35 | self.can_focus = False
36 | with Horizontal(id="status-bar"):
37 | with Vertical():
38 | yield Label("Setup")
39 | yield Label(self.setup_status, id="setup-status")
40 | with Vertical():
41 | yield Label("Pre-request")
42 | yield Label(self.request_status, id="request-status")
43 | with Vertical():
44 | yield Label("Post-response")
45 | yield Label(self.response_status, id="response-status")
46 |
47 | yield Label("Script output", id="script-output-title")
48 | yield PostingRichLog(markup=True, highlight=True)
49 |
50 | def set_setup_status(self, status: ScriptStatus) -> None:
51 | """Set the status of the setup script."""
52 | self.setup_status = status
53 | self.set_label_status("setup-status", status)
54 |
55 | def set_request_status(self, status: ScriptStatus) -> None:
56 | """Set the status of the request."""
57 | self.request_status = status
58 | self.set_label_status("request-status", status)
59 |
60 | def set_response_status(self, status: ScriptStatus) -> None:
61 | """Set the status of the response."""
62 | self.response_status = status
63 | self.set_label_status("response-status", status)
64 |
65 | def set_label_status(self, label_id: str, status: ScriptStatus) -> None:
66 | """Set the status of a label."""
67 | label = self.query_one(f"#{label_id}")
68 | success = status == "success"
69 | error = status == "error"
70 | no_script = status == "no-script"
71 |
72 | if isinstance(label, Label):
73 | label.set_class(success, "-success")
74 | label.set_class(error, "-error")
75 | label.set_class(no_script, "-no-script")
76 |
77 | if success:
78 | label.update("Success ✔︎")
79 | elif error:
80 | label.update("Error ⨯")
81 | elif no_script:
82 | label.update("-")
83 |
84 | def reset(self) -> None:
85 | """Reset the output."""
86 | self.rich_log.clear()
87 | self.set_setup_status("no-script")
88 | self.set_request_status("no-script")
89 | self.set_response_status("no-script")
90 |
91 | def log_function_call_start(self, function: str) -> None:
92 | """Log the start of a function call."""
93 | self.rich_log.write(f"[b dim]Running {function}[/]")
94 |
95 | @property
96 | def rich_log(self) -> RichLog:
97 | """Get the RichLog widget which stdout and stderr are printed to."""
98 | return self.query_one(RichLog)
99 |
--------------------------------------------------------------------------------
/src/posting/widgets/rich_log.py:
--------------------------------------------------------------------------------
1 | from io import StringIO
2 | from typing import Literal
3 | from textual.binding import Binding
4 | from textual.widgets import RichLog
5 |
6 |
7 | class RichLogIO(StringIO):
8 | def __init__(self, rich_log: RichLog, stream_type: Literal["stdout", "stderr"]):
9 | super().__init__()
10 | self.rich_log: RichLog = rich_log
11 | self.stream_type: Literal["stdout", "stderr"] = stream_type
12 | self._buffer: str = ""
13 |
14 | def write(self, s: str) -> int:
15 | lines = (self._buffer + s).splitlines(True)
16 | self._buffer = ""
17 | for line in lines:
18 | if line.endswith("\n"):
19 | self._flush_line(line.rstrip("\n"))
20 | else:
21 | self._buffer = line
22 | return len(s)
23 |
24 | def _flush_line(self, line: str) -> None:
25 | if self.stream_type == "stdout":
26 | self.rich_log.write(f" [green]out[/green] {line}")
27 | else:
28 | self.rich_log.write(f" [red]err[/red] {line}")
29 |
30 | def flush(self) -> None:
31 | if self._buffer:
32 | self._flush_line(self._buffer)
33 | self._buffer = ""
34 | super().flush()
35 |
36 |
37 | class PostingRichLog(RichLog):
38 | BINDINGS = [
39 | Binding("j", "scroll_down", "Scroll down"),
40 | Binding("k", "scroll_up", "Scroll up"),
41 | Binding("h", "scroll_left", "Scroll left"),
42 | Binding("l", "scroll_right", "Scroll right"),
43 | ]
44 |
--------------------------------------------------------------------------------
/src/posting/widgets/select.py:
--------------------------------------------------------------------------------
1 | from typing import TypeVar
2 | from textual.binding import Binding
3 | from textual.widgets import Select
4 | from textual.widgets._select import SelectOverlay
5 |
6 | T = TypeVar("T")
7 |
8 |
9 | class PostingSelect(Select[T], inherit_bindings=False):
10 | BINDINGS = [
11 | Binding("enter,space,l", "show_overlay", "Show Overlay", show=False),
12 | Binding("up,k", "cursor_up", "Cursor Up", show=False),
13 | Binding("down,j", "cursor_down", "Cursor Down", show=False),
14 | ]
15 |
16 | def action_cursor_up(self):
17 | if self.expanded:
18 | self.select_overlay.action_cursor_up()
19 | else:
20 | self.screen.focus_previous()
21 |
22 | def action_cursor_down(self):
23 | if self.expanded:
24 | self.select_overlay.action_cursor_down()
25 | else:
26 | self.screen.focus_next()
27 |
28 | @property
29 | def select_overlay(self) -> SelectOverlay:
30 | return self.query_one(SelectOverlay)
31 |
--------------------------------------------------------------------------------
/src/posting/widgets/tabbed_content.py:
--------------------------------------------------------------------------------
1 | from textual.binding import Binding
2 | from textual.widgets import TabbedContent, Tabs
3 |
4 |
5 | class PostingTabbedContent(TabbedContent):
6 | BINDINGS = [
7 | Binding("l", "next_tab", "Next tab", show=False),
8 | Binding("h", "previous_tab", "Previous tab", show=False),
9 | Binding("down,j", "app.focus_next", "Focus next", show=False),
10 | Binding("up,k", "app.focus_previous", "Focus previous", show=False),
11 | ]
12 |
13 | def action_next_tab(self) -> None:
14 | tabs = self.query_one(Tabs)
15 | if tabs.has_focus:
16 | tabs.action_next_tab()
17 |
18 | def action_previous_tab(self) -> None:
19 | tabs = self.query_one(Tabs)
20 | if tabs.has_focus:
21 | tabs.action_previous_tab()
22 |
--------------------------------------------------------------------------------
/src/posting/widgets/tree.py:
--------------------------------------------------------------------------------
1 | from typing import Generator, TypeVar
2 | from textual.binding import Binding
3 | from textual.widgets import Tree
4 | from textual.widgets.tree import TreeNode
5 |
6 | T = TypeVar("T")
7 |
8 |
9 | class PostingTree(Tree[T]):
10 | DEFAULT_CSS = """\
11 | PostingTree {
12 | scrollbar-size-horizontal: 0;
13 | & .node-selected {
14 | background: $primary-lighten-1;
15 | color: $text;
16 | text-style: bold;
17 | }
18 | }
19 |
20 | """
21 |
22 | BINDINGS = [
23 | Binding("k", "cursor_up", "Cursor Up", show=False),
24 | Binding("j", "cursor_down", "Cursor Down", show=False),
25 | Binding("K", "cursor_up_parent", "Cursor Up Parent", show=False),
26 | Binding("J", "cursor_down_parent", "Cursor Down Parent", show=False),
27 | Binding("g", "scroll_home", "Cursor To Top", show=False),
28 | Binding("G", "scroll_end", "Cursor To Bottom", show=False),
29 | Binding("enter,l,h", "select_cursor", "Select Cursor", show=False),
30 | Binding("space,r", "toggle_node", "Toggle Expand", show=False),
31 | ]
32 |
33 | def action_cursor_up_parent(self) -> None:
34 | """Move the cursor to the previous collapsible node."""
35 | start_line = max(self.cursor_line - 1, 0)
36 | for line in range(start_line, -1, -1):
37 | node = self.get_node_at_line(line)
38 | if node and node.allow_expand:
39 | self.cursor_line = line
40 | return
41 |
42 | def action_cursor_down_parent(self) -> None:
43 | """Move the cursor to the next collapsible node."""
44 | max_index = len(self._tree_lines) - 1
45 | start_line = min(self.cursor_line + 1, max_index)
46 | for line in range(start_line, max_index + 1):
47 | node = self.get_node_at_line(line)
48 | if node and node.allow_expand:
49 | self.cursor_line = line
50 | return
51 |
52 | def walk_nodes(self) -> Generator[TreeNode[T], None, None]:
53 | """Walk the nodes of the tree."""
54 | for node in self._tree_nodes.values():
55 | yield node
56 |
--------------------------------------------------------------------------------
/src/posting/widgets/variable_autocomplete.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Sequence
2 | from textual.widgets import Input
3 | from textual_autocomplete import (
4 | AutoComplete,
5 | DropdownItem,
6 | TargetState,
7 | )
8 |
9 | from posting.variables import (
10 | find_variable_end,
11 | find_variable_start,
12 | get_variable_at_cursor,
13 | get_variables,
14 | is_cursor_within_variable,
15 | )
16 |
17 |
18 | class VariableAutoComplete(AutoComplete):
19 | def __init__(
20 | self,
21 | target: Input | str,
22 | candidates: Sequence[DropdownItem | str]
23 | | Callable[[TargetState], list[DropdownItem]]
24 | | None = None,
25 | variable_candidates: Sequence[DropdownItem]
26 | | Callable[[TargetState], list[DropdownItem]]
27 | | None = None,
28 | prevent_default_enter: bool = True,
29 | prevent_default_tab: bool = True,
30 | name: str | None = None,
31 | id: str | None = None,
32 | classes: str | None = None,
33 | disabled: bool = False,
34 | ) -> None:
35 | super().__init__(
36 | target=target,
37 | candidates=candidates,
38 | prevent_default_enter=prevent_default_enter,
39 | prevent_default_tab=prevent_default_tab,
40 | name=name,
41 | id=id,
42 | classes=classes,
43 | disabled=disabled,
44 | )
45 | if variable_candidates is None:
46 | variable_candidates = [
47 | DropdownItem(main=f"${variable}") for variable in get_variables()
48 | ]
49 | self.variable_candidates = variable_candidates
50 |
51 | def get_candidates(self, target_state: TargetState) -> list[DropdownItem]:
52 | cursor = target_state.cursor_position
53 | text = target_state.text
54 | if is_cursor_within_variable(cursor, text):
55 | candidates = self.get_variable_candidates(target_state)
56 | else:
57 | candidates = super().get_candidates(target_state)
58 | return candidates
59 |
60 | def apply_completion(self, value: str, state: TargetState) -> None:
61 | """Modify the target state to reflect the completion.
62 |
63 | Only works in Inputs for now.
64 | """
65 | cursor = state.cursor_position
66 | text = state.text
67 | target: Input = self.target
68 | if is_cursor_within_variable(cursor, text):
69 | # Replace the text from the variable start
70 | # with the completion text.
71 | start = find_variable_start(cursor, text)
72 | end = find_variable_end(cursor, text)
73 | old_value = text
74 | new_value = old_value[:start] + value + old_value[end:]
75 |
76 | target.value = new_value
77 | target.cursor_position = start + len(value)
78 | else:
79 | target.value = value
80 | target.cursor_position = len(value)
81 |
82 | self.post_completion()
83 |
84 | def get_search_string(self, target_state: TargetState) -> str:
85 | cursor = target_state.cursor_position
86 | text = target_state.text
87 | if is_cursor_within_variable(cursor, text):
88 | variable_at_cursor = get_variable_at_cursor(cursor, text)
89 | return variable_at_cursor or ""
90 | else:
91 | return target_state.text
92 |
93 | def get_variable_candidates(self, target_state: TargetState) -> list[DropdownItem]:
94 | candidates = self.variable_candidates
95 | return candidates(target_state) if callable(candidates) else candidates
96 |
--------------------------------------------------------------------------------
/src/posting/widgets/variable_input.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable
2 | from textual_autocomplete import DropdownItem, TargetState
3 | from posting.help_data import HelpData
4 | from posting.highlighters import VariableHighlighter
5 | from posting.themes import Theme, VariableStyles
6 | from posting.variables import get_variables
7 | from posting.widgets.input import PostingInput
8 |
9 | from posting.widgets.variable_autocomplete import VariableAutoComplete
10 |
11 |
12 | class VariableInput(PostingInput):
13 | help = HelpData(
14 | title="Variable-Aware Input",
15 | description="""\
16 | An input field which supports auto-completion and highlighting of variables
17 | from the environment (loaded via `--env`).
18 | Use the `up` and `down` keys to navigate the dropdown list when it's visible.
19 | Press `enter` to insert a dropdown item.
20 | Press `tab` to both insert *and* shift focus.
21 | """,
22 | )
23 |
24 | BINDING_GROUP_TITLE = "Variable Input"
25 |
26 | def __init__(
27 | self,
28 | *args: Any,
29 | candidates: list[DropdownItem | str]
30 | | Callable[[TargetState], list[DropdownItem]]
31 | | None = None,
32 | **kwargs: Any,
33 | ):
34 | super().__init__(*args, **kwargs)
35 | self.candidates = candidates
36 | """Non-variable candidates to show in the dropdown list."""
37 |
38 | def on_mount(self) -> None:
39 | self.highlighter = VariableHighlighter()
40 | self.auto_complete = VariableAutoComplete(
41 | candidates=self.candidates or [],
42 | variable_candidates=self._get_variable_candidates,
43 | target=self,
44 | )
45 | self.screen.mount(self.auto_complete)
46 |
47 | def on_theme_change(self, theme: Theme) -> None:
48 | """Callback which fires when the app-level theme changes in order
49 | to update the color scheme of the variable highlighter.
50 |
51 | Args:
52 | theme: The new app theme.
53 | """
54 | super().on_theme_change(theme)
55 | self.highlighter.variable_styles = VariableStyles(
56 | resolved=theme.variables.get("variable-resolved"),
57 | unresolved=theme.variables.get("variable-unresolved"),
58 | )
59 | self.refresh()
60 |
61 | def _get_variable_candidates(self, target_state: TargetState) -> list[DropdownItem]:
62 | return [DropdownItem(main=f"${variable}") for variable in get_variables()]
63 |
--------------------------------------------------------------------------------
/src/posting/xresources.py:
--------------------------------------------------------------------------------
1 | import itertools
2 | import subprocess
3 | from typing import Any
4 |
5 | from posting.themes import Theme
6 | from textual.theme import Theme as TextualTheme
7 |
8 | XRDB_MAPPING = {
9 | "color0": ["primary"],
10 | "color8": ["secondary"],
11 | "color1": ["error"],
12 | "color2": ["success"],
13 | "color3": ["warning"],
14 | "color4": ["accent"],
15 | "background": ["background"],
16 | "color7": ["surface", "panel"],
17 | }
18 |
19 |
20 | def load_xresources_themes() -> dict[str, TextualTheme]:
21 | """Runs xrdb -query and returns a dictionary of theme_name -> ColorSystem objects."""
22 | try:
23 | result = subprocess.run(
24 | ["xrdb", "-query"], capture_output=True, text=True, check=True
25 | )
26 | except subprocess.CalledProcessError as e:
27 | raise RuntimeError(f"Error running xrdb: {e}")
28 | except OSError as e:
29 | raise RuntimeError(f"Error running xrdb: {e}")
30 |
31 | supplied_colors: dict[str, Any] = {}
32 | for line in result.stdout.splitlines():
33 | if line.startswith("*"):
34 | name, value = line.removeprefix("*").split(":", 1)
35 | for kwarg in XRDB_MAPPING.get(name.strip(), []):
36 | supplied_colors[kwarg] = value.strip()
37 |
38 | missing_colors = (
39 | set(itertools.chain(*XRDB_MAPPING.values())) - supplied_colors.keys()
40 | )
41 | if missing_colors:
42 | missing_colors_string = ", ".join(missing_colors)
43 | raise RuntimeError(f"Missing colors from xrdb: {missing_colors_string}")
44 |
45 | return {
46 | "xresources-dark": Theme(
47 | name="xresources-dark",
48 | **supplied_colors,
49 | dark=True,
50 | ).to_textual_theme(),
51 | "xresources-light": Theme(
52 | name="xresources-light",
53 | **supplied_colors,
54 | dark=False,
55 | ).to_textual_theme(),
56 | }
57 |
--------------------------------------------------------------------------------
/src/posting/yaml.py:
--------------------------------------------------------------------------------
1 | from yaml import load, dump
2 | import yaml
3 |
4 | try:
5 | from yaml import CLoader as Loader, Dumper as Dumper
6 | except ImportError:
7 | from yaml import Loader, Dumper
8 |
9 |
10 | def str_presenter(dumper: Dumper, data: str) -> yaml.ScalarNode:
11 | if data.count("\n") > 0:
12 | data = "\n".join(
13 | [line.rstrip() for line in data.splitlines()]
14 | ) # Remove any trailing spaces, then put it back together again
15 | return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
16 | return dumper.represent_scalar("tag:yaml.org,2002:str", data)
17 |
18 |
19 | yaml.add_representer(str, str_presenter)
20 | yaml.representer.SafeRepresenter.add_representer(str, str_presenter)
21 |
22 |
23 | __all__ = ["load", "dump", "Loader", "Dumper"]
24 |
--------------------------------------------------------------------------------
/tests/posting_snapshot_app.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from posting.__main__ import make_posting
3 |
4 | COLLECTION = Path(__file__).parent / "sample-collections"
5 | BASE_ENV = COLLECTION / "sample_base.env"
6 | EXTRA_ENV = COLLECTION / "sample_extra.env"
7 |
8 | envs = tuple(str(env) for env in [BASE_ENV, EXTRA_ENV])
9 | app = make_posting(collection=COLLECTION, env=envs)
10 | # app.run()
11 |
--------------------------------------------------------------------------------
/tests/sample-collections/echo-post-01.posting.yaml:
--------------------------------------------------------------------------------
1 | name: echo post
2 | description: Echo server for post requests.
3 | method: POST
4 | url: https://postman-echo.com/post
5 | body:
6 | content: |-
7 | {
8 | "string": "Hello, world!",
9 | "booleans": [true, false],
10 | "numbers": [1, 2, 42],
11 | "null": null
12 | }
13 | content_type: application/json
14 | auth:
15 | type: basic
16 | basic:
17 | username: darren
18 | headers:
19 | - name: Content-Type
20 | value: application/json
21 | - name: Accept
22 | value: '*'
23 | - name: Cache-Control
24 | value: no-cache
25 | - name: Accept-Encoding
26 | value: gzip
27 | params:
28 | - name: key1
29 | value: value1
30 | - name: another-key
31 | value: another-value
32 | - name: number
33 | value: '123'
34 | options:
35 | timeout: 0.2
36 |
--------------------------------------------------------------------------------
/tests/sample-collections/echo.posting.yaml:
--------------------------------------------------------------------------------
1 | name: echo
2 | description: This is an echo server we can use to see exactly what request is being
3 | sent.
4 | url: https://postman-echo.com/get
5 | body:
6 | form_data:
7 | - name: something
8 | value: '123'
9 | headers:
10 | - name: X-Setup-Var
11 | value: $setup_var
12 | scripts:
13 | setup: scripts/my_script.py
14 | on_request: scripts/my_script.py
15 | on_response: scripts/my_script.py
16 | options:
17 | follow_redirects: false
18 |
--------------------------------------------------------------------------------
/tests/sample-collections/get-random-user.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get random user
2 | url: https://api.randomuser.me
3 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/comments/edit.posting.yaml:
--------------------------------------------------------------------------------
1 | name: edit a comment
2 | description: Edit a comment
3 | method: PUT
4 | url: https://jsonplaceholder.typicode.com/comments/1
5 | body:
6 | content: |-
7 | {
8 | "postId": 1,
9 | "name": "Updated Commenter Name",
10 | "email": "updated.email@example.com",
11 | "body": "This is the updated comment body."
12 | }
13 | headers:
14 | - name: Content-Type
15 | value: application/json
16 | - name: Referer
17 | value: https://example.com/
18 | - name: Accept-Encoding
19 | value: gzip
20 | - name: Cache-Control
21 | value: no-cache
22 | params:
23 | - name: postId
24 | value: '1'
25 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/comments/get-comments-query.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get comments (via param)
2 | description: Retrieve the comments for a post using the query string
3 | url: https://jsonplaceholder.typicode.com/comments
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 | params:
14 | - name: postId
15 | value: '1'
16 | - name: foo
17 | value: bar
18 | - name: beep
19 | value: boop
20 | - name: onetwothree
21 | value: '123'
22 | - name: areallyreallyreallyreallylongkey
23 | value: avalue
24 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/comments/get-comments.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get comments
2 | description: Retrieve the comments for a post
3 | url: https://jsonplaceholder.typicode.com/posts/1/comments
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/create.posting.yaml:
--------------------------------------------------------------------------------
1 | name: create
2 | description: Create a new post
3 | method: POST
4 | url: https://jsonplaceholder.typicode.com/posts
5 | body:
6 | content: |-
7 | {
8 | "title": "foo",
9 | "body": "bar",
10 | "userId": 1
11 | }
12 | headers:
13 | - name: Content-Type
14 | value: application/json
15 | - name: Referer
16 | value: https://example.com/
17 | - name: Accept-Encoding
18 | value: gzip
19 | - name: Cache-Control
20 | value: no-cache
21 | options:
22 | timeout: 0.2
23 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/delete.posting.yaml:
--------------------------------------------------------------------------------
1 | name: delete a post
2 | description: Delete a single post
3 | method: DELETE
4 | url: https://jsonplaceholder.typicode.com/posts/1
5 | headers:
6 | - name: Content-Type
7 | value: application/json
8 | - name: Referer
9 | value: https://example.com/
10 | - name: Accept-Encoding
11 | value: gzip
12 | - name: Cache-Control
13 | value: no-cache
14 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/get-all.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get all
2 | description: Retrieve all posts
3 | url: https://jsonplaceholder.typicode.com/posts
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/posts/get-one.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get one
2 | description: Retrieve one post
3 | url: https://jsonplaceholder.typicode.com/posts/$POST_ID/
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/todos/get-all.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get all
2 | description: Retrieve all todos
3 | url: https://jsonplaceholder.typicode.com/todos
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 | options:
14 | follow_redirects: false
15 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/todos/get-one.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get one
2 | description: Retrieve one todo
3 | url: https://jsonplaceholder.typicode.com/todos/$TODO_ID
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 | options:
14 | follow_redirects: false
15 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/users/create.posting.yaml:
--------------------------------------------------------------------------------
1 | name: create a user
2 | description: Create a new user
3 | method: POST
4 | url: https://jsonplaceholder.typicode.com/users
5 | body:
6 | content: |-
7 | {
8 | "name": "John Doe",
9 | "username": "john.doe",
10 | "email": "john.doe@example.com"
11 | }
12 | content_type: application/json
13 | headers:
14 | - name: Content-Type
15 | value: application/json
16 | - name: Referer
17 | value: https://example.com/
18 | - name: Accept-Encoding
19 | value: gzip
20 | - name: Cache-Control
21 | value: no-cache
22 | scripts:
23 | on_request: scripts/my_script.py
24 | options:
25 | follow_redirects: false
26 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/users/delete.posting.yaml:
--------------------------------------------------------------------------------
1 | name: delete a user
2 | description: Delete a user
3 | method: DELETE
4 | url: https://jsonplaceholder.typicode.com/users/1
5 | headers:
6 | - name: Content-Type
7 | value: application/json
8 | - name: Referer
9 | value: https://example.com/
10 | - name: Accept-Encoding
11 | value: gzip
12 | - name: Cache-Control
13 | value: no-cache
14 | options:
15 | follow_redirects: false
16 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/users/get-all.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get all users
2 | description: Retrieve all users
3 | url: https://jsonplaceholder.typicode.com/users
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 | options:
14 | follow_redirects: false
15 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/users/get-one.posting.yaml:
--------------------------------------------------------------------------------
1 | name: get a user
2 | description: Retrieve a single user
3 | url: https://jsonplaceholder.typicode.com/users/${USER_ID_UNRESOLVED}/$ALSO_UNRESOLVED
4 | headers:
5 | - name: Content-Type
6 | value: application/json
7 | - name: Referer
8 | value: https://example.com/
9 | - name: Accept-Encoding
10 | value: gzip
11 | - name: Cache-Control
12 | value: no-cache
13 | options:
14 | follow_redirects: false
15 |
--------------------------------------------------------------------------------
/tests/sample-collections/jsonplaceholder/users/update.posting.yaml:
--------------------------------------------------------------------------------
1 | name: update a user
2 | description: Update a user
3 | method: PUT
4 | url: https://jsonplaceholder.typicode.com/users/1
5 | body:
6 | content: |-
7 | {
8 | "name": "James Doe",
9 | "username": "james.doe",
10 | "email": "james.doe@example.com"
11 | }
12 | headers:
13 | - name: Content-Type
14 | value: application/json
15 | - name: Referer
16 | value: https://example.com/
17 | - name: Accept-Encoding
18 | value: gzip
19 | - name: Cache-Control
20 | value: no-cache
21 | options:
22 | follow_redirects: false
23 |
--------------------------------------------------------------------------------
/tests/sample-collections/scripts/my_script.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import httpx
3 |
4 | from posting import Auth, Header, RequestModel, Posting
5 |
6 |
7 | def setup(posting: Posting) -> None:
8 | print("Hello from my_script.py:setup!")
9 | sys.stderr.write("error from setup!\n")
10 | posting.set_variable("setup_var", "ADDED IN SETUP")
11 |
12 |
13 | def on_request(request: RequestModel, posting: Posting) -> None:
14 | new_header = "Foo-Bar-Baz!!!!!"
15 | header = Header(name="X-Custom-Header", value=new_header)
16 | request.headers.append(header)
17 | print(f"Set header:\n{header}!")
18 | # request.body.content = "asdf"
19 | request.auth = Auth.basic_auth("username", "password")
20 | posting.notify(
21 | message="Hello from my_script.py!",
22 | )
23 | posting.set_variable("set_in_script", "foo")
24 | sys.stderr.write("Hello from my_script.py:on_request - i'm an error!")
25 |
26 |
27 | def on_response(response: httpx.Response, posting: Posting) -> None:
28 | print(response.status_code)
29 | print(posting.variables["set_in_script"]) # prints "foo"
30 | sys.stderr.write("Hello from my_script.py:on_response - i'm an error!")
31 | sys.stdout.write("Hello from my_script.py!")
32 |
--------------------------------------------------------------------------------
/tests/sample-configs/custom_theme.yaml:
--------------------------------------------------------------------------------
1 | # Use a user-defined theme from the themes dir.
2 | theme: serene_ocean # corresponds to two.yaml
3 | use_host_environment: false
4 | response:
5 | show_size_and_time: false
6 | heading:
7 | show_host: false
8 | show_version: false
9 | text_input:
10 | blinking_cursor: false
11 | watch_env_files: false
12 | watch_collection_files: false
13 | watch_themes: false
14 |
15 |
--------------------------------------------------------------------------------
/tests/sample-configs/custom_theme2.yaml:
--------------------------------------------------------------------------------
1 | # Use a user-defined theme from the themes dir.
2 | theme: anothertest
3 | use_host_environment: false
4 | response:
5 | show_size_and_time: false
6 | heading:
7 | show_host: false
8 | show_version: false
9 | text_input:
10 | blinking_cursor: false
11 | watch_env_files: false
12 | watch_collection_files: false
13 | watch_themes: false
--------------------------------------------------------------------------------
/tests/sample-configs/general.yaml:
--------------------------------------------------------------------------------
1 | use_host_environment: false
2 | load_user_themes: false
3 | response:
4 | show_size_and_time: false
5 | heading:
6 | show_host: false
7 | show_version: false
8 | text_input:
9 | blinking_cursor: false
10 | watch_env_files: false
11 | watch_collection_files: false
12 | watch_themes: false
--------------------------------------------------------------------------------
/tests/sample-configs/modified_config.yaml:
--------------------------------------------------------------------------------
1 | theme: galaxy
2 | layout: horizontal
3 | use_host_environment: false
4 | load_user_themes: false
5 | response:
6 | show_size_and_time: false
7 | focus:
8 | on_startup: collection
9 | on_response: body
10 | heading:
11 | visible: false
12 | text_input:
13 | blinking_cursor: false
14 | watch_env_files: false
15 | watch_collection_files: false
16 | watch_themes: false
--------------------------------------------------------------------------------
/tests/sample-envs/sample_base.env:
--------------------------------------------------------------------------------
1 | POST_ID=1
2 | USER_ID=2
3 | TODO_ID=1
4 | FILE="base"
5 | ONLY_BASE=true
--------------------------------------------------------------------------------
/tests/sample-envs/sample_extra.env:
--------------------------------------------------------------------------------
1 | FILE="extra"
2 | POST_ID=2
3 | ONLY_EXTRA=true
--------------------------------------------------------------------------------
/tests/sample-importable-collections/test-postman-collection.json:
--------------------------------------------------------------------------------
1 | {
2 | "info": {
3 | "name": "Test API",
4 | "description": "A test API",
5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
6 | },
7 | "variable": [{"key": "baseUrl", "value": "https://api.example.com"}],
8 | "item": [
9 | {
10 | "name": "Users",
11 | "item": [
12 | {
13 | "name": "Get Users",
14 | "request": {
15 | "method": "GET",
16 | "url": {
17 | "raw": "{{host}}/api/users?email=example@gmail.com&relations=organization,impersonating_user",
18 | "host": ["{{host}}"],
19 | "path": ["api", "users"],
20 | "query": [
21 | {"key": "email", "value": "example@gmail.com"},
22 | {
23 | "key": "relations",
24 | "value": "organization,impersonating_user"
25 | }
26 | ]
27 | }
28 | }
29 | },
30 | {
31 | "name": "User Details",
32 | "item": [
33 | {
34 | "name": "Get User",
35 | "request": {
36 | "method": "GET",
37 | "url": {
38 | "raw": "{{baseUrl}}/users/{id}",
39 | "host": ["{{baseUrl}}"],
40 | "path": ["users", "{id}"]
41 | }
42 | }
43 | }
44 | ]
45 | }
46 | ]
47 | }
48 | ]
49 | }
--------------------------------------------------------------------------------
/tests/sample-themes/another_test.yml:
--------------------------------------------------------------------------------
1 | name: anothertest
2 | primary: '#2ecc71'
3 | secondary: '#3498db'
4 | accent: '#9b59b6'
5 | background: '#ecf0f1'
6 | surface: '#bdc3c7'
7 | error: '#e74c3c'
8 | warning: '#f39c12'
9 | success: '#27ae60'
10 | panel: '#95a5a6'
11 |
12 | # Extended properties
13 | text_area:
14 | gutter: 'black on red'
15 | cursor: 'reverse'
16 | cursor_line: 'italic'
17 | cursor_line_gutter: 'black on blue'
18 | matched_bracket: 'underline #f1c40f'
19 | selection: 'on #3498db'
20 |
21 | syntax:
22 | json_key: '#8e44ad'
23 | json_string: '#27ae60'
24 | json_number: '#e67e22'
25 | json_boolean: '#e74c3c'
26 | json_null: '#95a5a6'
27 |
28 | url:
29 | base: 'italic #3498db on green'
30 | protocol: 'u bold #9b59b6 on magenta'
31 | separator: 'dim red u'
32 |
33 | variable:
34 | resolved: 'black on #00ff00'
35 | unresolved: 'black on #ff0000'
36 |
37 | # Optional metadata
38 | author: "Your Name"
39 | description: "A vibrant and colorful theme with extended properties"
40 | homepage: "https://github.com/yourusername/posting-themes"
--------------------------------------------------------------------------------
/tests/sample-themes/serene_ocean.yaml:
--------------------------------------------------------------------------------
1 | name: serene_ocean
2 | primary: '#1E88E5' # Ocean Blue
3 | secondary: '#00ACC1' # Teal
4 | accent: '#D32F2F' # Crimson Red
5 | background: '#E3F2FD' # Light Sky Blue
6 | surface: '#FFFFFF' # White
7 | error: '#D32F2F' # Crimson Red
8 | success: '#43A047' # Forest Green
9 | text: '#212121' # Almost Black
10 | dark: false
11 |
--------------------------------------------------------------------------------
/tests/test_files.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from posting.files import is_valid_filename
3 |
4 |
5 | @pytest.mark.parametrize(
6 | "filename, expected",
7 | [
8 | ("valid_filename.txt", True),
9 | ("", False),
10 | (" ", False),
11 | ("file/with/path.txt", False),
12 | ("a" * 255, True),
13 | ("a" * 256, False),
14 | ("CON", False),
15 | ("PRN.txt", False),
16 | ("AUX.log", False),
17 | ("NUL.dat", False),
18 | ("COM1.bin", False),
19 | ("LPT1.tmp", False),
20 | ("file..with..dots.txt", False),
21 | (".hidden_file.txt", False),
22 | ("normal.file.txt", True),
23 | ("file-with-dashes.txt", True),
24 | ("file_with_underscores.txt", True),
25 | ("file with spaces.txt", True),
26 | ("file.with.multiple.extensions.txt", True),
27 | # Path traversal attack tests
28 | ("../filename.txt", False),
29 | ("filename/../something.txt", False),
30 | ("foo/../bar/baz.txt", False),
31 | ("foo/./bar/baz.txt", False),
32 | # Absolute path tests
33 | ("/etc/passwd", False),
34 | ("/var/log/system.log", False),
35 | ("/home/user/file.txt", False),
36 | ("/file.txt", False),
37 | ("C:/Program Files/file.txt", False),
38 | ],
39 | )
40 | def test_is_valid_filename(filename, expected):
41 | assert is_valid_filename(filename) == expected
42 |
43 |
44 | def test_is_valid_filename_with_none():
45 | assert is_valid_filename(None) is False
46 |
47 |
48 | @pytest.mark.parametrize(
49 | "os_specific_filename, expected",
50 | [
51 | ("COM0", True), # COM0 is not in the reserved list
52 | ("LPT0", True), # LPT0 is not in the reserved list
53 | ("CON.txt", False),
54 | ("AUX.log", False),
55 | ],
56 | )
57 | def test_is_valid_filename_os_specific(os_specific_filename, expected):
58 | assert is_valid_filename(os_specific_filename) == expected
59 |
--------------------------------------------------------------------------------
/tests/test_open_api_import.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | from posting.importing.open_api import import_openapi_spec
5 |
6 |
7 | def test_import(tmp_path: Path):
8 | """Test importing security schemes."""
9 | spec = {
10 | "openapi": "3.1.0",
11 | "info": {"title": "Test", "version": "1.0", "description": "Test"},
12 | "paths": {
13 | "/": {
14 | "get": {
15 | "parameters": [
16 | {
17 | "name": "page",
18 | "in": "query",
19 | },
20 | {
21 | "name": "account_id",
22 | "in": "header",
23 | "deprecated": True,
24 | },
25 | ],
26 | "responses": {"200": {"description": "OK"}},
27 | "security": [
28 | {"bearerAuth": []},
29 | ],
30 | }
31 | }
32 | },
33 | "components": {
34 | "securitySchemes": {
35 | "bearerAuth": {
36 | "type": "http",
37 | "scheme": "bearer",
38 | },
39 | },
40 | },
41 | }
42 | spec_path = tmp_path / "spec.json"
43 | spec_path.write_text(json.dumps(spec))
44 | collection = import_openapi_spec(spec_path)
45 |
46 | assert len(collection.requests) == 1
47 |
48 | request = collection.requests[0]
49 | assert request.url == "${BASE_URL}/"
50 | assert request.method == "GET"
51 |
52 | assert len(request.params) == 1
53 | param = request.params[0]
54 | assert param.name == "page"
55 | assert param.value == ""
56 | assert param.enabled
57 |
58 | assert len(request.headers) == 1
59 | header = request.headers[0]
60 | assert header.name == "account_id"
61 | assert header.value == ""
62 | assert not header.enabled
63 |
64 | assert request.auth is not None
65 | assert request.auth.type == "bearer_token"
66 | assert request.auth.bearer_token is not None
67 | assert request.auth.bearer_token.token == "${BEARERAUTH_BEARER_TOKEN}"
68 |
--------------------------------------------------------------------------------
/tests/test_postman_import.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from posting.collection import Collection, Options, QueryParam, RequestModel, Scripts
3 | from posting.importing.postman import Variable, import_postman_spec
4 |
5 |
6 | def test_import_postman_spec():
7 | # Write mock Postman spec to a temporary file
8 | collection, postman_collection = import_postman_spec(
9 | spec_path="tests/sample-importable-collections/test-postman-collection.json",
10 | output_path="foo/bar/baz",
11 | )
12 |
13 | assert collection == Collection(
14 | path=Path("foo/bar/baz"),
15 | name="Test API",
16 | requests=[],
17 | children=[
18 | Collection(
19 | path=Path("foo/bar/baz/Users"),
20 | name="Users",
21 | requests=[
22 | RequestModel(
23 | name="Get Users",
24 | description="",
25 | method="GET",
26 | url="$HOST/api/users",
27 | path=Path("foo/bar/baz/Users/GetUsers.posting.yaml"),
28 | body=None,
29 | auth=None,
30 | headers=[],
31 | params=[
32 | QueryParam(
33 | name="email", value="example@gmail.com", enabled=True
34 | ),
35 | QueryParam(
36 | name="relations",
37 | value="organization,impersonating_user",
38 | enabled=True,
39 | ),
40 | ],
41 | cookies=[],
42 | posting_version="2.6.0",
43 | scripts=Scripts(setup=None, on_request=None, on_response=None),
44 | options=Options(
45 | follow_redirects=True,
46 | verify_ssl=True,
47 | attach_cookies=True,
48 | proxy_url="",
49 | timeout=5.0,
50 | ),
51 | )
52 | ],
53 | children=[
54 | Collection(
55 | path=Path("foo/bar/baz/Users/User Details"),
56 | name="User Details",
57 | requests=[
58 | RequestModel(
59 | name="Get User",
60 | description="",
61 | method="GET",
62 | url="$BASE_URL/users/{id}",
63 | path=Path(
64 | "foo/bar/baz/Users/User Details/GetUser.posting.yaml"
65 | ),
66 | body=None,
67 | auth=None,
68 | headers=[],
69 | params=[],
70 | cookies=[],
71 | posting_version="2.6.0",
72 | scripts=Scripts(
73 | setup=None, on_request=None, on_response=None
74 | ),
75 | options=Options(
76 | follow_redirects=True,
77 | verify_ssl=True,
78 | attach_cookies=True,
79 | proxy_url="",
80 | timeout=5.0,
81 | ),
82 | )
83 | ],
84 | children=[],
85 | readme=None,
86 | )
87 | ],
88 | readme=None,
89 | )
90 | ],
91 | readme="# Test API\n\nA test API\n\nVersion: 2.0.0",
92 | )
93 |
94 | assert postman_collection.variable == [
95 | Variable(key="baseUrl", value="https://api.example.com")
96 | ]
97 |
--------------------------------------------------------------------------------
/tests/test_urls.py:
--------------------------------------------------------------------------------
1 | from posting.urls import ensure_protocol
2 |
3 |
4 | def test_ensure_protocol():
5 | assert ensure_protocol("example.com") == "http://example.com"
6 | assert ensure_protocol("https://example.com") == "https://example.com"
7 | assert ensure_protocol("http://example.com") == "http://example.com"
8 | assert ensure_protocol("ftp://example.com") == "ftp://example.com"
9 | assert ensure_protocol("localhost:8000") == "http://localhost:8000"
10 | assert ensure_protocol("localhost") == "http://localhost"
11 | assert ensure_protocol("localhost:8000/path") == "http://localhost:8000/path"
12 | assert ensure_protocol("localhost/path") == "http://localhost/path"
13 | assert ensure_protocol("localhost:8000/path/to/resource") == "http://localhost:8000/path/to/resource"
14 |
--------------------------------------------------------------------------------
/tests/test_variables.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from posting.variables import find_variables, variable_range_at_cursor
3 |
4 |
5 | @pytest.mark.parametrize(
6 | "text, expected",
7 | [
8 | ("", []), # Empty string
9 | ("hello", []), # No variables
10 | ("Hello, $name!", [("name", 7, 12)]), # Single var
11 | ("Hello, $name! $age", [("name", 7, 12), ("age", 14, 18)]), # Multiple vars
12 | ("Hello, $$foo $bar", [("bar", 13, 17)]), # Escaped vars
13 | ("$hello", [("hello", 0, 6)]), # Single var at start
14 | ("$", []), # Empty var name
15 | ("$$", []), # No variables
16 | ("${hello}", [("hello", 0, 8)]), # Variable with braces
17 | (
18 | "${hello} ${world}",
19 | [("hello", 0, 8), ("world", 9, 17)],
20 | ), # Multiple vars with braces
21 | ("$${hello}", []), # Escaped braces
22 | ("${}", []), # Empty braces
23 | ],
24 | )
25 | def test_find_variables(text: str, expected: list[tuple[str, int, int]]):
26 | assert find_variables(text) == expected
27 |
28 |
29 | @pytest.mark.parametrize(
30 | "text, cursor, expected",
31 | [
32 | # Basic checks
33 | ("", 0, None),
34 | ("", 1, None), # Out of bounds
35 | ("", -1, None), # Out of bounds
36 | ("Hello, $name!", 0, None),
37 | ("Hello, $name!", 7, None),
38 | ("Hello, $name!", 8, (7, 12)),
39 | ("Hello, $name!", 9, (7, 12)),
40 | ("Hello, $name!", 10, (7, 12)),
41 | ("Hello, $name!", 11, (7, 12)),
42 | ("Hello, $name!", 12, None),
43 | ("Hello, $name!", 13, None),
44 | # Escaped variables
45 | ("Hello, $$name!", 0, None),
46 | ("Hello, $$name!", 7, None),
47 | ("Hello, $$name!", 8, None),
48 | ("Hello, $$name!", 9, None),
49 | ("Hello, $$name!", 10, None),
50 | ("Hello, $$name!", 11, None),
51 | ("Hello, $$name!", 12, None),
52 | # Braces
53 | ("${}", 0, None),
54 | ("${name}", 0, None),
55 | ("${name}", 1, (0, 7)),
56 | ("${name}", 2, (0, 7)),
57 | # Empty Braces
58 | ("${}", 0, None),
59 | ("${}", 1, None),
60 | ("${}", -1, None),
61 | ("${}", 2, None),
62 | ("${}", 3, None),
63 | # Escaped braces
64 | ("$${name}", 0, None),
65 | ("$${name}", 1, None),
66 | ("$${name}", 2, None),
67 | ("$${name}", 3, None),
68 | ("$${name}", 4, None),
69 | ("$${name}", 5, None),
70 | ],
71 | )
72 | def test_variable_range_at_cursor(
73 | text: str, cursor: int, expected: tuple[int, int] | None
74 | ):
75 | assert variable_range_at_cursor(cursor, text) == expected
76 |
--------------------------------------------------------------------------------