├── .github
└── workflows
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── Makefile
├── README.md
├── docker
├── mysql.env
├── nacos-standlone-mysql.env
└── standalone-mysql-8.yaml
├── examples
├── use_config.py
├── use_fastapi.py
└── use_instance.py
├── pyproject.toml
├── src
└── use_nacos
│ ├── __init__.py
│ ├── _chooser.py
│ ├── cache.py
│ ├── client.py
│ ├── endpoints
│ ├── __init__.py
│ ├── config.py
│ ├── endpoint.py
│ ├── instance.py
│ ├── namespace.py
│ └── service.py
│ ├── exception.py
│ ├── helper.py
│ ├── serializer.py
│ └── typings.py
└── tests
├── test_config.py
└── test_instance.py
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*.*.*'
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v3
15 |
16 | - name: Set up Python 3.10
17 | uses: actions/setup-python@v4
18 | with:
19 | python-version: "3.10"
20 |
21 | - name: Install Poetry
22 | uses: snok/install-poetry@v1
23 | with:
24 | virtualenvs-create: true
25 | virtualenvs-in-project: true
26 | installer-parallel: true
27 |
28 | - name: Build project for distribution
29 | run: poetry build
30 |
31 | - name: Check Version
32 | id: check-version
33 | run: |
34 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
35 | || echo ::set-output name=prerelease::true
36 |
37 | - name: Create Release
38 | uses: ncipollo/release-action@v1
39 | with:
40 | artifacts: "dist/*"
41 | token: ${{ secrets.GITHUB_TOKEN }}
42 | draft: false
43 | prerelease: steps.check-version.outputs.prerelease == 'true'
44 | allowUpdates: true
45 |
46 | - name: Publish to PyPI
47 | env:
48 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
49 | run: poetry publish
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test suite
2 |
3 | on:
4 | push:
5 | paths:
6 | - "src/**"
7 | - "tests/**"
8 | pull_request:
9 | paths:
10 | - "src/**"
11 | - "tests/**"
12 |
13 | jobs:
14 | test:
15 | strategy:
16 | fail-fast: true
17 | matrix:
18 | os: [ "ubuntu-latest" ]
19 | python-version: [ "3.8" ]
20 | runs-on: ${{ matrix.os }}
21 | environment: test
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Set up Python ${{ matrix.python-version }}
25 | uses: actions/setup-python@v2
26 | with:
27 | python-version: ${{ matrix.python-version }}
28 | - name: Install Poetry
29 | uses: snok/install-poetry@v1
30 | with:
31 | virtualenvs-create: true
32 | virtualenvs-in-project: true
33 | installer-parallel: true
34 | - name: Load cached venv
35 | id: cached-poetry-dependencies
36 | uses: actions/cache@v2
37 | with:
38 | path: .venv
39 | key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
40 | - name: Install dependencies
41 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
42 | run: poetry install --no-interaction --no-root
43 | - name: Install library
44 | run: poetry install --no-interaction
45 | - name: Run tests
46 | run: |
47 | source .venv/bin/activate
48 | pytest tests/
49 | env:
50 | NACOS_SERVER_ADDR: ${{ secrets.NACOS_SERVER_ADDR }}
51 | NACOS_USERNAME: ${{ secrets.NACOS_USERNAME }}
52 | NACOS_PASSWORD: ${{ secrets.NACOS_PASSWORD }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # created by virtualenv automatically
2 | # Byte-compiled / optimized / DLL files
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | pip-wheel-metadata/
25 | share/python-wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | *.py,cover
52 | .hypothesis/
53 | .pytest_cache/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | .python-version
87 |
88 | # pipenv
89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
92 | # install all needed dependencies.
93 | #Pipfile.lock
94 |
95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96 | __pypackages__/
97 |
98 | # Celery stuff
99 | celerybeat-schedule
100 | celerybeat.pid
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
132 | # pycharm
133 | .idea
134 | poetry.lock
135 | *.DS_Store
136 |
137 | # docs/node_modules
138 | docs/node_modules
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | install: ## Run `poetry install`
2 | poetry install --no-root
3 |
4 | lint:
5 | poetry run isort --check .
6 | poetry run black --check .
7 | poetry run flake8 src tests
8 |
9 | format: ## Formats you code with Black
10 | poetry run isort .
11 | poetry run black .
12 |
13 | run: ## run `poetry run use-nacos`
14 | poetry run use-nacos
15 |
16 |
17 | test:
18 | poetry run pytest -v tests
19 |
20 | publish:
21 | poetry publish --build
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # use-nacos
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | A python nacos client based on the official [open-api](https://nacos.io/zh-cn/docs/open-api.html).
15 |
16 | ## install
17 |
18 | ```shell
19 | pip install use-nacos
20 | ```
21 |
22 | ## usage
23 |
24 | ### config
25 |
26 | ```python
27 | from use_nacos import NacosClient
28 |
29 | client = NacosClient(...)
30 |
31 | # publish config
32 | client.config.publish("test_config", "DEFAULT_GROUP", "test_value")
33 | # get config
34 | assert client.config.get("test_config", "DEFAULT_GROUP") == "test_value"
35 |
36 |
37 | # subscribe config
38 |
39 | def config_update(config):
40 | print(config)
41 |
42 |
43 | client.config.subscribe(
44 | "test_config",
45 | "DEFAULT_GROUP",
46 | callback=config_update
47 | )
48 | ```
49 |
50 | ### instance
51 |
52 | ```python
53 | from use_nacos import NacosClient
54 |
55 | nacos = NacosClient()
56 |
57 | nacos.instance.register(
58 | service_name="test",
59 | ip="10.10.10.10",
60 | port=8000,
61 | weight=10.0
62 | )
63 |
64 | nacos.instance.heartbeat(
65 | service_name="test",
66 | ip="10.10.10.10",
67 | port=8000,
68 | )
69 | ```
70 |
71 | ### 😘support `async` mode
72 |
73 | ```python
74 | # example: fastapi
75 |
76 | from contextlib import asynccontextmanager
77 |
78 | import uvicorn
79 | from fastapi import FastAPI
80 |
81 | from use_nacos import NacosAsyncClient
82 |
83 |
84 | def config_update(config):
85 | print(config)
86 |
87 |
88 | @asynccontextmanager
89 | async def lifespan(app: FastAPI):
90 | nacos = NacosAsyncClient()
91 |
92 | config_subscriber = await nacos.config.subscribe(
93 | data_id="test-config",
94 | group="DEFAULT_GROUP",
95 | callback=config_update,
96 | )
97 | yield
98 | config_subscriber.cancel()
99 |
100 |
101 | app = FastAPI(lifespan=lifespan)
102 |
103 | if __name__ == '__main__':
104 | uvicorn.run("in_fastapi:app", host="0.0.0.0", port=1081)
105 | ```
106 |
107 | ## Developing
108 |
109 | ```text
110 | make install # Run `poetry install`
111 | make lint # Runs bandit and black in check mode
112 | make format # Formats you code with Black
113 | make test # run pytest with coverage
114 | make publish # run `poetry publish --build` to build source and wheel package and publish to pypi
115 | ```
--------------------------------------------------------------------------------
/docker/mysql.env:
--------------------------------------------------------------------------------
1 | MYSQL_ROOT_PASSWORD=root
2 | MYSQL_DATABASE=nacos_devtest
3 | MYSQL_USER=nacos
4 | MYSQL_PASSWORD=nacos
5 | LANG=C.UTF-8
6 |
--------------------------------------------------------------------------------
/docker/nacos-standlone-mysql.env:
--------------------------------------------------------------------------------
1 | PREFER_HOST_MODE=hostname
2 | MODE=standalone
3 | SPRING_DATASOURCE_PLATFORM=mysql
4 | MYSQL_SERVICE_HOST=mysql
5 | MYSQL_SERVICE_DB_NAME=nacos_devtest
6 | MYSQL_SERVICE_PORT=3306
7 | MYSQL_SERVICE_USER=nacos
8 | MYSQL_SERVICE_PASSWORD=nacos
9 | MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true
10 | NACOS_AUTH_IDENTITY_KEY=2222
11 | NACOS_AUTH_IDENTITY_VALUE=2xxx
12 | NACOS_AUTH_TOKEN=SecretKey012345678901234567890123456789012345678901234567890123456789
13 |
--------------------------------------------------------------------------------
/docker/standalone-mysql-8.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 | services:
3 | nacos:
4 | image: nacos/nacos-server:${NACOS_VERSION}
5 | container_name: nacos-standalone-mysql
6 | env_file:
7 | - .nacos-standlone-mysql.env
8 | volumes:
9 | - ./standalone-logs/:/home/nacos/logs
10 | ports:
11 | - "8848:8848"
12 | - "9848:9848"
13 | depends_on:
14 | mysql:
15 | condition: service_healthy
16 | restart: always
17 | mysql:
18 | container_name: mysql
19 | build:
20 | context: .
21 | dockerfile: ./image/mysql/8/Dockerfile
22 | image: example/mysql:8.0.30
23 | env_file:
24 | - ./mysql.env
25 | volumes:
26 | - ./mysql:/var/lib/mysql
27 | ports:
28 | - "3306:3306"
29 | healthcheck:
30 | test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
31 | interval: 5s
32 | timeout: 10s
33 | retries: 10
34 |
35 |
36 |
--------------------------------------------------------------------------------
/examples/use_config.py:
--------------------------------------------------------------------------------
1 | from use_nacos import NacosClient
2 |
3 | client = NacosClient()
4 |
5 | # publish config
6 | client.config.publish("test_config", "DEFAULT_GROUP", "test_value")
7 |
8 | # get config
9 | assert client.config.get("test_config", "DEFAULT_GROUP") == "test_value"
10 |
11 | # get config with default value
12 | assert client.config.get(
13 | "test_config_miss", "DEFAULT_GROUP", default="default_value"
14 | ) == "default_value"
15 |
16 |
17 | # subscribe config
18 |
19 | def config_update(config):
20 | print(config)
21 |
22 |
23 | client.config.subscribe(
24 | "test_config",
25 | "DEFAULT_GROUP",
26 | callback=config_update
27 | )
28 |
--------------------------------------------------------------------------------
/examples/use_fastapi.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 |
3 | import uvicorn
4 | from fastapi import FastAPI
5 |
6 | from use_nacos import NacosAsyncClient
7 |
8 |
9 | def config_update(config):
10 | print(config)
11 |
12 |
13 | @asynccontextmanager
14 | async def lifespan(app: FastAPI):
15 | nacos = NacosAsyncClient()
16 |
17 | config_subscriber = await nacos.config.subscribe(
18 | data_id="test-config",
19 | group="DEFAULT_GROUP",
20 | callback=config_update,
21 | )
22 | await nacos.instance.register(
23 | service_name=f"python-api-1",
24 | ip="10.10.10.1",
25 | port=8000,
26 | weight=1
27 | )
28 | yield
29 | config_subscriber.cancel()
30 |
31 |
32 | app = FastAPI(lifespan=lifespan)
33 |
34 | if __name__ == '__main__':
35 | uvicorn.run("in_fastapi:app", host="0.0.0.0", port=1081)
36 |
--------------------------------------------------------------------------------
/examples/use_instance.py:
--------------------------------------------------------------------------------
1 | from use_nacos import NacosClient
2 |
3 | nacos = NacosClient()
4 |
5 | nacos.instance.register(
6 | service_name="test",
7 | ip="10.10.10.10",
8 | port=8000,
9 | weight=10.0
10 | )
11 |
12 | nacos.instance.heartbeat(
13 | service_name="test",
14 | ip="10.10.10.10",
15 | port=8000,
16 | )
17 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "use-nacos"
3 | version = "0.1.5"
4 | description = "A python nacos client based on the official open-api"
5 | authors = ["MicLon "]
6 | readme = "README.md"
7 | license = "MIT"
8 | homepage = "https://github.com/use-py/use-nacos"
9 | repository = "https://github.com/use-py/use-nacos"
10 | packages = [
11 | { include = "use_nacos", from = "src" }
12 | ]
13 |
14 | [tool.poetry.urls]
15 | "Bug Tracker" = "https://github.com/use-py/use-nacos/issues"
16 |
17 | [tool.poetry.dependencies]
18 | python = "^3.8"
19 | httpx = "^0.25.2"
20 | tomli = { version = "^2.0.0", python = "<3.11" }
21 |
22 |
23 | [tool.poetry.group.test.dependencies]
24 | pylint = "*"
25 | pytest = "*"
26 | pytest-asyncio = "*"
27 | pytest-mock = "*"
28 | black = "*"
29 | flake8 = "*"
30 | isort = "*"
31 | pre-commit = "*"
32 | pre-commit-hooks = "*"
33 |
34 | [build-system]
35 | requires = ["poetry-core"]
36 | build-backend = "poetry.core.masonry.api"
37 |
--------------------------------------------------------------------------------
/src/use_nacos/__init__.py:
--------------------------------------------------------------------------------
1 | from use_nacos.client import NacosClient, NacosAsyncClient
2 |
3 | __all__ = [
4 | "NacosClient",
5 | "NacosAsyncClient"
6 | ]
7 |
--------------------------------------------------------------------------------
/src/use_nacos/_chooser.py:
--------------------------------------------------------------------------------
1 | # created by gpt-4
2 |
3 | import random
4 |
5 |
6 | class Chooser:
7 | def __init__(self, host_with_weight: list):
8 | self.host_with_weight = host_with_weight
9 | self.items = []
10 | self.weights = []
11 |
12 | def refresh(self):
13 | origin_weight_sum = 0.0
14 | # Preparing the valid items list and calculating the original weights sum
15 | for item, weight in self.host_with_weight:
16 | if weight <= 0:
17 | continue
18 | if float('inf') == weight:
19 | weight = 10000.0
20 | elif float('nan') == weight:
21 | weight = 1.0
22 | origin_weight_sum += weight
23 | self.items.append(item)
24 |
25 | if not self.items:
26 | return
27 |
28 | # Computing the exact weights for each item
29 | exact_weights = [weight / origin_weight_sum for _, weight in self.host_with_weight if weight > 0]
30 |
31 | # Initializing the cumulative weights array
32 | random_range = 0.0
33 | for single_weight in exact_weights:
34 | random_range += single_weight
35 | self.weights.append(random_range)
36 |
37 | # Checking the final weight
38 | double_precision_delta = 0.0001
39 | if abs(self.weights[-1] - 1) < double_precision_delta:
40 | return
41 | raise ValueError("Cumulative Weight calculate wrong, the sum of probabilities does not equal 1.")
42 |
43 | def random_with_weight(self):
44 | # Generating a random number between 0 and 1
45 | random_value = random.random()
46 |
47 | # Using binary search to find the index for the random value
48 | index = self._find_index(self.weights, random_value)
49 |
50 | return self.items[index]
51 |
52 | @staticmethod
53 | def _find_index(weights, value):
54 | # Perform a binary search manually since weights are not just keys
55 | low = 0
56 | high = len(weights) - 1
57 | while low <= high:
58 | mid = (low + high) // 2
59 | if weights[mid] < value:
60 | low = mid + 1
61 | elif weights[mid] > value:
62 | high = mid - 1
63 | else:
64 | return mid # This is the exact match case
65 | return low # This is the case where value should be inserted
66 |
--------------------------------------------------------------------------------
/src/use_nacos/cache.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 |
5 | class BaseCache:
6 | def set(self, key, value):
7 | raise NotImplementedError
8 |
9 | def get(self, key):
10 | raise NotImplementedError
11 |
12 | def exists(self, key):
13 | raise NotImplementedError
14 |
15 |
16 | class MemoryCache(BaseCache):
17 | def __init__(self):
18 | self.storage = {}
19 |
20 | def set(self, key, value):
21 | self.storage[key] = value
22 |
23 | def get(self, key):
24 | return self.storage.get(key)
25 |
26 | def exists(self, key):
27 | return key in self.storage
28 |
29 |
30 | class FileCache(BaseCache):
31 | def __init__(self, file_path=None):
32 | self.file_path = file_path or '_nacos_config_cache.json'
33 | if not os.path.exists(self.file_path):
34 | with open(self.file_path, 'w') as f:
35 | json.dump({}, f)
36 |
37 | def _read_file(self):
38 | with open(self.file_path, 'r') as f:
39 | return json.load(f)
40 |
41 | def _write_file(self, data):
42 | with open(self.file_path, 'w') as f:
43 | json.dump(data, f)
44 |
45 | def set(self, key, value):
46 | data = self._read_file()
47 | data[key] = value
48 | self._write_file(data)
49 |
50 | def get(self, key):
51 | data = self._read_file()
52 | return data.get(key)
53 |
54 | def exists(self, key):
55 | data = self._read_file()
56 | return key in data
57 |
58 |
59 | memory_cache = MemoryCache()
60 |
--------------------------------------------------------------------------------
/src/use_nacos/client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from abc import abstractmethod
4 | from json import JSONDecodeError
5 | from typing import Any, Optional, Dict, List, Generator
6 |
7 | import httpx
8 | from httpx import Request, Response, Auth, HTTPTransport, AsyncHTTPTransport
9 |
10 | from .endpoints import (
11 | ConfigEndpoint, InstanceEndpoint, ServiceEndpoint, NamespaceEndpoint,
12 | ConfigAsyncEndpoint, InstanceAsyncEndpoint
13 | )
14 | from .exception import HTTPResponseError
15 | from .typings import SyncAsync, HttpxClient
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 | DEFAULT_SERVER_ADDR = "http://localhost:8848/"
20 | DEFAULT_NAMESPACE = ""
21 |
22 |
23 | class BaseClient:
24 |
25 | def __init__(
26 | self,
27 | client: HttpxClient,
28 | server_addr: Optional[str] = None,
29 | username: Optional[str] = None,
30 | password: Optional[str] = None,
31 | namespace_id: Optional[str] = None,
32 | ):
33 | self.server_addr = server_addr or os.environ.get("NACOS_SERVER_ADDR") or DEFAULT_SERVER_ADDR
34 | self.username = username or os.environ.get("NACOS_USERNAME")
35 | self.password = password or os.environ.get("NACOS_PASSWORD")
36 | self.namespace_id = namespace_id or os.environ.get("NACOS_NAMESPACE") or DEFAULT_NAMESPACE
37 |
38 | self._clients: List[HttpxClient] = []
39 | self.client = client
40 | # endpoints
41 | self.config = ConfigEndpoint(self)
42 | self.instance = InstanceEndpoint(self)
43 | self.service = ServiceEndpoint(self)
44 | self.namespace = NamespaceEndpoint(self)
45 |
46 | @property
47 | def client(self) -> HttpxClient:
48 | return self._clients[-1]
49 |
50 | @client.setter
51 | def client(self, client: HttpxClient) -> None:
52 | client.base_url = httpx.URL(self.server_addr)
53 | client.timeout = httpx.Timeout(timeout=60_000 / 1_000)
54 | client.headers = httpx.Headers(
55 | {
56 | "User-Agent": "use-py/use-nacos",
57 | }
58 | )
59 | self._clients.append(client)
60 |
61 | def _build_request(
62 | self,
63 | method: str,
64 | path: str,
65 | query: Optional[Dict[Any, Any]] = None,
66 | body: Optional[Dict[Any, Any]] = None,
67 | headers: Optional[Dict[Any, Any]] = None,
68 | **kwargs
69 | ) -> Request:
70 | _headers = httpx.Headers()
71 | if headers:
72 | _headers.update(headers)
73 | return self.client.build_request(
74 | method, path, params=query, data=body, headers=_headers, **kwargs
75 | )
76 |
77 | @staticmethod
78 | def _parse_response(response: Response, serialized: bool) -> Any:
79 | """ Parse response body """
80 | if not serialized:
81 | return response.text
82 | try:
83 | body = response.json()
84 | except JSONDecodeError:
85 | body = response.text
86 | return body
87 |
88 | @abstractmethod
89 | def request(
90 | self,
91 | path: str,
92 | method: str,
93 | query: Optional[Dict[Any, Any]] = None,
94 | body: Optional[Dict[Any, Any]] = None,
95 | headers: Optional[Dict[Any, Any]] = None
96 | ) -> SyncAsync[Any]:
97 | raise NotImplementedError
98 |
99 |
100 | class NacosClient(BaseClient):
101 | client: httpx.Client
102 |
103 | def __init__(
104 | self,
105 | server_addr: Optional[str] = None,
106 | username: Optional[str] = None,
107 | password: Optional[str] = None,
108 | namespace_id: Optional[str] = None,
109 | client: Optional[httpx.Client] = None,
110 | *,
111 | http_retries: Optional[int] = 3,
112 | ):
113 | """ Nacos Sync Client """
114 | client = client or httpx.Client(transport=HTTPTransport(retries=http_retries))
115 | super().__init__(
116 | client=client,
117 | server_addr=server_addr,
118 | username=username,
119 | password=password,
120 | namespace_id=namespace_id
121 | )
122 |
123 | def request(
124 | self,
125 | path: str,
126 | method: str = "GET",
127 | query: Optional[Dict[Any, Any]] = None,
128 | body: Optional[Dict[Any, Any]] = None,
129 | headers: Optional[Dict[Any, Any]] = None,
130 | serialized: Optional[bool] = True,
131 | **kwargs
132 | ) -> Any:
133 | request = self._build_request(method, path, query, body, headers, **kwargs)
134 | try:
135 | response = self.client.send(
136 | request,
137 | auth=NacosAPIAuth(self.username, self.password)
138 | )
139 | response.raise_for_status()
140 | return self._parse_response(response, serialized)
141 | except httpx.HTTPStatusError as exc:
142 | raise HTTPResponseError(exc.response)
143 |
144 |
145 | class NacosAsyncClient(BaseClient):
146 | client: httpx.AsyncClient
147 |
148 | def __init__(
149 | self,
150 | server_addr: Optional[str] = None,
151 | username: Optional[str] = None,
152 | password: Optional[str] = None,
153 | namespace_id: Optional[str] = None,
154 | client: Optional[httpx.AsyncClient] = None,
155 | *,
156 | http_retries: Optional[int] = 3,
157 | ):
158 | """ Nacos Async Client """
159 | client = client or httpx.AsyncClient(transport=AsyncHTTPTransport(retries=http_retries))
160 | super().__init__(
161 | client=client,
162 | server_addr=server_addr,
163 | username=username,
164 | password=password,
165 | namespace_id=namespace_id
166 | )
167 | self.config = ConfigAsyncEndpoint(self)
168 | self.instance = InstanceAsyncEndpoint(self)
169 |
170 | async def request(
171 | self,
172 | path: str,
173 | method: str = "GET",
174 | query: Optional[Dict[Any, Any]] = None,
175 | body: Optional[Dict[Any, Any]] = None,
176 | headers: Optional[Dict[Any, Any]] = None,
177 | serialized: Optional[bool] = True,
178 | **kwargs
179 | ) -> Any:
180 | request = self._build_request(method, path, query, body, headers, **kwargs)
181 | try:
182 | response = await self.client.send(
183 | request,
184 | auth=NacosAPIAuth(self.username, self.password)
185 | )
186 | response.raise_for_status()
187 | return self._parse_response(response, serialized)
188 | except httpx.HTTPStatusError as exc:
189 | raise HTTPResponseError(exc.response)
190 |
191 |
192 | class NacosAPIAuth(Auth):
193 | """ Attaches HTTP Nacos Authentication to the given Request object. """
194 |
195 | def __init__(
196 | self, username: str, password: str
197 | ) -> None:
198 | self.auth_params = {"username": username, "password": password}
199 |
200 | def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
201 | request.url = request.url.copy_merge_params(params=self.auth_params)
202 | yield request
203 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import ConfigEndpoint, ConfigAsyncEndpoint
2 | from .instance import InstanceEndpoint, InstanceAsyncEndpoint
3 | from .service import ServiceEndpoint
4 | from .namespace import NamespaceEndpoint
5 |
6 | __all__ = [
7 | "ConfigEndpoint",
8 | "ConfigAsyncEndpoint",
9 | "InstanceEndpoint",
10 | "InstanceAsyncEndpoint",
11 | "ServiceEndpoint",
12 | "NamespaceEndpoint"
13 | ]
14 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/config.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import hashlib
3 | import logging
4 | import threading
5 | from typing import Optional, Any, Callable, Union
6 |
7 | import httpx
8 |
9 | from .endpoint import Endpoint
10 | from ..cache import BaseCache, MemoryCache, memory_cache
11 | from ..exception import HTTPResponseError
12 | from ..serializer import Serializer, AutoSerializer
13 | from ..typings import SyncAsync
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | def _get_md5(content: Any):
19 | string_content = str(content) if not isinstance(content, str) else content
20 | return hashlib.md5(string_content.encode('utf-8')).hexdigest() if content else ''
21 |
22 |
23 | def _get_config_key(data_id: str, group: str, tenant: str):
24 | # because `#` is illegal character in Nacos
25 | return '#'.join([data_id, group, tenant])
26 |
27 |
28 | def _parse_config_key(key: str):
29 | return key.split('#')
30 |
31 |
32 | def _serialize_config(
33 | config: Any,
34 | serializer: Optional[Union["Serializer", bool]] = None
35 | ):
36 | """ Serialize config with serializer """
37 | if isinstance(serializer, bool) and serializer is True:
38 | serializer = AutoSerializer()
39 | if isinstance(serializer, Serializer):
40 | return serializer(config)
41 | return config
42 |
43 |
44 | class _BaseConfigEndpoint(Endpoint):
45 |
46 | def _get(
47 | self,
48 | data_id: str,
49 | group: str,
50 | tenant: Optional[str] = ''
51 | ) -> SyncAsync[Any]:
52 | return self.client.request(
53 | "/nacos/v1/cs/configs",
54 | query={
55 | "dataId": data_id,
56 | "group": group,
57 | "tenant": tenant,
58 | },
59 | serialized=False
60 | )
61 |
62 | def publish(
63 | self,
64 | data_id: str,
65 | group: str,
66 | content: str,
67 | tenant: Optional[str] = '',
68 | type: Optional[str] = None,
69 | ) -> SyncAsync[Any]:
70 | return self.client.request(
71 | "/nacos/v1/cs/configs",
72 | method="POST",
73 | body={
74 | "dataId": data_id,
75 | "group": group,
76 | "tenant": tenant,
77 | "content": content,
78 | "type": type,
79 | }
80 | )
81 |
82 | def delete(
83 | self,
84 | data_id: str,
85 | group: str,
86 | tenant: Optional[str] = '',
87 | ) -> SyncAsync[Any]:
88 | return self.client.request(
89 | "/nacos/v1/cs/configs",
90 | method="DELETE",
91 | query={
92 | "dataId": data_id,
93 | "group": group,
94 | "tenant": tenant,
95 | }
96 | )
97 |
98 | @staticmethod
99 | def _format_listening_configs(
100 | data_id: str,
101 | group: str,
102 | content_md5: Optional[str] = None,
103 | tenant: Optional[str] = ''
104 | ) -> str:
105 | return u'\x02'.join([data_id, group, content_md5 or "", tenant]) + u'\x01'
106 |
107 | def subscriber(
108 | self,
109 | data_id: str,
110 | group: str,
111 | content_md5: Optional[str] = None,
112 | tenant: Optional[str] = '',
113 | timeout: Optional[int] = 30_000,
114 | ) -> SyncAsync[Any]:
115 | listening_configs = self._format_listening_configs(
116 | data_id, group, content_md5, tenant
117 | )
118 | return self.client.request(
119 | "/nacos/v1/cs/configs/listener",
120 | method="POST",
121 | body={
122 | "Listening-Configs": listening_configs
123 | },
124 | headers={
125 | "Long-Pulling-Timeout": f"{timeout}",
126 | },
127 | timeout=timeout
128 | )
129 |
130 |
131 | class ConfigOperationMixin:
132 |
133 | @staticmethod
134 | def _config_callback(callback, config, serializer):
135 | if not callable(callback):
136 | return
137 | config = _serialize_config(config, serializer)
138 | callback(config)
139 |
140 | def get(
141 | self,
142 | data_id: str,
143 | group: str,
144 | tenant: Optional[str] = '',
145 | *,
146 | serializer: Optional[Union["Serializer", bool]] = None,
147 | cache: Optional[BaseCache] = None,
148 | default: Optional[str] = None
149 | ) -> SyncAsync[Any]:
150 | cache = cache or memory_cache
151 | config_key = _get_config_key(data_id, group, tenant)
152 | try:
153 | config = self._get(data_id, group, tenant)
154 | # todo: this function need to be optimized
155 | cache.set(config_key, config)
156 | return _serialize_config(config, serializer)
157 | except (httpx.ConnectError, httpx.TimeoutException) as exc:
158 | logger.error("Failed to get config from server, try to get from cache. %s", exc)
159 | return _serialize_config(cache.get(config_key), serializer)
160 | except HTTPResponseError as exc:
161 | logger.debug("Failed to get config from server. %s", exc)
162 | if exc.status == 404 and default is not None:
163 | return default
164 | raise
165 |
166 | def subscribe(
167 | self,
168 | data_id: str,
169 | group: str,
170 | tenant: Optional[str] = '',
171 | timeout: Optional[int] = 30_000,
172 | serializer: Optional[Union["Serializer", bool]] = None,
173 | cache: Optional[BaseCache] = None,
174 | callback: Optional[Callable] = None
175 | ) -> SyncAsync[Any]:
176 | cache = cache or MemoryCache()
177 | config_key = _get_config_key(data_id, group, tenant)
178 | last_md5 = _get_md5(cache.get(config_key) or '')
179 | stop_event = threading.Event()
180 | stop_event.cancel = stop_event.set
181 |
182 | def _subscriber():
183 | nonlocal last_md5
184 | while not stop_event.is_set():
185 | try:
186 | response = self.subscriber(data_id, group, last_md5, tenant, timeout)
187 | if not response:
188 | continue
189 | logging.info("Configuration update detected.")
190 | last_config = self._get(data_id, group, tenant)
191 | last_md5 = _get_md5(last_config)
192 | cache.set(config_key, last_config)
193 | self._config_callback(callback, last_config, serializer)
194 | except Exception as exc:
195 | logging.error(exc)
196 | stop_event.wait(1)
197 |
198 | thread = threading.Thread(target=_subscriber)
199 | thread.start()
200 | return stop_event
201 |
202 |
203 | class ConfigAsyncOperationMixin:
204 |
205 | @staticmethod
206 | async def _config_callback(callback, config, serializer):
207 | if not callable(callback):
208 | return
209 |
210 | config = _serialize_config(config, serializer)
211 | if asyncio.iscoroutinefunction(callback):
212 | await callback(config)
213 | else:
214 | callback(config)
215 |
216 | async def get(
217 | self,
218 | data_id: str,
219 | group: str,
220 | tenant: Optional[str] = '',
221 | *,
222 | serializer: Optional[Union["Serializer", bool]] = None,
223 | cache: Optional[BaseCache] = None,
224 | default: Optional[str] = None
225 | ) -> SyncAsync[Any]:
226 | cache = cache or memory_cache
227 | config_key = _get_config_key(data_id, group, tenant)
228 | try:
229 | config = await self._get(data_id, group, tenant)
230 | cache.set(config_key, config)
231 | return _serialize_config(config, serializer)
232 | except (httpx.ConnectError, httpx.TimeoutException) as exc:
233 | logger.error("Failed to get config from server, try to get from cache. %s", exc)
234 | return _serialize_config(cache.get(config_key), serializer)
235 | except HTTPResponseError as exc:
236 | logger.debug("Failed to get config from server. %s", exc)
237 | if exc.status == 404 and default is not None:
238 | return default
239 | raise
240 |
241 | async def subscribe(
242 | self,
243 | data_id: str,
244 | group: str,
245 | tenant: Optional[str] = '',
246 | timeout: Optional[int] = 30_000,
247 | serializer: Optional[Union["Serializer", bool]] = None,
248 | cache: Optional[BaseCache] = None,
249 | callback: Optional[Callable] = None,
250 | ) -> SyncAsync[Any]:
251 | cache = cache or MemoryCache()
252 | config_key = _get_config_key(data_id, group, tenant)
253 | last_md5 = _get_md5(cache.get(config_key) or '')
254 | stop_event = threading.Event()
255 | stop_event.cancel = stop_event.set
256 |
257 | async def _async_subscriber():
258 | nonlocal last_md5
259 | while True:
260 | try:
261 | response = await self.subscriber(
262 | data_id, group, last_md5, tenant, timeout
263 | )
264 | if not response:
265 | continue
266 | logging.info("Configuration update detected.")
267 | last_config = await self._get(data_id, group, tenant)
268 | last_md5 = _get_md5(last_config)
269 | cache.set(config_key, last_config)
270 | await self._config_callback(callback, last_config, serializer)
271 | except asyncio.CancelledError:
272 | break
273 | except Exception as exc:
274 | logging.error(exc)
275 | await asyncio.sleep(1)
276 |
277 | return asyncio.create_task(_async_subscriber())
278 |
279 |
280 | class ConfigEndpoint(_BaseConfigEndpoint, ConfigOperationMixin):
281 | ...
282 |
283 |
284 | class ConfigAsyncEndpoint(_BaseConfigEndpoint, ConfigAsyncOperationMixin):
285 | ...
286 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/endpoint.py:
--------------------------------------------------------------------------------
1 | class Endpoint:
2 | def __init__(self, client: "BaseClient") -> None:
3 | self.client = client
4 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/instance.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import logging
4 | import threading
5 | import time
6 | from functools import partial
7 | from typing import Optional, Any, Literal, List, TypedDict
8 |
9 | import httpx
10 |
11 | from .endpoint import Endpoint
12 | from .._chooser import Chooser
13 | from ..exception import EmptyHealthyInstanceError
14 | from ..typings import SyncAsync, BeatType
15 |
16 | _ConsistencyType = Literal["ephemeral", "persist"]
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class InstanceType(TypedDict):
22 | ip: str
23 | port: int
24 | weight: float
25 | enabled: bool
26 | healthy: bool
27 | metadata: Optional[dict]
28 |
29 |
30 | def _choose_one_healthy(instances: List[InstanceType]) -> InstanceType:
31 | """ Choose one healthy instance """
32 | hosts = [
33 | (host, host.get('weight'))
34 | for host in instances
35 | ]
36 | if not hosts:
37 | raise EmptyHealthyInstanceError("No healthy instance found")
38 | chooser = Chooser(hosts)
39 | chooser.refresh()
40 | return chooser.random_with_weight()
41 |
42 |
43 | class InstanceOperationMixin:
44 |
45 | def __getattr__(self, attr) -> SyncAsync[Any]:
46 | return partial(self.request, service_name=attr)
47 |
48 | def request(
49 | self,
50 | method: str,
51 | path: str,
52 | instance: Optional[InstanceType] = None,
53 | service_name: Optional[str] = None,
54 | *args, **kwargs
55 | ) -> SyncAsync[Any]:
56 | """ Request with instance """
57 | if not any([instance, service_name]):
58 | raise ValueError("Either `instance` or `service_name` should be provided")
59 | if not instance:
60 | instance = self.get_one_healthy(service_name)
61 | url = f"http://{instance['ip']}:{instance['port']}{path}" # noqa
62 | return httpx.Client().request(method=method, url=url, *args, **kwargs)
63 |
64 | def heartbeat(
65 | self,
66 | service_name: str,
67 | ip: str,
68 | port: int,
69 | weight: Optional[float] = 1.0,
70 | namespace_id: Optional[str] = '',
71 | group_name: Optional[str] = None,
72 | ephemeral: Optional[bool] = True,
73 | interval: Optional[int] = 1_000,
74 | skip_exception: Optional[bool] = True,
75 | **kwargs
76 | ) -> SyncAsync[Any]:
77 | stop_event = threading.Event()
78 | stop_event.cancel = stop_event.set
79 |
80 | def _heartbeat():
81 | while not stop_event.is_set():
82 | time.sleep(interval / 1_000)
83 | try:
84 | self.beat(
85 | service_name=service_name,
86 | ip=ip,
87 | port=port,
88 | weight=weight,
89 | namespace_id=namespace_id,
90 | group_name=group_name,
91 | ephemeral=ephemeral,
92 | **kwargs
93 | )
94 | except Exception as exc:
95 | logger.error("Heartbeat error: %s", exc)
96 | if skip_exception:
97 | continue
98 | raise exc
99 |
100 | thread = threading.Thread(target=_heartbeat)
101 | thread.start()
102 | return stop_event
103 |
104 | def get_one_healthy(
105 | self,
106 | service_name: str,
107 | namespace_id: Optional[str] = None,
108 | group_name: Optional[str] = None,
109 | clusters: Optional[str] = None,
110 |
111 | ) -> InstanceType:
112 | """ Get a healthy instance """
113 | instances = self.list(
114 | service_name=service_name,
115 | namespace_id=namespace_id,
116 | group_name=group_name,
117 | clusters=clusters,
118 | healthy_only=True
119 | )
120 | return _choose_one_healthy(instances["hosts"])
121 |
122 |
123 | class InstanceAsyncOperationMixin:
124 |
125 | def __getattr__(self, attr) -> SyncAsync[Any]:
126 | return partial(self.request, service_name=attr)
127 |
128 | async def request(
129 | self,
130 | method: str,
131 | path: str,
132 | instance: Optional[InstanceType] = None,
133 | service_name: Optional[str] = None,
134 | *args, **kwargs
135 | ) -> SyncAsync[Any]:
136 | """ Request with instance """
137 | if not any([instance, service_name]):
138 | raise ValueError("Either `instance` or `service_name` should be provided")
139 | if not instance:
140 | instance = await self.get_one_healthy(service_name)
141 | url = f"http://{instance['ip']}:{instance['port']}{path}" # noqa
142 | return await httpx.AsyncClient().request(method=method, url=url, *args, **kwargs)
143 |
144 | async def heartbeat(
145 | self,
146 | service_name: str,
147 | ip: str,
148 | port: int,
149 | weight: Optional[float] = 1.0,
150 | namespace_id: Optional[str] = '',
151 | group_name: Optional[str] = None,
152 | ephemeral: Optional[bool] = True,
153 | interval: Optional[int] = 1_000,
154 | skip_exception: Optional[bool] = True,
155 | **kwargs
156 | ) -> SyncAsync[Any]:
157 | stop_event = threading.Event()
158 | stop_event.cancel = stop_event.set
159 |
160 | async def _async_heartbeat():
161 | while True:
162 | await asyncio.sleep(interval / 1_000)
163 | try:
164 | await self.beat(
165 | service_name=service_name,
166 | ip=ip,
167 | port=port,
168 | weight=weight,
169 | namespace_id=namespace_id,
170 | group_name=group_name,
171 | ephemeral=ephemeral,
172 | **kwargs
173 | )
174 | except asyncio.CancelledError:
175 | break
176 | except Exception as exc:
177 | logger.error("Heartbeat error: %s", exc)
178 | if skip_exception:
179 | continue
180 | raise exc
181 |
182 | return asyncio.create_task(_async_heartbeat())
183 |
184 | async def get_one_healthy(
185 | self,
186 | service_name: str,
187 | namespace_id: Optional[str] = None,
188 | group_name: Optional[str] = None,
189 | clusters: Optional[str] = None,
190 |
191 | ) -> InstanceType:
192 | """ Get a healthy instance """
193 | instances = await self.list(
194 | service_name=service_name,
195 | namespace_id=namespace_id,
196 | group_name=group_name,
197 | clusters=clusters,
198 | healthy_only=True
199 | )
200 | return _choose_one_healthy(instances["hosts"])
201 |
202 |
203 | class _BaseInstanceEndpoint(Endpoint):
204 | """ Instance Management API """
205 |
206 | def register(
207 | self,
208 | service_name: str,
209 | ip: str,
210 | port: int,
211 | namespace_id: Optional[str] = '',
212 | weight: Optional[float] = 1.0,
213 | enabled: Optional[bool] = True,
214 | healthy: Optional[bool] = True,
215 | metadata: Optional[str] = None,
216 | cluster_name: Optional[str] = None,
217 | group_name: Optional[str] = None,
218 | ephemeral: Optional[bool] = None,
219 | ) -> SyncAsync[Any]:
220 | """ Register instance """
221 |
222 | return self.client.request(
223 | "/nacos/v1/ns/instance",
224 | method="POST",
225 | query={
226 | "ip": ip,
227 | "port": port,
228 | "serviceName": service_name,
229 | "namespaceId": namespace_id,
230 | "weight": weight,
231 | "enabled": enabled,
232 | "healthy": healthy,
233 | "metadata": metadata,
234 | "clusterName": cluster_name,
235 | "groupName": group_name,
236 | "ephemeral": ephemeral,
237 | }
238 | )
239 |
240 | def delete(
241 | self,
242 | service_name: str,
243 | ip: str,
244 | port: str,
245 | group_name: Optional[str] = None,
246 | cluster_name: Optional[str] = None,
247 | namespace_id: Optional[str] = None,
248 | ephemeral: Optional[bool] = None,
249 | ) -> SyncAsync[Any]:
250 | return self.client.request(
251 | "/nacos/v1/ns/instance",
252 | method="DELETE",
253 | query={
254 | "serviceName": service_name,
255 | "ip": ip,
256 | "port": port,
257 | "groupName": group_name,
258 | "clusterName": cluster_name,
259 | "namespaceId": namespace_id,
260 | "ephemeral": ephemeral,
261 | }
262 | )
263 |
264 | def list(
265 | self,
266 | service_name: str,
267 | namespace_id: Optional[str] = None,
268 | group_name: Optional[str] = None,
269 | clusters: Optional[str] = None,
270 | healthy_only: Optional[bool] = False,
271 | ) -> SyncAsync[Any]:
272 | return self.client.request(
273 | "/nacos/v1/ns/instance/list",
274 | query={
275 | "serviceName": service_name,
276 | "namespaceId": namespace_id,
277 | "groupName": group_name,
278 | "clusters": clusters,
279 | "healthyOnly": healthy_only,
280 | }
281 | )
282 |
283 | def update(
284 | self,
285 | service_name: str,
286 | ip: str,
287 | port: int,
288 | namespace_id: Optional[str] = None,
289 | weight: Optional[float] = None,
290 | enabled: Optional[bool] = None,
291 | metadata: Optional[dict] = None,
292 | cluster_name: Optional[str] = None,
293 | group_name: Optional[str] = None,
294 | ephemeral: Optional[bool] = None,
295 | ) -> SyncAsync[Any]:
296 | return self.client.request(
297 | "/nacos/v1/ns/instance",
298 | method="PUT",
299 | query={
300 | "ip": ip,
301 | "port": port,
302 | "serviceName": service_name,
303 | "namespaceId": namespace_id,
304 | "weight": weight,
305 | "enabled": enabled,
306 | "metadata": metadata,
307 | "clusterName": cluster_name,
308 | "groupName": group_name,
309 | "ephemeral": ephemeral,
310 | }
311 | )
312 |
313 | def get(
314 | self,
315 | service_name: str,
316 | ip: str,
317 | port: int,
318 | namespace_id: Optional[str] = '',
319 | group_name: Optional[str] = None,
320 | cluster: Optional[str] = None,
321 | healthy_only: Optional[bool] = False,
322 | ephemeral: Optional[bool] = None,
323 | ) -> SyncAsync[Any]:
324 | return self.client.request(
325 | "/nacos/v1/ns/instance",
326 | query={
327 | "serviceName": service_name,
328 | "ip": ip,
329 | "port": port,
330 | "namespaceId": namespace_id,
331 | "groupName": group_name,
332 | "cluster": cluster,
333 | "healthyOnly": healthy_only,
334 | "ephemeral": ephemeral,
335 | }
336 | )
337 |
338 | def beat(
339 | self,
340 | service_name: str,
341 | ip: str,
342 | port: int,
343 | weight: Optional[float] = 1.0,
344 | namespace_id: Optional[str] = '',
345 | group_name: Optional[str] = None,
346 | ephemeral: Optional[bool] = None,
347 | **kwargs
348 | ) -> SyncAsync[Any]:
349 | # see: https://github.com/alibaba/nacos/issues/10448#issuecomment-1538178112
350 | serverName = f"{group_name}@@{service_name}" if group_name else service_name
351 | beat_params: BeatType = {
352 | "serviceName": serverName,
353 | "ip": ip,
354 | "port": port,
355 | "weight": weight,
356 | "ephemeral": ephemeral,
357 | **kwargs
358 | }
359 | return self.client.request(
360 | "/nacos/v1/ns/instance/beat",
361 | method="PUT",
362 | query={
363 | "serviceName": serverName,
364 | "beat": json.dumps(beat_params),
365 | "namespaceId": namespace_id,
366 | "groupName": group_name,
367 | }
368 | )
369 |
370 | def update_health(
371 | self,
372 | service_name: str,
373 | ip: str,
374 | port: int,
375 | healthy: bool,
376 | namespace_id: Optional[str] = '',
377 | group_name: Optional[str] = None,
378 | cluster_name: Optional[str] = None,
379 | ) -> SyncAsync[Any]:
380 | return self.client.request(
381 | "/nacos/v1/ns/health/instance",
382 | method="PUT",
383 | query={
384 | "serviceName": service_name,
385 | "ip": ip,
386 | "port": port,
387 | "healthy": healthy,
388 | "namespaceId": namespace_id,
389 | "groupName": group_name,
390 | "clusterName": cluster_name,
391 | }
392 | )
393 |
394 | def batch_update_metadata(
395 | self,
396 | service_name: str,
397 | namespace_id: str,
398 | metadata: dict,
399 | consistency_type: Optional["_ConsistencyType"] = None,
400 | instances: Optional[list] = None,
401 | ) -> SyncAsync[Any]:
402 | return self.client.request(
403 | "/nacos/v1/ns/instance/metadata/batch",
404 | method="PUT",
405 | query={
406 | "serviceName": service_name,
407 | "namespaceId": namespace_id,
408 | "metadata": json.dumps(metadata),
409 | "consistencyType": consistency_type,
410 | "instances": json.dumps(instances),
411 | }
412 | )
413 |
414 | def batch_delete_metadata(
415 | self,
416 | service_name: str,
417 | namespace_id: str,
418 | metadata: dict,
419 | consistency_type: Optional["_ConsistencyType"] = None,
420 | instances: Optional[list] = None,
421 | ) -> SyncAsync[Any]:
422 | return self.client.request(
423 | "/nacos/v1/ns/instance/metadata/batch",
424 | method="DELETE",
425 | query={
426 | "serviceName": service_name,
427 | "namespaceId": namespace_id,
428 | "metadata": json.dumps(metadata),
429 | "consistencyType": consistency_type,
430 | "instances": json.dumps(instances),
431 | }
432 | )
433 |
434 |
435 | class InstanceEndpoint(_BaseInstanceEndpoint, InstanceOperationMixin):
436 | ...
437 |
438 |
439 | class InstanceAsyncEndpoint(_BaseInstanceEndpoint, InstanceAsyncOperationMixin):
440 | ...
441 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/namespace.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional, Any
3 |
4 | from .endpoint import Endpoint
5 | from ..typings import SyncAsync
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | class NamespaceEndpoint(Endpoint):
11 | """ Namespace Management API """
12 |
13 | def create(
14 | self,
15 | custom_namespace_id: str,
16 | namespace_name: str,
17 | namespace_desc: Optional[str] = None,
18 | ) -> SyncAsync[Any]:
19 | return self.client.request(
20 | "/nacos/v1/console/namespaces",
21 | method="POST",
22 | query={
23 | "customNamespaceId": custom_namespace_id,
24 | "namespaceName": namespace_name,
25 | "namespaceDesc": namespace_desc
26 | }
27 | )
28 |
29 | def delete(
30 | self,
31 | namespace_id: str,
32 | ) -> SyncAsync[Any]:
33 | return self.client.request(
34 | "/nacos/v1/console/namespaces",
35 | method="DELETE",
36 | query={
37 | "namespaceId": namespace_id
38 | }
39 | )
40 |
41 | def list(self) -> SyncAsync[Any]:
42 | return self.client.request(
43 | "/nacos/v1/console/namespaces"
44 | )
45 |
46 | def update(
47 | self,
48 | namespace: str,
49 | namespace_show_name: str,
50 | namespace_desc: str
51 | ) -> SyncAsync[Any]:
52 | return self.client.request(
53 | "/nacos/v1/console/namespaces",
54 | method="PUT",
55 | query={
56 | "namespace": namespace,
57 | "namespaceShowName": namespace_show_name,
58 | "namespaceDesc": namespace_desc
59 | }
60 | )
61 |
--------------------------------------------------------------------------------
/src/use_nacos/endpoints/service.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional, Any
3 |
4 | from .endpoint import Endpoint
5 | from ..typings import SyncAsync
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | class ServiceEndpoint(Endpoint):
11 | """ Service Management API """
12 |
13 | def create(
14 | self,
15 | service_name: str,
16 | namespace_id: Optional[str] = None,
17 | group_name: Optional[str] = None,
18 | protect_threshold: Optional[float] = 0,
19 | metadata: Optional[str] = None,
20 | selector: Optional[str] = None,
21 | ) -> SyncAsync[Any]:
22 | return self.client.request(
23 | "/nacos/v1/ns/service",
24 | method="POST",
25 | query={
26 | "serviceName": service_name,
27 | "namespaceId": namespace_id,
28 | "groupName": group_name,
29 | "protectThreshold": protect_threshold,
30 | "metadata": metadata,
31 | "selector": selector,
32 | }
33 | )
34 |
35 | def delete(
36 | self,
37 | service_name: str,
38 | namespace_id: Optional[str] = None,
39 | group_name: Optional[str] = None,
40 | ) -> SyncAsync[Any]:
41 | return self.client.request(
42 | "/nacos/v1/ns/service",
43 | method="DELETE",
44 | query={
45 | "serviceName": service_name,
46 | "groupName": group_name,
47 | "namespaceId": namespace_id
48 | }
49 | )
50 |
51 | def list(
52 | self,
53 | page_no: Optional[int] = 1,
54 | page_size: Optional[int] = 20,
55 | namespace_id: Optional[str] = None,
56 | group_name: Optional[str] = None,
57 | ) -> SyncAsync[Any]:
58 | return self.client.request(
59 | "/nacos/v1/ns/service/list",
60 | query={
61 | "pageNo": page_no,
62 | "pageSize": page_size,
63 | "namespaceId": namespace_id,
64 | "groupName": group_name,
65 | }
66 | )
67 |
68 | def update(
69 | self,
70 | service_name: str,
71 | namespace_id: Optional[str] = None,
72 | group_name: Optional[str] = None,
73 | protect_threshold: Optional[float] = None,
74 | metadata: Optional[str] = None,
75 | selector: Optional[str] = None,
76 | ) -> SyncAsync[Any]:
77 | return self.client.request(
78 | "/nacos/v1/ns/service",
79 | method="PUT",
80 | query={
81 | "serviceName": service_name,
82 | "namespaceId": namespace_id,
83 | "groupName": group_name,
84 | "protectThreshold": protect_threshold,
85 | "metadata": metadata,
86 | "selector": selector,
87 | }
88 | )
89 |
90 | def get(
91 | self,
92 | service_name: str,
93 | namespace_id: Optional[str] = '',
94 | group_ame: Optional[str] = None,
95 | ) -> SyncAsync[Any]:
96 | return self.client.request(
97 | "/nacos/v1/ns/service",
98 | query={
99 | "serviceName": service_name,
100 | "namespaceId": namespace_id,
101 | "groupName": group_ame,
102 | }
103 | )
104 |
--------------------------------------------------------------------------------
/src/use_nacos/exception.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import httpx
4 |
5 |
6 | class HTTPResponseError(Exception):
7 | """Exception for HTTP errors.
8 |
9 | Responses from the API use HTTP response codes that are used to indicate general
10 | classes of success and error.
11 | """
12 |
13 | code: str = "client_response_error"
14 | status: int
15 | headers: httpx.Headers
16 | body: str
17 |
18 | def __init__(self, response: httpx.Response, message: Optional[str] = None) -> None:
19 | if message is None:
20 | message = (
21 | f"Request to Nacos API failed: {response.text}"
22 | )
23 | super().__init__(message)
24 | self.status = response.status_code
25 | self.headers = response.headers
26 | self.body = response.text
27 |
28 |
29 | class EmptyHealthyInstanceError(Exception):
30 | """Exception for empty healthy instance list error."""
31 |
--------------------------------------------------------------------------------
/src/use_nacos/helper.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | import httpx
4 |
5 |
6 | def is_async_client(client: Union[httpx.Client, httpx.AsyncClient]):
7 | """ Check if the client is async client """
8 | return isinstance(client, httpx.AsyncClient)
9 |
--------------------------------------------------------------------------------
/src/use_nacos/serializer.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import json
3 | import sys
4 |
5 | import yaml
6 |
7 | if sys.version_info >= (3, 11):
8 | import tomllib
9 | else:
10 | import tomli as tomllib
11 |
12 |
13 | class Serializer(abc.ABC):
14 |
15 | @abc.abstractmethod
16 | def __call__(self, *args, **kwargs):
17 | raise NotImplementedError
18 |
19 |
20 | class TextSerializer(Serializer):
21 | """
22 | >>> text = TextSerializer()
23 | >>> text('a = 1')
24 | 'a = 1'
25 | >>> text('a = 1\\n[foo]\\nb = 2')
26 | 'a = 1\\n[foo]\\nb = 2'
27 | """
28 |
29 | def __call__(self, data) -> str:
30 | return data
31 |
32 |
33 | class JsonSerializer(Serializer):
34 | """
35 | >>> json_ = JsonSerializer()
36 | >>> json_('{"a": 1}')
37 | {'a': 1}
38 | >>> json_('{"a": 1, "foo": {"b": 2}}')
39 | {'a': 1, 'foo': {'b': 2}}
40 | """
41 |
42 | def __call__(self, data) -> dict:
43 | try:
44 | return json.loads(data)
45 | except json.JSONDecodeError:
46 | raise SerializerException(f"Cannot parse data: {data!r}")
47 |
48 |
49 | class YamlSerializer(Serializer):
50 | """
51 | >>> yaml_ = YamlSerializer()
52 | >>> yaml_('a: 1')
53 | {'a': 1}
54 | >>> yaml_('a: 1\\nfoo:\\n b: 2')
55 | {'a': 1, 'foo': {'b': 2}}
56 | """
57 |
58 | def __call__(self, data) -> dict:
59 | try:
60 | return yaml.safe_load(data)
61 | except yaml.YAMLError:
62 | raise SerializerException(f"Cannot parse data: {data!r}")
63 |
64 |
65 | class TomlSerializer(Serializer):
66 | """
67 | >>> toml = TomlSerializer()
68 | >>> toml('a = 1')
69 | {'a': 1}
70 | >>> toml('a = 1\\n[foo]\\nb = 2')
71 | {'a': 1, 'foo': {'b': 2}}
72 | """
73 |
74 | def __call__(self, data) -> dict:
75 | try:
76 | return tomllib.loads(data)
77 | except Exception:
78 | raise SerializerException(f"Cannot parse data: {data!r}")
79 |
80 |
81 | class SerializerException(Exception):
82 | pass
83 |
84 |
85 | class AutoSerializer(Serializer):
86 | """
87 | >>> auto = AutoSerializer()
88 | >>> auto('a = 1')
89 | {'a': 1}
90 | >>> auto('a = 1\\n[foo]\\nb = 2')
91 | {'a': 1, 'foo': {'b': 2}}
92 | >>> auto('{"a": 1}')
93 | {'a': 1}
94 | >>> auto('{"a": 1, "foo": {"b": 2}}')
95 | {'a': 1, 'foo': {'b': 2}}
96 | >>> auto('a: 1')
97 | {'a': 1}
98 | >>> auto('a: 1\\nfoo:\\n b: 2')
99 | {'a': 1, 'foo': {'b': 2}}
100 | """
101 |
102 | def __init__(self):
103 | self.serializers = (
104 | JsonSerializer(),
105 | TomlSerializer(),
106 | YamlSerializer(),
107 | TextSerializer(),
108 | )
109 |
110 | def __call__(self, data) -> dict:
111 | for serializer in self.serializers:
112 | try:
113 | return serializer(data)
114 | except SerializerException:
115 | pass
116 | raise SerializerException(f"Cannot parse data: {data!r}")
117 |
--------------------------------------------------------------------------------
/src/use_nacos/typings.py:
--------------------------------------------------------------------------------
1 | from typing import Awaitable, Union, TypeVar, TypedDict, Optional
2 |
3 | import httpx
4 |
5 | T = TypeVar("T")
6 | SyncAsync = Union[T, Awaitable[T]]
7 | HttpxClient = Union[httpx.Client, httpx.AsyncClient]
8 |
9 |
10 | class BeatType(TypedDict):
11 | service_name: str
12 | ip: str
13 | port: int
14 | weight: int
15 | ephemeral: bool
16 | cluster: Optional[str]
17 | metadata: Optional[Union[dict, str]]
18 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import json
2 | import random
3 | import time
4 |
5 | import httpx
6 | import pytest
7 |
8 | from use_nacos.cache import MemoryCache
9 | from use_nacos.client import NacosClient, NacosAsyncClient
10 | from use_nacos.endpoints import ConfigEndpoint, ConfigAsyncEndpoint, config as conf
11 | from use_nacos.exception import HTTPResponseError
12 | from use_nacos.serializer import JsonSerializer, AutoSerializer, YamlSerializer, TomlSerializer
13 |
14 |
15 | @pytest.fixture
16 | def client():
17 | return NacosClient()
18 |
19 |
20 | @pytest.fixture
21 | def async_client():
22 | return NacosAsyncClient()
23 |
24 |
25 | @pytest.fixture
26 | def config(client):
27 | yield ConfigEndpoint(client)
28 |
29 |
30 | @pytest.fixture
31 | def async_config(async_client):
32 | yield ConfigAsyncEndpoint(async_client)
33 |
34 |
35 | def test_config_get_not_found(config):
36 | with pytest.raises(HTTPResponseError):
37 | config.get('not_found', 'not_found')
38 |
39 |
40 | def test_config_get_not_found_default_value(config, mocker):
41 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=HTTPResponseError(response=httpx.Response(404)))
42 | assert config.get('test_config_miss', 'DEFAULT_GROUP', default="default_value") == "default_value"
43 |
44 |
45 | @pytest.mark.parametrize('data_id, group', [
46 | ('test_config', 'DEFAULT_GROUP'),
47 | ])
48 | @pytest.mark.parametrize('content ,tenant, type, serialized, expected', [
49 | ('test_config', '', None, None, 'test_config'),
50 | (json.dumps({"a": "b"}), '', 'json', True, {"a": "b"}),
51 | ('hello nacos
', '', 'html', False, 'hello nacos
'),
52 | (1234, '', None, True, 1234),
53 | ])
54 | def test_config_publish_get(config, data_id, group, content, tenant, type, serialized, expected):
55 | assert config.publish(data_id, group, content, tenant, type)
56 | assert config.get(
57 | data_id,
58 | group,
59 | tenant,
60 | serializer=serialized
61 | ) == expected
62 |
63 |
64 | @pytest.mark.parametrize('data_id, group', [
65 | ('test_config_delete', 'DEFAULT_GROUP'),
66 | ])
67 | def test_config_delete(config, data_id, group):
68 | config.publish(data_id, group, "123")
69 | assert config.delete(data_id, group)
70 | time.sleep(.1)
71 | with pytest.raises(HTTPResponseError):
72 | config.get(data_id, group)
73 |
74 |
75 | def test_config_subscriber(config):
76 | dataId = f"test_config_{random.randint(0, 1000)}"
77 | assert config.publish(data_id=dataId, group="DEFAULT_GROUP", content="123")
78 |
79 | def _callback(new_config):
80 | assert new_config == "456"
81 | config_subscriber.cancel()
82 |
83 | config_subscriber = config.subscribe(
84 | data_id=dataId,
85 | group="DEFAULT_GROUP",
86 | callback=_callback
87 | )
88 | # update config
89 | config.publish(data_id=dataId, group="DEFAULT_GROUP", content="456")
90 |
91 |
92 | # ===================== async config tests =====================
93 | @pytest.mark.asyncio
94 | async def test_async_config_get_not_found(async_config):
95 | with pytest.raises(HTTPResponseError):
96 | await async_config.get('not_found', 'not_found')
97 |
98 |
99 | @pytest.mark.parametrize('data_id, group', [
100 | ('test_config', 'DEFAULT_GROUP'),
101 | ])
102 | @pytest.mark.parametrize('content ,tenant, type, serializer, expected', [
103 | ('test_config', '', None, False, 'test_config'),
104 | (json.dumps({"a": "b"}), '', 'json', JsonSerializer(), {"a": "b"}),
105 | ('hello nacos
', '', 'html', False, 'hello nacos
'),
106 | (1234, '', None, True, 1234),
107 | ])
108 | @pytest.mark.asyncio
109 | async def test_async_config_publish_get(async_config, data_id, group, content, tenant, type, serializer, expected):
110 | assert await async_config.publish(data_id, group, content, tenant, type)
111 | assert await async_config.get(
112 | data_id,
113 | group,
114 | tenant,
115 | serializer=serializer
116 | ) == expected
117 |
118 |
119 | @pytest.mark.parametrize('data_id, group', [
120 | ('test_config_delete_async', 'DEFAULT_GROUP'),
121 | ])
122 | @pytest.mark.asyncio
123 | async def test_async_config_delete(async_config, data_id, group):
124 | assert await async_config.publish(data_id, group, "123")
125 | assert await async_config.delete(data_id, group)
126 | time.sleep(.1)
127 | with pytest.raises(HTTPResponseError):
128 | await async_config.get(data_id, group)
129 |
130 |
131 | @pytest.mark.parametrize("data_id, group, tenant, expected", [
132 | ("test_config", "DEFAULT_GROUP", "", "test_config#DEFAULT_GROUP#"),
133 | ("test_config", "DEFAULT_GROUP", "test_tenant", "test_config#DEFAULT_GROUP#test_tenant"),
134 | ])
135 | def test__get_config_key(config, data_id, group, tenant, expected):
136 | assert conf._get_config_key(data_id, group, tenant) == expected
137 |
138 |
139 | @pytest.mark.parametrize("content, expected", [
140 | ("1234", "81dc9bdb52d04dc20036dbd8313ed055"),
141 | ({"a": 1}, "5268827fe25d043c696340679639cf67")
142 | ])
143 | def test__get_md5(content, expected):
144 | assert conf._get_md5(content) == expected
145 |
146 |
147 | def test_mock_exception(config, mocker):
148 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=HTTPResponseError(response=httpx.Response(500)))
149 | with pytest.raises(HTTPResponseError):
150 | config.get('test_config', 'DEFAULT_GROUP')
151 |
152 |
153 | def test_mock_network_error_exception(config, mocker):
154 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.TimeoutException(""))
155 | assert config.get('test_config_1', 'DEFAULT_GROUP') is None
156 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.ConnectError(""))
157 | assert config.get('test_config_1', 'DEFAULT_GROUP') is None
158 |
159 |
160 | def test_config_from_cache(config, mocker):
161 | mc = MemoryCache()
162 | mc.set("test_config_cache#DEFAULT_GROUP#", "abc")
163 | # mock timeout
164 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.TimeoutException(""))
165 | assert config.get('test_config_cache', 'DEFAULT_GROUP', cache=mc) == "abc"
166 |
167 |
168 | @pytest.mark.parametrize("conf_str, serializer, expected", [
169 | ("123", AutoSerializer(), 123),
170 | ('{"a": 2}', JsonSerializer(), {"a": 2}),
171 | ('a: 1\nfoo:\n b: 2', YamlSerializer(), {'a': 1, 'foo': {'b': 2}}),
172 | ('a = 1\n[foo]\nb = 2', TomlSerializer(), {'a': 1, 'foo': {'b': 2}}),
173 | ])
174 | def test_config_serializer(conf_str, serializer, expected):
175 | assert conf._serialize_config(conf_str, serializer) == expected
176 |
--------------------------------------------------------------------------------
/tests/test_instance.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 |
4 | import pytest
5 |
6 | from use_nacos.client import NacosClient
7 | from use_nacos.endpoints import InstanceEndpoint
8 | from use_nacos.exception import HTTPResponseError
9 |
10 | server_addr = os.environ.get('SERVER_ADDR')
11 |
12 |
13 | @pytest.fixture
14 | def client():
15 | return NacosClient(server_addr=server_addr, username="nacos", password="nacos")
16 |
17 |
18 | @pytest.fixture
19 | def instance(client):
20 | return InstanceEndpoint(client)
21 |
22 |
23 | def test_instance_get_not_found(instance):
24 | with pytest.raises(HTTPResponseError):
25 | instance.get('not_found', 'not_found', 8000)
26 |
27 |
28 | def test_instance_register(instance):
29 | assert instance.register('test', '127.0.0.1', 8000) == 'ok'
30 |
31 |
32 | def test_instance_get(instance):
33 | _instance = instance.get('test', '127.0.0.1', 8000)
34 | assert _instance['metadata'] == {}
35 | assert _instance['ip'] == '127.0.0.1'
36 |
37 |
38 | def test_instance_update(instance):
39 | mock_service = {
40 | 'service_name': 'test',
41 | 'ip': '127.0.0.1',
42 | 'port': 8000,
43 | }
44 | assert instance.register(**mock_service) == 'ok'
45 | assert instance.update(weight=2.0, **mock_service) == 'ok'
46 | time.sleep(.5)
47 | _instance = instance.get(**mock_service)
48 | assert _instance['weight'] == 2.0
49 | assert _instance['healthy'] is True
50 |
--------------------------------------------------------------------------------