From 9e256557e44c09aed7da1420bbd066d434a10951 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 07:38:14 +0200 Subject: Adding upstream version 0.7.1. Signed-off-by: Daniel Baumann --- .coveragerc | 4 + .github/PULL_REQUEST_TEMPLATE.rst | 5 + .github/dependabot.yml | 14 + .github/workflows/automerge.yml | 39 +++ .github/workflows/build.yml | 11 + .github/workflows/linters.yml | 18 + .github/workflows/pr-check.yml | 27 ++ .github/workflows/pypi.yml | 12 + .github/workflows/tests-macos.yml | 17 + .github/workflows/tests.yml | 15 + .gitignore | 13 + CHANGES.rst | 68 ++++ LICENSE.txt | 19 ++ MANIFEST.in | 4 + Pipfile | 16 + Pipfile.lock | 388 +++++++++++++++++++++ README.rst | 170 ++++++++++ mypy.ini | 25 ++ newsfragments/.gitignore | 1 + port_for/__init__.py | 29 ++ port_for/_download_ranges.py | 95 ++++++ port_for/_ranges.py | 689 ++++++++++++++++++++++++++++++++++++++ port_for/api.py | 207 ++++++++++++ port_for/cmd.py | 66 ++++ port_for/docopt.py | 471 ++++++++++++++++++++++++++ port_for/ephemeral.py | 72 ++++ port_for/exceptions.py | 3 + port_for/py.typed | 0 port_for/store.py | 87 +++++ port_for/utils.py | 29 ++ pyproject.toml | 143 ++++++++ setup.cfg | 3 + tests/__init__.py | 1 + tests/test_cases.py | 184 ++++++++++ 34 files changed, 2945 insertions(+) create mode 100644 .coveragerc create mode 100644 .github/PULL_REQUEST_TEMPLATE.rst create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/automerge.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/linters.yml create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/pypi.yml create mode 100644 .github/workflows/tests-macos.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CHANGES.rst create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.rst create mode 100644 mypy.ini create mode 100644 newsfragments/.gitignore create mode 100644 port_for/__init__.py create mode 100644 port_for/_download_ranges.py create mode 100644 port_for/_ranges.py create mode 100644 port_for/api.py create mode 100644 port_for/cmd.py create mode 100644 port_for/docopt.py create mode 100644 port_for/ephemeral.py create mode 100644 port_for/exceptions.py create mode 100644 port_for/py.typed create mode 100644 port_for/store.py create mode 100644 port_for/utils.py create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/test_cases.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2332963 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + port_for/_download_ranges.py + port_for/docopt.py diff --git a/.github/PULL_REQUEST_TEMPLATE.rst b/.github/PULL_REQUEST_TEMPLATE.rst new file mode 100644 index 0000000..910a482 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.rst @@ -0,0 +1,5 @@ +Chore that needs to be done: + +* [ ] Add newsfragment `pipenv run towncrier create [issue_number].[type].rst` + +Types are defined in the pyproject.toml, issue_numer either from issue tracker or the Pull request number \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1173e0c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + time: "04:00" + open-pull-requests-limit: 1 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + time: "02:00" + open-pull-requests-limit: 1 \ No newline at end of file diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..aa0e102 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,39 @@ +name: Merge me test dependencies! + +on: + workflow_run: + types: + - completed + workflows: + # List all required workflow names here. + - 'Run linters' + - 'Run tests' + - 'Run tests on macos' + - 'Test build package' + - 'Run test commands' + +jobs: + merge-me: + name: Merge me! + runs-on: ubuntu-latest + steps: + - # It is often a desired behavior to merge only when a workflow execution + # succeeds. This can be changed as needed. + if: ${{ github.event.workflow_run.conclusion == 'success' }} + name: Merge me! + uses: ridedott/merge-me-action@v2 + with: + # Depending on branch protection rules, a manually populated + # `GITHUB_TOKEN_WORKAROUND` secret with permissions to push to + # a protected branch must be used. This secret can have an arbitrary + # name, as an example, this repository uses `DOTTBOTT_TOKEN`. + # + # When using a custom token, it is recommended to leave the following + # comment for other developers to be aware of the reasoning behind it: + # + # This must be used as GitHub Actions token does not support pushing + # to protected branches. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_METHOD: MERGE + PRESET: DEPENDABOT_MINOR + ENABLED_FOR_MANUAL_CHANGES: 'true' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ebaa45f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,11 @@ +name: Test build package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + uses: fizyk/actions-reuse/.github/workflows/pypi.yml@v2.1.2 diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..dec9e2e --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,18 @@ +name: Run linters + +on: + push: + branches: [ master ] + paths: + - '**.py' + - .github/workflows/linters.yml + - requirements-lint.txt + pull_request: + branches: [ master ] +jobs: + lint: + uses: fizyk/actions-reuse/.github/workflows/linters-python.yml@v2.1.2 + with: + pipenv: true + mypy: true + pydocstyle: false \ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..758fd46 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: Run test commands + +on: + pull_request: + branches: [ master ] + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - uses: fizyk/actions-reuse/.github/actions/pipenv@v2.1.2 + with: + python-version: "3.11" + pipenv-install-options: "--skip-lock" + command: tbump --dry-run --only-patch $(pipenv run tbump current-version)"-x" + towncrier: + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + steps: + - uses: fizyk/actions-reuse/.github/actions/pipenv@v2.1.2 + with: + python-version: "3.11" + pipenv-install-options: "--skip-lock" + command: towncrier check --compare-with origin/master + fetch-depth: 0 diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..f90d2ce --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,12 @@ +name: Package and publish +on: + push: + tags: + - v* +jobs: + build: + uses: fizyk/actions-reuse/.github/workflows/pypi.yml@v2.1.2 + with: + publish: true + secrets: + pypi_token: ${{ secrets.pypi_token }} diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml new file mode 100644 index 0000000..4732209 --- /dev/null +++ b/.github/workflows/tests-macos.yml @@ -0,0 +1,17 @@ +name: Run tests on macos + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + +jobs: + tests: + uses: fizyk/actions-reuse/.github/workflows/tests-pytests.yml@v2.1.2 + with: + python-versions: '["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"]' + os: macos-latest + pipenv: true + pipenv-install-options: --skip-lock diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..adbb036 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,15 @@ +name: Run tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + tests: + uses: fizyk/actions-reuse/.github/workflows/tests-pytests.yml@v2.1.2 + with: + python-versions: '["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"]' + pipenv: true + pipenv-install-options: --skip-lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f46f8ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.pyc +build/ +dist/ +*.egg-info/ +.tox +.idea +htmlcov +.coverage +.cache +.ipynb_checkpoints/ +MANIFEST +coverage +venv/ \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..3f8c3f2 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,68 @@ +CHANGELOG +========= + +.. towncrier release notes start + +0.7.1 (2023-07-14) +================== + +Features +-------- + +- Add `PortType` type alias for easier typing related code (`#149 `_) + + +0.7.0 (2023-06-15) +================== + +Features +-------- + +- get_port will now allow passing additional exclude_ports parameter - these ports will not be chosen. (`#143 `_) + + +0.6.3 (2022-12-15) +================== + +Features +-------- + +- Add python 3.11 to the list of supported python versions. (`#111 `_) + + +Miscellaneus +------------ + +- Use towncrier as a changelog management tool. (`#107 `_) +- Moved development dependencies to be managed by pipenv. + All development process can be managed with it - which means automatic isolation. (`#108 `_) +- Migrate versioning tool to tbump, and move package definition to pyproject.toml (`#109 `_) +- Moved as much of the setup.cfg settings into the pyproject.toml as possible. + Dropped pydocstyle support. (`#112 `_) + + +0.6.2 +---------- + +Misc +++++ + +- Added Python 3.10 to trove classifiers and to CI + +0.6.1 +---------- + +Bugfix +++++++ + +- Fixed typing definition for get_port function + +0.6.0 +---------- + +Feature ++++++++ + +- Added `get_port` helper that can randomly select open port out of given set, or range-tuple +- Added type annotations and compatibility with PEP 561 +- Support only python 3.7 and up diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f361884 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2012 Mikhail Korobov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9b0e932 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include *.txt +include *.rst +recursive-include docs *.txt +recursive-include scripts * \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..a9e2661 --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +towncrier = "==23.6.0" +pytest = "==7.4.0" +pytest-cov = "==4.1.0" +coverage = "==7.2.7" +black = "==23.7.0" +pycodestyle = "==2.10.0" +mypy = "==1.4.1" +tbump = "==6.10.0" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..9776937 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,388 @@ +{ + "_meta": { + "hash": { + "sha256": "643de800a20bbcc68b7b1d0fe879c91aab21620df5e3b74e70b7c54811746f7d" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "black": { + "hashes": [ + "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3", + "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb", + "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087", + "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320", + "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6", + "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3", + "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc", + "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f", + "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587", + "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91", + "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a", + "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad", + "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926", + "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9", + "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be", + "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd", + "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96", + "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491", + "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2", + "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a", + "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f", + "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" + ], + "index": "pypi", + "version": "==23.7.0" + }, + "cli-ui": { + "hashes": [ + "sha256:2f67e50cf474e76ad160c3e660bbad98bf8b8dfb8d847765f3a261b7e13c05fa", + "sha256:6a1ebdbbcd83a0fa06b2f63f4434082a3ba8664aebedd91f1ff86b9e4289d53e" + ], + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==0.17.2" + }, + "click": { + "hashes": [ + "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3", + "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.4" + }, + "click-default-group": { + "hashes": [ + "sha256:d9560e8e8dfa44b3562fbc9425042a0fd6d21956fcc2db0077f63f34253ab904" + ], + "version": "==1.2.2" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "contextlib2": { + "hashes": [ + "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", + "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869" + ], + "markers": "python_version >= '3.6'", + "version": "==21.6.0" + }, + "coverage": { + "hashes": [ + "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", + "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", + "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", + "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", + "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", + "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", + "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", + "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", + "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", + "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", + "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", + "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", + "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", + "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", + "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", + "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", + "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", + "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", + "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", + "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", + "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", + "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", + "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", + "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", + "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", + "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", + "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", + "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", + "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", + "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", + "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", + "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", + "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", + "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", + "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", + "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", + "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", + "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", + "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", + "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", + "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", + "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", + "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", + "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", + "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", + "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", + "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", + "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", + "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", + "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", + "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", + "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", + "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", + "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", + "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", + "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", + "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", + "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", + "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", + "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + ], + "index": "pypi", + "version": "==7.2.7" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "incremental": { + "hashes": [ + "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0", + "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51" + ], + "version": "==22.10.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "markupsafe": { + "hashes": [ + "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", + "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", + "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", + "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", + "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", + "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", + "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", + "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", + "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", + "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", + "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", + "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", + "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", + "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", + "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", + "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", + "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", + "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", + "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", + "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", + "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", + "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", + "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", + "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", + "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", + "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", + "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", + "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", + "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", + "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", + "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", + "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", + "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", + "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", + "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", + "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", + "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", + "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", + "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", + "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", + "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", + "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.3" + }, + "mypy": { + "hashes": [ + "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042", + "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd", + "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2", + "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01", + "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7", + "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3", + "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816", + "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3", + "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc", + "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4", + "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b", + "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8", + "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c", + "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462", + "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7", + "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc", + "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258", + "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b", + "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9", + "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6", + "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f", + "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1", + "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828", + "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878", + "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f", + "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b" + ], + "index": "pypi", + "version": "==1.4.1" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "pathspec": { + "hashes": [ + "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", + "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.1" + }, + "platformdirs": { + "hashes": [ + "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c", + "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528" + ], + "markers": "python_version >= '3.7'", + "version": "==3.8.1" + }, + "pluggy": { + "hashes": [ + "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", + "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", + "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" + ], + "index": "pypi", + "version": "==2.10.0" + }, + "pytest": { + "hashes": [ + "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", + "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" + ], + "index": "pypi", + "version": "==7.4.0" + }, + "pytest-cov": { + "hashes": [ + "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", + "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" + ], + "index": "pypi", + "version": "==4.1.0" + }, + "schema": { + "hashes": [ + "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", + "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c" + ], + "version": "==0.7.5" + }, + "tabulate": { + "hashes": [ + "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc", + "sha256:436f1c768b424654fce8597290d2764def1eea6a77cfa5c33be00b1bc0f4f63d", + "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.8.10" + }, + "tbump": { + "hashes": [ + "sha256:170a4395d167daee357cb96af5e874119c470feaba9f605e73f3426e768c2542", + "sha256:9ebf5d69bc92ca8be1afb13a80f51e374526cb9988f4c3b167036a9e8a10a684" + ], + "index": "pypi", + "version": "==6.10.0" + }, + "tomlkit": { + "hashes": [ + "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171", + "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.8" + }, + "towncrier": { + "hashes": [ + "sha256:da552f29192b3c2b04d630133f194c98e9f14f0558669d427708e203fea4d0a5", + "sha256:fc29bd5ab4727c8dacfbe636f7fb5dc53b99805b62da1c96b214836159ff70c1" + ], + "index": "pypi", + "version": "==23.6.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + ], + "markers": "python_version >= '3.7'", + "version": "==4.7.1" + }, + "unidecode": { + "hashes": [ + "sha256:547d7c479e4f377b430dd91ac1275d593308dce0fc464fb2ab7d41f82ec653be", + "sha256:fed09cf0be8cf415b391642c2a5addfc72194407caee4f98719e40ec2a72b830" + ], + "markers": "python_version >= '3.5'", + "version": "==1.3.6" + } + } +} diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d863f61 --- /dev/null +++ b/README.rst @@ -0,0 +1,170 @@ +======== +port-for +======== + +.. image:: https://img.shields.io/pypi/v/port-for.svg + :target: https://pypi.python.org/pypi/port-for + :alt: PyPI Version + +.. image:: http://codecov.io/github/kmike/port-for/coverage.svg?branch=master + :target: http://codecov.io/github/kmike/port-for?branch=master + :alt: Code Coverage + + +``port-for`` is a command-line utility and a python library that +helps with local TCP ports management. + +It can find an unused TCP localhost port and remember the association:: + + $ sudo port-for foo + 37987 + +This can be useful when you are installing a stack of software +with multiple parts needing port numbers. + +.. note:: + + If you're looking for a temporary port then ``socket.bind((host, 0))`` + is your best bet:: + + >>> import socket + >>> s = socket.socket() + >>> s.bind(("", 0)) + >>> s.getsockname() + ('0.0.0.0', 54485) + + ``port-for`` is necessary when you need *persistent* free local port number. + + ``port-for`` is the exact opposite of ``s.bind((host, 0))`` + in the sense that it shouldn't return ports that ``s.bind((host, 0))`` + may return (because such ports are likely to be temporary used by OS). + + +There are several rules ``port-for`` is trying to follow to find and +return a new unused port: + +1) Port must be unused: ``port-for`` checks this by trying to connect + to the port and to bind to it. + +2) Port must be IANA unassigned and otherwise not well-known: + this is acheived by maintaining unassigned ports list + (parsed from IANA and Wikipedia). + +3) Port shouldn't be inside ephemeral port range. + This is important because ports from ephemeral port range can + be assigned temporary by OS (e.g. by machine's IP stack) and + this may prevent service restart in some circumstances. + ``port-for`` doesn't return ports from ephemeral port ranges + configured at the current machine. + +4) Other heuristics are also applied: ``port-for`` tries to return + a port from larger port ranges; it also doesn't return ports that are + too close to well-known ports. + +Installation +============ + +System-wide using easy_install (something like ``python-setuptools`` +should be installed):: + + sudo pip install port-for + +or:: + + sudo easy_install port-for + +or inside a virtualenv:: + + pip install port-for + +Script usage +============ + +``port-for `` script finds an unused port and associates +it with ````. Subsequent calls return the same port number. + +This utility doesn't actually bind the port or otherwise prevents the +port from being taken by another software. It tries to select +a port that is less likely to be used by another software +(and that is unused at the time of calling of course). Utility also makes +sure that ``port-for bar`` won't return the same port as ``port-for foo`` +on the same machine. + +:: + + $ sudo port-for foo + 37987 + + $ port-for foo + 37987 + +You may want to develop some naming conventions (e.g. prefix your app names) +in order to enable multiple sites on the same server:: + + $ sudo port-for example.com/apache + 35456 + +Please note that ``port-for`` script requires read and write access +to ``/etc/port-for.conf``. This usually means regular users can read +port values but sudo is required to associate a new port. + +List all associated ports:: + + $ port-for --list + foo: 37987 + example.com/apache: 35456 + +Remove an association:: + + $ sudo port-for --unbind foo + $ port-for --list + example.com/apache: 35456 + + +Library usage +============= + +:: + + >>> import port_for + >>> port_for.select_random() + 37774 + + >>> port_for.select_random() + 48324 + + >>> 80 in port_for.available_good_ports() + False + + >>> port_for.get_port() + 34455 + + >>> port_for.get_port("1234") + 1234 + + >>> port_for.get_port((2000, 3000)) + 2345 + + >>> port_for.get_port({4001, 4003, 4005}) + 4005 + + >>> port_for.get_port([{4000, 4001}, (4100, 4200)]) + 4111 + +Dig into source code for more. + +Contributing +============ + +Development happens at github: https://github.com/kmike/port-for/ + +Issue tracker: https://github.com/kmike/port-for/issues/new + +Release +======= + +Install pipenv and --dev dependencies first, Then run: + +.. code-block:: + + pipenv run tbump [NEW_VERSION] diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..cd300cd --- /dev/null +++ b/mypy.ini @@ -0,0 +1,25 @@ +[mypy] +allow_redefinition = False +allow_untyped_globals = False +check_untyped_defs = True +disallow_incomplete_defs = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +follow_imports = silent +ignore_missing_imports = False +implicit_reexport = False +no_implicit_optional = True +pretty = True +show_error_codes = True +strict_equality = True +warn_no_return = True +warn_return_any = True +warn_unreachable = True +warn_unused_ignores = True + +# Bundled third-party package. +[mypy-port_for.docopt.*] +check_untyped_defs = False +disallow_untyped_defs = False diff --git a/newsfragments/.gitignore b/newsfragments/.gitignore new file mode 100644 index 0000000..b722e9e --- /dev/null +++ b/newsfragments/.gitignore @@ -0,0 +1 @@ +!.gitignore \ No newline at end of file diff --git a/port_for/__init__.py b/port_for/__init__.py new file mode 100644 index 0000000..15c664d --- /dev/null +++ b/port_for/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""port_for package.""" +__version__ = "0.7.1" + +from ._ranges import UNASSIGNED_RANGES +from .api import ( + available_good_ports, + available_ports, + is_available, + good_port_ranges, + port_is_used, + select_random, + get_port, +) +from .store import PortStore +from .exceptions import PortForException + +__all__ = ( + "UNASSIGNED_RANGES", + "available_good_ports", + "available_ports", + "is_available", + "good_port_ranges", + "port_is_used", + "select_random", + "get_port", + "PortStore", + "PortForException", +) diff --git a/port_for/_download_ranges.py b/port_for/_download_ranges.py new file mode 100644 index 0000000..5e6a8fb --- /dev/null +++ b/port_for/_download_ranges.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +This module/script is for updating port_for._ranges with recent information +from IANA and Wikipedia. +""" +import sys +import os +import re +import datetime +from urllib.request import Request, urlopen +from xml.etree import ElementTree +from typing import Set, Iterator, Iterable, Tuple + +from port_for.utils import to_ranges, ranges_to_set + +name = os.path.abspath( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) +sys.path.insert(0, name) + +IANA_DOWNLOAD_URL = ( + "https://www.iana.org/assignments" + "/service-names-port-numbers/service-names-port-numbers.xml" +) +IANA_NS = "http://www.iana.org/assignments" +WIKIPEDIA_PAGE = "http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers" + + +def _write_unassigned_ranges(out_filename: str) -> None: + """ + Downloads ports data from IANA & Wikipedia and converts + it to a python module. This function is used to generate _ranges.py. + """ + with open(out_filename, "wt") as f: + f.write( + "# auto-generated by port_for._download_ranges (%s)\n" + % datetime.date.today() + ) + f.write("UNASSIGNED_RANGES = [\n") + for range in to_ranges(sorted(list(_unassigned_ports()))): + f.write(" (%d, %d),\n" % range) + f.write("]\n") + + +def _unassigned_ports() -> Set[int]: + """Return a set of all unassigned ports (according to IANA and Wikipedia)""" + free_ports = ranges_to_set(_parse_ranges(_iana_unassigned_port_ranges())) + known_ports = ranges_to_set(_wikipedia_known_port_ranges()) + return free_ports.difference(known_ports) + + +def _wikipedia_known_port_ranges() -> Iterator[Tuple[int, int]]: + """ + Returns used port ranges according to Wikipedia page. + This page contains unofficial well-known ports. + """ + req = Request(WIKIPEDIA_PAGE, headers={"User-Agent": "Magic Browser"}) + page = urlopen(req).read().decode("utf8") + + # just find all numbers in table cells + ports = re.findall(r"((\d+)(\W(\d+))?)", page, re.U) + return ((int(p[1]), int(p[3] if p[3] else p[1])) for p in ports) + + +def _iana_unassigned_port_ranges() -> Iterator[str]: + """ + Returns unassigned port ranges according to IANA. + """ + page = urlopen(IANA_DOWNLOAD_URL).read() + xml = ElementTree.fromstring(page) + records = xml.findall("{%s}record" % IANA_NS) + for record in records: + description_el = record.find("{%s}description" % IANA_NS) + assert description_el is not None + description = description_el.text + if description == "Unassigned": + number_el = record.find("{%s}number" % IANA_NS) + assert number_el is not None + numbers = number_el.text + assert numbers is not None + yield numbers + + +def _parse_ranges(ranges: Iterable[str]) -> Iterator[Tuple[int, int]]: + """Converts a list of string ranges to a list of [low, high] tuples.""" + for txt in ranges: + if "-" in txt: + low, high = txt.split("-") + else: + low, high = txt, txt + yield int(low), int(high) + + +if __name__ == "__main__": + _write_unassigned_ranges("_ranges.py") diff --git a/port_for/_ranges.py b/port_for/_ranges.py new file mode 100644 index 0000000..abe5435 --- /dev/null +++ b/port_for/_ranges.py @@ -0,0 +1,689 @@ +# auto-generated by port_for._download_ranges (2017-07-19) +UNASSIGNED_RANGES = [ + (28, 28), + (30, 30), + (32, 32), + (34, 34), + (36, 36), + (60, 60), + (258, 258), + (272, 279), + (285, 285), + (288, 299), + (301, 307), + (325, 332), + (334, 343), + (703, 703), + (708, 708), + (717, 728), + (732, 740), + (743, 743), + (745, 746), + (755, 757), + (766, 766), + (768, 768), + (778, 779), + (781, 781), + (784, 799), + (803, 807), + (809, 809), + (811, 827), + (834, 842), + (844, 846), + (849, 852), + (855, 859), + (863, 872), + (874, 885), + (889, 896), + (899, 899), + (904, 909), + (1002, 1007), + (1009, 1009), + (1491, 1491), + (2194, 2194), + (2259, 2259), + (2378, 2378), + (2693, 2693), + (2794, 2794), + (2873, 2873), + (3092, 3092), + (3126, 3126), + (3546, 3546), + (3694, 3694), + (3994, 3994), + (4048, 4048), + (4120, 4120), + (4144, 4144), + (4194, 4196), + (4315, 4315), + (4317, 4319), + (4332, 4332), + (4337, 4339), + (4363, 4365), + (4367, 4367), + (4380, 4388), + (4397, 4399), + (4424, 4424), + (4434, 4440), + (4459, 4483), + (4489, 4499), + (4501, 4501), + (4539, 4544), + (4561, 4562), + (4564, 4565), + (4571, 4572), + (4574, 4589), + (4606, 4609), + (4641, 4657), + (4693, 4699), + (4705, 4710), + (4712, 4712), + (4714, 4724), + (4734, 4736), + (4748, 4748), + (4757, 4773), + (4775, 4783), + (4792, 4799), + (4805, 4826), + (4828, 4836), + (4852, 4866), + (4872, 4875), + (4886, 4893), + (4895, 4898), + (4903, 4911), + (4916, 4935), + (4938, 4939), + (4943, 4948), + (4954, 4968), + (4971, 4979), + (4981, 4983), + (4992, 4998), + (5016, 5019), + (5035, 5036), + (5038, 5041), + (5076, 5077), + (5088, 5089), + (5095, 5098), + (5108, 5110), + (5113, 5113), + (5118, 5119), + (5122, 5123), + (5126, 5132), + (5138, 5144), + (5147, 5149), + (5158, 5160), + (5169, 5171), + (5173, 5189), + (5204, 5208), + (5210, 5214), + (5216, 5220), + (5238, 5241), + (5244, 5244), + (5255, 5263), + (5266, 5268), + (5273, 5279), + (5283, 5297), + (5311, 5311), + (5316, 5316), + (5319, 5319), + (5322, 5342), + (5345, 5348), + (5365, 5393), + (5395, 5396), + (5438, 5442), + (5444, 5444), + (5446, 5449), + (5451, 5452), + (5460, 5460), + (5466, 5467), + (5469, 5469), + (5476, 5479), + (5482, 5494), + (5496, 5497), + (5508, 5516), + (5518, 5549), + (5551, 5552), + (5558, 5564), + (5570, 5572), + (5576, 5578), + (5587, 5596), + (5606, 5617), + (5619, 5626), + (5640, 5645), + (5647, 5655), + (5657, 5665), + (5668, 5669), + (5685, 5686), + (5690, 5692), + (5694, 5695), + (5697, 5699), + (5701, 5704), + (5706, 5712), + (5731, 5740), + (5749, 5749), + (5751, 5754), + (5756, 5756), + (5758, 5765), + (5772, 5776), + (5778, 5779), + (5788, 5792), + (5795, 5799), + (5801, 5812), + (5815, 5840), + (5843, 5858), + (5860, 5862), + (5864, 5867), + (5869, 5880), + (5882, 5882), + (5884, 5899), + (5901, 5909), + (5914, 5930), + (5932, 5937), + (5939, 5962), + (5964, 5967), + (5970, 5983), + (5994, 5998), + (6067, 6067), + (6078, 6079), + (6089, 6098), + (6119, 6120), + (6125, 6128), + (6131, 6132), + (6134, 6135), + (6137, 6139), + (6150, 6158), + (6164, 6199), + (6202, 6208), + (6210, 6221), + (6223, 6224), + (6226, 6226), + (6228, 6229), + (6231, 6239), + (6245, 6250), + (6254, 6254), + (6256, 6256), + (6258, 6259), + (6261, 6261), + (6263, 6266), + (6270, 6299), + (6302, 6305), + (6307, 6314), + (6318, 6319), + (6323, 6323), + (6327, 6342), + (6345, 6345), + (6348, 6349), + (6351, 6354), + (6356, 6359), + (6361, 6362), + (6364, 6369), + (6371, 6378), + (6380, 6381), + (6383, 6388), + (6391, 6399), + (6411, 6416), + (6422, 6431), + (6433, 6435), + (6438, 6441), + (6447, 6454), + (6457, 6463), + (6465, 6470), + (6472, 6479), + (6490, 6499), + (6504, 6504), + (6512, 6512), + (6516, 6521), + (6524, 6540), + (6545, 6546), + (6552, 6555), + (6557, 6557), + (6559, 6559), + (6562, 6565), + (6569, 6570), + (6572, 6578), + (6584, 6599), + (6603, 6618), + (6630, 6631), + (6637, 6639), + (6641, 6652), + (6654, 6654), + (6658, 6659), + (6674, 6677), + (6680, 6686), + (6691, 6695), + (6698, 6698), + (6700, 6702), + (6707, 6713), + (6717, 6766), + (6772, 6776), + (6779, 6782), + (6792, 6800), + (6802, 6816), + (6818, 6830), + (6832, 6840), + (6843, 6849), + (6851, 6867), + (6870, 6880), + (10011, 10019), + (10021, 10023), + (10026, 10041), + (10043, 10049), + (10052, 10054), + (10056, 10079), + (10082, 10099), + (10105, 10106), + (10108, 10109), + (10112, 10112), + (10118, 10124), + (10126, 10127), + (10130, 10159), + (10163, 10171), + (10173, 10199), + (10205, 10211), + (10213, 10251), + (10254, 10259), + (10262, 10287), + (10289, 10300), + (10303, 10307), + (10309, 10320), + (10322, 10438), + (10440, 10479), + (10481, 10499), + (10501, 10504), + (10506, 10513), + (10515, 10539), + (10545, 10547), + (10549, 10630), + (10632, 10799), + (10801, 10804), + (10806, 10808), + (10811, 10822), + (10824, 10859), + (10861, 10879), + (10881, 10890), + (10892, 10932), + (10934, 10989), + (10991, 10999), + (11002, 11022), + (11024, 11094), + (11096, 11102), + (11107, 11107), + (11113, 11154), + (11156, 11160), + (11166, 11170), + (11176, 11200), + (11203, 11207), + (11209, 11210), + (11212, 11213), + (11216, 11234), + (11236, 11310), + (11312, 11318), + (11322, 11366), + (11368, 11370), + (11372, 11429), + (11431, 11488), + (11490, 11575), + (11577, 11599), + (11601, 11622), + (11624, 11719), + (11721, 11722), + (11724, 11750), + (11752, 11752), + (11754, 11795), + (11797, 11875), + (11878, 11949), + (11952, 11966), + (11968, 11996), + (12014, 12029), + (12033, 12108), + (12110, 12120), + (12122, 12167), + (12169, 12171), + (12173, 12200), + (12202, 12221), + (12224, 12299), + (12301, 12301), + (12303, 12320), + (12323, 12344), + (12346, 12442), + (12444, 12488), + (12490, 12752), + (12754, 12864), + (12866, 12974), + (12976, 13000), + (13002, 13007), + (13009, 13074), + (13076, 13159), + (13161, 13194), + (13197, 13215), + (13219, 13222), + (13225, 13399), + (13401, 13719), + (13723, 13723), + (13725, 13781), + (13784, 13784), + (13787, 13817), + (13824, 13893), + (13895, 13928), + (13931, 13999), + (14003, 14032), + (14035, 14140), + (14144, 14144), + (14146, 14148), + (14151, 14153), + (14155, 14249), + (14251, 14413), + (14415, 14499), + (14501, 14549), + (14551, 14566), + (14568, 14899), + (14901, 14935), + (14938, 14999), + (15001, 15001), + (15003, 15117), + (15119, 15344), + (15346, 15362), + (15364, 15554), + (15557, 15566), + (15568, 15659), + (15661, 15739), + (15741, 15997), + (16004, 16019), + (16022, 16079), + (16081, 16160), + (16163, 16199), + (16201, 16224), + (16226, 16249), + (16251, 16260), + (16262, 16299), + (16301, 16308), + (16312, 16359), + (16362, 16366), + (16369, 16383), + (16388, 16392), + (16473, 16481), + (16483, 16566), + (16568, 16618), + (16620, 16664), + (16667, 16788), + (16790, 16899), + (16901, 16949), + (16951, 16990), + (16996, 17006), + (17008, 17010), + (17012, 17183), + (17186, 17218), + (17226, 17233), + (17236, 17499), + (17501, 17554), + (17556, 17728), + (17730, 17753), + (17757, 17776), + (17778, 17999), + (18001, 18090), + (18093, 18103), + (18105, 18135), + (18137, 18180), + (18188, 18199), + (18202, 18205), + (18207, 18240), + (18244, 18261), + (18263, 18299), + (18302, 18305), + (18307, 18332), + (18334, 18399), + (18402, 18462), + (18464, 18504), + (18507, 18604), + (18607, 18633), + (18636, 18667), + (18669, 18768), + (18770, 18880), + (18882, 18887), + (18889, 18999), + (19002, 19006), + (19008, 19019), + (19021, 19131), + (19133, 19149), + (19151, 19190), + (19192, 19193), + (19195, 19219), + (19221, 19225), + (19227, 19282), + (19284, 19293), + (19296, 19301), + (19303, 19314), + (19316, 19397), + (19399, 19409), + (19413, 19538), + (19542, 19787), + (19789, 19811), + (19815, 19997), + (20004, 20004), + (20006, 20011), + (20015, 20033), + (20035, 20045), + (20047, 20047), + (20050, 20056), + (20058, 20166), + (20168, 20201), + (20203, 20221), + (20223, 20479), + (20481, 20559), + (20561, 20594), + (20596, 20669), + (20671, 20701), + (20703, 20719), + (20721, 20789), + (20791, 20807), + (20809, 20998), + (21001, 21009), + (21011, 21024), + (21026, 21220), + (21222, 21552), + (21555, 21589), + (21591, 21799), + (21801, 21844), + (21850, 21999), + (22006, 22124), + (22126, 22127), + (22129, 22135), + (22137, 22221), + (22223, 22272), + (22274, 22304), + (22306, 22334), + (22336, 22342), + (22344, 22346), + (22348, 22348), + (22352, 22536), + (22538, 22554), + (22556, 22762), + (22764, 22799), + (22801, 22950), + (22952, 22999), + (23006, 23052), + (23054, 23072), + (23074, 23271), + (23273, 23283), + (23285, 23293), + (23295, 23332), + (23334, 23398), + (23403, 23455), + (23458, 23512), + (23514, 23545), + (23547, 23999), + (24007, 24241), + (24243, 24248), + (24250, 24320), + (24323, 24385), + (24387, 24440), + (24442, 24443), + (24445, 24464), + (24466, 24553), + (24555, 24576), + (24578, 24665), + (24667, 24675), + (24679, 24679), + (24681, 24753), + (24755, 24799), + (24801, 24841), + (24843, 24849), + (24851, 24921), + (24923, 24999), + (25011, 25104), + (25106, 25470), + (25472, 25559), + (25561, 25564), + (25566, 25569), + (25571, 25574), + (25577, 25603), + (25605, 25792), + (25794, 25825), + (25827, 25827), + (25841, 25887), + (25889, 25899), + (25904, 25953), + (25956, 25998), + (26001, 26132), + (26134, 26207), + (26209, 26256), + (26258, 26259), + (26265, 26485), + (26488, 26488), + (26490, 26899), + (26902, 26949), + (26951, 26999), + (27051, 27344), + (27346, 27373), + (27375, 27441), + (27443, 27499), + (27911, 27949), + (27951, 27959), + (27970, 27998), + (28002, 28014), + (28016, 28118), + (28120, 28199), + (28201, 28239), + (28241, 28588), + (28590, 28769), + (28772, 28784), + (28787, 28851), + (28853, 28909), + (28911, 28959), + (28961, 28999), + (29001, 29069), + (29071, 29117), + (29119, 29166), + (29170, 29899), + (29902, 29919), + (29921, 29998), + (30005, 30099), + (30101, 30259), + (30261, 30563), + (30565, 30831), + (30833, 30998), + (31000, 31015), + (31017, 31019), + (31021, 31028), + (31030, 31336), + (31338, 31399), + (31401, 31415), + (31417, 31437), + (31439, 31456), + (31458, 31619), + (31621, 31684), + (31686, 31764), + (31766, 31947), + (31950, 32033), + (32035, 32136), + (32138, 32248), + (32250, 32399), + (32401, 32482), + (32484, 32634), + (32637, 32763), + (32765, 32766), + (32778, 32800), + (32802, 32810), + (32812, 32886), + (32888, 32895), + (32897, 32975), + (32977, 33059), + (33061, 33122), + (33124, 33330), + (33332, 33332), + (33335, 33433), + (33435, 33655), + (33657, 33847), + (33849, 33999), + (34001, 34248), + (34250, 34377), + (34380, 34566), + (34568, 34961), + (34965, 34979), + (34981, 34999), + (35007, 35099), + (35101, 35353), + (35358, 36000), + (36002, 36410), + (36413, 36421), + (36425, 36442), + (36445, 36461), + (36463, 36523), + (36525, 36601), + (36603, 36699), + (36701, 36864), + (36866, 37474), + (37476, 37482), + (37484, 37600), + (37602, 37653), + (37655, 37999), + (38003, 38200), + (38204, 38411), + (38413, 38421), + (38423, 38471), + (38473, 38799), + (38801, 38864), + (38866, 39680), + (39682, 39999), + (40001, 40022), + (40024, 40403), + (40405, 40840), + (40844, 40852), + (40854, 41110), + (41112, 41120), + (41122, 41229), + (41231, 41793), + (41798, 42507), + (42511, 42999), + (43001, 43593), + (43596, 44320), + (44323, 44404), + (44406, 44443), + (44445, 44543), + (44545, 44552), + (44554, 44599), + (44601, 44817), + (44819, 44899), + (44901, 44999), + (45003, 45044), + (45046, 45053), + (45055, 45513), + (45515, 45677), + (45679, 45823), + (45826, 45965), + (45967, 46335), + (46337, 46997), + (47002, 47099), + (47101, 47556), + (47558, 47623), + (47625, 47805), + (47807, 47807), + (47810, 47999), + (48006, 48048), + (48051, 48127), + (48130, 48555), + (48557, 48618), + (48620, 48652), + (48654, 48999), + (49002, 49150), +] diff --git a/port_for/api.py b/port_for/api.py new file mode 100644 index 0000000..4ecf9f8 --- /dev/null +++ b/port_for/api.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +import contextlib +import socket +import errno +import random +from itertools import chain +from typing import Optional, Set, List, Tuple, Iterable, TypeVar, Type, Union +from port_for import ephemeral, utils +from ._ranges import UNASSIGNED_RANGES +from .exceptions import PortForException + + +SYSTEM_PORT_RANGE = (0, 1024) + + +def select_random( + ports: Optional[Set[int]] = None, + exclude_ports: Optional[Iterable[int]] = None, +) -> int: + """ + Returns random unused port number. + """ + if ports is None: + ports = available_good_ports() + + if exclude_ports is None: + exclude_ports = set() + + ports.difference_update(set(exclude_ports)) + + for port in random.sample(tuple(ports), min(len(ports), 100)): + if not port_is_used(port): + return port + raise PortForException("Can't select a port") + + +def is_available(port: int) -> bool: + """ + Returns if port is good to choose. + """ + return port in available_ports() and not port_is_used(port) + + +def available_ports( + low: int = 1024, + high: int = 65535, + exclude_ranges: Optional[List[Tuple[int, int]]] = None, +) -> Set[int]: + """ + Returns a set of possible ports (excluding system, + ephemeral and well-known ports). + Pass ``high`` and/or ``low`` to limit the port range. + """ + if exclude_ranges is None: + exclude_ranges = [] + available = utils.ranges_to_set(UNASSIGNED_RANGES) + exclude = utils.ranges_to_set( + # Motivation behind excluding ephemeral port ranges: + # let's say you decided to use an ephemeral local port + # as a persistent port, and "reserve" it to your software. + # OS won't know about it, and still can try to use this port. + # This is not a problem if your service is always running and occupying + # this port (OS would pick next one). But if the service is temporarily + # not using the port (because of restart of other reason), + # OS might reuse the same port, + # which might prevent the service from starting. + ephemeral.port_ranges() + + exclude_ranges + + [SYSTEM_PORT_RANGE, (SYSTEM_PORT_RANGE[1], low), (high, 65536)] + ) + return available.difference(exclude) + + +def good_port_ranges( + ports: Optional[Set[int]] = None, min_range_len: int = 20, border: int = 3 +) -> List[Tuple[int, int]]: + """ + Returns a list of 'good' port ranges. + Such ranges are large and don't contain ephemeral or well-known ports. + Ranges borders are also excluded. + """ + min_range_len += border * 2 + if ports is None: + ports = available_ports() + ranges = utils.to_ranges(list(ports)) + lenghts = sorted([(r[1] - r[0], r) for r in ranges], reverse=True) + long_ranges = [ + length[1] for length in lenghts if length[0] >= min_range_len + ] + without_borders = [ + (low + border, high - border) for low, high in long_ranges + ] + return without_borders + + +def available_good_ports(min_range_len: int = 20, border: int = 3) -> Set[int]: + return utils.ranges_to_set( + good_port_ranges(min_range_len=min_range_len, border=border) + ) + + +def port_is_used(port: int, host: str = "127.0.0.1") -> bool: + """ + Returns if port is used. Port is considered used if the current process + can't bind to it or the port doesn't refuse connections. + """ + unused = _can_bind(port, host) and _refuses_connection(port, host) + return not unused + + +def _can_bind(port: int, host: str) -> bool: + sock = socket.socket() + with contextlib.closing(sock): + try: + sock.bind((host, port)) + except socket.error: + return False + return True + + +def _refuses_connection(port: int, host: str) -> bool: + sock = socket.socket() + with contextlib.closing(sock): + sock.settimeout(1) + err = sock.connect_ex((host, port)) + return err == errno.ECONNREFUSED + + +T = TypeVar("T") + + +def filter_by_type(lst: Iterable, type_of: Type[T]) -> List[T]: + """Returns a list of elements with given type.""" + return [e for e in lst if isinstance(e, type_of)] + + +PortType = Union[ + str, + int, + Tuple[int, int], + Set[int], + List[str], + List[int], + List[Tuple[int, int]], + List[Set[int]], + List[Union[Set[int], Tuple[int, int]]], + List[Union[str, int, Tuple[int, int], Set[int]]], +] + + +def get_port( + ports: Optional[PortType], + exclude_ports: Optional[Iterable[int]] = None, +) -> Optional[int]: + """ + Retuns a random available port. If there's only one port passed + (e.g. 5000 or '5000') function does not check if port is available. + If there's -1 passed as an argument, function returns None. + + :param ports: + exact port (e.g. '8000', 8000) + randomly selected port (None) - any random available port + [(2000,3000)] or (2000,3000) - random available port from a given range + [{4002,4003}] or {4002,4003} - random of 4002 or 4003 ports + [(2000,3000), {4002,4003}] -random of given range and set + :param exclude_ports: A set of known ports that can not be selected. + :returns: a random free port + :raises: ValueError + """ + if ports == -1: + return None + elif not ports: + return select_random(None, exclude_ports) + + try: + return int(ports) # type: ignore[arg-type] + except TypeError: + pass + + ports_set: Set[int] = set() + + try: + if not isinstance(ports, list): + ports = [ports] + ranges: Set[int] = utils.ranges_to_set( + filter_by_type(ports, tuple) # type: ignore[arg-type] + ) + nums: Set[int] = set(filter_by_type(ports, int)) + sets: Set[int] = set( + chain( + *filter_by_type( + ports, (set, frozenset) # type: ignore[arg-type] + ) + ) + ) + ports_set = ports_set.union(ranges, sets, nums) + except ValueError: + raise PortForException( + "Unknown format of ports: %s.\n" + 'You should provide a ports range "[(4000,5000)]"' + 'or "(4000,5000)" or a comma-separated ports set' + '"[{4000,5000,6000}]" or list of ints "[400,5000,6000,8000]"' + 'or all of them "[(20000, 30000), {48889, 50121}, 4000, 4004]"' + % (ports,) + ) + + return select_random(ports_set, exclude_ports) diff --git a/port_for/cmd.py b/port_for/cmd.py new file mode 100644 index 0000000..be28902 --- /dev/null +++ b/port_for/cmd.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +""" +cmd.py is a command-line utility that helps with local TCP ports management. + +It finds 'good' unused TCP localhost port and remembers the association. + +Usage: + port-for + port-for --bind + port-for --bind --port + port-for --port + port-for --unbind + port-for --list + port-for --version + port-for --help + +Options: + -h --help Show this screen. + -v, --version Show version. + -b FOO, --bind FOO Find and return a port for FOO; this is an alias for + 'port-for FOO'. + -p PORT, --port PORT (Optional) specific port number for the --bind command. + -u FOO, --unbind FOO Remove association for FOO. + -l, --list List all associated ports. +""" + +import sys +from typing import Optional +import port_for +from port_for.docopt import docopt + +store = port_for.PortStore() + + +def _list() -> None: + for app, port in store.bound_ports(): + sys.stdout.write("%s: %s\n" % (app, port)) + + +def _bind(app: str, port: Optional[str] = None) -> None: + bound_port = store.bind_port(app, port) + sys.stdout.write("%s\n" % bound_port) + + +def _unbind(app: str) -> None: + store.unbind_port(app) + + +def main() -> None: + """port-for executable entrypoint.""" + args = docopt( + __doc__, + version="port-for %s" % port_for.__version__, + ) # type: ignore[no-untyped-call] + if args[""]: + _bind(args[""], args["--port"]) + elif args["--bind"]: + _bind(args["--bind"], args["--port"]) + elif args["--list"]: + _list() + elif args["--unbind"]: + _unbind(args["--unbind"]) + + +if __name__ == "__main__": + main() diff --git a/port_for/docopt.py b/port_for/docopt.py new file mode 100644 index 0000000..eff4e2f --- /dev/null +++ b/port_for/docopt.py @@ -0,0 +1,471 @@ +from copy import copy +import sys +import re + + +class DocoptLanguageError(Exception): + """Error in construction of usage-message by developer.""" + + +class DocoptExit(SystemExit): + """Exit in case user invoked program with incorrect arguments.""" + + usage = "" + + def __init__(self, message=""): + SystemExit.__init__(self, (message + "\n" + self.usage).strip()) + + +class Pattern(object): + def __init__(self, *children): + self.children = list(children) + + def __eq__(self, other): + return repr(self) == repr(other) + + def __hash__(self): + return hash(repr(self)) + + def __repr__(self): + return "%s(%s)" % ( + self.__class__.__name__, + ", ".join(repr(a) for a in self.children), + ) + + @property + def flat(self): + if not hasattr(self, "children"): + return [self] + return sum([c.flat for c in self.children], []) + + def fix(self): + self.fix_identities() + self.fix_list_arguments() + return self + + def fix_identities(self, uniq=None): + """Make pattern-tree tips point to same object if they are equal.""" + if not hasattr(self, "children"): + return self + uniq = list(set(self.flat)) if uniq is None else uniq + for i, c in enumerate(self.children): + if not hasattr(c, "children"): + assert c in uniq + self.children[i] = uniq[uniq.index(c)] + else: + c.fix_identities(uniq) + + def fix_list_arguments(self): + """Find arguments that should accumulate values and fix them.""" + either = [list(c.children) for c in self.either.children] + for case in either: + case = [c for c in case if case.count(c) > 1] + for a in [e for e in case if type(e) == Argument]: + a.value = [] + return self + + @property + def either(self): + """Transform pattern into an equivalent, with only top-level Either.""" + # Currently the pattern will not be equivalent, but more "narrow", + # although good enough to reason about list arguments. + if not hasattr(self, "children"): + return Either(Required(self)) + else: + ret = [] + groups = [[self]] + while groups: + children = groups.pop(0) + types = [type(c) for c in children] + if Either in types: + either = [c for c in children if type(c) is Either][0] + children.pop(children.index(either)) + for c in either.children: + groups.append([c] + children) + elif Required in types: + required = [c for c in children if type(c) is Required][0] + children.pop(children.index(required)) + groups.append(list(required.children) + children) + elif Optional in types: + optional = [c for c in children if type(c) is Optional][0] + children.pop(children.index(optional)) + groups.append(list(optional.children) + children) + elif OneOrMore in types: + oneormore = [c for c in children if type(c) is OneOrMore][0] + children.pop(children.index(oneormore)) + groups.append(list(oneormore.children) * 2 + children) + else: + ret.append(children) + return Either(*[Required(*e) for e in ret]) + + +class Argument(Pattern): + def __init__(self, name, value=None): + self.name = name + self.value = value + + def match(self, left, collected=None): + collected = [] if collected is None else collected + args = [arg_left for arg_left in left if type(arg_left) is Argument] + if not len(args): + return False, left, collected + left.remove(args[0]) + if type(self.value) is not list: + return True, left, collected + [Argument(self.name, args[0].value)] + same_name = [ + a for a in collected if type(a) is Argument and a.name == self.name + ] + if len(same_name): + same_name[0].value += [args[0].value] + return True, left, collected + else: + return ( + True, + left, + collected + [Argument(self.name, [args[0].value])], + ) + + def __repr__(self): + return "Argument(%r, %r)" % (self.name, self.value) + + +class Command(Pattern): + def __init__(self, name, value=False): + self.name = name + self.value = value + + def match(self, left, collected=None): + collected = [] if collected is None else collected + args = [arg_left for arg_left in left if type(arg_left) is Argument] + if not len(args) or args[0].value != self.name: + return False, left, collected + left.remove(args[0]) + return True, left, collected + [Command(self.name, True)] + + def __repr__(self): + return "Command(%r, %r)" % (self.name, self.value) + + +class Option(Pattern): + def __init__(self, short=None, long=None, argcount=0, value=False): + assert argcount in (0, 1) + self.short, self.long = short, long + self.argcount, self.value = argcount, value + self.value = None if not value and argcount else value # HACK + + @classmethod + def parse(class_, option_description): + short, long, argcount, value = None, None, 0, False + options, _, description = option_description.strip().partition(" ") + options = options.replace(",", " ").replace("=", " ") + for s in options.split(): + if s.startswith("--"): + long = s + elif s.startswith("-"): + short = s + else: + argcount = 1 + if argcount: + matched = re.findall(r"\[default: (.*)\]", description, flags=re.I) + value = matched[0] if matched else None + return class_(short, long, argcount, value) + + def match(self, left, collected=None): + collected = [] if collected is None else collected + left_ = [] + for arg_left in left: + # if this is so greedy, how to handle OneOrMore then? + if not ( + type(arg_left) is Option + and (self.short, self.long) == (arg_left.short, arg_left.long) + ): + left_.append(arg_left) + return (left != left_), left_, collected + + @property + def name(self): + return self.long or self.short + + def __repr__(self): + return "Option(%r, %r, %r, %r)" % ( + self.short, + self.long, + self.argcount, + self.value, + ) + + +class AnyOptions(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + left_ = [opt_left for opt_left in left if not type(opt_left) == Option] + return (left != left_), left_, collected + + +class Required(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + copied_left = copy(left) + c = copy(collected) + for p in self.children: + matched, copied_left, c = p.match(copied_left, c) + if not matched: + return False, left, collected + return True, copied_left, c + + +class Optional(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + left = copy(left) + for p in self.children: + m, left, collected = p.match(left, collected) + return True, left, collected + + +class OneOrMore(Pattern): + def match(self, left, collected=None): + assert len(self.children) == 1 + collected = [] if collected is None else collected + pattern_left = copy(left) + c = copy(collected) + l_ = None + matched = True + times = 0 + while matched: + # could it be that something didn't match but + # changed pattern_left or c? + matched, pattern_left, c = self.children[0].match(pattern_left, c) + times += 1 if matched else 0 + if l_ == pattern_left: + break + l_ = copy(pattern_left) + if times >= 1: + return True, pattern_left, c + return False, left, collected + + +class Either(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + outcomes = [] + for p in self.children: + matched, _, _ = outcome = p.match(copy(left), copy(collected)) + if matched: + outcomes.append(outcome) + if outcomes: + return min(outcomes, key=lambda outcome: len(outcome[1])) + return False, left, collected + + +class TokenStream(list): + def __init__(self, source, error): + self += source.split() if type(source) is str else source + self.error = error + + def move(self): + return self.pop(0) if len(self) else None + + def current(self): + return self[0] if len(self) else None + + +def parse_long(tokens, options): + raw, eq, value = tokens.move().partition("=") + value = None if eq == value == "" else value + opt = [o for o in options if o.long and o.long.startswith(raw)] + if len(opt) < 1: + if tokens.error is DocoptExit: + raise tokens.error("%s is not recognized" % raw) + else: + o = Option(None, raw, (1 if eq == "=" else 0)) + options.append(o) + return [o] + if len(opt) > 1: + raise tokens.error( + "%s is not a unique prefix: %s?" + % (raw, ", ".join("%s" % o.long for o in opt)) + ) + opt = copy(opt[0]) + if opt.argcount == 1: + if value is None: + if tokens.current() is None: + raise tokens.error("%s requires argument" % opt.name) + value = tokens.move() + elif value is not None: + raise tokens.error("%s must not have an argument" % opt.name) + opt.value = value or True + return [opt] + + +def parse_shorts(tokens, options): + raw = tokens.move()[1:] + parsed = [] + while raw != "": + opt = [ + o + for o in options + if o.short and o.short.lstrip("-").startswith(raw[0]) + ] + if len(opt) > 1: + raise tokens.error( + "-%s is specified ambiguously %d times" % (raw[0], len(opt)) + ) + if len(opt) < 1: + if tokens.error is DocoptExit: + raise tokens.error("-%s is not recognized" % raw[0]) + else: + o = Option("-" + raw[0], None) + options.append(o) + parsed.append(o) + raw = raw[1:] + continue + opt = copy(opt[0]) + raw = raw[1:] + if opt.argcount == 0: + value = True + else: + if raw == "": + if tokens.current() is None: + raise tokens.error("-%s requires argument" % opt.short[0]) + raw = tokens.move() + value, raw = raw, "" + opt.value = value + parsed.append(opt) + return parsed + + +def parse_pattern(source, options): + tokens = TokenStream( + re.sub(r"([\[\]\(\)\|]|\.\.\.)", r" \1 ", source), DocoptLanguageError + ) + result = parse_expr(tokens, options) + if tokens.current() is not None: + raise tokens.error("unexpected ending: %r" % " ".join(tokens)) + return Required(*result) + + +def parse_expr(tokens, options): + """expr ::= seq ( '|' seq )* ;""" + seq = parse_seq(tokens, options) + if tokens.current() != "|": + return seq + result = [Required(*seq)] if len(seq) > 1 else seq + while tokens.current() == "|": + tokens.move() + seq = parse_seq(tokens, options) + result += [Required(*seq)] if len(seq) > 1 else seq + return [Either(*result)] if len(result) > 1 else result + + +def parse_seq(tokens, options): + """seq ::= ( atom [ '...' ] )* ;""" + result = [] + while tokens.current() not in [None, "]", ")", "|"]: + atom = parse_atom(tokens, options) + if tokens.current() == "...": + atom = [OneOrMore(*atom)] + tokens.move() + result += atom + return result + + +def parse_atom(tokens, options): + """atom ::= '(' expr ')' | '[' expr ']' | 'options' + | long | shorts | argument | command ; + """ + token = tokens.current() + result = [] + if token == "(": + tokens.move() + result = [Required(*parse_expr(tokens, options))] + if tokens.move() != ")": + raise tokens.error("Unmatched '('") + return result + elif token == "[": + tokens.move() + result = [Optional(*parse_expr(tokens, options))] + if tokens.move() != "]": + raise tokens.error("Unmatched '['") + return result + elif token == "options": + tokens.move() + return [AnyOptions()] + elif token.startswith("--") and token != "--": + return parse_long(tokens, options) + elif token.startswith("-") and token not in ("-", "--"): + return parse_shorts(tokens, options) + elif token.startswith("<") and token.endswith(">") or token.isupper(): + return [Argument(tokens.move())] + else: + return [Command(tokens.move())] + + +def parse_args(source, options): + tokens = TokenStream(source, DocoptExit) + options = copy(options) + parsed = [] + while tokens.current() is not None: + if tokens.current() == "--": + return parsed + [Argument(None, v) for v in tokens] + elif tokens.current().startswith("--"): + parsed += parse_long(tokens, options) + elif tokens.current().startswith("-") and tokens.current() != "-": + parsed += parse_shorts(tokens, options) + else: + parsed.append(Argument(None, tokens.move())) + return parsed + + +def parse_doc_options(doc): + return [Option.parse("-" + s) for s in re.split("^ *-|\n *-", doc)[1:]] + + +def printable_usage(doc): + usage_split = re.split(r"([Uu][Ss][Aa][Gg][Ee]:)", doc) + if len(usage_split) < 3: + raise DocoptLanguageError('"usage:" (case-insensitive) not found.') + if len(usage_split) > 3: + raise DocoptLanguageError('More than one "usage:" (case-insensitive).') + return re.split(r"\n\s*\n", "".join(usage_split[1:]))[0].strip() + + +def formal_usage(printable_usage): + pu = printable_usage.split()[1:] # split and drop "usage:" + return " ".join("|" if s == pu[0] else s for s in pu[1:]) + + +def extras(help, version, options, doc): + if help and any((o.name in ("-h", "--help")) and o.value for o in options): + print(doc.strip()) + exit() + if version and any(o.name == "--version" and o.value for o in options): + print(version) + exit() + + +class Dict(dict): + """Dictionary with custom repr bbehaviour.""" + + def __repr__(self): + """Dictionary representation for docopt.""" + return "{%s}" % ",\n ".join("%r: %r" % i for i in sorted(self.items())) + + +def docopt(doc, argv=sys.argv[1:], help=True, version=None): + DocoptExit.usage = docopt.usage = usage = printable_usage(doc) + pot_options = parse_doc_options(doc) + formal_pattern = parse_pattern(formal_usage(usage), options=pot_options) + argv = parse_args(argv, options=pot_options) + extras(help, version, argv, doc) + matched, left, arguments = formal_pattern.fix().match(argv) + if matched and left == []: # better message if left? + options = [o for o in argv if type(o) is Option] + pot_arguments = [ + a for a in formal_pattern.flat if type(a) in [Argument, Command] + ] + return Dict( + (a.name, a.value) + for a in (pot_options + options + pot_arguments + arguments) + ) + raise DocoptExit() diff --git a/port_for/ephemeral.py b/port_for/ephemeral.py new file mode 100644 index 0000000..a3c03ec --- /dev/null +++ b/port_for/ephemeral.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +""" +This module provide utilities to find ephemeral port ranges for the current OS. +See http://www.ncftp.com/ncftpd/doc/misc/ephemeral_ports.html for more info +about ephemeral port ranges. + +Currently only Linux and BSD (including OS X) are supported. +""" +import subprocess +from typing import List, Tuple, Dict + +DEFAULT_EPHEMERAL_PORT_RANGE = (32768, 65535) + + +def port_ranges() -> List[Tuple[int, int]]: + """ + Returns a list of ephemeral port ranges for current machine. + """ + try: + return _linux_ranges() + except (OSError, IOError): # not linux, try BSD + try: + ranges = _bsd_ranges() + if ranges: + return ranges + except (OSError, IOError): + pass + + # fallback + return [DEFAULT_EPHEMERAL_PORT_RANGE] + + +def _linux_ranges() -> List[Tuple[int, int]]: + with open("/proc/sys/net/ipv4/ip_local_port_range") as f: + # use readline() instead of read() for linux + musl + low, high = f.readline().split() + return [(int(low), int(high))] + + +def _bsd_ranges() -> List[Tuple[int, int]]: + pp = subprocess.Popen( + ["sysctl", "net.inet.ip.portrange"], stdout=subprocess.PIPE + ) + stdout, stderr = pp.communicate() + lines = stdout.decode("ascii").split("\n") + out: Dict[str, str] = dict( + [ + [x.strip().rsplit(".")[-1] for x in line.split(":")] + for line in lines + if line + ] + ) + + ranges = [ + # FreeBSD & Mac + ("first", "last"), + ("lowfirst", "lowlast"), + ("hifirst", "hilast"), + # OpenBSD + ("portfirst", "portlast"), + ("porthifirst", "porthilast"), + ] + + res = [] + for rng in ranges: + try: + low, high = int(out[rng[0]]), int(out[rng[1]]) + if low <= high: + res.append((low, high)) + except KeyError: + pass + return res diff --git a/port_for/exceptions.py b/port_for/exceptions.py new file mode 100644 index 0000000..256a255 --- /dev/null +++ b/port_for/exceptions.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +class PortForException(Exception): + pass diff --git a/port_for/py.typed b/port_for/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/port_for/store.py b/port_for/store.py new file mode 100644 index 0000000..a07b737 --- /dev/null +++ b/port_for/store.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +import os +from configparser import ConfigParser, DEFAULTSECT +from typing import Optional, List, Tuple, Union + +from .api import select_random +from .exceptions import PortForException + + +DEFAULT_CONFIG_PATH = "/etc/port-for.conf" + + +class PortStore(object): + def __init__(self, config_filename: str = DEFAULT_CONFIG_PATH): + self._config = config_filename + + def bind_port( + self, app: str, port: Optional[Union[int, str]] = None + ) -> int: + if "=" in app or ":" in app: + raise Exception('invalid app name: "%s"' % app) + + requested_port: Optional[str] = None + if port is not None: + requested_port = str(port) + + parser = self._get_parser() + + # this app already use some port; return it + if parser.has_option(DEFAULTSECT, app): + actual_port = parser.get(DEFAULTSECT, app) + if requested_port is not None and requested_port != actual_port: + msg = ( + "Can't bind to port %s: %s is already associated " + "with port %s" % (requested_port, app, actual_port) + ) + raise PortForException(msg) + return int(actual_port) + + # port is already used by an another app + app_by_port = dict((v, k) for k, v in parser.items(DEFAULTSECT)) + bound_port_numbers = map(int, app_by_port.keys()) + + if requested_port is None: + requested_port = str( + select_random(exclude_ports=bound_port_numbers) + ) + + if requested_port in app_by_port: + binding_app = app_by_port[requested_port] + if binding_app != app: + raise PortForException( + "Port %s is already used by %s!" + % (requested_port, binding_app) + ) + + # new app & new port + parser.set(DEFAULTSECT, app, requested_port) + self._save(parser) + + return int(requested_port) + + def unbind_port(self, app: str) -> None: + parser = self._get_parser() + parser.remove_option(DEFAULTSECT, app) + self._save(parser) + + def bound_ports(self) -> List[Tuple[str, int]]: + return [ + (app, int(port)) + for app, port in self._get_parser().items(DEFAULTSECT) + ] + + def _ensure_config_exists(self) -> None: + if not os.path.exists(self._config): + with open(self._config, "wb"): + pass + + def _get_parser(self) -> ConfigParser: + self._ensure_config_exists() + parser = ConfigParser() + parser.read(self._config) + return parser + + def _save(self, parser: ConfigParser) -> None: + with open(self._config, "wt") as f: + parser.write(f) diff --git a/port_for/utils.py b/port_for/utils.py new file mode 100644 index 0000000..361d5c0 --- /dev/null +++ b/port_for/utils.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import itertools +from typing import Iterable, Iterator, Tuple, Set + + +def ranges_to_set(lst: Iterable[Tuple[int, int]]) -> Set[int]: + """ + Convert a list of ranges to a set of numbers:: + + >>> ranges = [(1,3), (5,6)] + >>> sorted(list(ranges_to_set(ranges))) + [1, 2, 3, 5, 6] + + """ + return set(itertools.chain(*(range(x[0], x[1] + 1) for x in lst))) + + +def to_ranges(lst: Iterable[int]) -> Iterator[Tuple[int, int]]: + """ + Convert a list of numbers to a list of ranges:: + + >>> numbers = [1,2,3,5,6] + >>> list(to_ranges(numbers)) + [(1, 3), (5, 6)] + + """ + for a, b in itertools.groupby(enumerate(lst), lambda t: t[1] - t[0]): + c = list(b) + yield c[0][1], c[-1][1] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..608e240 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,143 @@ +[project] +name = "port-for" +version = "0.7.1" +description = "Utility that helps with local TCP ports management. It can find an unused TCP localhost port and remember the association." +readme = "README.rst" +keywords = ["port", "posix"] +license = {file = "LICENSE.txt"} +authors = [ + {name = "Mikhail Korobov", email = "kmike84@gmail.com"} +] +maintainers = [ + {name = "Grzegorz Śliwiński", email = "fizyk+pypi@fizyk.dev"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: POSIX", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Systems Administration", + "Topic :: Internet :: WWW/HTTP :: Site Management", +] +requires-python = ">= 3.7" + +[project.urls] +"Source" = "https://github.com/kmike/port-for/" +"Bug Tracker" = "https://github.com/kmike/port-for/issues" +"Changelog" = "https://github.com/kmike/port-for/blob/v0.7.1/CHANGES.rst" + +[project.scripts] +port-for = "port_for.cmd:main" + +[build-system] +requires = ["setuptools >= 61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +zip-safe = true +packages = ["port_for"] + +[tool.setuptools.package-data] +port_for = ["py.typed"] + +[tool.pytest.ini_options] +xfail_strict=true +testpaths = "tests" +filterwarnings = "error" + +[tool.towncrier] +directory = "newsfragments" +single_file=true +filename="CHANGES.rst" +issue_format="`#{issue} `_" + +[tool.towncrier.fragment.feature] +name = "Features" +showcontent = true + +[tool.towncrier.fragment.bugfix] +name = "Bugfixes" +showcontent = true + +[tool.towncrier.fragment.break] +name = "Breaking changes" +showcontent = true + +[tool.towncrier.fragment.misc] +name = "Miscellaneus" +showcontent = true + +[tool.black] +line-length = 80 +target-version = ['py38'] +include = '.*\.pyi?$' + + +[tool.tbump] +# Uncomment this if your project is hosted on GitHub: +# github_url = "https://github.com///" + +[tool.tbump.version] +current = "0.7.1" + +# Example of a semver regexp. +# Make sure this matches current_version before +# using tbump +regex = ''' + (?P\d+) + \. + (?P\d+) + \. + (?P\d+) + (\- + (?P.+) + )? + ''' + +[tool.tbump.git] +message_template = "Release {new_version}" +tag_template = "v{new_version}" + +[[tool.tbump.field]] +# the name of the field +name = "extra" +# the default value to use, if there is no match +default = "" + + +# For each file to patch, add a [[file]] config +# section containing the path of the file, relative to the +# tbump.toml location. +[[tool.tbump.file]] +src = "port_for/__init__.py" + +[[tool.tbump.file]] +src = "pyproject.toml" +search = 'version = "{current_version}"' + +[[tool.tbump.file]] +src = "pyproject.toml" +search = '"Changelog" = "https://github.com/kmike/port-for/blob/v{current_version}/CHANGES.rst"' + +# You can specify a list of commands to +# run after the files have been patched +# and before the git commit is made + +[[tool.tbump.before_commit]] +name = "Build changelog" +cmd = "pipenv run towncrier build --version {new_version} --yes" + +# Or run some commands after the git tag and the branch +# have been pushed: +# [[tool.tbump.after_push]] +# name = "publish" +# cmd = "./publish.sh" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2627357 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[pycodestyle] +max-line-length = 80 +exclude = docs/*,build/*,venv/* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0ea089d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test's module.""" diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..02772a8 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +import unittest +import tempfile +from unittest import mock +import socket +import os +from typing import Union, List, Set, Tuple + +import pytest + +import port_for +from port_for.api import get_port +from port_for.utils import ranges_to_set + + +def test_common_ports() -> None: + assert not port_for.is_available(80) + assert not port_for.is_available(11211) + + +def test_good_port_ranges() -> None: + ranges = [ + (10, 15), # too short + (100, 200), # good + (220, 245), # a bit short + (300, 330), # good + (440, 495), # also good + ] + + ports = ranges_to_set(ranges) + good_ranges = port_for.good_port_ranges(ports, 20, 3) + assert good_ranges == [(103, 197), (443, 492), (303, 327)], good_ranges + + +def test_something_works() -> None: + assert len(port_for.good_port_ranges()) > 10 + assert len(port_for.available_good_ports()) > 1000 + + +def test_binding() -> None: + # low ports are not available + assert port_for.port_is_used(10) + + +def test_binding_high() -> None: + s = socket.socket() + s.bind(("", 0)) + port = s.getsockname()[1] + assert port_for.port_is_used(port) + s.close() + assert not port_for.port_is_used(port) + + +def test_get_port_random() -> None: + """Test case allowing get port to randomly select any port.""" + assert get_port(None) + + +def test_get_port_none() -> None: + """Test special case for get_port to return None.""" + assert not get_port(-1) + + +def test_get_port_exclude() -> None: + """Only one port is available at that range.""" + assert 8002 == get_port( + (8000, 8010), + [8000, 8001, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010], + ) + + +@pytest.mark.parametrize("port", (1234, "1234")) +def test_get_port_specific(port: Union[str, int]) -> None: + """Test special case for get_port to return same value.""" + assert get_port(port) == 1234 + + +@pytest.mark.parametrize( + "port_range", + ( + [(2000, 3000)], + (2000, 3000), + ), +) +def test_get_port_from_range( + port_range: Union[List[Tuple[int, int]], Tuple[int, int]] +) -> None: + """Test getting random port from given range.""" + assert get_port(port_range) in list(range(2000, 3000 + 1)) + + +@pytest.mark.parametrize( + "port_set", + ( + [{4001, 4002, 4003}], + {4001, 4002, 4003}, + ), +) +def test_get_port_from_set(port_set: Union[List[Set[int]], Set[int]]) -> None: + """Test getting random port from given set.""" + assert get_port(port_set) in {4001, 4002, 4003} + + +def test_port_mix() -> None: + """Test getting random port from given set and range.""" + sets_and_ranges: List[Union[Tuple[int, int], Set[int]]] = [ + (2000, 3000), + {4001, 4002, 4003}, + ] + assert get_port(sets_and_ranges) in set(range(2000, 3000 + 1)) and { + 4001, + 4002, + 4003, + } + + +class SelectPortTest(unittest.TestCase): + @mock.patch("port_for.api.port_is_used") + def test_all_used(self, port_is_used: mock.MagicMock) -> None: + port_is_used.return_value = True + self.assertRaises(port_for.PortForException, port_for.select_random) + + @mock.patch("port_for.api.port_is_used") + def test_random_port(self, port_is_used: mock.MagicMock) -> None: + ports = set([1, 2, 3]) + used = {1: True, 2: False, 3: True} + port_is_used.side_effect = lambda port: used[port] + + for x in range(100): + self.assertEqual(port_for.select_random(ports), 2) + + +class StoreTest(unittest.TestCase): + def setUp(self) -> None: + fd, self.fname = tempfile.mkstemp() + self.store = port_for.PortStore(self.fname) + + def tearDown(self) -> None: + os.remove(self.fname) + + def test_store(self) -> None: + assert self.store.bound_ports() == [] + + port = self.store.bind_port("foo") + self.assertTrue(port) + self.assertEqual(self.store.bound_ports(), [("foo", port)]) + self.assertEqual(port, self.store.bind_port("foo")) + + port2 = self.store.bind_port("aar") + self.assertNotEqual(port, port2) + self.assertEqual( + self.store.bound_ports(), [("foo", port), ("aar", port2)] + ) + + self.store.unbind_port("aar") + self.assertEqual(self.store.bound_ports(), [("foo", port)]) + + def test_rebind(self) -> None: + # try to rebind an used port for an another app + port = self.store.bind_port("foo") + self.assertRaises( + port_for.PortForException, self.store.bind_port, "baz", port + ) + + def test_change_port(self) -> None: + # changing app ports is not supported. + port = self.store.bind_port("foo") + another_port = port_for.select_random() + assert port != another_port + self.assertRaises( + port_for.PortForException, self.store.bind_port, "foo", another_port + ) + + def test_bind_unavailable(self) -> None: + # it is possible to explicitly bind currently unavailable port + port = self.store.bind_port("foo", 80) + self.assertEqual(port, 80) + self.assertEqual(self.store.bound_ports(), [("foo", 80)]) + + def test_bind_non_auto(self) -> None: + # it is possible to pass a port + port = port_for.select_random() + res_port = self.store.bind_port("foo", port) + self.assertEqual(res_port, port) -- cgit v1.2.3