sapling-py 4.1.0__tar.gz → 4.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sapling-py
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: Sapling Python Client
5
5
  Home-page: https://sapling.ai
6
6
  Author: Sapling Intelligence
@@ -40,7 +40,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
40
40
  ## Overview
41
41
 
42
42
  Sapling is a toolkit for helping developers build language model-powered applications.
43
- The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection, and more.
43
+ The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
44
+ zero-shot text classification, structured data extraction, PII detection and redaction, content safety
45
+ scoring, SEO analysis, and more.
44
46
 
45
47
  Key Features:
46
48
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -8,7 +8,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
8
8
  ## Overview
9
9
 
10
10
  Sapling is a toolkit for helping developers build language model-powered applications.
11
- The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection, and more.
11
+ The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
12
+ zero-shot text classification, structured data extraction, PII detection and redaction, content safety
13
+ scoring, SEO analysis, and more.
12
14
 
13
15
  Key Features:
14
16
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -1,5 +1,6 @@
1
1
  import requests
2
2
  import uuid
3
+ from collections.abc import Iterable
3
4
 
4
5
 
5
6
  class SaplingError(Exception):
@@ -631,6 +632,92 @@ class SaplingClient:
631
632
  }
632
633
  return self._request(url, data)
633
634
 
635
+ def extract(
636
+ self,
637
+ text,
638
+ fields,
639
+ context=None,
640
+ ):
641
+ '''
642
+ Extracts structured data from unstructured text: you name the fields you
643
+ want, the API returns their values pulled from the document. Every value is
644
+ grounded in a verbatim span of the input, so this is extraction rather than
645
+ summarization or inference; a field the text does not state is reported
646
+ missing rather than guessed.
647
+
648
+ Example::
649
+
650
+ client.extract(
651
+ 'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.',
652
+ fields=[
653
+ 'invoice_number',
654
+ {'name': 'total', 'type': 'number', 'description': 'Amount due',
655
+ 'required': True},
656
+ {'name': 'due_date', 'type': 'date'},
657
+ 'purchase_order',
658
+ ],
659
+ context='A vendor invoice',
660
+ )
661
+ # {'data': {'invoice_number': 'INV-1042', 'total': 1299.0,
662
+ # 'due_date': '2026-03-05', 'purchase_order': None},
663
+ # 'fields': [{'name': 'invoice_number', 'type': 'string',
664
+ # 'value': 'INV-1042', 'evidence': 'Invoice INV-1042',
665
+ # 'found': True},
666
+ # {'name': 'total', 'type': 'number', 'value': 1299.0,
667
+ # 'evidence': 'total $1,299.00', 'found': True},
668
+ # {'name': 'due_date', 'type': 'date', 'value': '2026-03-05',
669
+ # 'evidence': 'due March 5, 2026', 'found': True},
670
+ # {'name': 'purchase_order', 'type': 'string', 'value': None,
671
+ # 'evidence': '', 'found': False}],
672
+ # 'missing': ['purchase_order']}
673
+
674
+ :param text: Document to extract from, plain text or HTML (tags are
675
+ stripped), up to 10,000 characters.
676
+ :type text: str
677
+ :param fields: 1-20 fields to extract. Each entry is either a field name
678
+ (str) or a dict ``{'name': str, 'type': str, 'description': str,
679
+ 'required': bool}`` where everything but ``name`` is optional. Names are
680
+ up to 50 characters and must be unique (case-insensitive). ``type`` is
681
+ one of ``'string'`` (the default), ``'number'``, ``'integer'``,
682
+ ``'boolean'``, ``'date'`` or ``'list'``. ``description`` is up to 200
683
+ characters and sharpens ambiguous fields. ``required`` is a hint to the
684
+ model; a required field the text does not state is still reported
685
+ missing, never guessed.
686
+ :type fields: list[str | dict]
687
+ :raises TypeError: If ``fields`` is None, a single string or a dict rather
688
+ than a list/tuple of fields.
689
+ :param context: Up to 500 characters describing what the document is or how
690
+ to read ambiguous fields (e.g. ``'A vendor invoice'``).
691
+ :type context: str
692
+ :rtype: dict
693
+ :return:
694
+ - data: ``{field name: value}`` map. Every requested field is present,
695
+ with ``None`` where the text did not state it. ``date`` values are
696
+ ``YYYY-MM-DD`` strings and ``list`` values are lists of strings.
697
+ - fields: One dict per requested field, in the order requested, with
698
+ ``name``, ``type``, ``value``, the verbatim ``evidence`` span the value
699
+ came from (``''`` when not found) and ``found``.
700
+ - missing: Names of the fields the text did not yield, in request order.
701
+ Finding nothing is a normal, successful response.
702
+ '''
703
+ # A bare string would silently be split into one-character field names,
704
+ # a dict would be reduced to its keys, and a non-iterable (None, an int)
705
+ # would raise an opaque "'int' object is not iterable" from list().
706
+ # Fail loudly with a message that names the right shape instead.
707
+ if (not isinstance(fields, Iterable)
708
+ or isinstance(fields, (str, bytes, dict))):
709
+ raise TypeError('fields must be a list of field names or '
710
+ '{name, type, description, required} dicts')
711
+ url = self.url_endpoint + 'extract'
712
+ data = {
713
+ 'key': self.api_key,
714
+ 'text': text,
715
+ 'fields': list(fields),
716
+ }
717
+ if context is not None:
718
+ data['context'] = context
719
+ return self._request(url, data)
720
+
634
721
  def tone(
635
722
  self,
636
723
  text,
@@ -682,43 +769,79 @@ class SaplingClient:
682
769
  def quality(
683
770
  self,
684
771
  text,
772
+ sentence_scores=None,
773
+ rubric=None,
685
774
  ):
686
775
  '''
687
776
  Computes a quality score for the provided text based on how "surprising"
688
777
  the text is to the model. Lower surprisal corresponds to higher scores.
778
+ Optionally also returns a per-sentence breakdown and an LLM-judged rubric.
689
779
 
690
- :param text: Text to compute a quality score for.
780
+ :param text: Text to compute a quality score for. The base score is computed
781
+ on the leading ~4,000 characters; the rubric evaluates the whole text
782
+ (up to 20,000 characters).
691
783
  :type text: str
784
+ :param sentence_scores: If true, also return a 1-5 score for each sentence
785
+ under `sentences`.
786
+ :type sentence_scores: bool
787
+ :param rubric: If true, also return an LLM-judged rubric under `rubric`
788
+ (billed at the Tone rate).
789
+ :type rubric: bool
692
790
  :rtype: dict
693
791
  :return:
694
792
  - score: A score from 1 (low quality) to 5 (high quality).
793
+ - sentences: If sentence_scores is set, list of {start, end, text, score} in
794
+ document order; start/end are character offsets into the text.
795
+ - rubric: If rubric is set, {overall, dimensions: {clarity, coherence,
796
+ correctness, concision}, summary, issues: [{quote, dimension, note,
797
+ suggestion, start, end}]}; scores are integers 1-5.
695
798
  '''
696
799
  url = self.url_endpoint + 'quality'
697
800
  data = {
698
801
  'key': self.api_key,
699
802
  'text': text,
700
803
  }
804
+ if sentence_scores is not None:
805
+ data['sentence_scores'] = sentence_scores
806
+ if rubric is not None:
807
+ data['rubric'] = rubric
701
808
  return self._request(url, data)
702
809
 
703
810
  def langdetect(
704
811
  self,
705
812
  text,
813
+ top_k=None,
814
+ segments=None,
706
815
  ):
707
816
  '''
708
- Identifies the language of the provided text.
817
+ Identifies the language of the provided text, with optional runner-up
818
+ candidates and a per-segment breakdown for mixed-language text.
709
819
 
710
- :param text: Text to identify the language for.
820
+ :param text: Text to identify the language for (up to 20,000 characters).
711
821
  :type text: str
822
+ :param top_k: Number of language candidates to return, 1-10. Defaults to 3 server-side.
823
+ :type top_k: int
824
+ :param segments: If true, also detect the language of each line/sentence
825
+ of the text and return them under `segments`.
826
+ :type segments: bool
712
827
  :rtype: dict
713
828
  :return:
714
- - lang: The detected language code.
829
+ - lang: The detected language code (mostly ISO 639-1).
830
+ - name: English name of the detected language.
715
831
  - conf: Confidence score for the detection result.
832
+ - candidates: List of {lang, name, conf} for the top languages, most likely first.
833
+ - segments: If segments is set, list of {start, end, text, lang, name, conf, candidates}
834
+ in document order; start/end are character offsets into the text.
716
835
  '''
717
836
  url = self.url_endpoint + 'langdetect'
718
837
  data = {
719
838
  'key': self.api_key,
720
839
  'text': text,
721
840
  }
841
+ if top_k is not None:
842
+ data['top_k'] = top_k
843
+ if segments is not None:
844
+ data['segments'] = segments
722
845
  return self._request(url, data)
723
846
 
724
847
  def profanity(
@@ -741,3 +864,229 @@ class SaplingClient:
741
864
  'text': text,
742
865
  }
743
866
  return self._request(url, data)
867
+
868
+ def safety(
869
+ self,
870
+ text,
871
+ threshold=None,
872
+ ):
873
+ '''
874
+ Scores the provided text on seven content-safety categories: toxicity,
875
+ profanity, harassment, hate_speech, self_harm, sexual, and violence.
876
+
877
+ :param text: Text to score, up to 20,000 characters.
878
+ :type text: str
879
+ :param threshold: Score at or above which a category is flagged.
880
+ Between 0 and 1 inclusive; the API defaults to 0.5.
881
+ :type threshold: float
882
+ :rtype: dict
883
+ :return:
884
+ - scores: A probability from 0 to 1 for each category.
885
+ - flagged: True if any category scored at or above the threshold.
886
+ - flagged_categories: Categories that scored at or above the threshold.
887
+ - threshold: The threshold that was applied.
888
+ '''
889
+ url = self.url_endpoint + 'safety'
890
+ data = {
891
+ 'key': self.api_key,
892
+ 'text': text,
893
+ }
894
+ if threshold is not None:
895
+ data['threshold'] = threshold
896
+ return self._request(url, data)
897
+
898
+ def pii(
899
+ self,
900
+ text,
901
+ types=None,
902
+ redact=None,
903
+ ):
904
+ '''
905
+ Detects personally identifiable information (PII) in the provided text and
906
+ optionally returns a redacted copy.
907
+
908
+ Detection is deterministic (pattern matching plus checksum validation, no ML)
909
+ and every entity comes with exact character offsets into ``text``, so pass
910
+ raw text (not HTML) and apply the spans to your own copy of the document.
911
+ Person names and street addresses are not detected.
912
+
913
+ :param text: Text to scan for PII.
914
+ :type text: str
915
+ :param types: Optional list of PII types to detect. Defaults to all types:
916
+ ``email``, ``phone``, ``ssn``, ``credit_card``, ``ip_address``, ``iban``,
917
+ ``us_bank_routing``.
918
+ :type types: list[str]
919
+ :param redact: If True, the response also includes ``redacted_text`` with every
920
+ detected entity replaced by a placeholder such as ``[EMAIL]``.
921
+ :type redact: bool
922
+ :rtype: dict
923
+ :return:
924
+ - entities: List of ``{type, text, start, end, replacement}`` dicts, sorted
925
+ by ``start``; spans never overlap.
926
+ - flagged: True if any PII was detected.
927
+ - types: Sorted list of the PII types found.
928
+ - redacted_text: Only present when ``redact`` is True.
929
+ '''
930
+ url = self.url_endpoint + 'pii'
931
+ data = {
932
+ 'key': self.api_key,
933
+ 'text': text,
934
+ }
935
+ if types is not None:
936
+ if (not isinstance(types, Iterable)
937
+ or isinstance(types, (str, bytes, dict))):
938
+ raise TypeError('types must be a list of entity type names')
939
+ data['types'] = list(types)
940
+ if redact is not None:
941
+ data['redact'] = redact
942
+ return self._request(url, data)
943
+
944
+ def seo(
945
+ self,
946
+ text,
947
+ keywords=None,
948
+ suggestions=None,
949
+ lang=None,
950
+ ):
951
+ '''
952
+ Analyzes the provided page or article text for SEO: deterministic content
953
+ statistics and readability, target-keyword usage, the most frequent terms, and
954
+ (optionally) LLM-generated titles, meta descriptions, a URL slug, and focus
955
+ keywords.
956
+
957
+ :param text: Page or article body, plain text or HTML (block tags become line
958
+ breaks, inline tags are removed), up to 20,000 characters.
959
+ :type text: str
960
+ :param keywords: Optional list of up to 10 target keyword phrases, most
961
+ important first (the first is the primary keyword). Each 1-100 characters.
962
+ Measured in the text and steered into the suggestions.
963
+ :type keywords: list[str]
964
+ :param suggestions: If False, only the deterministic ``stats``, ``keywords`` and
965
+ ``top_terms`` are returned (free); no ``suggestions`` are generated. The API
966
+ defaults to True (billed at the Tone rate).
967
+ :type suggestions: bool
968
+ :param lang: ISO 639-1 language code of the text (a region subtag such as
969
+ ``pt-BR`` is allowed, or ``auto``). The API defaults to ``en``. Only selects
970
+ the readability formulas: ``flesch_reading_ease`` and
971
+ ``flesch_kincaid_grade`` are computed for ``en``, ``de``, ``es``, ``fr``,
972
+ ``it``, ``nl``, ``pl`` and ``ru`` and are None for any other language.
973
+ :type lang: str
974
+ :rtype: dict
975
+ :return:
976
+ - stats: ``{chars, words, sentences, paragraphs, reading_time_min,
977
+ flesch_reading_ease, flesch_kincaid_grade}``; the two readability fields
978
+ are None for languages without readability support.
979
+ - keywords: One ``{keyword, count, density, in_first_100_words}`` dict per
980
+ submitted target keyword, in the same order; empty when none were sent.
981
+ - top_terms: Up to 10 most frequent content unigrams/bigrams as
982
+ ``{term, count}`` dicts (lower-cased, English stopwords removed).
983
+ - suggestions: Only present when ``suggestions`` is not False:
984
+ ``{titles, meta_descriptions, slug, keywords}`` where ``titles`` and
985
+ ``meta_descriptions`` are lists of strings, ``slug`` is a kebab-case
986
+ string (or None when no ASCII slug could be derived, e.g. purely CJK
987
+ content) and ``keywords`` is a list of focus-keyword phrases.
988
+ '''
989
+ url = self.url_endpoint + 'seo'
990
+ data = {
991
+ 'key': self.api_key,
992
+ 'text': text,
993
+ }
994
+ if keywords is not None:
995
+ if (not isinstance(keywords, Iterable)
996
+ or isinstance(keywords, (str, bytes, dict))):
997
+ raise TypeError('keywords must be a list of keyword strings')
998
+ data['keywords'] = list(keywords)
999
+ if suggestions is not None:
1000
+ data['suggestions'] = suggestions
1001
+ if lang is not None:
1002
+ data['lang'] = lang
1003
+ return self._request(url, data)
1004
+
1005
+ def classify(
1006
+ self,
1007
+ text,
1008
+ labels,
1009
+ multi_label=None,
1010
+ threshold=None,
1011
+ context=None,
1012
+ ):
1013
+ '''
1014
+ Classifies the provided text into one (or several) of the labels you supply.
1015
+ Zero-shot: no training data or setup is needed, just the label names and,
1016
+ optionally, a short description per label.
1017
+
1018
+ Example::
1019
+
1020
+ client.classify(
1021
+ 'I was charged twice for my last invoice.',
1022
+ labels=[
1023
+ 'billing',
1024
+ {'name': 'technical issue', 'description': 'Bugs, crashes, errors'},
1025
+ 'shipping',
1026
+ 'other',
1027
+ ],
1028
+ context='Support tickets for a SaaS billing product',
1029
+ )
1030
+ # {'label': 'billing',
1031
+ # 'labels': ['billing'],
1032
+ # 'scores': [{'label': 'billing', 'score': 0.86},
1033
+ # {'label': 'technical issue', 'score': 0.07},
1034
+ # {'label': 'shipping', 'score': 0.04},
1035
+ # {'label': 'other', 'score': 0.03}],
1036
+ # 'rationale': 'The customer reports being charged twice on one invoice.',
1037
+ # 'multi_label': False}
1038
+
1039
+ :param text: Text to classify, plain text or HTML (tags are stripped), up to
1040
+ 10,000 characters.
1041
+ :type text: str
1042
+ :param labels: 2-20 candidate labels. Each entry is either a label name (str) or
1043
+ a dict ``{'name': str, 'description': str}`` where ``description`` is
1044
+ optional. Names are up to 50 characters and must be unique
1045
+ (case-insensitive); descriptions are up to 200 characters and help the
1046
+ model with borderline decisions.
1047
+ :type labels: list[str | dict]
1048
+ :raises TypeError: If ``labels`` is None, a single string or a dict rather
1049
+ than a list/tuple of labels.
1050
+ :param multi_label: If False (the API default), exactly one label applies and
1051
+ the scores form a probability distribution over the labels. If True, zero,
1052
+ one or several labels may apply and each score is an independent 0-1
1053
+ probability.
1054
+ :type multi_label: bool
1055
+ :param threshold: Multi-label cut-off, 0-1: ``labels`` in the response contains
1056
+ every label with ``score >= threshold``. The API defaults to 0.5. Ignored in
1057
+ single-label mode.
1058
+ :type threshold: float
1059
+ :param context: Up to 500 characters describing what the texts are or how to
1060
+ decide (e.g. ``'Support tickets for a SaaS billing product'``). Guidance
1061
+ only; the model can still only answer with the labels you provide.
1062
+ :type context: str
1063
+ :rtype: dict
1064
+ :return:
1065
+ - label: Best-matching label name (always one of the input labels).
1066
+ - labels: Single-label mode: ``[label]``. Multi-label mode: every label
1067
+ with ``score >= threshold``, descending by score; may be empty when none
1068
+ of the labels apply.
1069
+ - scores: One ``{label, score}`` dict per input label, sorted descending
1070
+ by score (0-1, rounded to 4 decimals).
1071
+ - rationale: One sentence naming the evidence in the text (may be empty).
1072
+ - multi_label: The mode that was used.
1073
+ '''
1074
+ # A bare string would silently be split into one-character labels and
1075
+ # None would raise an opaque TypeError from list(); fail loudly instead.
1076
+ if (not isinstance(labels, Iterable)
1077
+ or isinstance(labels, (str, bytes, dict))):
1078
+ raise TypeError('labels must be a list of label names or '
1079
+ '{name, description} dicts')
1080
+ url = self.url_endpoint + 'classify'
1081
+ data = {
1082
+ 'key': self.api_key,
1083
+ 'text': text,
1084
+ 'labels': list(labels),
1085
+ }
1086
+ if multi_label is not None:
1087
+ data['multi_label'] = multi_label
1088
+ if threshold is not None:
1089
+ data['threshold'] = threshold
1090
+ if context is not None:
1091
+ data['context'] = context
1092
+ return self._request(url, data)
@@ -0,0 +1 @@
1
+ __version__ = '4.2.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sapling-py
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: Sapling Python Client
5
5
  Home-page: https://sapling.ai
6
6
  Author: Sapling Intelligence
@@ -40,7 +40,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
40
40
  ## Overview
41
41
 
42
42
  Sapling is a toolkit for helping developers build language model-powered applications.
43
- The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection, and more.
43
+ The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
44
+ zero-shot text classification, structured data extraction, PII detection and redaction, content safety
45
+ scoring, SEO analysis, and more.
44
46
 
45
47
  Key Features:
46
48
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -9,5 +9,9 @@ sapling_py.egg-info/SOURCES.txt
9
9
  sapling_py.egg-info/dependency_links.txt
10
10
  sapling_py.egg-info/requires.txt
11
11
  sapling_py.egg-info/top_level.txt
12
- test/__init__.py
13
- test/test_client.py
12
+ test/test_classify.py
13
+ test/test_client.py
14
+ test/test_extract.py
15
+ test/test_pii.py
16
+ test/test_safety.py
17
+ test/test_seo.py
@@ -0,0 +1,200 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient, SaplingError
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ LABELS = ['billing', 'technical issue', 'shipping', 'other']
12
+
13
+ CLASSIFY_RESPONSE = {
14
+ 'label': 'billing',
15
+ 'labels': ['billing'],
16
+ 'scores': [
17
+ {'label': 'billing', 'score': 0.86},
18
+ {'label': 'technical issue', 'score': 0.07},
19
+ {'label': 'shipping', 'score': 0.04},
20
+ {'label': 'other', 'score': 0.03},
21
+ ],
22
+ 'rationale': 'The customer reports being charged twice on one invoice.',
23
+ 'multi_label': False,
24
+ }
25
+
26
+ MULTI_LABEL_RESPONSE = {
27
+ 'label': 'billing',
28
+ 'labels': ['billing', 'technical issue'],
29
+ 'scores': [
30
+ {'label': 'billing', 'score': 0.92},
31
+ {'label': 'technical issue', 'score': 0.81},
32
+ {'label': 'shipping', 'score': 0.03},
33
+ {'label': 'other', 'score': 0.02},
34
+ ],
35
+ 'rationale': 'Mentions a duplicate charge and an app crash.',
36
+ 'multi_label': True,
37
+ }
38
+
39
+
40
+ @pytest.fixture
41
+ def client():
42
+ return SaplingClient(api_key=API_KEY)
43
+
44
+
45
+ def _last_request_body():
46
+ return json.loads(responses.calls[-1].request.body)
47
+
48
+
49
+ @responses.activate
50
+ def test_classify_returns_json_and_sends_key(client):
51
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
52
+ result = client.classify('I was charged twice for my last invoice.', LABELS)
53
+ assert result == CLASSIFY_RESPONSE
54
+ body = _last_request_body()
55
+ assert body == {'key': API_KEY, 'text': 'I was charged twice for my last invoice.',
56
+ 'labels': LABELS}
57
+
58
+
59
+ @responses.activate
60
+ def test_classify_posts_to_classify_endpoint(client):
61
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
62
+ client.classify('some text', LABELS)
63
+ assert len(responses.calls) == 1
64
+ assert responses.calls[0].request.url == BASE + 'classify'
65
+ assert responses.calls[0].request.method == 'POST'
66
+
67
+
68
+ @responses.activate
69
+ def test_classify_label_dicts_passed_through(client):
70
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
71
+ labels = [
72
+ 'billing',
73
+ {'name': 'technical issue', 'description': 'Bugs, crashes, errors'},
74
+ {'name': 'shipping'},
75
+ 'other',
76
+ ]
77
+ client.classify('some text', labels)
78
+ body = _last_request_body()
79
+ assert body['labels'] == labels
80
+
81
+
82
+ @responses.activate
83
+ def test_classify_labels_tuple_sent_as_list(client):
84
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
85
+ client.classify('some text', ('billing', 'shipping'))
86
+ body = _last_request_body()
87
+ assert body['labels'] == ['billing', 'shipping']
88
+
89
+
90
+ @responses.activate
91
+ def test_classify_optional_params_omitted_by_default(client):
92
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
93
+ client.classify('some text', LABELS)
94
+ body = _last_request_body()
95
+ assert 'multi_label' not in body
96
+ assert 'threshold' not in body
97
+ assert 'context' not in body
98
+
99
+
100
+ @responses.activate
101
+ def test_classify_optional_params_sent_when_provided(client):
102
+ responses.add(responses.POST, BASE + 'classify', json=MULTI_LABEL_RESPONSE, status=200)
103
+ result = client.classify('I was charged twice and the app crashes on launch', LABELS,
104
+ multi_label=True, threshold=0.7,
105
+ context='Support tickets for a SaaS billing product')
106
+ assert result == MULTI_LABEL_RESPONSE
107
+ assert result['labels'] == ['billing', 'technical issue']
108
+ body = _last_request_body()
109
+ assert body == {'key': API_KEY,
110
+ 'text': 'I was charged twice and the app crashes on launch',
111
+ 'labels': LABELS,
112
+ 'multi_label': True, 'threshold': 0.7,
113
+ 'context': 'Support tickets for a SaaS billing product'}
114
+
115
+
116
+ @responses.activate
117
+ def test_classify_multi_label_false_sent(client):
118
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
119
+ client.classify('some text', LABELS, multi_label=False)
120
+ body = _last_request_body()
121
+ assert body['multi_label'] is False
122
+ assert 'threshold' not in body
123
+ assert 'context' not in body
124
+
125
+
126
+ @responses.activate
127
+ def test_classify_threshold_zero_sent(client):
128
+ responses.add(responses.POST, BASE + 'classify', json=MULTI_LABEL_RESPONSE, status=200)
129
+ client.classify('some text', LABELS, multi_label=True, threshold=0)
130
+ body = _last_request_body()
131
+ assert body['threshold'] == 0
132
+ assert body['multi_label'] is True
133
+
134
+
135
+ @responses.activate
136
+ def test_classify_context_sent_when_provided(client):
137
+ responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
138
+ client.classify('some text', LABELS, context='Product reviews')
139
+ body = _last_request_body()
140
+ assert body['context'] == 'Product reviews'
141
+ assert 'multi_label' not in body
142
+ assert 'threshold' not in body
143
+
144
+
145
+ @responses.activate
146
+ def test_classify_multi_label_empty_labels_returned_verbatim(client):
147
+ # "None of these apply" is a valid multi-label answer.
148
+ payload = dict(MULTI_LABEL_RESPONSE, labels=[], rationale='')
149
+ responses.add(responses.POST, BASE + 'classify', json=payload, status=200)
150
+ result = client.classify('some text', LABELS, multi_label=True, threshold=0.95)
151
+ assert result['labels'] == []
152
+ assert result['rationale'] == ''
153
+ assert result['scores'] == MULTI_LABEL_RESPONSE['scores']
154
+
155
+
156
+ @responses.activate
157
+ def test_classify_error_raises_saplingerror_with_body(client):
158
+ responses.add(responses.POST, BASE + 'classify',
159
+ json={'msg': 'Invalid labels: Needs between 2 and 20 labels.'}, status=400)
160
+ with pytest.raises(SaplingError) as exc_info:
161
+ client.classify('some text', ['only one'])
162
+ err = exc_info.value
163
+ assert err.status_code == 400
164
+ assert 'Invalid labels: Needs between 2 and 20 labels.' in str(err)
165
+
166
+
167
+ @responses.activate
168
+ def test_classify_upstream_error_raises_saplingerror(client):
169
+ responses.add(responses.POST, BASE + 'classify',
170
+ json={'msg': 'Unexpected error classifying text.'}, status=502)
171
+ with pytest.raises(SaplingError) as exc_info:
172
+ client.classify('some text', LABELS)
173
+ err = exc_info.value
174
+ assert err.status_code == 502
175
+ assert 'Unexpected error classifying text.' in str(err)
176
+
177
+
178
+ @responses.activate
179
+ def test_classify_unauthorized_raises_saplingerror(client):
180
+ responses.add(responses.POST, BASE + 'classify', json={'msg': 'Invalid API key.'},
181
+ status=401)
182
+ with pytest.raises(SaplingError) as exc_info:
183
+ client.classify('some text', LABELS)
184
+ assert exc_info.value.status_code == 401
185
+
186
+
187
+ @responses.activate
188
+ def test_classify_custom_hostname_and_pathname():
189
+ client = SaplingClient(api_key=API_KEY, hostname='https://sapling.example.com',
190
+ pathname='/custom/v1/')
191
+ responses.add(responses.POST, 'https://sapling.example.com/custom/v1/classify',
192
+ json=CLASSIFY_RESPONSE, status=200)
193
+ result = client.classify('some text', LABELS)
194
+ assert result == CLASSIFY_RESPONSE
195
+
196
+
197
+ @pytest.mark.parametrize('bad_labels', [None, 'billing', b'billing', {'name': 'billing'}, 42, True])
198
+ def test_classify_rejects_non_list_labels(client, bad_labels):
199
+ with pytest.raises(TypeError):
200
+ client.classify('I was charged twice.', bad_labels)
@@ -139,6 +139,10 @@ def test_rephrase_sends_expected_params(client):
139
139
  ('quality', 'quality', {'score': 3.5}),
140
140
  ('langdetect', 'langdetect', {'lang': 'en', 'conf': 0.99}),
141
141
  ('profanity', 'profanity', {'toks': ['a'], 'labels': [0]}),
142
+ ('safety', 'safety', {
143
+ 'scores': {'toxicity': 0.9}, 'flagged': True,
144
+ 'flagged_categories': ['toxicity'], 'threshold': 0.5,
145
+ }),
142
146
  ],
