summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.coveragerc4
-rw-r--r--.github/PULL_REQUEST_TEMPLATE.rst5
-rw-r--r--.github/dependabot.yml14
-rw-r--r--.github/workflows/automerge.yml39
-rw-r--r--.github/workflows/build.yml11
-rw-r--r--.github/workflows/linters.yml18
-rw-r--r--.github/workflows/pr-check.yml27
-rw-r--r--.github/workflows/pypi.yml12
-rw-r--r--.github/workflows/tests-macos.yml17
-rw-r--r--.github/workflows/tests.yml15
-rw-r--r--.gitignore13
-rw-r--r--CHANGES.rst68
-rw-r--r--LICENSE.txt19
-rw-r--r--MANIFEST.in4
-rw-r--r--Pipfile16
-rw-r--r--Pipfile.lock388
-rw-r--r--README.rst170
-rw-r--r--mypy.ini25
-rw-r--r--newsfragments/.gitignore1
-rw-r--r--port_for/__init__.py29
-rw-r--r--port_for/_download_ranges.py95
-rw-r--r--port_for/_ranges.py689
-rw-r--r--port_for/api.py207
-rw-r--r--port_for/cmd.py66
-rw-r--r--port_for/docopt.py471
-rw-r--r--port_for/ephemeral.py72
-rw-r--r--port_for/exceptions.py3
-rw-r--r--port_for/py.typed0
-rw-r--r--port_for/store.py87
-rw-r--r--port_for/utils.py29
-rw-r--r--pyproject.toml143
-rw-r--r--setup.cfg3
-rw-r--r--tests/__init__.py1
-rw-r--r--tests/test_cases.py184
34 files changed, 2945 insertions, 0 deletions
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 <https://github.com/kmike/port-for/issues/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 <https://github.com/kmike/port-for/issues/143>`_)
+
+
+0.6.3 (2022-12-15)
+==================
+
+Features
+--------
+
+- Add python 3.11 to the list of supported python versions. (`#111 <https://github.com/kmike/port-for/issues/111>`_)
+
+
+Miscellaneus
+------------
+
+- Use towncrier as a changelog management tool. (`#107 <https://github.com/kmike/port-for/issues/107>`_)
+- Moved development dependencies to be managed by pipenv.
+ All development process can be managed with it - which means automatic isolation. (`#108 <https://github.com/kmike/port-for/issues/108>`_)
+- Migrate versioning tool to tbump, and move package definition to pyproject.toml (`#109 <https://github.com/kmike/port-for/issues/109>`_)
+- Moved as much of the setup.cfg settings into the pyproject.toml as possible.
+ Dropped pydocstyle support. (`#112 <https://github.com/kmike/port-for/issues/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 <foo>`` script finds an unused port and associates
+it with ``<foo>``. 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"<td>((\d+)(\W(\d+))?)</td>", 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 <NAME>
+ port-for --bind <NAME>
+ port-for --bind <NAME> --port <NUMBER>
+ port-for <NAME> --port <NUMBER>
+ port-for --unbind <NAME>
+ 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["<NAME>"]:
+ _bind(args["<NAME>"], 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
--- /dev/null
+++ b/port_for/py.typed
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} <https://github.com/kmike/port-for/issues/{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/<user or organization>/<project>/"
+
+[tool.tbump.version]
+current = "0.7.1"
+
+# Example of a semver regexp.
+# Make sure this matches current_version before
+# using tbump
+regex = '''
+ (?P<major>\d+)
+ \.
+ (?P<minor>\d+)
+ \.
+ (?P<patch>\d+)
+ (\-
+ (?P<extra>.+)
+ )?
+ '''
+
+[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)