Repository navigation
OpenConceptLab/ocl_issues#2864 | Name export downloads with full repo coordinates - #929
Conversation
… coordinates The export GET signs its redirect URL with a Content-Disposition, so every client saves the file as [owner-type]_[owner]_[repo-type]_[repo]_[repo-version]_[expansion]_[lastUpdated].zip, e.g. orgs_PIH_sources_PIH_1.8.24_2026-09-30_123456.zip. lastUpdated comes from the storage key, which is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 1 (codex-cli 0.160.1, commit 7347e6c)
-
[P2] Timestamp fallback can mislabel cached content — core/common/models.py:1420. When key parsing fails, the filename uses the live model’s
last_child_update. For the timestamp-less key explicitly supported by the new test, I reproduced the same cached ZIP receiving different timestamps after changing model state. That violates the content-matching requirement. Legacy handling needs a trustworthy export timestamp rather than a live-model guess. -
[P2] Quoted versions produce truncated filenames in TBv2 — aws.py:71, also minio.py:97. Django correctly escapes quotes, but TBv2’s existing parser cannot consume that representation. With version
1 "draft", it truncates the filename before the timestamp and.zip;1;draftalso truncates. Version100%throwsURIErrorwhen read from the response header, or falls back to the old storage-key filename when read from the redirect URL. These cases need coordinated encoding/parser handling and compatibility tests. -
[P2] Unicode filenames are invisible to curl’s filename handling — aws.py:71, also minio.py:97. For version
révision, Django emits onlyfilename*=utf-8''…, with nofilename=fallback. Curl explicitly does not supportfilename*, so-OJcannot use the advertised name. Signing the header does not resolve that compatibility gap. curl documentation
The added tests miss these client-parsing cases; MinIO signing is mocked. Read-only reproductions confirmed ordinary HEAD/released names, dotted versions, expansions, spaces, and valid key timestamps. Storage keys remain unchanged, and I found no signature-call or existing url_for caller breakage.
No files changed. Container tests were unavailable because Docker access was denied.
Overall verdict: request changes — three P2 findings; no P0/P1 findings.
… key, filename-safe characters - lastUpdated is read only from the storage key (current or legacy 14-digit form). A key without one gets no name, so the download keeps its storage name rather than a timestamp guessed from the live repo. - Characters other than letters, digits and . _ - @ become '-', so names stay ASCII and parse in TBv2, curl -OJ and on Windows (56 prod versions have spaces, ':', '&', ',', '[]' or non-ASCII). Addresses Codex pass 1 on oclapi2#929. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replies to Codex pass 1. All three are fixed in b7a4ac7:
|
…r the key's known prefix A date-like version (1.20260930123456) in a key without a timestamp was read as the timestamp. Addresses Codex pass 2 on oclapi2#929. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 2 (codex-cli 0.160.1, commit b7a4ac7)
-
[P2] Timestamp-less dotted versions can still invent
lastUpdated— core/common/models.py:1413. For version1.20260930123456, the timestamp-less keyorgs/PIH/PIH_PIH_v1.20260930123456.zipproduces a filename ending_2026-09-30_123456.zip. That date belongs to the version identifier, not the export. I reproduced this directly. Validate the timestamp suffix separately from the complete repository/version/expansion prefix. The existing timestamp-less test misses date-like version components. -
[P2] Sanitization violates the exact stored-version requirement — core/common/models.py:1433. The compatibility fix changes
1.0 betato1.0-betaandrévisiontor-vision. These become indistinguishable from different, valid versions with those literal names when timestamps match. I reproduced both collisions. This fixes client parsing, but silently changes version identity; it needs reconciliation with the agreed naming contract.
The earlier quote, semicolon, percent-sign and Unicode parsing failures are resolved by normalization. Ten in-memory cases passed through Django 5.2.17 and the TBv2 header/redirect parsers. Ordinary HEAD, released, expansion and legacy-timestamp cases behaved correctly. Storage keys remain unchanged, and existing single-argument url_for calls remain compatible. The presigning arguments match botocore 1.36.10 and MinIO 7.2.9.
Reviewed git diff origin/master...HEAD; git diff --check passed. No files modified. No API container was running, so the Django suite and live storage downloads were not tested.
Overall verdict: request changes — two P2 findings; no P0/P1 findings.
|
Replies to Codex pass 2:
|
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 3 (codex-cli 0.160.1, commit a99559b)
No remaining or new [P0]–[P3] findings in git diff origin/master...HEAD. Prior fixes verified; deliberate decisions excluded.
336 in-memory checks passed. Database-backed tests and live S3/MinIO downloads were not run. No files modified.
Overall verdict: approve.
|
Quick note:
|
snyaggarwal
left a comment
There was a problem hiding this comment.
Suggestions below simplify get_export_download_name. Behaviour is the same except for one edge case, noted at the end. These are non-functional changes that remove some redundancy:
- Second prefix call.
export_pathwas built fromget_version_export_path(), and the method callsget_version_export_path(suffix=None)again just to slice the timestamp back out. The timestamp is the last.-separated part of the key, so one regex reads it in both the current (2026-09-30_123456) and legacy (20260930123456) forms. That replaces thestrptimeloop. - Name parts. The owner/repo/version/expansion parts can come straight from the URI:
expansion_uri(minusexpansions/) orself.uri. This also drops theself.expansionDB query that ran on every download. The version segment still comes fromself.version, because version URIs are percent-encoded (1.0 beta:→1.0%20beta%253A) and a HEAD source/collection URI has no version segment.
Two more suggestions are needed for this to apply cleanly. One removes the now-unused datetime import. The other updates test_date_like_version_is_not_read_as_the_timestamp. Without the prefix, a legacy key with no timestamp whose version ends in exactly 14 digits (PIH_PIH_v1.20260930123456.zip) would read the version as the timestamp. That case seems unrealistic enough to accept. The updated test covers a date-like version with a real key timestamp instead.
Something to consider: with this PR, the downloaded file name (orgs_PIH_sources_PIH_1.8.24_2026-09-30_123456.zip) no longer matches the S3 key (orgs/PIH/PIH_PIH_v1.8.24.2026-09-30_123456.zip). Before, the file someone had downloaded mapped directly to the stored object, and that helped a lot when debugging export issues. Now someone has to translate between the two formats to find the object. It isn't a blocker, but worth keeping in mind. Logging the key with the download name, or keeping the names closer to the key format, would close that gap.
| prefix = self.get_version_export_path(suffix=None) | ||
| key_timestamp = export_path[len(prefix):].removesuffix('.zip') if export_path.startswith(prefix) else '' | ||
| for key_format in ('%Y-%m-%d_%H%M%S', '%Y%m%d%H%M%S'): | ||
| try: | ||
| last_update = datetime.strptime(key_timestamp, key_format).strftime('%Y-%m-%d_%H%M%S') | ||
| break | ||
| except ValueError: | ||
| continue | ||
| else: | ||
| return None | ||
|
|
||
| owner = self.parent | ||
| parts = [ | ||
| f'{owner.get_url_kwarg()}s', owner.mnemonic, | ||
| f'{self.get_resource_url_kwarg()}s', self.mnemonic, | ||
| self.version, | ||
| ] | ||
| expansion = get(self, 'expansion.mnemonic') | ||
| if expansion: | ||
| parts.append(expansion) | ||
| parts.append(last_update) | ||
| return '_'.join(re.sub(r'[^A-Za-z0-9._@-]+', '-', part) for part in parts) + '.zip' |
There was a problem hiding this comment.
Read the timestamp straight from the key and build the name from expansion_uri/uri. This drops the second get_version_export_path call and the expansion query.
| prefix = self.get_version_export_path(suffix=None) | |
| key_timestamp = export_path[len(prefix):].removesuffix('.zip') if export_path.startswith(prefix) else '' | |
| for key_format in ('%Y-%m-%d_%H%M%S', '%Y%m%d%H%M%S'): | |
| try: | |
| last_update = datetime.strptime(key_timestamp, key_format).strftime('%Y-%m-%d_%H%M%S') | |
| break | |
| except ValueError: | |
| continue | |
| else: | |
| return None | |
| owner = self.parent | |
| parts = [ | |
| f'{owner.get_url_kwarg()}s', owner.mnemonic, | |
| f'{self.get_resource_url_kwarg()}s', self.mnemonic, | |
| self.version, | |
| ] | |
| expansion = get(self, 'expansion.mnemonic') | |
| if expansion: | |
| parts.append(expansion) | |
| parts.append(last_update) | |
| return '_'.join(re.sub(r'[^A-Za-z0-9._@-]+', '-', part) for part in parts) + '.zip' | |
| # current (2026-09-30_123456) or legacy (20260930123456) key timestamp | |
| match = re.fullmatch( | |
| r'(\d{4})-?(\d{2})-?(\d{2})_?(\d{6})', export_path.removesuffix('.zip').rsplit('.', 1)[-1]) | |
| if not match: | |
| return None | |
| parts = (get(self, 'expansion_uri') or self.uri).replace('/expansions/', '/').strip('/').split('/') | |
| # uri percent-encodes the version and HEAD's uri has none, so take it from the model | |
| parts[4:5] = [self.version] | |
| year, month, day, time_of_day = match.groups() | |
| parts.append(f'{year}-{month}-{day}_{time_of_day}') | |
| return '_'.join(re.sub(r'[^A-Za-z0-9._@-]+', '-', part) for part in parts) + '.zip' |
| import logging | ||
| import re | ||
| import time | ||
| from datetime import datetime |
There was a problem hiding this comment.
datetime is unused after the suggestion on get_export_download_name.
| from datetime import datetime |
| def test_date_like_version_is_not_read_as_the_timestamp(self): | ||
| source_v1 = OrganizationSourceFactory( | ||
| organization=OrganizationFactory(mnemonic='PIH'), mnemonic='PIH', version='1.20260930123456') | ||
|
|
||
| self.assertIsNone(source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.zip')) | ||
| self.assertIsNone(source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.1.zip')) |
There was a problem hiding this comment.
Matches the simplified parsing. A key with no timestamp and a 14-digit version is accepted as an edge case. This covers a date-like version with a real key timestamp instead.
| def test_date_like_version_is_not_read_as_the_timestamp(self): | |
| source_v1 = OrganizationSourceFactory( | |
| organization=OrganizationFactory(mnemonic='PIH'), mnemonic='PIH', version='1.20260930123456') | |
| self.assertIsNone(source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.zip')) | |
| self.assertIsNone(source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.1.zip')) | |
| def test_date_like_version(self): | |
| source_v1 = OrganizationSourceFactory( | |
| organization=OrganizationFactory(mnemonic='PIH'), mnemonic='PIH', version='1.20260930123456') | |
| self.assertEqual( | |
| source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.2026-10-01_090000.zip'), | |
| 'orgs_PIH_sources_PIH_1.20260930123456_2026-10-01_090000.zip' | |
| ) | |
| self.assertIsNone(source_v1.get_export_download_name('orgs/PIH/PIH_PIH_v1.20260930123456.1.zip')) |
…imestamp, name parts from the uri From the review on #929: - One regex reads the key's timestamp, in the current or the legacy form. It replaces the strptime loop and the datetime import. - The owner, repo and expansion parts come from expansion_uri or uri, and the version from the model. This drops the second expansion query; the prefix still needs one. Two changes to the suggestion: - The regex stays anchored to the version's key prefix. get_export_path() lists keys by prefix, so 1.8.24's lookup can return 1.8.24.1's export, and reading the key's last '.' part would name that file 1.8.24. Test: test_key_of_another_version_gets_no_name. - parts[4:6] replaces the uri's version, and an expansion uri's 'expansions' segment, with the model's version. replace('/expansions/', '/') would also drop an owner or repo named 'expansions'. Test: test_mnemonics_named_expansions. The anchor also leaves a timestamp-less key for a 14-digit version unnamed, so test_date_like_version keeps that case and adds a real key timestamp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF3UX7Xk4JwPkZ7yjU29X5
… the download name The download name no longer matches the storage key. The GET now logs both on one line, so a downloaded file can be traced back to its object (review on #929). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF3UX7Xk4JwPkZ7yjU29X5
- S3.url_for keeps master's one-line `if file_path else None` guard. - test_url_for_with_filename drops a bucket it never used: presigning a URL doesn't touch the bucket. From the simplification review on #929. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF3UX7Xk4JwPkZ7yjU29X5
Closes OpenConceptLab/ocl_issues#2864
The API part of OpenConceptLab/ocl_issues#2864. The other parts are TBv3 (OpenConceptLab/oclweb3 5fa6141a), OpenConceptLab/ocl-cli#13 and OpenConceptLab/ocl-docs#68.
What
GET …/export/now signs its redirect URL with aContent-Disposition, so every client saves the export under one name:[owner-type]_[owner]_[repo-type]_[repo]_[repo-version]_[expansion]_[lastUpdated].ziporgs_PIH_sources_PIH_1.8.24_2026-09-30_123456.ziporgs_PIH_collections_PIHEMR_Concepts_1.0_autoexpand-1.0_2026-09-30_123456.ziporgs_PIH_sources_PIH_HEAD_2026-09-30_123456.zipHow
get_export_download_name(export_path)on the repo version model builds the name in one place.lastUpdatedis read from the storage key, so the name matches the export's content. A key without a timestamp right after the version's key prefix gets no name, and the download keeps its storage name.. _ - @become-, so every name is ASCII and needs no quoting.url_for(file_path, filename=None)on the storage services: S3 passesResponseContentDisposition, MinIO passesresponse-content-disposition. Azure returns an unsigned public URL, which can't carry a name, so it ignoresfilename.Tests
RepoExportDownloadNameTest: an org source version, a user collection version with its expansion, a source HEAD, a collection HEAD with its expansion, and a key without a timestamp.url_forwith a filename. The source and collection export view tests now assert the filename passed tourl_for.🤖 Generated with Claude Code