143
147
  )
144
148
  @responses.activate
@@ -150,9 +154,42 @@ def test_single_text_endpoints(client, method, path, payload):
150
154
  assert body == {'key': API_KEY, 'text': 'some text'}
151
155
 
152
156
 
157
+ @responses.activate
158
+ def test_langdetect_options(client):
159
+ payload = {
160
+ 'lang': 'en', 'name': 'English', 'conf': 0.6,
161
+ 'candidates': [{'lang': 'en', 'name': 'English', 'conf': 0.6}],
162
+ 'segments': [{'start': 0, 'end': 5, 'text': 'Hello', 'lang': 'en', 'name': 'English',
163
+ 'conf': 0.9, 'candidates': [{'lang': 'en', 'name': 'English', 'conf': 0.9}]}],
164
+ }
165
+ responses.add(responses.POST, BASE + 'langdetect', json=payload, status=200)
166
+ result = client.langdetect('Hello', top_k=1, segments=True)
167
+ assert result == payload
168
+ assert _last_request_body() == {'key': API_KEY, 'text': 'Hello', 'top_k': 1, 'segments': True}
169
+
170
+
153
171
  @responses.activate
154
172
  def test_hostname_and_pathname_override():
155
173
  client = SaplingClient(api_key=API_KEY, hostname='http://localhost:5000', pathname='/api/v1/')
