novelai-python 0.2.7__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {novelai_python-0.2.7 → novelai_python-0.2.9}/PKG-INFO +7 -3
  2. {novelai_python-0.2.7 → novelai_python-0.2.9}/README.md +6 -2
  3. {novelai_python-0.2.7 → novelai_python-0.2.9}/pyproject.toml +1 -1
  4. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/__init__.py +7 -1
  5. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_exceptions.py +27 -16
  6. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/ai/generate_image.py +3 -1
  7. novelai_python-0.2.9/src/novelai_python/_response/ai/upscale.py +19 -0
  8. novelai_python-0.2.9/src/novelai_python/_response/schema.py +11 -0
  9. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/user/information.py +4 -2
  10. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/user/login.py +3 -2
  11. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/user/subscription.py +3 -1
  12. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/credential/JwtToken.py +1 -0
  13. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/credential/UserAuth.py +1 -0
  14. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/__init__.py +1 -0
  15. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/ai/generate_image.py +39 -19
  16. novelai_python-0.2.9/src/novelai_python/sdk/ai/upscale.py +204 -0
  17. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/user/information.py +18 -13
  18. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/user/login.py +19 -14
  19. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/user/subscription.py +18 -13
  20. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/hash.py +3 -0
  21. novelai_python-0.2.9/src/novelai_python/utils/useful.py +44 -0
  22. novelai_python-0.2.9/tests/test_upscale.py +78 -0
  23. novelai_python-0.2.7/src/novelai_python/utils/useful.py +0 -7
  24. {novelai_python-0.2.7 → novelai_python-0.2.9}/LICENSE +0 -0
  25. {novelai_python-0.2.7 → novelai_python-0.2.9}/NOTICE.MD +0 -0
  26. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/__init__.py +0 -0
  27. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/ai/__init__.py +0 -0
  28. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/_response/user/__init__.py +0 -0
  29. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/credential/ApiToken.py +0 -0
  30. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/credential/__init__.py +0 -0
  31. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/credential/_base.py +0 -0
  32. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/ai/LICENSE +0 -0
  33. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/ai/__init__.py +0 -0
  34. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/schema.py +0 -0
  35. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/sdk/user/__init__.py +0 -0
  36. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/server.py +0 -0
  37. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/__init__.py +0 -0
  38. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/encode.py +0 -0
  39. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/random_prompt/__init__.py +0 -0
  40. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/random_prompt/tag.py +0 -0
  41. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/random_prompt/tag_artist.py +0 -0
  42. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/random_prompt/tag_character.py +0 -0
  43. {novelai_python-0.2.7 → novelai_python-0.2.9}/src/novelai_python/utils/random_prompt/tag_nsfw.py +0 -0
  44. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/__init__.py +0 -0
  45. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_random_prompt.py +0 -0
  46. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_server.py +0 -0
  47. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_server_run.py +0 -0
  48. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_user_information.py +0 -0
  49. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_user_login.py +0 -0
  50. {novelai_python-0.2.7 → novelai_python-0.2.9}/tests/test_user_subscription.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: novelai-python
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Novelai Python Binding With Pydantic
5
5
  Author-Email: sudoskys <coldlando@hotmail.com>
6
6
  License: Apache License 2.0
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # novelai-python
28
28
 
