sfq 0.0.16__tar.gz → 0.0.18__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.
@@ -0,0 +1,106 @@
1
+ name: Version, Build, and Publish
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: write
10
+ id-token: write
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ environment: release
16
+
17
+ steps:
18
+ - name: Checkout repo
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python 3.12
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: 3.12
25
+
26
+ - name: Install uv CLI
27
+ uses: astral-sh/setup-uv@v4
28
+ with:
29
+ version: latest
30
+ enable-cache: true
31
+
32
+ - name: Sync dependencies with uv
33
+ run: uv sync
34
+
35
+ - name: Authenticate GitHub CLI
36
+ run: |
37
+ echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token
38
+
39
+ - name: Calculate next patch version from latest tag (using gh)
40
+ id: get_version
41
+ run: |
42
+ echo "Fetching tags using gh CLI..."
43
+
44
+ TAGS=$(gh api repos/${{ github.repository }}/tags --jq '.[].name' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
45
+
46
+ echo "All semver tags found:"
47
+ echo "$TAGS"
48
+
49
+ LATEST_TAG=$(echo "$TAGS" | tail -n1)
50
+ echo "Latest tag found: $LATEST_TAG"
51
+
52
+ if [ -z "$LATEST_TAG" ]; then
53
+ NEXT_VERSION="0.0.1"
54
+ echo "No tags found, starting at $NEXT_VERSION"
55
+ else
56
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_TAG"
57
+ PATCH=$((PATCH + 1))
58
+ NEXT_VERSION="$MAJOR.$MINOR.$PATCH"
59
+ echo "Incremented patch version to $NEXT_VERSION"
60
+ fi
61
+
62
+ echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
63
+
64
+ - name: Update version strings in files
65
+ run: |
66
+ VERSION=${{ steps.get_version.outputs.version }}
67
+
68
+ echo "Updating pyproject.toml version to $VERSION"
69
+ sed -i -E "s/(^version *= *\")([0-9]+\.[0-9]+\.[0-9]+)(\")/\1$VERSION\3/" pyproject.toml
70
+
71
+ echo "Updating uv.lock version to $VERSION"
72
+ sed -i -E "s/(^version *= *\")([0-9]+\.[0-9]+\.[0-9]+)(\")/\1$VERSION\3/" uv.lock
73
+
74
+ echo "Updating src/sfq/__init__.py user_agent to $VERSION"
75
+ sed -i -E "s/(user_agent: str = \"sfq\/)[0-9]+\.[0-9]+\.[0-9]+(\")/\1$VERSION\2/" src/sfq/__init__.py
76
+ sed -i -E "s/(default is \"sfq\/)[0-9]+\.[0-9]+\.[0-9]+(\")/\1$VERSION\2/" src/sfq/__init__.py
77
+
78
+ - name: Commit version updates
79
+ run: |
80
+ git config user.name "github-actions"
81
+ git config user.email "github-actions@users.noreply.github.com"
82
+ git add pyproject.toml uv.lock src/sfq/__init__.py
83
+ git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}"
84
+ git push
85
+
86
+ - name: Create and push git tag
87
+ run: |
88
+ VERSION=${{ steps.get_version.outputs.version }}
89
+ git tag $VERSION
90
+ git push origin $VERSION
91
+
92
+ - name: Build package with uv
93
+ run: uv build
94
+
95
+ - name: Publish package distributions to PyPI
96
+ uses: pypa/gh-action-pypi-publish@release/v1
97
+
98
+ - name: Upload artifacts to GitHub Release (prerelease)
99
+ uses: softprops/action-gh-release@v2
100
+ with:
101
+ tag_name: ${{ steps.get_version.outputs.version }}
102
+ name: Release ${{ steps.get_version.outputs.version }}
103
+ files: dist/*
104
+ prerelease: true
105
+ env:
106
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.16
3
+ Version: 0.0.18
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -56,18 +56,6 @@ print(sf.query("SELECT Id FROM Account LIMIT 5"))
56
56
  print(sf.tooling_query("SELECT Id, FullName, Metadata FROM SandboxSettings LIMIT 5"))
57
57
  ```
58
58
 
59
- ### sObject Key Prefixes
60
-
61
- ```python
62
- # Key prefix via IDs
63
- print(sf.get_sobject_prefixes())
64
- >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
65
-
66
- # Key prefix via names
67
- print(sf.get_sobject_prefixes(key_type="name"))
68
- >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
69
- ```
70
-
71
59
  ### Composite Batch Queries
72
60
 
73
61
  ```python
@@ -94,7 +82,7 @@ for subrequest_identifer, subrequest_response in batched_response.items():
94
82
 
95
83
  ```python
96
84
  response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
97
- >>> [{'id': '500aj000006wtdZAAQ', 'success': True, 'errors': []}, {'id': '500aj000006wtdaAAA', 'success': True, 'errors': []}, {'id': '500aj000006wtdbAAA', 'success': True, 'errors': []}]
85
+ >>> [{'id': '07La0000000bYgj', 'success': True, 'errors': []}, {'id': '07La0000000bYgk', 'success': True, 'errors': []}, {'id': '07La0000000bYgl', 'success': True, 'errors': []}]
98
86
  ```
99
87
 
100
88
  ### Static Resources
@@ -110,6 +98,18 @@ print(f'Updated resource: {page}')
110
98
  sf.update_static_resource_id('081aj000009jUMXAA2', '<h1>It works!</h1>')
111
99
  ```
112
100
 
101
+ ### sObject Key Prefixes
102
+
103
+ ```python
104
+ # Key prefix via IDs
105
+ print(sf.get_sobject_prefixes())
106
+ >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
107
+
108
+ # Key prefix via names
109
+ print(sf.get_sobject_prefixes(key_type="name"))
110
+ >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
111
+ ```
112
+
113
113
  ## How to Obtain Salesforce Tokens
114
114
 
115
115
  To use the `sfq` library, you'll need a **client ID** and **refresh token**. The easiest way to obtain these is by using the Salesforce CLI:
@@ -40,18 +40,6 @@ print(sf.query("SELECT Id FROM Account LIMIT 5"))
40
40
  print(sf.tooling_query("SELECT Id, FullName, Metadata FROM SandboxSettings LIMIT 5"))
41
41
  ```
42
42
 
43
- ### sObject Key Prefixes
44
-
45
- ```python
46
- # Key prefix via IDs
47
- print(sf.get_sobject_prefixes())
48
- >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
49
-
50
- # Key prefix via names
51
- print(sf.get_sobject_prefixes(key_type="name"))
52
- >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
53
- ```
54
-
55
43
  ### Composite Batch Queries
56
44
 
57
45
  ```python
@@ -78,7 +66,7 @@ for subrequest_identifer, subrequest_response in batched_response.items():
78
66
 
79
67
  ```python
80
68
  response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
81
- >>> [{'id': '500aj000006wtdZAAQ', 'success': True, 'errors': []}, {'id': '500aj000006wtdaAAA', 'success': True, 'errors': []}, {'id': '500aj000006wtdbAAA', 'success': True, 'errors': []}]
69
+ >>> [{'id': '07La0000000bYgj', 'success': True, 'errors': []}, {'id': '07La0000000bYgk', 'success': True, 'errors': []}, {'id': '07La0000000bYgl', 'success': True, 'errors': []}]
82
70
  ```
83
71
 
84
72
  ### Static Resources
@@ -94,6 +82,18 @@ print(f'Updated resource: {page}')
94
82
  sf.update_static_resource_id('081aj000009jUMXAA2', '<h1>It works!</h1>')
95
83
  ```
96
84
 
85
+ ### sObject Key Prefixes
86
+
87
+ ```python
88
+ # Key prefix via IDs
89
+ print(sf.get_sobject_prefixes())
90
+ >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
91
+
92
+ # Key prefix via names
93
+ print(sf.get_sobject_prefixes(key_type="name"))
94
+ >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
95
+ ```
96
+
97
97
  ## How to Obtain Salesforce Tokens
98
98
 
99
99
  To use the `sfq` library, you'll need a **client ID** and **refresh token**. The easiest way to obtain these is by using the Salesforce CLI:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.16"
3
+ version = "0.0.18"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -84,9 +84,9 @@ class SFAuth:
84
84
  access_token: Optional[str] = None,
85
85
  token_expiration_time: Optional[float] = None,
86
86
  token_lifetime: int = 15 * 60,
87
- user_agent: str = "sfq/0.0.16",
87
+ user_agent: str = "sfq/0.0.18",
88
88
  sforce_client: str = "_auto",
89
- proxy: str = "auto",
89
+ proxy: str = "_auto",
90
90
  ) -> None:
91
91
  """
92
92
  Initializes the SFAuth with necessary parameters.
@@ -100,9 +100,9 @@ class SFAuth:
100
100
  :param access_token: The access token for the current session (default is None).
101
101
  :param token_expiration_time: The expiration time of the access token (default is None).
102
102
  :param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
103
- :param user_agent: Custom User-Agent string (default is "sfq/0.0.16").
103
+ :param user_agent: Custom User-Agent string (default is "sfq/0.0.18").
104
104
  :param sforce_client: Custom Application Identifier (default is user_agent).
105
- :param proxy: The proxy configuration, "auto" to use environment (default is "auto").
105
+ :param proxy: The proxy configuration, "_auto" to use environment (default is "_auto").
106
106
  """
107
107
  self.instance_url = self._format_instance_url(instance_url)
108
108
  self.client_id = client_id
@@ -114,12 +114,12 @@ class SFAuth:
114
114
  self.token_expiration_time = token_expiration_time
115
115
  self.token_lifetime = token_lifetime
116
116
  self.user_agent = user_agent
117
- self.sforce_client = quote(str(sforce_client), safe="")
117
+ self.sforce_client = str(sforce_client).replace(",", "")
118
118
  self._auto_configure_proxy(proxy)
119
119
  self._high_api_usage_threshold = 80
120
120
 
121
121
  if sforce_client == "_auto":
122
- self.sforce_client = quote(str(user_agent), safe="")
122
+ self.sforce_client = user_agent
123
123
 
124
124
  if self.client_secret == "_deprecation_warning":
125
125
  warnings.warn(
@@ -134,6 +134,13 @@ class SFAuth:
134
134
  )
135
135
 
136
136
  def _format_instance_url(self, instance_url) -> str:
137
+ """
138
+ HTTPS is mandatory with Spring '21 release,
139
+ This method ensures that the instance URL is formatted correctly.
140
+
141
+ :param instance_url: The Salesforce instance URL.
142
+ :return: The formatted instance URL.
143
+ """
137
144
  if instance_url.startswith("https://"):
138
145
  return instance_url
139
146
  if instance_url.startswith("http://"):
@@ -144,8 +151,8 @@ class SFAuth:
144
151
  """
145
152
  Automatically configure the proxy based on the environment or provided value.
146
153
  """
147
- if proxy == "auto":
148
- self.proxy = os.environ.get("https_proxy")
154
+ if proxy == "_auto":
155
+ self.proxy = os.environ.get("https_proxy") # HTTPs is mandatory
149
156
  if self.proxy:
150
157
  logger.debug("Auto-configured proxy: %s", self.proxy)
151
158
  else:
@@ -798,3 +805,74 @@ class SFAuth:
798
805
  if isinstance(result, (dict, list))
799
806
  ]
800
807
  return combined_response or None
808
+
809
+ def _cupdate(self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None) -> Optional[Dict[str, Any]]:
810
+ """
811
+ Execute the Composite Update API to update multiple records.
812
+
813
+ :param update_dict: A dictionary of keys of records to be updated, and a dictionary of field-value pairs to be updated, with a special key '_' overriding the sObject type which is otherwise inferred from the key. Example:
814
+ {'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
815
+ :param batch_size: The number of records to update in each batch (default is 25).
816
+ :return: JSON response from the update request or None on failure.
817
+ """
818
+ allOrNone = False
819
+ endpoint = f"/services/data/{self.api_version}/composite"
820
+
821
+ compositeRequest_payload = []
822
+ sobject_prefixes = {}
823
+
824
+ for key, record in update_dict.items():
825
+ sobject = record.copy().pop("_", None)
826
+ if not sobject and not sobject_prefixes:
827
+ sobject_prefixes = self.get_sobject_prefixes()
828
+
829
+ sobject = str(sobject) or str(sobject_prefixes.get(str(key[:3]), None))
830
+
831
+ compositeRequest_payload.append(
832
+ {
833
+ 'method': 'PATCH',
834
+ 'url': f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
835
+ 'referenceId': key,
836
+ 'body': record,
837
+ }
838
+ )
839
+
840
+ chunks = [compositeRequest_payload[i:i+batch_size] for i in range(0, len(compositeRequest_payload), batch_size)]
841
+
842
+ def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
843
+ payload = {
844
+ "allOrNone": bool(allOrNone),
845
+ "compositeRequest": chunk
846
+ }
847
+
848
+ status_code, resp_data = self._send_request(
849
+ method="POST",
850
+ endpoint=endpoint,
851
+ headers=self._get_common_headers(),
852
+ body=json.dumps(payload),
853
+ )
854
+
855
+ if status_code == 200:
856
+ logger.debug("Composite update API response without errors.")
857
+ return json.loads(resp_data)
858
+ else:
859
+ logger.error("Composite update API request failed: %s", status_code)
860
+ logger.debug("Response body: %s", resp_data)
861
+ return None
862
+
863
+ results = []
864
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
865
+ futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
866
+ for future in as_completed(futures):
867
+ result = future.result()
868
+ if result:
869
+ results.append(result)
870
+
871
+ combined_response = [
872
+ item
873
+ for result in results
874
+ for item in (result if isinstance(result, list) else [result])
875
+ if isinstance(result, (dict, list))
876
+ ]
877
+
878
+ return combined_response or None
@@ -3,5 +3,5 @@ requires-python = ">=3.9"
3
3
 
4
4
  [[package]]
5
5
  name = "sfq"
6
- version = "0.0.16"
6
+ version = "0.0.18"
7
7
  source = { editable = "." }
@@ -1,37 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- release:
5
- types: [published]
6
- workflow_dispatch:
7
-
8
- jobs:
9
-
10
- publish-release:
11
- name: upload release to PyPI
12
- runs-on: ubuntu-latest
13
- permissions:
14
- id-token: write
15
- environment: release
16
- steps:
17
- - name: Check out repository
18
- uses: actions/checkout@v4
19
-
20
- - uses: actions/setup-python@v5
21
- with:
22
- python-version: "3.12"
23
-
24
- - name: Install uv
25
- uses: astral-sh/setup-uv@v4
26
- with:
27
- version: "latest"
28
- enable-cache: true
29
-
30
- - name: Uv sync
31
- run: uv sync
32
-
33
- - name: Build package
34
- run: uv build
35
-
36
- - name: Publish package distributions to PyPI
37
- uses: pypa/gh-action-pypi-publish@release/v1
File without changes
File without changes
File without changes
File without changes