156
174
  responses.add(responses.POST, 'http://localhost:5000/api/v1/edits', json={'edits': []}, status=200)
157
175
  client.edits('hi')
158
176
  assert responses.calls[-1].request.url == 'http://localhost:5000/api/v1/edits'
177
+
178
+ @responses.activate
179
+ def test_quality_options(client):
180
+ payload = {
181
+ 'score': 3.4,
182
+ 'sentences': [{'start': 0, 'end': 5, 'text': 'Hello', 'score': 4.1}],
183
+ 'rubric': {
184
+ 'overall': 3,
185
+ 'dimensions': {'clarity': 4, 'coherence': 3, 'correctness': 3, 'concision': 2},
186
+ 'summary': 'Fine.',
187
+ 'issues': [{'quote': 'Hello', 'dimension': 'clarity', 'note': 'n',
188
+ 'suggestion': 'Hi', 'start': 0, 'end': 5}],
189
+ },
190
+ }
191
+ responses.add(responses.POST, BASE + 'quality', json=payload, status=200)
192
+ result = client.quality('Hello', sentence_scores=True, rubric=True)
193
+ assert result == payload
194
+ assert _last_request_body() == {'key': API_KEY, 'text': 'Hello',
195
+ 'sentence_scores': True, 'rubric': True}
@@ -0,0 +1,213 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient, SaplingError
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ TEXT = 'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.'
12
+
13
+ FIELDS = [
14
+ 'invoice_number',
15
+ {'name': 'total', 'type': 'number', 'description': 'Amount due', 'required': True},
16
+ {'name': 'due_date', 'type': 'date'},
17
+ 'purchase_order',
18
+ ]
19
+
20
+ EXTRACT_RESPONSE = {
21
+ 'data': {
22
+ 'invoice_number': 'INV-1042',
23
+ 'total': 1299.0,
24
+ 'due_date': '2026-03-05',
25
+ 'purchase_order': None,
26
+ },
27
+ 'fields': [
28
+ {'name': 'invoice_number', 'type': 'string', 'value': 'INV-1042',
29
+ 'evidence': 'Invoice INV-1042', 'found': True},
30
+ {'name': 'total', 'type': 'number', 'value': 1299.0,
31
+ 'evidence': 'total $1,299.00', 'found': True},
32
+ {'name': 'due_date', 'type': 'date', 'value': '2026-03-05',
33
+ 'evidence': 'due March 5, 2026', 'found': True},
34
+ {'name': 'purchase_order', 'type': 'string', 'value': None,
35
+ 'evidence': '', 'found': False},
36
+ ],
37
+ 'missing': ['purchase_order'],
38
+ }
39
+
40
+
41
+ @pytest.fixture
42
+ def client():
43
+ return SaplingClient(api_key=API_KEY)
44
+
45
+
46
+ def _last_request_body():
47
+ return json.loads(responses.calls[-1].request.body)
48
+
49
+
50
+ @responses.activate
51
+ def test_extract_returns_json_and_sends_key(client):
52
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
53
+ result = client.extract(TEXT, FIELDS)
54
+ assert result == EXTRACT_RESPONSE
55
+ body = _last_request_body()
56
+ assert body == {'key': API_KEY, 'text': TEXT, 'fields': FIELDS}
57
+
58
+
59
+ @responses.activate
60
+ def test_extract_posts_to_extract_endpoint(client):
61
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
62
+ client.extract('some text', ['invoice_number'])
63
+ assert len(responses.calls) == 1
64
+ assert responses.calls[0].request.url == BASE + 'extract'
65
+ assert responses.calls[0].request.method == 'POST'
66
+
67
+
68
+ @responses.activate
69
+ def test_extract_field_dicts_passed_through(client):
70
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
71
+ fields = [
72
+ 'invoice_number',
73
+ {'name': 'total', 'type': 'number', 'description': 'Amount due', 'required': True},
74
+ {'name': 'line_items', 'type': 'list'},
75
+ {'name': 'is_urgent', 'type': 'boolean'},
76
+ ]
77
+ client.extract('some text', fields)
78
+ body = _last_request_body()
79
+ assert body['fields'] == fields
80
+
81
+
82
+ @responses.activate
83
+ def test_extract_fields_tuple_sent_as_list(client):
84
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
85
+ client.extract('some text', ('invoice_number', 'total'))
86
+ body = _last_request_body()
87
+ assert body['fields'] == ['invoice_number', 'total']
88
+
89
+
90
+ @responses.activate
91
+ def test_extract_optional_params_omitted_by_default(client):
92
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
93
+ client.extract('some text', FIELDS)
94
+ body = _last_request_body()
95
+ assert 'context' not in body
96
+
97
+
98
+ @responses.activate
99
+ def test_extract_context_sent_when_provided(client):
100
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
101
+ result = client.extract(TEXT, FIELDS, context='A vendor invoice')
102
+ assert result == EXTRACT_RESPONSE
103
+ body = _last_request_body()
104
+ assert body == {'key': API_KEY, 'text': TEXT, 'fields': FIELDS,
105
+ 'context': 'A vendor invoice'}
106
+
107
+
108
+ @responses.activate
109
+ def test_extract_empty_context_sent(client):
110
+ # '' is not None, so it is forwarded rather than dropped.
111
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
112
+ client.extract('some text', FIELDS, context='')
113
+ body = _last_request_body()
114
+ assert body['context'] == ''
115
+
116
+
117
+ @responses.activate
118
+ def test_extract_nothing_found_returned_verbatim(client):
119
+ # Finding nothing is a normal 200, not an error.
120
+ payload = {
121
+ 'data': {'invoice_number': None, 'total': None},
122
+ 'fields': [
123
+ {'name': 'invoice_number', 'type': 'string', 'value': None,
124
+ 'evidence': '', 'found': False},
125
+ {'name': 'total', 'type': 'number', 'value': None,
126
+ 'evidence': '', 'found': False},
127
+ ],
128
+ 'missing': ['invoice_number', 'total'],
129
+ }
130
+ responses.add(responses.POST, BASE + 'extract', json=payload, status=200)
131
+ result = client.extract('Nothing invoice-like here.', ['invoice_number', 'total'])
132
+ assert result == payload
133
+ assert result['missing'] == ['invoice_number', 'total']
134
+ assert all(value is None for value in result['data'].values())
135
+
136
+
137
+ @responses.activate
138
+ def test_extract_error_raises_saplingerror_with_body(client):
139
+ responses.add(responses.POST, BASE + 'extract',
140
+ json={'msg': 'Invalid fields: Needs between 1 and 20 fields.'},
141
+ status=400)
142
+ with pytest.raises(SaplingError) as exc_info:
143
+ client.extract('some text', [])
144
+ err = exc_info.value
145
+ assert err.status_code == 400
146
+ assert 'Invalid fields: Needs between 1 and 20 fields.' in str(err)
147
+
148
+
149
+ @responses.activate
150
+ def test_extract_upstream_error_raises_saplingerror(client):
151
+ responses.add(responses.POST, BASE + 'extract',
152
+ json={'msg': 'Unexpected error extracting from text.'}, status=502)
153
+ with pytest.raises(SaplingError) as exc_info:
154
+ client.extract('some text', FIELDS)
155
+ err = exc_info.value
156
+ assert err.status_code == 502
157
+ assert 'Unexpected error extracting from text.' in str(err)
158
+
159
+
160
+ @responses.activate
161
+ def test_extract_unauthorized_raises_saplingerror(client):
162
+ responses.add(responses.POST, BASE + 'extract', json={'msg': 'Invalid API key.'},
163
+ status=401)
164
+ with pytest.raises(SaplingError) as exc_info:
165
+ client.extract('some text', FIELDS)
166
+ assert exc_info.value.status_code == 401
167
+
168
+
169
+ @responses.activate
170
+ def test_extract_rate_limited_raises_saplingerror(client):
171
+ responses.add(responses.POST, BASE + 'extract',
172
+ json={'msg': 'Too many requests.'}, status=429)
173
+ with pytest.raises(SaplingError) as exc_info:
174
+ client.extract('some text', FIELDS)
175
+ assert exc_info.value.status_code == 429
176
+
177
+
178
+ @responses.activate
179
+ def test_extract_custom_hostname_and_pathname():
180
+ client = SaplingClient(api_key=API_KEY, hostname='https://sapling.example.com',
181
+ pathname='/custom/v1/')
182
+ responses.add(responses.POST, 'https://sapling.example.com/custom/v1/extract',
183
+ json=EXTRACT_RESPONSE, status=200)
184
+ result = client.extract(TEXT, FIELDS)
185
+ assert result == EXTRACT_RESPONSE
186
+
187
+
188
+ # None / an int / a bool are not iterable at all; a str, bytes or dict is
189
+ # iterable but into the wrong thing (characters, bytes, keys).
190
+ BAD_FIELDS = [None, 42, 3.5, True, 'invoice_number', b'invoice_number',
191
+ {'name': 'invoice_number'}]
192
+
193
+
194
+ @pytest.mark.parametrize('bad_fields', BAD_FIELDS)
195
+ def test_extract_rejects_non_list_fields(client, bad_fields):
196
+ with pytest.raises(TypeError):
197
+ client.extract(TEXT, bad_fields)
198
+
199
+
200
+ @pytest.mark.parametrize('bad_fields', BAD_FIELDS)
201
+ def test_extract_non_list_fields_message_is_helpful(client, bad_fields):
202
+ # Not the opaque "'int' object is not iterable" from list().
203
+ with pytest.raises(TypeError, match='fields must be a list'):
204
+ client.extract(TEXT, bad_fields)
205
+
206
+
207
+ @responses.activate
208
+ def test_extract_accepts_any_iterable_of_fields(client):
209
+ # list(fields) already handled tuples; a generator is iterable too and must
210
+ # not be rejected by the stricter guard.
211
+ responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
212
+ client.extract(TEXT, (name for name in ['invoice_number', 'total']))
213
+ assert _last_request_body()['fields'] == ['invoice_number', 'total']
@@ -0,0 +1,66 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ PII_RESPONSE = {
12
+ 'entities': [{
13
+ 'type': 'email', 'text': 'jane@example.com', 'start': 6, 'end': 22,
14
+ 'replacement': '[EMAIL]',
15
+ }],
16
+ 'flagged': True,
17
+ 'types': ['email'],
18
+ }
19
+
20
+
21
+ @pytest.fixture
22
+ def client():
23
+ return SaplingClient(api_key=API_KEY)
24
+
25
+
26
+ def _last_request_body():
27
+ return json.loads(responses.calls[-1].request.body)
28
+
29
+
30
+ @responses.activate
31
+ def test_pii_returns_json_and_sends_key(client):
32
+ responses.add(responses.POST, BASE + 'pii', json=PII_RESPONSE, status=200)
33
+ result = client.pii('Email jane@example.com')
34
+ assert result == PII_RESPONSE
35
+ body = _last_request_body()
36
+ assert body == {'key': API_KEY, 'text': 'Email jane@example.com'}
37
+
38
+
39
+ @responses.activate
40
+ def test_pii_optional_params_sent_when_provided(client):
41
+ payload = dict(PII_RESPONSE, redacted_text='Email [EMAIL]')
42
+ responses.add(responses.POST, BASE + 'pii', json=payload, status=200)
43
+ result = client.pii('Email jane@example.com', types=('email', 'phone'), redact=True)
44
+ assert result['redacted_text'] == 'Email [EMAIL]'
45
+ body = _last_request_body()
46
+ assert body['types'] == ['email', 'phone']
47
+ assert body['redact'] is True
48
+
49
+
50
+ @responses.activate
51
+ def test_pii_optional_params_omitted_by_default(client):
52
+ responses.add(responses.POST, BASE + 'pii', json=PII_RESPONSE, status=200)
53
+ client.pii('some text')
54
+ body = _last_request_body()
55
+ assert 'types' not in body
56
+ assert 'redact' not in body
57
+
58
+
59
+ @responses.activate
60
+ @pytest.mark.parametrize('bad_types', ['email', b'email', {'email': True}, 42, True])
61
+ def test_pii_rejects_invalid_types(client, bad_types):
62
+ # A bare string would be split into characters and a dict reduced to its keys;
63
+ # non-iterables would raise an opaque TypeError from list().
64
+ with pytest.raises(TypeError):
65
+ client.pii('Email jane@example.com', types=bad_types)
66
+ assert len(responses.calls) == 0
@@ -0,0 +1,30 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+
12
+ @pytest.fixture
13
+ def client():
14
+ return SaplingClient(api_key=API_KEY)
15
+
16
+
17
+ @responses.activate
18
+ def test_safety_threshold_sent_when_provided(client):
19
+ responses.add(responses.POST, BASE + 'safety', json={'flagged': False}, status=200)
20
+ client.safety('some text', threshold=0.8)
21
+ body = json.loads(responses.calls[-1].request.body)
22
+ assert body == {'key': API_KEY, 'text': 'some text', 'threshold': 0.8}
23
+
24
+
25
+ @responses.activate
26
+ def test_safety_threshold_omitted_by_default(client):
27
+ responses.add(responses.POST, BASE + 'safety', json={'flagged': False}, status=200)
28
+ client.safety('some text')
29
+ body = json.loads(responses.calls[-1].request.body)
30
+ assert 'threshold' not in body
@@ -0,0 +1,116 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient, SaplingError
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ SEO_STATS_RESPONSE = {
12
+ 'stats': {
13
+ 'chars': 372, 'words': 63, 'sentences': 6, 'paragraphs': 3,
14
+ 'reading_time_min': 1, 'flesch_reading_ease': 66.4, 'flesch_kincaid_grade': 7.2,
15
+ },
16
+ 'keywords': [],
17
+ 'top_terms': [{'term': 'grammar', 'count': 6}, {'term': 'grammar checker', 'count': 5}],
18
+ }
19
+
20
+ SEO_RESPONSE = dict(
21
+ SEO_STATS_RESPONSE,
22
+ keywords=[{'keyword': 'grammar checker', 'count': 5, 'density': 7.94,
23
+ 'in_first_100_words': True}],
24
+ suggestions={
25
+ 'titles': ['Grammar Checker Guide: What Modern Tools Catch'],
26
+ 'meta_descriptions': ['Learn what a modern grammar checker catches beyond typos.'],
27
+ 'slug': 'grammar-checker-guide',
28
+ 'keywords': ['grammar checker', 'grammar checker for teams'],
29
+ },
30
+ )
31
+
32
+
33
+ @pytest.fixture
34
+ def client():
35
+ return SaplingClient(api_key=API_KEY)
36
+
37
+
38
+ def _last_request_body():
39
+ return json.loads(responses.calls[-1].request.body)
40
+
41
+
42
+ @responses.activate
43
+ def test_seo_returns_json_and_sends_key(client):
44
+ responses.add(responses.POST, BASE + 'seo', json=SEO_RESPONSE, status=200)
45
+ result = client.seo('Grammar checkers catch more than typos.')
46
+ assert result == SEO_RESPONSE
47
+ body = _last_request_body()
48
+ assert body == {'key': API_KEY, 'text': 'Grammar checkers catch more than typos.'}
49
+
50
+
51
+ @responses.activate
52
+ def test_seo_optional_params_sent_when_provided(client):
53
+ responses.add(responses.POST, BASE + 'seo', json=SEO_RESPONSE, status=200)
54
+ result = client.seo('some text', keywords=('grammar checker', 'passive voice'),
55
+ suggestions=True, lang='en')
56
+ assert result['suggestions']['slug'] == 'grammar-checker-guide'
57
+ body = _last_request_body()
58
+ assert body == {'key': API_KEY, 'text': 'some text',
59
+ 'keywords': ['grammar checker', 'passive voice'],
60
+ 'suggestions': True, 'lang': 'en'}
61
+
62
+
63
+ @responses.activate
64
+ def test_seo_suggestions_false_sent(client):
65
+ responses.add(responses.POST, BASE + 'seo', json=SEO_STATS_RESPONSE, status=200)
66
+ result = client.seo('some text', suggestions=False)
67
+ assert result == SEO_STATS_RESPONSE
68
+ assert 'suggestions' not in result
69
+ body = _last_request_body()
70
+ assert body['suggestions'] is False
71
+ assert 'keywords' not in body
72
+
73
+
74
+ @responses.activate
75
+ def test_seo_lang_sent_when_provided(client):
76
+ # Unsupported readability language: readability fields come back as null.
77
+ payload = dict(SEO_STATS_RESPONSE, stats=dict(
78
+ SEO_STATS_RESPONSE['stats'], flesch_reading_ease=None, flesch_kincaid_grade=None))
79
+ responses.add(responses.POST, BASE + 'seo', json=payload, status=200)
80
+ result = client.seo('some text', lang='pt-BR')
81
+ assert result['stats']['flesch_reading_ease'] is None
82
+ body = _last_request_body()
83
+ assert body['lang'] == 'pt-BR'
84
+ assert 'keywords' not in body
85
+ assert 'suggestions' not in body
86
+
87
+
88
+ @responses.activate
89
+ def test_seo_optional_params_omitted_by_default(client):
90
+ responses.add(responses.POST, BASE + 'seo', json=SEO_STATS_RESPONSE, status=200)
91
+ client.seo('some text')
92
+ body = _last_request_body()
93
+ assert 'keywords' not in body
94
+ assert 'suggestions' not in body
95
+ assert 'lang' not in body
96
+
97
+
98
+ @responses.activate
99
+ def test_seo_error_raises_saplingerror_with_body(client):
100
+ responses.add(responses.POST, BASE + 'seo',
101
+ json={'msg': 'Unexpected error generating SEO suggestions.'}, status=502)
102
+ with pytest.raises(SaplingError) as exc_info:
103
+ client.seo('some text')
104
+ err = exc_info.value
105
+ assert err.status_code == 502
106
+ assert 'Unexpected error generating SEO suggestions.' in str(err)
107
+
108
+
109
+ @responses.activate
110
+ @pytest.mark.parametrize('bad_keywords', ['grammar', b'grammar', {'grammar': True}, 42, True])
111
+ def test_seo_rejects_invalid_keywords(client, bad_keywords):
112
+ # A bare string would be split into characters and a dict reduced to its keys;
113
+ # non-iterables would raise an opaque TypeError from list().
114
+ with pytest.raises(TypeError):
115
+ client.seo('some text', keywords=bad_keywords)
116
+ assert len(responses.calls) == 0
@@ -1 +0,0 @@
1
- __version__ = '4.1.0'
File without changes
File without changes
File without changes
File without changes