29
- [![PyPI version](https://badge.fury.io/py/novelai_python.svg)](https://badge.fury.io/py/novelai_python)
29
+ [![PyPI version](https://badge.fury.io/py/novelai-python.svg)](https://badge.fury.io/py/novelai-python)
30
30
  [![Downloads](https://pepy.tech/badge/novelai_python)](https://pepy.tech/project/novelai_python)
31
31
 
32
32
  The goal of this repository is to use Pydantic to build legitimate requests to access the Novelai API service.
@@ -37,16 +37,20 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
37
37
  - [x] /user/subscription
38
38
  - [x] /user/login
39
39
  - [x] /user/information
40
+ - [x] /ai/upscale
40
41
  - [ ] /ai/generate-image/suggest-tags
41
42
  - [ ] /ai/annotate-image
42
43
  - [ ] /ai/classify
43
- - [ ] /ai/upscale
44
44
  - [ ] /ai/generate-prompt
45
45
  - [ ] /ai/generate
46
46
  - [ ] /ai/generate-voice
47
47
 
48
48
  ### Usage 🖥️
49
49
 
50
+ ```shell
51
+ pip install novelai-python
52
+ ```
53
+
50
54
  More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground) directory.
51
55
 
52
56
  ```python
@@ -1,6 +1,6 @@
1
1
  # novelai-python
2
2
 
3
- [![PyPI version](https://badge.fury.io/py/novelai_python.svg)](https://badge.fury.io/py/novelai_python)
3
+ [![PyPI version](https://badge.fury.io/py/novelai-python.svg)](https://badge.fury.io/py/novelai-python)
4
4
  [![Downloads](https://pepy.tech/badge/novelai_python)](https://pepy.tech/project/novelai_python)
5
5
 
6
6
  The goal of this repository is to use Pydantic to build legitimate requests to access the Novelai API service.
@@ -11,16 +11,20 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
11
11
  - [x] /user/subscription
12
12
  - [x] /user/login
13
13
  - [x] /user/information
14
+ - [x] /ai/upscale
14
15
  - [ ] /ai/generate-image/suggest-tags
15
16
  - [ ] /ai/annotate-image
16
17
  - [ ] /ai/classify
17
- - [ ] /ai/upscale
18
18
  - [ ] /ai/generate-prompt
19
19
  - [ ] /ai/generate
20
20
  - [ ] /ai/generate-voice
21
21
 
22
22
  ### Usage 🖥️
23
23
 
24
+ ```shell
25
+ pip install novelai-python
26
+ ```
27
+
24
28
  More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground) directory.
25
29
 
26
30
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "novelai-python"
3
- version = "0.2.7"
3
+ version = "0.2.9"
4
4
  description = "Novelai Python Binding With Pydantic"
5
5
  authors = [
6
6
  { name = "sudoskys", email = "coldlando@hotmail.com" },
@@ -8,18 +8,23 @@ from ._exceptions import (
8
8
  NovelAiError,
9
9
  APIError,
10
10
  AuthError,
11
- ConcurrentGenerationError
11
+ ConcurrentGenerationError,
12
+ SessionHttpError
12
13
  )
13
14
  from .credential import JwtCredential, LoginCredential, ApiCredential
14
15
  from .sdk import GenerateImageInfer, ImageGenerateResp
15
16
  from .sdk import Information, InformationResp
16
17
  from .sdk import Login, LoginResp
17
18
  from .sdk import Subscription, SubscriptionResp
19
+ from .sdk import Upscale, UpscaleResp
18
20
 
19
21
  __all__ = [
20
22
  "GenerateImageInfer",
21
23
  "ImageGenerateResp",
22
24
 
25
+ "Upscale",
26
+ "UpscaleResp",
27
+
23
28
  "Subscription",
24
29
  "SubscriptionResp",
25
30
 
@@ -34,6 +39,7 @@ __all__ = [
34
39
  "ApiCredential",
35
40
 
36
41
  "APIError",
42
+ "SessionHttpError",
37
43
  "AuthError",
38
44
  "NovelAiError",
39
45
  "ConcurrentGenerationError"
@@ -10,7 +10,17 @@ class NovelAiError(Exception):
10
10
  """
11
11
  NovelAiError is the base exception for all novelai_python errors.
12
12
  """
13
- pass
13
+ message: str
14
+ exception: Optional[Exception] = None
15
+
16
+ def __init__(self, message: str) -> None:
17
+ self.message = message
18
+
19
+ @property
20
+ def __dict__(self):
21
+ return {
22
+ "message": self.message,
23
+ }
14
24
 
15
25
 
16
26
  class InvalidRequestHeader(NovelAiError):
@@ -20,33 +30,36 @@ class InvalidRequestHeader(NovelAiError):
20
30
  pass
21
31
 
22
32
 
33
+ class SessionHttpError(NovelAiError):
34
+ """
35
+ HTTPError is raised when a request to the API fails.
36
+ """
37
+ pass
38
+
39
+
23
40
  class APIError(NovelAiError):
24
41
  """
25
42
  APIError is raised when the API returns an error.
26
43
  """
27
- message: str
28
44
  request: Any
29
45
  code: Optional[str] = None
30
46
  response: Union[Dict[str, Any], str] = None
31
47
 
32
- def __init__(self, message: str, request: dict, response: Union[dict, str], status_code: str) -> None:
33
- if not isinstance(response, dict):
34
- response = {"error": f"data type error, should be dict, but got {type(response)}"}
35
- if not isinstance(request, dict):
36
- request = {"error": f"data type error, should be dict, but got {type(request)}"}
48
+ def __init__(self, message: str, request: Any, response: Union[Dict[str, Any], str], code: Optional[str]) -> None:
49
+ super().__init__(message)
37
50
  self.request = request
38
- self.message = message
39
- self.code = status_code
40
51
  self.response = response
52
+ self.code = code
41
53
 
42
54
  @property
43
55
  def __dict__(self):
44
- return {
45
- "message": self.message,
56
+ parent_dict = super().__dict__
57
+ parent_dict.update({
46
58
  "request": self.request,
47
59
  "response": self.response,
48
60
  "code": self.code
49
- }
61
+ })
62
+ return parent_dict
50
63
 
51
64
 
52
65
  class ConcurrentGenerationError(APIError):
@@ -54,8 +67,7 @@ class ConcurrentGenerationError(APIError):
54
67
  ConcurrentGenerationError is raised when the API returns an error.
55
68
  """
56
69
 
57
- def __init__(self, message: str, request: dict, response: Union[dict, str], status_code: str) -> None:
58
- super().__init__(message, request, response, status_code)
70
+ pass
59
71
 
60
72
 
61
73
  class AuthError(APIError):
@@ -63,5 +75,4 @@ class AuthError(APIError):
63
75
  AuthError is raised when the API returns an error.
64
76
  """
65
77
 
66
- def __init__(self, message: str, request: dict, response: Union[dict, str], status_code: str) -> None:
67
- super().__init__(message, request, response, status_code)
78
+ pass
@@ -7,8 +7,10 @@ from typing import Tuple, List
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from ..schema import RespBase
10
11
 
11
- class ImageGenerateResp(BaseModel):
12
+
13
+ class ImageGenerateResp(RespBase):
12
14
  class RequestParams(BaseModel):
13
15
  endpoint: str
14
16
  raw_request: dict = None
@@ -0,0 +1,19 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/13 上午11:29
3
+ # @Author : sudoskys
4
+ # @File : upscale.py
5
+ # @Software: PyCharm
6
+ from typing import Tuple
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from ..schema import RespBase
11
+
12
+
13
+ class UpscaleResp(RespBase):
14
+ class RequestParams(BaseModel):
15
+ endpoint: str
16
+ raw_request: dict = None
17
+
18
+ meta: RequestParams
19
+ files: Tuple[str, bytes] = None
@@ -0,0 +1,11 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/13 上午11:30
3
+ # @Author : sudoskys
4
+ # @File : schema.py
5
+ # @Software: PyCharm
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class RespBase(BaseModel):
11
+ pass
@@ -4,10 +4,12 @@
4
4
  # @File : information.py
5
5
  # @Software: PyCharm
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import Field
8
8
 
9
+ from ..schema import RespBase
9
10
 
10
- class InformationResp(BaseModel):
11
+
12
+ class InformationResp(RespBase):
11
13
  emailVerified: bool = Field(..., description="Email verification status")
12
14
  emailVerificationLetterSent: bool = Field(..., description="Email verification letter sent status")
13
15
  trialActivated: bool = Field(..., description="Trial activation status")
@@ -3,8 +3,9 @@
3
3
  # @Author : sudoskys
4
4
  # @File : login.py
5
5
  # @Software: PyCharm
6
- from pydantic import BaseModel
7
6
 
7
+ from ..schema import RespBase
8
8
 
9
- class LoginResp(BaseModel):
9
+
10
+ class LoginResp(RespBase):
10
11
  accessToken: str
@@ -7,6 +7,8 @@ from typing import Optional, Dict, Any, List
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from ..schema import RespBase
11
+
10
12
 
11
13
  class TrainingSteps(BaseModel):
12
14
  fixedTrainingStepsLeft: int
@@ -30,7 +32,7 @@ class Perks(BaseModel):
30
32
  contextTokens: int
31
33
 
32
34
 
33
- class SubscriptionResp(BaseModel):
35
+ class SubscriptionResp(RespBase):
34
36
  tier: int = Field(..., description="Subscription tier")
35
37
  active: bool = Field(..., description="Subscription status")
36
38
  expiresAt: int = Field(..., description="Subscription expiration time")
@@ -24,6 +24,7 @@ class JwtCredential(CredentialBase):
24
24
  self._session = AsyncSession(timeout=timeout, headers={
25
25
  "Accept": "*/*",
26
26
  "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
27
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
27
28
  "Accept-Encoding": "gzip, deflate, br",
28
29
  "Authorization": f"Bearer {self.jwt_token.get_secret_value()}",
29
30
  "Content-Type": "application/json",
@@ -30,6 +30,7 @@ class LoginCredential(CredentialBase):
30
30
  resp = await Login.build(user_name=self.username, password=self.password.get_secret_value()).request()
31
31
  self._session = AsyncSession(timeout=timeout, headers={
32
32
  "Accept": "*/*",
33
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
33
34
  "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
34
35
  "Accept-Encoding": "gzip, deflate, br",
35
36
  "Authorization": f"Bearer {resp.accessToken}",
@@ -8,3 +8,4 @@ from .ai.generate_image import GenerateImageInfer, ImageGenerateResp # noqa 401
8
8
  from .user.information import Information, InformationResp # noqa 401
9
9
  from .user.login import Login, LoginResp # noqa 401
10
10
  from .user.subscription import Subscription, SubscriptionResp # noqa 401
11
+ from .ai.upscale import Upscale, UpscaleResp # noqa 401
@@ -21,8 +21,8 @@ from pydantic import BaseModel, ConfigDict, PrivateAttr, field_validator, model_
21
21
  from typing_extensions import override
22
22
 
23
23
  from ..schema import ApiBaseModel
24
- from ..._exceptions import APIError, AuthError, ConcurrentGenerationError
25
- from ..._response import ImageGenerateResp
24
+ from ..._exceptions import APIError, AuthError, ConcurrentGenerationError, SessionHttpError
25
+ from ..._response.ai.generate_image import ImageGenerateResp
26
26
  from ...credential import CredentialBase
27
27
  from ...utils import try_jsonfy, NovelAiMetadata
28
28
 
@@ -102,7 +102,6 @@ class Resolution(Enum):
102
102
 
103
103
  class GenerateImageInfer(ApiBaseModel):
104
104
  _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
105
- _charge: bool = PrivateAttr(False)
106
105
 
107
106
  class Params(BaseModel):
108
107
  # Inpaint
@@ -412,14 +411,30 @@ class GenerateImageInfer(ApiBaseModel):
412
411
  request_data = self.model_dump(mode="json", exclude_none=True)
413
412
  # Header
414
413
  if isinstance(session, AsyncSession):
415
- session.headers.update(self.necessary_headers(request_data))
414
+ session.headers.update(await self.necessary_headers(request_data))
416
415
  elif isinstance(session, CredentialBase):
417
416
  update_header = await self.necessary_headers(request_data)
418
417
  session = await session.get_session(update_headers=update_header)
419
418
  if override_headers:
420
419
  session.headers.clear()
421
420
  session.headers.update(override_headers)
422
- logger.debug(f"Request Data: {request_data}")
421
+ try:
422
+ _log_data = request_data.copy()
423
+ if self.action == Action.GENERATE:
424
+ logger.debug(f"Request Data: {_log_data}")
425
+ else:
426
+ _log_data.get("parameters", {}).update({
427
+ "image": "base64 data" if self.parameters.image else "None",
428
+ }
429
+ )
430
+ _log_data.get("parameters", {}).update(
431
+ {
432
+ "mask": "base64 data" if self.parameters.mask else "None",
433
+ }
434
+ )
435
+ logger.debug(f"Request Data: {request_data}")
436
+ except Exception as e:
437
+ logger.warning(f"Error when print log data: {e}")
423
438
  try:
424
439
  assert hasattr(session, "post"), "session must have post method."
425
440
  response = await session.post(
@@ -432,13 +447,17 @@ class GenerateImageInfer(ApiBaseModel):
432
447
  )
433
448
  try:
434
449
  _msg = response.json()
435
- except Exception:
436
- raise APIError(
437
- message=f"Unexpected content type: {response.headers.get('Content-Type')}",
438
- request=request_data,
439
- status_code=response.status_code,
440
- response=try_jsonfy(response.content)
441
- )
450
+ except Exception as e:
451
+ logger.warning(e)
452
+ if not isinstance(response.content, str) and len(response.content) < 50:
453
+ raise APIError(
454
+ message=f"Unexpected content type: {response.headers.get('Content-Type')}",
455
+ request=request_data,
456
+ code=response.status_code,
457
+ response=try_jsonfy(response.content)
458
+ )
459
+ else:
460
+ _msg = {"statusCode": response.status_code, "message": response.content}
442
461
  status_code = _msg.get("statusCode", response.status_code)
443
462
  message = _msg.get("message", "Unknown error")
444
463
  if status_code in [400, 401, 402]:
@@ -446,19 +465,19 @@ class GenerateImageInfer(ApiBaseModel):
446
465
  # 401 : unauthorized
447
466
  # 402 : payment required
448
467
  # 409 : conflict
449
- raise AuthError(message, request=request_data, status_code=status_code, response=_msg)
468
+ raise AuthError(message, request=request_data, code=status_code, response=_msg)
450
469
  if status_code in [409]:
451
470
  # conflict error
452
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
471
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
453
472
  if status_code in [429]:
454
473
  # concurrent error
455
474
  raise ConcurrentGenerationError(
456
475
  message=message,
457
476
  request=request_data,
458
- status_code=status_code,
477
+ code=status_code,
459
478
  response=_msg
460
479
  )
461
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
480
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
462
481
  zip_file = ZipFile(BytesIO(response.content))
463
482
  unzip_content = []
464
483
  with zip_file as zf:
@@ -467,7 +486,7 @@ class GenerateImageInfer(ApiBaseModel):
467
486
  raise APIError(
468
487
  message="No file in zip",
469
488
  request=request_data,
470
- status_code=response.status_code,
489
+ code=response.status_code,
471
490
  response=try_jsonfy(response.content)
472
491
  )
473
492
  for filename in file_list:
@@ -486,9 +505,10 @@ class GenerateImageInfer(ApiBaseModel):
486
505
  )
487
506
  except curl_cffi.requests.errors.RequestsError as exc:
488
507
  logger.exception(exc)
489
- raise RuntimeError(f"An AsyncSession error occurred: {exc}")
508
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
490
509
  except httpx.HTTPError as exc:
491
- raise RuntimeError(f"An HTTP error occurred: {exc}")
510
+ logger.exception(exc)
511
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
492
512
  except APIError as e:
493
513
  raise e
494
514
  except Exception as e:
@@ -0,0 +1,204 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/13 上午10:48
3
+ # @Author : sudoskys
4
+ # @File : upscale.py
5
+ # @Software: PyCharm
6
+ import base64
7
+ import json
8
+ from io import BytesIO
9
+ from typing import Optional, Union
10
+ from urllib.parse import urlparse
11
+ from zipfile import ZipFile
12
+
13
+ import curl_cffi
14
+ import httpx
15
+ from curl_cffi.requests import AsyncSession
16
+ from loguru import logger
17
+ from pydantic import ConfigDict, PrivateAttr, model_validator
18
+
19
+ from ..schema import ApiBaseModel
20
+ from ..._exceptions import APIError, AuthError, SessionHttpError
21
+ from ..._response.ai.upscale import UpscaleResp
22
+ from ...credential import CredentialBase
23
+ from ...utils import try_jsonfy, NovelAiMetadata
24
+
25
+
26
+ class Upscale(ApiBaseModel):
27
+ _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
28
+ image: Union[str, bytes] # base64
29
+ width: Optional[int] = None
30
+ height: Optional[int] = None
31
+ scale: float = 4
32
+ model_config = ConfigDict(extra="ignore")
33
+
34
+ @model_validator(mode="after")
35
+ def validate_model(self):
36
+ if isinstance(self.image, str) and self.image.startswith("data:image/"):
37
+ raise ValueError("Invalid image format, must be base64 encoded.")
38
+ if isinstance(self.image, bytes):
39
+ self.image = base64.b64encode(self.image).decode("utf-8")
40
+ # Auto detect image size
41
+ try:
42
+ from PIL import Image
43
+ with Image.open(BytesIO(base64.b64decode(self.image))) as img:
44
+ width, height = img.size
45
+ except Exception as e:
46
+ logger.warning(f"Error when validate image size: {e}")
47
+ if self.width is None or self.height is None:
48
+ raise ValueError("Invalid image size and cant auto detect, must be set width and height.")
49
+ else:
50
+ if self.width is None:
51
+ self.width = width
52
+ if self.height is None:
53
+ self.height = height
54
+ return self
55
+
56
+ @property
57
+ def base_url(self):
58
+ return f"{self.endpoint.strip('/')}/ai/upscale"
59
+
60
+ @property
61
+ def endpoint(self):
62
+ return self._endpoint
63
+
64
+ @endpoint.setter
65
+ def endpoint(self, value):
66
+ self._endpoint = value
67
+
68
+ async def necessary_headers(self, request_data) -> dict:
69
+ """
70
+ :param request_data:
71
+ :return:
72
+ """
73
+ return {
74
+ "Host": urlparse(self.endpoint).netloc,
75
+ "Accept": "*/*",
76
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
77
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
78
+ "Accept-Encoding": "gzip, deflate, br",
79
+ "Referer": "https://novelai.net/",
80
+ "Content-Type": "application/json",
81
+ "Origin": "https://novelai.net",
82
+ "Content-Length": str(len(json.dumps(request_data).encode("utf-8"))),
83
+ "Connection": "keep-alive",
84
+ "Sec-Fetch-Dest": "empty",
85
+ "Sec-Fetch-Mode": "cors",
86
+ "Sec-Fetch-Site": "same-site",
87
+ "Pragma": "no-cache",
88
+ "Cache-Control": "no-cache",
89
+ }
90
+
91
+ async def request(self,
92
+ session: Union[AsyncSession, "CredentialBase"],
93
+ *,
94
+ override_headers: Optional[dict] = None,
95
+ remove_sign: bool = False
96
+ ) -> UpscaleResp:
97
+ """
98
+ 生成图片
99
+ :param override_headers:
100
+ :param session: session
101
+ :param remove_sign: 移除追踪信息
102
+ :return:
103
+ """
104
+ # Data Build
105
+ request_data = self.model_dump(mode="json", exclude_none=True)
106
+ # Header
107
+ if isinstance(session, AsyncSession):
108
+ session.headers.update(await self.necessary_headers(request_data))
109
+ elif isinstance(session, CredentialBase):
110
+ update_header = await self.necessary_headers(request_data)
111
+ session = await session.get_session(update_headers=update_header)
112
+ if override_headers:
113
+ session.headers.clear()
114
+ session.headers.update(override_headers)
115
+ try:
116
+ _log_data = request_data.copy()
117
+ _log_data.update({"image": "base64 data"}) if isinstance(_log_data.get("image"), str) else None
118
+ logger.info(f"Upscale request data: {_log_data}")
119
+ except Exception as e:
120
+ logger.warning(f"Error when print log data: {e}")
121
+ try:
122
+ assert hasattr(session, "post"), "session must have post method."
123
+ response = await session.post(
124
+ self.base_url,
125
+ data=json.dumps(request_data).encode("utf-8")
126
+ )
127
+ if response.headers.get('Content-Type') not in ['binary/octet-stream', 'application/x-zip-compressed']:
128
+ logger.error(
129
+ f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}"
130
+ )
131
+ try:
132
+ _msg = response.json()
133
+ except Exception as e:
134
+ logger.warning(e)
135
+ if not isinstance(response.content, str) and len(response.content) < 50:
136
+ raise APIError(
137
+ message=f"Unexpected content type: {response.headers.get('Content-Type')}",
138
+ request=request_data,
139
+ code=response.status_code,
140
+ response=try_jsonfy(response.content)
141
+ )
142
+ else:
143
+ _msg = {"statusCode": response.status_code, "message": response.content}
144
+ status_code = _msg.get("statusCode", response.status_code)
145
+ message = _msg.get("message", "Unknown error")
146
+ if status_code in [400, 401, 402]:
147
+ # 400 : validation error
148
+ # 401 : unauthorized
149
+ # 402 : payment required
150
+ # 409 : conflict
151
+ raise AuthError(message, request=request_data, code=status_code, response=_msg)
152
+ if status_code in [409]:
153
+ # conflict error
154
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
155
+ """
156
+ if status_code in [429]:
157
+ # concurrent error
158
+ raise ConcurrentGenerationError(
159
+ message=message,
160
+ request=request_data,
161
+ code=status_code,
162
+ response=_msg
163
+ )
164
+ """
165
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
166
+ zip_file = ZipFile(BytesIO(response.content))
167
+ unzip_content = []
168
+ with zip_file as zf:
169
+ file_list = zf.namelist()
170
+ if not file_list:
171
+ raise APIError(
172
+ message="No file in zip",
173
+ request=request_data,
174
+ code=response.status_code,
175
+ response=try_jsonfy(response.content)
176
+ )
177
+ for filename in file_list:
178
+ data = zip_file.read(filename)
179
+ if remove_sign:
180
+ try:
181
+ data = NovelAiMetadata.rehash(BytesIO(data), remove_stealth=True)
182
+ if not isinstance(data, bytes):
183
+ data = data.getvalue()
184
+ except Exception as e:
185
+ logger.exception(f"SdkWarn:Remove sign error: {e}")
186
+ unzip_content.append((filename, data))
187
+ return UpscaleResp(
188
+ meta=UpscaleResp.RequestParams(
189
+ endpoint=self.base_url,
190
+ raw_request=request_data,
191
+ ),
192
+ files=unzip_content[0]
193
+ )
194
+ except curl_cffi.requests.errors.RequestsError as exc:
195
+ logger.exception(exc)
196
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
197
+ except httpx.HTTPError as exc:
198
+ logger.exception(exc)
199
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
200
+ except APIError as e:
201
+ raise e
202
+ except Exception as e:
203
+ logger.opt(exception=e).exception("An Unexpected error occurred")
204
+ raise e
@@ -12,7 +12,7 @@ from loguru import logger
12
12
  from pydantic import PrivateAttr
13
13
 
14
14
  from ..schema import ApiBaseModel
15
- from ..._exceptions import APIError, AuthError
15
+ from ..._exceptions import APIError, AuthError, SessionHttpError
16
16
  from ..._response.user.information import InformationResp
17
17
  from ...credential import CredentialBase
18
18
  from ...utils import try_jsonfy
@@ -77,13 +77,17 @@ class Information(ApiBaseModel):
77
77
  )
78
78
  try:
79
79
  _msg = response.json()
80
- except Exception:
81
- raise APIError(
82
- message=f"Unexpected content type: {response.headers.get('Content-Type')}",
83
- request=request_data,
84
- status_code=response.status_code,
85
- response=try_jsonfy(response.content)
86
- )
80
+ except Exception as e:
81
+ logger.warning(e)
82
+ if not isinstance(response.content, str) and len(response.content) < 50:
83
+ raise APIError(
84
+ message=f"Unexpected content type: {response.headers.get('Content-Type')}",
85
+ request=request_data,
86
+ code=response.status_code,
87
+ response=try_jsonfy(response.content)
88
+ )
89
+ else:
90
+ _msg = {"statusCode": response.status_code, "message": response.content}
87
91
  status_code = _msg.get("statusCode", response.status_code)
88
92
  message = _msg.get("message", "Unknown error")
89
93
  if status_code in [400, 401, 402]:
@@ -91,17 +95,18 @@ class Information(ApiBaseModel):
91
95
  # 401 : unauthorized
92
96
  # 402 : payment required
93
97
  # 409 : conflict
94
- raise AuthError(message, request=request_data, status_code=status_code, response=_msg)
98
+ raise AuthError(message, request=request_data, code=status_code, response=_msg)
95
99
  if status_code in [500]:
96
100
  # An unknown error occured.
97
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
98
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
101
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
102
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
99
103
  return InformationResp.model_validate(response.json())
100
104
  except curl_cffi.requests.errors.RequestsError as exc:
101
105
  logger.exception(exc)
102
- raise RuntimeError(f"An AsyncSession error occurred: {exc}")
106
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
103
107
  except httpx.HTTPError as exc:
104
- raise RuntimeError(f"An HTTP error occurred: {exc}")
108
+ logger.exception(exc)
109
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
105
110
  except APIError as e:
106
111
  raise e
107
112
  except Exception as e:
@@ -13,7 +13,7 @@ from loguru import logger
13
13
  from pydantic import PrivateAttr, Field
14
14
 
15
15
  from ..schema import ApiBaseModel
16
- from ..._exceptions import APIError
16
+ from ..._exceptions import APIError, SessionHttpError
17
17
  from ..._response.user.login import LoginResp
18
18
  from ...credential import CredentialBase
19
19
  from ...utils import try_jsonfy, encode_access_key
@@ -77,7 +77,7 @@ class Login(ApiBaseModel):
77
77
  # Data Build
78
78
  request_data = self.model_dump(mode="json", exclude_none=True)
79
79
  if isinstance(session, AsyncSession):
80
- session.headers.update(self.necessary_headers(request_data))
80
+ session.headers.update(await self.necessary_headers(request_data))
81
81
  elif isinstance(session, CredentialBase):
82
82
  session = await session.get_session(update_headers=await self.necessary_headers(request_data))
83
83
  # Header
@@ -97,29 +97,34 @@ class Login(ApiBaseModel):
97
97
  )
98
98
  try:
99
99
  _msg = response.json()
100
- except Exception:
101
- raise APIError(
102
- message=f"Unexpected content type: {response.headers.get('Content-Type')}",
103
- request=request_data,
104
- status_code=response.status_code,
105
- response=try_jsonfy(response.content)
106
- )
100
+ except Exception as e:
101
+ logger.warning(e)
102
+ if not isinstance(response.content, str) and len(response.content) < 50:
103
+ raise APIError(
104
+ message=f"Unexpected content type: {response.headers.get('Content-Type')}",
105
+ request=request_data,
106
+ code=response.status_code,
107
+ response=try_jsonfy(response.content)
108
+ )
109
+ else:
110
+ _msg = {"statusCode": response.status_code, "message": response.content}
107
111
  status_code = _msg.get("statusCode", response.status_code)
108
112
  message = _msg.get("message", "Unknown error")
109
113
  if status_code in [400, 401]:
110
114
  # 400 : A validation error occured.
111
115
  # 401 : Access Key is incorrect.
112
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
116
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
113
117
  if status_code in [500]:
114
118
  # An unknown error occured.
115
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
116
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
119
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
120
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
117
121
  return LoginResp.model_validate(response.json())
118
122
  except curl_cffi.requests.errors.RequestsError as exc:
119
123
  logger.exception(exc)
120
- raise RuntimeError(f"An AsyncSession error occurred: {exc}")
124
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
121
125
  except httpx.HTTPError as exc:
122
- raise RuntimeError(f"An HTTP error occurred: {exc}")
126
+ logger.exception(exc)
127
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
123
128
  except APIError as e:
124
129
  raise e
125
130
  except Exception as e:
@@ -12,7 +12,7 @@ from loguru import logger
12
12
  from pydantic import PrivateAttr
13
13
 
14
14
  from ..schema import ApiBaseModel
15
- from ..._exceptions import APIError, AuthError
15
+ from ..._exceptions import APIError, AuthError, SessionHttpError
16
16
  from ..._response.user.subscription import SubscriptionResp
17
17
  from ...credential import CredentialBase
18
18
  from ...utils import try_jsonfy
@@ -84,13 +84,17 @@ class Subscription(ApiBaseModel):
84
84
  )
85
85
  try:
86
86
  _msg = response.json()
87
- except Exception:
88
- raise APIError(
89
- message=f"Unexpected content type: {response.headers.get('Content-Type')}",
90
- request=request_data,
91
- status_code=response.status_code,
92
- response=try_jsonfy(response.content)
93
- )
87
+ except Exception as e:
88
+ logger.warning(e)
89
+ if not isinstance(response.content, str) and len(response.content) < 50:
90
+ raise APIError(
91
+ message=f"Unexpected content type: {response.headers.get('Content-Type')}",
92
+ request=request_data,
93
+ code=response.status_code,
94
+ response=try_jsonfy(response.content)
95
+ )
96
+ else:
97
+ _msg = {"statusCode": response.status_code, "message": response.content}
94
98
  status_code = _msg.get("statusCode", response.status_code)
95
99
  message = _msg.get("message", "Unknown error")
96
100
  if status_code in [400, 401, 402]:
@@ -98,17 +102,18 @@ class Subscription(ApiBaseModel):
98
102
  # 401 : unauthorized
99
103
  # 402 : payment required
100
104
  # 409 : conflict
101
- raise AuthError(message, request=request_data, status_code=status_code, response=_msg)
105
+ raise AuthError(message, request=request_data, code=status_code, response=_msg)
102
106
  if status_code in [500]:
103
107
  # An unknown error occured.
104
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
105
- raise APIError(message, request=request_data, status_code=status_code, response=_msg)
108
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
109
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
106
110
  return SubscriptionResp.model_validate(response.json())
107
111
  except curl_cffi.requests.errors.RequestsError as exc:
108
112
  logger.exception(exc)
109
- raise RuntimeError(f"An AsyncSession error occurred: {exc}")
113
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
110
114
  except httpx.HTTPError as exc:
111
- raise RuntimeError(f"An HTTP error occurred: {exc}")
115
+ logger.exception(exc)
116
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
112
117
  except APIError as e:
113
118
  raise e
114
119
  except Exception as e:
@@ -28,6 +28,9 @@ class NovelAiMetadata(BaseModel):
28
28
  title: str = "AI generated image"
29
29
  description: str
30
30
  comment: Union[dict, str] = {}
31
+ """
32
+ {'prompt': 'very very aesthetic, absurdres', 'steps': 28, 'height': 1216, 'width': 832, 'scale': 6.0, 'uncond_scale': 1.0, 'cfg_rescale': 0.0, 'seed': 121910159, 'n_samples': 1, 'hide_debug_overlay': False, 'noise_schedule': 'native', 'legacy_v3_extend': False, 'sampler': 'k_euler', 'controlnet_strength': 1.0, 'controlnet_model': None, 'dynamic_thresholding': False, 'dynamic_thresholding_percentile': 0.999, 'dynamic_thresholding_mimic_scale': 10.0, 'sm': False, 'sm_dyn': False, 'skip_cfg_below_sigma': 0.0, 'lora_unet_weights': None, 'lora_clip_weights': None, 'uc': ', lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], bad anatomy, bad hands, @_@, mismatched pupils, heart-shaped pupils, glowing eyes, nsfw, lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry', 'request_type': 'PromptGenerateRequest', 'signed_hash': '11111='}
33
+ """
31
34
 
32
35
  @property
33
36
  def metadata(self):
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/9 下午10:41
3
+ # @Author : sudoskys
4
+ # @File : useful.py
5
+ # @Software: PyCharm
6
+ import collections
7
+ import random
8
+ from typing import List, Union
9
+
10
+
11
+ def enum_to_list(enum_):
12
+ return list(map(lambda x: x.value, enum_._member_map_.values()))
13
+
14
+
15
+ class QueSelect(object):
16
+ def __init__(self, data: List[str]):
17
+ """
18
+ A queue selector
19
+ :param data:
20
+ """
21
+ self.data = collections.deque(data)
22
+ self.used = collections.deque()
23
+ self.users = {}
24
+
25
+ def get(self, user_id: Union[int, str]) -> str:
26
+ user_id = str(user_id)
27
+ if user_id not in self.users:
28
+ self.users[user_id] = {'data': self.data.copy(), 'used': collections.deque()}
29
+
30
+ user_data = self.users[user_id]['data']
31
+ user_used = self.users[user_id]['used']
32
+
33
+ if len(user_data) == 0:
34
+ user_data, user_used = user_used, user_data
35
+ # 随机掉 User data
36
+ if len(user_data) > 2:
37
+ random.shuffle(user_data)
38
+ self.users[user_id]['data'] = user_data
39
+ self.users[user_id]['used'] = user_used
40
+
41
+ selected = user_data.popleft()
42
+ user_used.append(selected)
43
+
44
+ return selected
@@ -0,0 +1,78 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/13 下午12:03
3
+ # @Author : sudoskys
4
+ # @File : test_upscale.py
5
+ # @Software: PyCharm
6
+ # -*- coding: utf-8 -*-
7
+ # @Time : 2024/2/14 下午4:20
8
+ # @Author : sudoskys
9
+ # @File : test_upscale.py
10
+ # @Software: PyCharm
11
+ from unittest import mock
12
+
13
+ import pytest
14
+ from curl_cffi.requests import AsyncSession
15
+
16
+ from novelai_python import APIError, Upscale, AuthError
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_validation_error_during_upscale():
21
+ validation_error_response = mock.Mock()
22
+ validation_error_response.headers = {"Content-Type": "application/json"}
23
+ validation_error_response.status_code = 400
24
+ validation_error_response.json.return_value = {
25
+ "statusCode": 400,
26
+ "message": "A validation error occurred."
27
+ }
28
+ session = mock.MagicMock(spec=AsyncSession)
29
+ session.post = mock.AsyncMock(return_value=validation_error_response)
30
+ session.headers = {}
31
+
32
+ upscale = Upscale(image="base64_encoded_image", height=100, width=100)
33
+ with pytest.raises(AuthError) as e:
34
+ await upscale.request(session=session)
35
+ assert e.type is AuthError
36
+ expected_message = 'A validation error occurred.'
37
+ assert expected_message == str(e.value)
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_unauthorized_error_during_upscale():
42
+ unauthorized_error_response = mock.Mock()
43
+ unauthorized_error_response.headers = {"Content-Type": "application/json"}
44
+ unauthorized_error_response.status_code = 401
45
+ unauthorized_error_response.json.return_value = {
46
+ "statusCode": 401,
47
+ "message": "Unauthorized."
48
+ }
49
+ session = mock.MagicMock(spec=AsyncSession)
50
+ session.post = mock.AsyncMock(return_value=unauthorized_error_response)
51
+ session.headers = {}
52
+
53
+ upscale = Upscale(image="base64_encoded_image", height=100, width=100)
54
+ with pytest.raises(APIError) as e:
55
+ await upscale.request(session=session)
56
+ assert e.type is AuthError
57
+ expected_message = 'Unauthorized.'
58
+ assert expected_message == str(e.value)
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_unknown_error_during_upscale():
63
+ unknown_error_response = mock.Mock()
64
+ unknown_error_response.headers = {"Content-Type": "application/json"}
65
+ unknown_error_response.status_code = 500
66
+ unknown_error_response.json.return_value = {
67
+ "statusCode": 500,
68
+ "message": "Unknown error occurred."
69
+ }
70
+ session = mock.MagicMock(spec=AsyncSession)
71
+ session.post = mock.AsyncMock(return_value=unknown_error_response)
72
+ session.headers = {}
73
+
74
+ upscale = Upscale(image="base64_encoded_image", height=100, width=100)
75
+ with pytest.raises(APIError) as e:
76
+ await upscale.request(session=session)
77
+ expected_message = 'Unknown error occurred.'
78
+ assert expected_message == str(e.value)
@@ -1,7 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- # @Time : 2024/2/9 下午10:41
3
- # @Author : sudoskys
4
- # @File : useful.py
5
- # @Software: PyCharm
6
- def enum_to_list(enum_):
7
- return list(map(lambda x: x.value, enum_._member_map_.values()))
File without changes
File without changes